@mapslibvn/web 0.4.0 → 0.5.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.js CHANGED
@@ -16,6 +16,351 @@ function applyLanguage(gl, lang) {
16
16
  }
17
17
  }
18
18
 
19
+ // src/navigation.ts
20
+ import {
21
+ createNavigator
22
+ } from "@mapslibvn/core";
23
+
24
+ // src/position-source.ts
25
+ function toGeoFix(position) {
26
+ const c = position.coords;
27
+ return {
28
+ lng: c.longitude,
29
+ lat: c.latitude,
30
+ accuracy_m: c.accuracy,
31
+ heading: typeof c.heading === "number" && Number.isFinite(c.heading) ? c.heading : null,
32
+ speed_mps: typeof c.speed === "number" && Number.isFinite(c.speed) ? c.speed : null,
33
+ timestamp: Number.isFinite(position.timestamp) ? position.timestamp : Date.now()
34
+ };
35
+ }
36
+ function toPositionError(error) {
37
+ const code = error.code === 1 ? "denied" : error.code === 3 ? "timeout" : "unavailable";
38
+ return { code, message: error.message || code, raw: error };
39
+ }
40
+ function geolocationSource(options = {}) {
41
+ const geo = "geolocation" in options ? options.geolocation : typeof navigator !== "undefined" ? navigator.geolocation : void 0;
42
+ const positionOptions = {
43
+ enableHighAccuracy: options.enableHighAccuracy ?? true,
44
+ maximumAge: options.maximumAge ?? 1e3,
45
+ timeout: options.timeout ?? 1e4
46
+ };
47
+ return {
48
+ subscribe(onFix, onError) {
49
+ if (!geo) {
50
+ onError?.({ code: "unavailable", message: "Tr\xECnh duy\u1EC7t kh\xF4ng c\xF3 Geolocation" });
51
+ return () => {
52
+ };
53
+ }
54
+ const id = geo.watchPosition(
55
+ (p) => onFix(toGeoFix(p)),
56
+ (e) => onError?.(toPositionError(e)),
57
+ positionOptions
58
+ );
59
+ return () => geo.clearWatch(id);
60
+ }
61
+ };
62
+ }
63
+ function playbackSource(fixes, options = {}) {
64
+ const rate = options.rate ?? 1;
65
+ return {
66
+ subscribe(onFix) {
67
+ let stopped = false;
68
+ let timer = null;
69
+ let i = 0;
70
+ const emitNext = () => {
71
+ if (stopped) return;
72
+ const fix = fixes[i];
73
+ if (!fix) return;
74
+ onFix(fix);
75
+ i += 1;
76
+ const next = fixes[i];
77
+ if (!next) return;
78
+ timer = setTimeout(emitNext, Math.max(0, (next.timestamp - fix.timestamp) / rate));
79
+ };
80
+ timer = setTimeout(
81
+ rate <= 0 ? () => {
82
+ for (const f of fixes) {
83
+ if (stopped) break;
84
+ onFix(f);
85
+ }
86
+ } : emitNext,
87
+ 0
88
+ );
89
+ return () => {
90
+ stopped = true;
91
+ if (timer !== null) clearTimeout(timer);
92
+ };
93
+ }
94
+ };
95
+ }
96
+
97
+ // src/speech.ts
98
+ var BCP47 = { vi: "vi-VN", en: "en-US" };
99
+ function createSpeech(opts) {
100
+ const synth = "synth" in opts ? opts.synth : typeof speechSynthesis !== "undefined" ? speechSynthesis : void 0;
101
+ const prefix = opts.lang;
102
+ let voice = null;
103
+ let voicesKnown = false;
104
+ let reported = false;
105
+ let current = 0;
106
+ const reportUnavailable = () => {
107
+ if (reported) return;
108
+ reported = true;
109
+ opts.onUnavailable?.();
110
+ };
111
+ const pickVoice = () => {
112
+ if (!synth) return;
113
+ const voices = synth.getVoices();
114
+ if (voices.length === 0) return;
115
+ voicesKnown = true;
116
+ voice = voices.find((v) => v.lang.toLowerCase().replace("_", "-").startsWith(prefix)) ?? null;
117
+ if (!voice) reportUnavailable();
118
+ };
119
+ if (!synth) reportUnavailable();
120
+ else {
121
+ pickVoice();
122
+ if (!voicesKnown && typeof synth.addEventListener === "function") {
123
+ synth.addEventListener("voiceschanged", pickVoice, { once: true });
124
+ }
125
+ }
126
+ const utterance = (text) => {
127
+ const u = new SpeechSynthesisUtterance(text);
128
+ u.lang = BCP47[opts.lang];
129
+ if (voice) u.voice = voice;
130
+ u.rate = opts.rate ?? 1;
131
+ u.volume = opts.volume ?? 1;
132
+ return u;
133
+ };
134
+ return {
135
+ get available() {
136
+ return Boolean(synth) && (!voicesKnown || voice !== null);
137
+ },
138
+ speak(text, priority) {
139
+ if (!synth || !text) return;
140
+ if (voicesKnown && !voice) return;
141
+ if ((synth.speaking || synth.pending) && priority >= current) synth.cancel();
142
+ const u = utterance(text);
143
+ current = priority;
144
+ u.onend = () => {
145
+ current = 0;
146
+ };
147
+ u.onerror = () => {
148
+ current = 0;
149
+ };
150
+ synth.speak(u);
151
+ },
152
+ warmUp() {
153
+ if (!synth) return;
154
+ const u = utterance("");
155
+ u.volume = 0;
156
+ synth.speak(u);
157
+ },
158
+ cancel() {
159
+ synth?.cancel();
160
+ current = 0;
161
+ }
162
+ };
163
+ }
164
+
165
+ // src/navigation.ts
166
+ var FOLLOW_ZOOM = {
167
+ walk: 17,
168
+ motorbike: 16.5,
169
+ car: 15.5
170
+ };
171
+ var FOLLOW_PITCH = 45;
172
+ var PUCK_STYLE = "width:0;height:0;border-left:11px solid transparent;border-right:11px solid transparent;border-bottom:26px solid #2458a6;filter:drop-shadow(0 0 2px #fff) drop-shadow(0 1px 3px rgba(0,0,0,.5));";
173
+ var NAV_EVENTS = [
174
+ "status",
175
+ "progress",
176
+ "step",
177
+ "waypoint",
178
+ "offRoute",
179
+ "reroute",
180
+ "rerouteFailed",
181
+ "announce",
182
+ "arrive"
183
+ ];
184
+ function createNavigation(deps) {
185
+ const { gl, ml, routes } = deps;
186
+ const doc = "document" in deps ? deps.document : typeof document !== "undefined" ? document : void 0;
187
+ const wakeLockApi = "wakeLock" in deps ? deps.wakeLock : typeof navigator !== "undefined" ? navigator.wakeLock : void 0;
188
+ const listeners = /* @__PURE__ */ new Map();
189
+ const emit = (k, e) => {
190
+ for (const fn of listeners.get(k) ?? []) fn(e);
191
+ };
192
+ let nav = null;
193
+ let unsubscribe = null;
194
+ let speech = null;
195
+ let puck = null;
196
+ let puckOnMap = false;
197
+ let running = false;
198
+ let following = false;
199
+ let follow = null;
200
+ let sentinel = null;
201
+ let lastFixTs = null;
202
+ const onUserMove = () => {
203
+ if (!following) return;
204
+ following = false;
205
+ emit("followChange", false);
206
+ };
207
+ const camera = (p) => {
208
+ if (!follow) return;
209
+ const dt = lastFixTs === null ? 500 : p.fix.timestamp - lastFixTs;
210
+ lastFixTs = p.fix.timestamp;
211
+ const options = {
212
+ center: p.snapped,
213
+ bearing: p.bearing,
214
+ zoom: follow.zoom,
215
+ pitch: follow.pitch,
216
+ duration: Math.max(0, Math.min(1e3, dt))
217
+ };
218
+ if (follow.padding) options.padding = follow.padding;
219
+ gl.easeTo(options);
220
+ };
221
+ const requestWakeLock = async () => {
222
+ if (!wakeLockApi) return;
223
+ try {
224
+ sentinel = await wakeLockApi.request("screen");
225
+ } catch {
226
+ sentinel = null;
227
+ }
228
+ };
229
+ const releaseWakeLock = () => {
230
+ void sentinel?.release();
231
+ sentinel = null;
232
+ };
233
+ const onVisibility = () => {
234
+ if (running && doc?.visibilityState === "visible" && sentinel === null) void requestWakeLock();
235
+ };
236
+ function stop() {
237
+ if (!running) return;
238
+ running = false;
239
+ unsubscribe?.();
240
+ unsubscribe = null;
241
+ speech?.cancel();
242
+ speech = null;
243
+ releaseWakeLock();
244
+ doc?.removeEventListener("visibilitychange", onVisibility);
245
+ gl.off("dragstart", onUserMove);
246
+ gl.off("wheel", onUserMove);
247
+ puck?.remove();
248
+ puck = null;
249
+ puckOnMap = false;
250
+ nav?.stop();
251
+ nav = null;
252
+ lastFixTs = null;
253
+ following = false;
254
+ follow = null;
255
+ }
256
+ function start(opts) {
257
+ if (running) stop();
258
+ running = true;
259
+ const lang = opts.lang ?? deps.lang;
260
+ const routeIndex = opts.routeIndex ?? 0;
261
+ const navOptions = {
262
+ response: opts.response,
263
+ routeIndex,
264
+ provider: opts.provider ?? deps.places,
265
+ reroute: opts.reroute ?? "auto",
266
+ lang
267
+ };
268
+ if (opts.thresholds) navOptions.thresholds = opts.thresholds;
269
+ const engine = createNavigator(navOptions);
270
+ nav = engine;
271
+ routes.show(opts.response, { active: routeIndex });
272
+ if (opts.voice !== false) {
273
+ const v = typeof opts.voice === "object" ? opts.voice : {};
274
+ const speechOptions = {
275
+ lang,
276
+ onUnavailable: () => emit("voiceUnavailable", void 0)
277
+ };
278
+ if (v.rate !== void 0) speechOptions.rate = v.rate;
279
+ if (v.volume !== void 0) speechOptions.volume = v.volume;
280
+ speech = createSpeech(speechOptions);
281
+ speech.warmUp();
282
+ }
283
+ if (opts.follow !== false) {
284
+ const f = typeof opts.follow === "object" ? opts.follow : {};
285
+ const mode = opts.response.routes[routeIndex]?.mode ?? "motorbike";
286
+ follow = { zoom: f.zoom ?? FOLLOW_ZOOM[mode], pitch: f.pitch ?? FOLLOW_PITCH };
287
+ if (f.padding) follow.padding = f.padding;
288
+ following = true;
289
+ gl.on("dragstart", onUserMove);
290
+ gl.on("wheel", onUserMove);
291
+ }
292
+ const el = doc?.createElement("div");
293
+ if (el) {
294
+ el.setAttribute("style", PUCK_STYLE);
295
+ el.setAttribute("aria-hidden", "true");
296
+ puck = new ml.Marker({ element: el, rotationAlignment: "map", pitchAlignment: "map" });
297
+ }
298
+ engine.on("progress", (p) => {
299
+ routes.setProgress(p.shapeIndex, p.snapped);
300
+ if (puck) {
301
+ puck.setLngLat(p.snapped).setRotation(p.bearing);
302
+ if (!puckOnMap) {
303
+ puck.addTo(gl);
304
+ puckOnMap = true;
305
+ }
306
+ }
307
+ if (following) camera(p);
308
+ });
309
+ engine.on("announce", (a) => speech?.speak(a.text, a.priority));
310
+ engine.on("reroute", (e) => routes.show(e.response, { active: 0 }));
311
+ engine.on("arrive", () => {
312
+ unsubscribe?.();
313
+ unsubscribe = null;
314
+ releaseWakeLock();
315
+ });
316
+ for (const name of NAV_EVENTS) {
317
+ engine.on(name, (e) => emit(name, e));
318
+ }
319
+ const source = opts.source ?? geolocationSource();
320
+ unsubscribe = source.subscribe(
321
+ (fix) => engine.update(fix),
322
+ (error) => emit("positionError", error)
323
+ );
324
+ if (opts.wakeLock !== false) {
325
+ void requestWakeLock();
326
+ doc?.addEventListener("visibilitychange", onVisibility);
327
+ }
328
+ }
329
+ return {
330
+ start,
331
+ stop,
332
+ recenter() {
333
+ if (!running || !follow) return;
334
+ following = true;
335
+ emit("followChange", true);
336
+ if (nav?.progress) camera(nav.progress);
337
+ },
338
+ reroute() {
339
+ return nav ? nav.reroute() : Promise.reject(new Error("map.navigation ch\u01B0a start()"));
340
+ },
341
+ get state() {
342
+ return nav?.progress ?? null;
343
+ },
344
+ get status() {
345
+ return nav?.status ?? "idle";
346
+ },
347
+ get following() {
348
+ return following;
349
+ },
350
+ on(event, handler) {
351
+ let set = listeners.get(event);
352
+ if (!set) {
353
+ set = /* @__PURE__ */ new Set();
354
+ listeners.set(event, set);
355
+ }
356
+ set.add(handler);
357
+ },
358
+ off(event, handler) {
359
+ listeners.get(event)?.delete(handler);
360
+ }
361
+ };
362
+ }
363
+
19
364
  // src/protocol.ts
20
365
  import { Protocol } from "pmtiles";
21
366
  var registered = false;
@@ -25,6 +370,130 @@ function ensurePmtilesProtocol(host) {
25
370
  registered = true;
26
371
  }
27
372
 
373
+ // src/routes-layer.ts
374
+ import {
375
+ EMPTY_ROUTE_FEATURES,
376
+ decodeRoutes,
377
+ routeFeatures
378
+ } from "@mapslibvn/core";
379
+ var ROUTE_SOURCE_ID = "mapslibvn-route";
380
+ var ROUTE_LAYER_IDS = {
381
+ alt: "mapslibvn-route-alt",
382
+ casing: "mapslibvn-route-casing",
383
+ line: "mapslibvn-route-line",
384
+ traveled: "mapslibvn-route-traveled"
385
+ };
386
+ var ROUTE_COLOR = "#2458a6";
387
+ var ALT_COLOR = "#9ca8ba";
388
+ var DESTINATION_COLOR = "#d92d20";
389
+ function createRoutesLayer(gl, ml, onRouteClick) {
390
+ let response = null;
391
+ let coords = [];
392
+ let active = 0;
393
+ let showMarkers = true;
394
+ let progress = null;
395
+ let markers = [];
396
+ let clickBound = false;
397
+ const firstSymbolLayerId = () => gl.getStyle()?.layers?.find((layer) => layer.type === "symbol")?.id;
398
+ const addLine = (id, kind, paint, before) => {
399
+ gl.addLayer(
400
+ {
401
+ id,
402
+ type: "line",
403
+ source: ROUTE_SOURCE_ID,
404
+ filter: ["==", ["get", "kind"], kind],
405
+ layout: { "line-join": "round", "line-cap": "round" },
406
+ paint
407
+ },
408
+ before
409
+ );
410
+ };
411
+ const ensureLayers = () => {
412
+ if (gl.getSource(ROUTE_SOURCE_ID)) return;
413
+ gl.addSource(ROUTE_SOURCE_ID, { type: "geojson", data: EMPTY_ROUTE_FEATURES });
414
+ const before = firstSymbolLayerId();
415
+ addLine(ROUTE_LAYER_IDS.alt, "alt", { "line-color": ALT_COLOR, "line-width": 5 }, before);
416
+ addLine(ROUTE_LAYER_IDS.casing, "active", { "line-color": "#ffffff", "line-width": 9 }, before);
417
+ addLine(ROUTE_LAYER_IDS.line, "active", { "line-color": ROUTE_COLOR, "line-width": 6 }, before);
418
+ addLine(
419
+ ROUTE_LAYER_IDS.traveled,
420
+ "traveled",
421
+ { "line-color": ROUTE_COLOR, "line-width": 6, "line-opacity": 0.35 },
422
+ before
423
+ );
424
+ if (!clickBound) {
425
+ clickBound = true;
426
+ gl.on("click", ROUTE_LAYER_IDS.alt, (e) => {
427
+ const index = e.features?.[0]?.properties?.index;
428
+ if (typeof index === "number") onRouteClick(index);
429
+ });
430
+ }
431
+ };
432
+ const setData = () => {
433
+ const source = gl.getSource(ROUTE_SOURCE_ID);
434
+ const data = response ? routeFeatures(coords, { active, progress }) : EMPTY_ROUTE_FEATURES;
435
+ source?.setData(data);
436
+ };
437
+ const apply = () => {
438
+ if (!response) return;
439
+ if (!gl.isStyleLoaded()) {
440
+ gl.once("style.load", apply);
441
+ return;
442
+ }
443
+ ensureLayers();
444
+ setData();
445
+ };
446
+ const clearMarkers = () => {
447
+ for (const m of markers) m.remove();
448
+ markers = [];
449
+ };
450
+ const placeMarkers = () => {
451
+ clearMarkers();
452
+ if (!response || !showMarkers) return;
453
+ const last = response.waypoints.length - 1;
454
+ response.waypoints.forEach((w, i) => {
455
+ if (i === 0) return;
456
+ markers.push(
457
+ new ml.Marker({ color: i === last ? DESTINATION_COLOR : ALT_COLOR }).setLngLat(w.snapped).addTo(gl)
458
+ );
459
+ });
460
+ };
461
+ gl.on("style.load", () => {
462
+ if (response && !gl.getSource(ROUTE_SOURCE_ID)) {
463
+ ensureLayers();
464
+ setData();
465
+ }
466
+ });
467
+ return {
468
+ show(next, opts = {}) {
469
+ response = next;
470
+ coords = decodeRoutes(next);
471
+ active = opts.active ?? 0;
472
+ showMarkers = opts.markers ?? true;
473
+ progress = null;
474
+ apply();
475
+ placeMarkers();
476
+ },
477
+ setActive(index) {
478
+ active = index;
479
+ progress = null;
480
+ setData();
481
+ },
482
+ setProgress(shapeIndex, snapped) {
483
+ progress = { shapeIndex, snapped };
484
+ setData();
485
+ },
486
+ clear() {
487
+ response = null;
488
+ coords = [];
489
+ progress = null;
490
+ clearMarkers();
491
+ const source = gl.getSource(ROUTE_SOURCE_ID);
492
+ source?.setData(EMPTY_ROUTE_FEATURES);
493
+ }
494
+ };
495
+ }
496
+
28
497
  // src/map.ts
29
498
  var isTheme = (s) => s === "light" || s === "dark";
30
499
  function createMap(opts, deps) {
@@ -57,11 +526,14 @@ function createMap(opts, deps) {
57
526
  );
58
527
  const listeners = {
59
528
  poiClick: /* @__PURE__ */ new Set(),
60
- load: /* @__PURE__ */ new Set()
529
+ load: /* @__PURE__ */ new Set(),
530
+ routeClick: /* @__PURE__ */ new Set()
61
531
  };
62
532
  const emit = (k, e) => {
63
533
  for (const fn of listeners[k]) fn(e);
64
534
  };
535
+ const routes = createRoutesLayer(gl, ml, (index) => emit("routeClick", { index }));
536
+ const navigation = createNavigation({ gl, ml, places, routes, lang: opts.lang ?? "vi" });
65
537
  gl.on("load", () => {
66
538
  if (opts.lang && opts.lang !== "vi") applyLanguage(gl, opts.lang);
67
539
  if (opts.poiLayer === false) {
@@ -89,6 +561,8 @@ function createMap(opts, deps) {
89
561
  return {
90
562
  gl,
91
563
  places,
564
+ routes,
565
+ navigation,
92
566
  addMarker(o) {
93
567
  const marker = new ml.Marker(o.color ? { color: o.color } : void 0).setLngLat([
94
568
  o.lng,
@@ -116,6 +590,8 @@ function createMap(opts, deps) {
116
590
  listeners[event].delete(handler);
117
591
  },
118
592
  remove() {
593
+ navigation.stop();
594
+ routes.clear();
119
595
  gl.remove();
120
596
  }
121
597
  };
@@ -353,15 +829,37 @@ function defineAutocomplete() {
353
829
  }
354
830
 
355
831
  // src/index.ts
356
- import { attributionHtml as attributionHtml2, attributionText, createClient as createClient3, MapsLibVNError } from "@mapslibvn/core";
832
+ import {
833
+ attributionHtml as attributionHtml2,
834
+ attributionText,
835
+ createClient as createClient3,
836
+ createNavigator as createNavigator2,
837
+ formatDistance,
838
+ formatDistanceShort,
839
+ MapsLibVNError,
840
+ NAVIGATION_THRESHOLDS,
841
+ simulateFixes
842
+ } from "@mapslibvn/core";
357
843
  export {
844
+ FOLLOW_ZOOM,
358
845
  MapsLibVNAutocomplete,
359
846
  MapsLibVNError,
847
+ NAVIGATION_THRESHOLDS,
848
+ ROUTE_LAYER_IDS,
849
+ ROUTE_SOURCE_ID,
360
850
  applyLanguage,
361
851
  attributionHtml2 as attributionHtml,
362
852
  attributionText,
363
853
  createClient3 as createClient,
364
854
  createMap,
855
+ createNavigator2 as createNavigator,
856
+ createSpeech,
365
857
  defineAutocomplete,
366
- nameExpression
858
+ formatDistance,
859
+ formatDistanceShort,
860
+ geolocationSource,
861
+ nameExpression,
862
+ playbackSource,
863
+ simulateFixes,
864
+ toGeoFix
367
865
  };