@mapvx/web-js 3.5.0-dev.3 → 3.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.
Files changed (31) hide show
  1. package/dist/cjs/controllers/routeController.js +396 -106
  2. package/dist/cjs/controllers/routeController.js.map +1 -1
  3. package/dist/cjs/domain/models/routeInstance.js +1 -0
  4. package/dist/cjs/domain/models/routeInstance.js.map +1 -1
  5. package/dist/cjs/logger/sentry.js +1 -1
  6. package/dist/cjs/map/map.js +250 -24
  7. package/dist/cjs/map/map.js.map +1 -1
  8. package/dist/cjs/map/mapInteractionOptions.js +18 -0
  9. package/dist/cjs/map/mapInteractionOptions.js.map +1 -1
  10. package/dist/es/controllers/routeController.d.ts +91 -13
  11. package/dist/es/controllers/routeController.d.ts.map +1 -1
  12. package/dist/es/controllers/routeController.js +396 -106
  13. package/dist/es/controllers/routeController.js.map +1 -1
  14. package/dist/es/domain/models/mapConfig.d.ts +76 -0
  15. package/dist/es/domain/models/mapConfig.d.ts.map +1 -1
  16. package/dist/es/domain/models/routeInstance.d.ts +70 -1
  17. package/dist/es/domain/models/routeInstance.d.ts.map +1 -1
  18. package/dist/es/domain/models/routeInstance.js +1 -0
  19. package/dist/es/domain/models/routeInstance.js.map +1 -1
  20. package/dist/es/logger/sentry.js +1 -1
  21. package/dist/es/map/map.d.ts +76 -0
  22. package/dist/es/map/map.d.ts.map +1 -1
  23. package/dist/es/map/map.js +250 -24
  24. package/dist/es/map/map.js.map +1 -1
  25. package/dist/es/map/mapInteractionOptions.d.ts +5 -1
  26. package/dist/es/map/mapInteractionOptions.d.ts.map +1 -1
  27. package/dist/es/map/mapInteractionOptions.js +18 -0
  28. package/dist/es/map/mapInteractionOptions.js.map +1 -1
  29. package/dist/umd/index.js +844 -208
  30. package/dist/umd/index.js.map +1 -1
  31. package/package.json +1 -1
@@ -15,6 +15,24 @@ const DEFAULT_AUTO_FIT_PADDING = {
15
15
  bottom: 150,
16
16
  left: 150,
17
17
  };
18
+ /**
19
+ * Longest a step waits for its line to be uploaded before animating anyway, so
20
+ * a stalled worker never freezes the route.
21
+ *
22
+ * A deadline rather than a frame count. `animateStep` is handed a `timestamp`
23
+ * already, and a frame count would stretch to three seconds or more on the
24
+ * 20-30 fps panels this wait exists for — the devices where a frozen marker at
25
+ * every step boundary is most visible.
26
+ */
27
+ const MAX_SOURCE_WAIT_MS = 1500;
28
+ /** Fully transparent: the part of a step line the reveal has not reached yet. */
29
+ const TRANSPARENT_COLOR = "rgba(0, 0, 0, 0)";
30
+ /**
31
+ * Width of the reveal cut-off, as a fraction of the step line. One texel of
32
+ * maplibre's 256-sample gradient ramp, i.e. the narrowest edge the ramp can
33
+ * actually resolve.
34
+ */
35
+ const REVEAL_EDGE = 1 / 256;
18
36
  /**
19
37
  * Longest gap between two frames, in milliseconds, that still counts as
20
38
  * animation time.
@@ -65,9 +83,10 @@ class RouteController {
65
83
  * Add MapLibre sources and layers for a single route instance.
66
84
  *
67
85
  * All three sources set `lineMetrics`: maplibre computes `line-progress` only
68
- * when a source asks it to, and `preparePaintForLayer` emits a
69
- * `line-progress` gradient for a GradientStyle. Without it, a consumer
70
- * setting a gradient route style silently got no gradient at all.
86
+ * when a source asks it to. The CURRENT layer needs it to reveal the step
87
+ * line as the marker advances, and `preparePaintForLayer` emits a
88
+ * `line-progress` gradient for a GradientStyle on any of the three — without
89
+ * it, a consumer setting a gradient route style silently got no gradient.
71
90
  */
72
91
  addSourcesAndLayersForRoute(instance) {
73
92
  this.map.addSource(instance.aheadId, {
@@ -140,9 +159,36 @@ class RouteController {
140
159
  addSourcesAndLayers() {
141
160
  for (const instance of this.routes.values()) {
142
161
  this.addSourcesAndLayersForRoute(instance);
162
+ this.restoreLayerPaint(instance);
143
163
  }
144
164
  this.syncRouteCollision();
145
165
  }
166
+ /**
167
+ * Re-apply the paint a style reload dropped.
168
+ *
169
+ * {@link addSourcesAndLayersForRoute} re-adds the layers with no paint at all, so
170
+ * without this the route keeps its geometry but loses its colors — and the CURRENT
171
+ * layer loses its `line-gradient`, which is the only thing hiding the part of the
172
+ * step the marker has not reached yet: the whole step would be drawn at once.
173
+ */
174
+ restoreLayerPaint(instance) {
175
+ var _a;
176
+ for (const [layerId, drawConfig] of Object.entries(instance.layerDrawConfigs)) {
177
+ this.setLayerPaint(instance, layerId, drawConfig);
178
+ }
179
+ // `?? 0`: a step whose geometry was loaded while the style was mid-swap
180
+ // never got a reveal recorded, because `applyReveal` bails when there is no
181
+ // layer to paint. Leaving the gradient off entirely then draws the whole
182
+ // step at once in the traveled color, and the `awaitingSources` hold, the
183
+ // rotation phase, the floor-change wait and a pause can each postpone the
184
+ // frame that would correct it — indefinitely, in the case of a pause.
185
+ // Revealing nothing is the safe default.
186
+ const reveal = (_a = instance.revealFraction) !== null && _a !== void 0 ? _a : 0;
187
+ // Force the update: `applyReveal` skips a fraction it believes is already on
188
+ // the map, and the fresh layer has no gradient at all.
189
+ instance.revealFraction = undefined;
190
+ this.applyReveal(instance, reveal);
191
+ }
146
192
  /**
147
193
  * Recompute the GLB route-transparency collision path from all active routes and push it to the
148
194
  * map. Every polyline point of every step is tagged with the floor its segment is drawn on (same
@@ -173,7 +219,7 @@ class RouteController {
173
219
  this.lastAddedRouteId = instance.id;
174
220
  this.addSourcesAndLayersForRoute(instance);
175
221
  this.setSourceData(instance, instance.aheadId, route.steps);
176
- this.setLayerPaint(instance.aheadId, drawConfig);
222
+ this.setLayerPaint(instance, instance.aheadId, drawConfig);
177
223
  this.updateLayerFilter(instance.aheadId);
178
224
  this.syncRouteCollision();
179
225
  }
@@ -275,7 +321,7 @@ class RouteController {
275
321
  const aheadSource = this.map.getSource(instance.aheadId);
276
322
  aheadSource === null || aheadSource === void 0 ? void 0 : aheadSource.setData(instance.aheadSourceData);
277
323
  // Ensure paint is set on the behind layer
278
- this.setLayerPaint(instance.behindId, behindDrawConfig);
324
+ this.setLayerPaint(instance, instance.behindId, behindDrawConfig);
279
325
  this.updateLayerFilter(instance.behindId);
280
326
  this.updateLayerFilter(instance.aheadId);
281
327
  }
@@ -311,55 +357,199 @@ class RouteController {
311
357
  source === null || source === void 0 ? void 0 : source.setData(instance.behindSourceData);
312
358
  }
313
359
  }
314
- setCurrentSourceData(instance, step) {
360
+ /**
361
+ * Upload the whole polyline of `step` as the CURRENT feature and reset its
362
+ * reveal to zero. The line is then drawn progressively by moving a
363
+ * `line-progress` cut-off (see {@link applyReveal}) instead of re-uploading a
364
+ * growing geometry every frame: a paint change is applied synchronously, while
365
+ * a `setData` goes through the worker and lands several frames after the DOM
366
+ * marker already moved — on a slow device the marker visibly ran ahead of
367
+ * its line.
368
+ */
369
+ loadStepGeometry(instance, step) {
370
+ var _a;
315
371
  instance.currentSourceData.properties = {
316
372
  floor_key: this.getFloorKeyForStep(step),
317
373
  };
318
- const coordinate = step === null || step === void 0 ? void 0 : step.coordinates[0];
319
- if (coordinate != null) {
320
- instance.currentSourceData.geometry.coordinates = [[coordinate.lng, coordinate.lat]];
321
- }
322
- else {
323
- instance.currentSourceData.geometry.coordinates = [];
324
- }
374
+ const coordinates = (_a = step === null || step === void 0 ? void 0 : step.coordinates) !== null && _a !== void 0 ? _a : [];
375
+ // A LineString needs at least two positions; anything shorter draws nothing.
376
+ instance.currentSourceData.geometry.coordinates =
377
+ coordinates.length >= 2 ? coordinates.map((c) => [c.lng, c.lat]) : [];
378
+ const cumulative = [0];
379
+ for (let i = 1; i < coordinates.length; i++) {
380
+ cumulative.push(cumulative[i - 1] + nav_functions_1.NavFunctions.distance(coordinates[i - 1], coordinates[i]));
381
+ }
382
+ instance.stepMetrics = { cumulative, total: cumulative[cumulative.length - 1] };
383
+ instance.revealFraction = undefined;
384
+ this.applyReveal(instance, 0);
325
385
  const source = this.map.getSource(instance.currentId);
326
386
  source === null || source === void 0 ? void 0 : source.setData(instance.currentSourceData);
327
387
  }
328
- setLayerPaint(layerId, drawConfig) {
388
+ /**
389
+ * Fraction of the step line up to `coordinatesIndex` plus `extraMeters` along
390
+ * the following segment.
391
+ */
392
+ fractionAlongStep(instance, coordinatesIndex, extraMeters) {
393
+ var _a;
394
+ const metrics = instance.stepMetrics;
395
+ if (!metrics || metrics.total <= 0)
396
+ return 1;
397
+ const index = Math.min(coordinatesIndex, metrics.cumulative.length - 1);
398
+ return (((_a = metrics.cumulative[index]) !== null && _a !== void 0 ? _a : 0) + extraMeters) / metrics.total;
399
+ }
400
+ /**
401
+ * Show the first `fraction` of the CURRENT step line: a `line-gradient` step on
402
+ * `line-progress` that turns transparent past the cut-off. MapLibre treats it
403
+ * as a plain paint update (a 256px ramp re-rendered on the next draw), so the
404
+ * line head and the marker move in the same frame.
405
+ *
406
+ * The paint is set with `validate: false`: this runs on every animation frame and
407
+ * the expression is built here, not taken from a consumer, so re-parsing and
408
+ * re-validating it 60 times a second is pure overhead on a totem-class device.
409
+ */
410
+ applyReveal(instance, fraction) {
411
+ const color = instance.currentLineColor;
412
+ if (color === undefined)
413
+ return;
414
+ const clamped = Math.max(0, Math.min(1, fraction));
415
+ if (instance.revealFraction === clamped)
416
+ return;
417
+ // A full style swap removes the route's sources and layers and re-adds them a
418
+ // few frames later. Painting a layer that is not there fires a MapLibre error on
419
+ // every animation frame; `revealFraction` is deliberately left untouched so
420
+ // `restoreLayerPaint` re-applies this reveal once the layers are back.
421
+ if (!this.hasSource(instance.currentId))
422
+ return;
423
+ instance.revealFraction = clamped;
424
+ this.map.setPaintProperty(instance.currentId, "line-gradient", this.revealGradient(color, clamped), { validate: false });
425
+ }
426
+ /**
427
+ * The `line-gradient` that shows the first `fraction` of the step line and
428
+ * nothing after it.
429
+ *
430
+ * Deliberately built on `interpolate` rather than the `step` this reads like.
431
+ * `LineStyleLayer._handleSpecialPaintPropertyUpdate` sets `stepInterpolant`
432
+ * for any `step` root, and `stepInterpolant` swaps the gradient ramp from a
433
+ * fixed 256 texels to
434
+ * `clamp(nextPowerOfTwo(lineLength * 1024 * potentialOverzoom), 256, maxTextureSize)`
435
+ * — 4096 to 16384 in practice. Every `setPaintProperty` here bumps
436
+ * `gradientVersion`, so that ramp would be re-rendered and re-uploaded once
437
+ * per visible tile per frame, which is the opposite of what this reveal is
438
+ * for. An `interpolate` root keeps the ramp at 256 texels; the cost is a
439
+ * one-texel soft edge at the cut-off instead of a hard one.
440
+ */
441
+ revealGradient(color, fraction) {
442
+ if (fraction <= 0)
443
+ return TRANSPARENT_COLOR;
444
+ // `line-progress` reaches exactly 1 at the last vertex, so a fully revealed
445
+ // line is just the line's own color, with no cut-off to express.
446
+ if (fraction >= 1)
447
+ return color;
448
+ return [
449
+ "interpolate",
450
+ ["linear"],
451
+ ["line-progress"],
452
+ fraction,
453
+ color,
454
+ // `interpolate` clamps to its domain, so one texel past the cut-off is
455
+ // enough to make everything beyond it transparent.
456
+ Math.min(fraction + REVEAL_EDGE, 1),
457
+ TRANSPARENT_COLOR,
458
+ ];
459
+ }
460
+ /**
461
+ * Whether the route's three sources have finished uploading their latest
462
+ * data, i.e. what is on screen matches what was last set. Maps without
463
+ * `isSourceLoaded` (test doubles) count as ready.
464
+ */
465
+ stepSourcesReady(instance) {
466
+ try {
467
+ // CURRENT alone. It carries the step being drawn, which is what has to be
468
+ // on screen for the line, the floor and the marker to change together.
469
+ // AHEAD and BEHIND hold the whole route and are rewritten in full at every
470
+ // boundary, so waiting on them made the freeze at each step scale with the
471
+ // route's total length rather than the step's — and on a long route that
472
+ // is the difference between imperceptible and hitting the deadline.
473
+ if (!this.map.getSource(instance.currentId))
474
+ return false;
475
+ return this.map.isSourceLoaded(instance.currentId) !== false;
476
+ }
477
+ catch (_a) {
478
+ // The map is briefly style-less mid-swap and `getSource` throws there. This runs
479
+ // inside a `requestAnimationFrame` callback, where an exception would kill the
480
+ // animation loop for good, so treat it as "not ready yet" instead.
481
+ return false;
482
+ }
483
+ }
484
+ /** Whether `sourceId` is currently on the map (false while a style swap is in flight). */
485
+ hasSource(sourceId) {
486
+ try {
487
+ return this.map.getSource(sourceId) != null;
488
+ }
489
+ catch (_a) {
490
+ return false;
491
+ }
492
+ }
493
+ /**
494
+ * Apply a floor switch that was deferred until the next step's line was
495
+ * uploaded, then put the marker where that step starts. Floor, line and
496
+ * marker change in the same frame.
497
+ */
498
+ applyPendingFloor(instance) {
499
+ var _a;
500
+ const pending = (_a = instance.conditions) === null || _a === void 0 ? void 0 : _a.pendingFloor;
501
+ if (!pending || !instance.conditions)
502
+ return;
503
+ instance.conditions.pendingFloor = undefined;
504
+ this.updatePlaceFloorForRouting(instance, pending.placeId, pending.floorKey);
505
+ this.setMarkerLngLat(instance, pending.location);
506
+ }
507
+ setLayerPaint(instance, layerId, drawConfig) {
508
+ instance.layerDrawConfigs[layerId] = drawConfig;
329
509
  const paintConfig = this.preparePaintForLayer(drawConfig);
330
510
  for (const property in paintConfig) {
331
511
  this.map.setPaintProperty(layerId, property, paintConfig[property]);
332
512
  }
333
513
  }
334
- preparePaintForLayer(drawConfig) {
514
+ /**
515
+ * Set the paint the step line is drawn with, resolving its color once.
516
+ *
517
+ * See {@link RouteInstance.currentLineColor}: resolving a gradient allocates
518
+ * and can throw, and the reveal would otherwise redo it on every frame.
519
+ */
520
+ setCurrentDrawConfig(instance, drawConfig) {
521
+ instance.currentDrawConfig = drawConfig;
522
+ instance.currentLineColor = this.lineColorExpression(drawConfig);
523
+ }
524
+ /** The color of a route line: a solid color or a `line-progress` gradient ramp. */
525
+ lineColorExpression(drawConfig) {
335
526
  if ("color" in drawConfig.routeStyle) {
336
527
  // Check for propriety unique to SolidStyle
337
- return {
338
- "line-color": drawConfig.routeStyle.color,
339
- "line-gradient": null, // null to unset the existing value
340
- "line-width": drawConfig.polylineWidth,
341
- };
528
+ return drawConfig.routeStyle.color;
342
529
  }
343
- else {
344
- // Is GradienteStyle
345
- const { colors, positions } = drawConfig.routeStyle;
346
- const positionsToUse = positions !== null && positions !== void 0 ? positions : (0, utils_1.createPositionsForStops)(colors.length);
347
- if (positionsToUse.length !== colors.length) {
348
- throw new Error("Error: position array has different length than colors array");
349
- }
350
- const stops = (0, utils_1.createStops)(positionsToUse, colors);
351
- const gradient = [
352
- "interpolate",
353
- ["linear"],
354
- ["line-progress"],
355
- ...stops,
356
- ];
530
+ // Is GradienteStyle
531
+ const { colors, positions } = drawConfig.routeStyle;
532
+ const positionsToUse = positions !== null && positions !== void 0 ? positions : (0, utils_1.createPositionsForStops)(colors.length);
533
+ if (positionsToUse.length !== colors.length) {
534
+ throw new Error("Error: position array has different length than colors array");
535
+ }
536
+ const stops = (0, utils_1.createStops)(positionsToUse, colors);
537
+ return ["interpolate", ["linear"], ["line-progress"], ...stops];
538
+ }
539
+ preparePaintForLayer(drawConfig) {
540
+ const color = this.lineColorExpression(drawConfig);
541
+ if (typeof color === "string") {
357
542
  return {
358
- "line-color": null,
359
- "line-gradient": gradient,
543
+ "line-color": color,
544
+ "line-gradient": null, // null to unset the existing value
360
545
  "line-width": drawConfig.polylineWidth,
361
546
  };
362
547
  }
548
+ return {
549
+ "line-color": null,
550
+ "line-gradient": color,
551
+ "line-width": drawConfig.polylineWidth,
552
+ };
363
553
  }
364
554
  /**
365
555
  * Update the filter of the layer referenced with a ID.
@@ -498,15 +688,11 @@ class RouteController {
498
688
  this.updatePlaceFloorForRouting(instance, firstStep.startInsidePlace, firstStep.startInsideFloor);
499
689
  this.setSourceData(instance, instance.aheadId, route.steps);
500
690
  this.setSourceData(instance, instance.behindId, []);
501
- instance.currentSourceData.properties = {
502
- floor_key: this.getFloorKeyForStep(firstStep),
503
- };
504
- instance.currentSourceData.geometry.coordinates = [
505
- [firstStep.coordinates[0].lng, firstStep.coordinates[0].lat],
506
- ];
507
- this.setLayerPaint(instance.aheadId, drawConfig.aheadConfig);
508
- this.setLayerPaint(instance.behindId, drawConfig.behindConfig);
509
- this.setLayerPaint(instance.currentId, drawConfig.behindConfig);
691
+ this.setLayerPaint(instance, instance.aheadId, drawConfig.aheadConfig);
692
+ this.setLayerPaint(instance, instance.behindId, drawConfig.behindConfig);
693
+ this.setLayerPaint(instance, instance.currentId, drawConfig.behindConfig);
694
+ this.setCurrentDrawConfig(instance, drawConfig.behindConfig);
695
+ this.loadStepGeometry(instance, firstStep);
510
696
  instance.animationConfig = animationConfig;
511
697
  instance.routeMarker = new marker_1.MarkerAttribute({
512
698
  coordinate: firstStep.startLocation,
@@ -540,14 +726,34 @@ class RouteController {
540
726
  progress: 0,
541
727
  resetTime: false,
542
728
  waitStartTime: undefined,
729
+ awaitingSources: true,
543
730
  animationId: requestAnimationFrame((time) => this.animateStep(time, instance)),
544
731
  onFinish: (time) => this.prepareForNextStep(time, instance),
545
732
  };
546
733
  (_c = animationConfig.callBack) === null || _c === void 0 ? void 0 : _c.call(animationConfig, { isFinished: false, step: firstStep });
547
734
  this.syncRouteCollision();
548
735
  }
549
- clearRouteInstance(instance) {
736
+ /**
737
+ * Tear one route instance down.
738
+ *
739
+ * `applyPendingFloorChange` decides what happens to a floor switch this
740
+ * instance had decided on but not yet shown. Removing a single route mid-walk
741
+ * applies it: the consumer may be removing the route from the step callback,
742
+ * and dropping the switch would leave the map on the floor the route has
743
+ * already left. Clearing every route does not — a floor switch is a visible
744
+ * camera and filter change plus an `onFloorChange` to the consumer, and
745
+ * `removeAllRoutes` is what teardown calls, where moving the map is
746
+ * a side effect nobody asked for and a consumer that re-enters from
747
+ * `onFloorChange` re-enters teardown.
748
+ */
749
+ clearRouteInstance(instance, applyPendingFloorChange = true) {
550
750
  var _a;
751
+ if (applyPendingFloorChange) {
752
+ this.applyPendingFloor(instance);
753
+ }
754
+ else if (instance.conditions) {
755
+ instance.conditions.pendingFloor = undefined;
756
+ }
551
757
  (_a = instance.routeMarker) === null || _a === void 0 ? void 0 : _a.remove();
552
758
  instance.routeMarker = undefined;
553
759
  this.setSourceData(instance, instance.aheadId, []);
@@ -587,7 +793,7 @@ class RouteController {
587
793
  */
588
794
  removeAllRoutes() {
589
795
  for (const instance of this.routes.values()) {
590
- this.clearRouteInstance(instance);
796
+ this.clearRouteInstance(instance, false);
591
797
  this.removeSourcesAndLayersForRoute(instance);
592
798
  }
593
799
  this.routes.clear();
@@ -665,14 +871,7 @@ class RouteController {
665
871
  this.updatePlaceFloorForRouting(instance, firstStep.startInsidePlace, firstStep.startInsideFloor);
666
872
  this.setSourceData(instance, instance.aheadId, instance.route.steps);
667
873
  this.setSourceData(instance, instance.behindId, []);
668
- instance.currentSourceData.properties = {
669
- floor_key: this.getFloorKeyForStep(firstStep),
670
- };
671
- instance.currentSourceData.geometry.coordinates = [
672
- [firstStep.coordinates[0].lng, firstStep.coordinates[0].lat],
673
- ];
674
- const source = this.map.getSource(instance.currentId);
675
- source === null || source === void 0 ? void 0 : source.setData(instance.currentSourceData);
874
+ this.loadStepGeometry(instance, firstStep);
676
875
  (_a = this.setMarkerRotation(instance, 0)) === null || _a === void 0 ? void 0 : _a.setLngLat(firstStep.startLocation).addTo(this.map);
677
876
  // Auto-fit by place if enabled
678
877
  if (((_b = instance.animationConfig.autoFitByPlace) === null || _b === void 0 ? void 0 : _b.enabled) && instance.route.steps.length > 0) {
@@ -701,6 +900,7 @@ class RouteController {
701
900
  progress: 0,
702
901
  resetTime: false,
703
902
  waitStartTime: undefined,
903
+ awaitingSources: true,
704
904
  animationId: requestAnimationFrame((time) => this.animateStep(time, instance)),
705
905
  onFinish: (time) => this.prepareForNextStep(time, instance),
706
906
  };
@@ -738,10 +938,11 @@ class RouteController {
738
938
  this.updatePlaceFloorForRouting(instance, firstStep.startInsidePlace, firstStep.startInsideFloor);
739
939
  this.setSourceData(instance, instance.aheadId, route.steps);
740
940
  this.setSourceData(instance, instance.behindId, []);
741
- this.setCurrentSourceData(instance, firstStep);
742
- this.setLayerPaint(instance.aheadId, drawConfig.aheadConfig);
743
- this.setLayerPaint(instance.behindId, drawConfig.behindConfig);
744
- this.setLayerPaint(instance.currentId, drawConfig.behindConfig);
941
+ this.setLayerPaint(instance, instance.aheadId, drawConfig.aheadConfig);
942
+ this.setLayerPaint(instance, instance.behindId, drawConfig.behindConfig);
943
+ this.setLayerPaint(instance, instance.currentId, drawConfig.behindConfig);
944
+ this.setCurrentDrawConfig(instance, drawConfig.behindConfig);
945
+ this.loadStepGeometry(instance, firstStep);
745
946
  instance.animationConfig = animationConfig;
746
947
  instance.routeMarker = new marker_1.MarkerAttribute({
747
948
  coordinate: firstStep.startLocation,
@@ -791,7 +992,7 @@ class RouteController {
791
992
  const step = instance.route.steps[index];
792
993
  this.setSourceData(instance, instance.behindId, behindSteps);
793
994
  this.setSourceData(instance, instance.aheadId, aheadSteps);
794
- this.setCurrentSourceData(instance, step);
995
+ this.loadStepGeometry(instance, step);
795
996
  instance.conditions.index = index;
796
997
  // The status settles before either callback, as everywhere else: no frame
797
998
  // is live once `animateStep` hands over to `onFinish`, so "WAITING" is
@@ -883,7 +1084,7 @@ class RouteController {
883
1084
  const step = instance.route.steps[index];
884
1085
  this.setSourceData(instance, instance.behindId, behindSteps);
885
1086
  this.setSourceData(instance, instance.aheadId, aheadSteps);
886
- this.setCurrentSourceData(instance, step);
1087
+ this.loadStepGeometry(instance, step);
887
1088
  if (step.startInsideFloor != step.endInsideFloor && step.startInsideFloor) {
888
1089
  this.updatePlaceFloorForRouting(instance, step.startInsidePlace, step.startInsideFloor);
889
1090
  }
@@ -957,21 +1158,41 @@ class RouteController {
957
1158
  const aheadSteps = instance.route.steps.slice(index + 1);
958
1159
  this.setSourceData(instance, instance.behindId, behindSteps);
959
1160
  this.setSourceData(instance, instance.aheadId, aheadSteps);
960
- instance.currentSourceData.geometry.coordinates = [];
961
1161
  if (index + 1 < instance.route.steps.length) {
962
1162
  const step = instance.route.steps[index + 1];
963
- instance.currentSourceData.geometry.coordinates.push([
964
- step.startLocation.lng,
965
- step.startLocation.lat,
966
- ]);
967
- instance.currentSourceData.properties = {
968
- floor_key: this.getFloorKeyForStep(step),
969
- };
1163
+ this.loadStepGeometry(instance, step);
970
1164
  instance.conditions.startTime = timestamp;
971
1165
  instance.conditions.index++;
972
1166
  instance.conditions.coordinatesIndex = 0;
973
1167
  instance.conditions.progress = 0;
974
1168
  instance.conditions.rotatingIcon = true;
1169
+ // The step starts once its line (and the updated behind/ahead lines) are
1170
+ // on screen; a floor switch decided by the previous step, or implied by a
1171
+ // step that starts on another floor, is applied at that same moment.
1172
+ instance.conditions.awaitingSources = true;
1173
+ instance.conditions.awaitDeadline = undefined;
1174
+ // Compared against the floor the PREVIOUS step ended on, not the map's current
1175
+ // floor: the route decides its own floor changes, so a user who browsed to
1176
+ // another floor mid-animation is not yanked back at every step boundary.
1177
+ const previousStep = instance.route.steps[index];
1178
+ // Against the previous step's *effective* floor, not its raw
1179
+ // `endInsideFloor`. Venue data routinely carries `startInsideFloor` with
1180
+ // no `endInsideFloor` — `getFloorKeyForStep` exists to resolve exactly
1181
+ // that shape — and comparing against the raw field made every boundary
1182
+ // on such a route look like a floor change: the map moved, the consumer
1183
+ // got an `onFloorChange`, a settle poll ran and the step callback was
1184
+ // deferred a frame, at each step. A user who had browsed to another floor
1185
+ // was yanked back, which is what the deferral above is meant to avoid.
1186
+ const previousFloor = this.getFloorKeyForStep(previousStep);
1187
+ if (!instance.conditions.pendingFloor &&
1188
+ step.startInsideFloor &&
1189
+ step.startInsideFloor !== previousFloor) {
1190
+ instance.conditions.pendingFloor = {
1191
+ placeId: step.startInsidePlace,
1192
+ floorKey: step.startInsideFloor,
1193
+ location: step.startLocation,
1194
+ };
1195
+ }
975
1196
  if (!(0, nav_functions_1.compareLatLng)(instance.route.steps[index].endLocation, step.startLocation))
976
1197
  instance.conditions.nextFloorFirstStepIndex = index + 2;
977
1198
  instance.conditions.animationId = requestAnimationFrame((time) => this.animateStep(time, instance));
@@ -986,12 +1207,28 @@ class RouteController {
986
1207
  this.mapvxMap.fitRouteByPlace(instance.route, stepPlaceId, fitOptions);
987
1208
  }
988
1209
  }
989
- (_d = (_c = instance.animationConfig).callBack) === null || _d === void 0 ? void 0 : _d.call(_c, {
990
- isFinished: false,
991
- step: instance.route.steps[index + 1],
992
- });
1210
+ if (instance.conditions.pendingFloor) {
1211
+ // The switch lands a few frames later, in `animateStep`. Firing the callback
1212
+ // now would report the step while the map is still on the previous floor —
1213
+ // and a consumer that stops the route from it would drop the switch entirely.
1214
+ instance.conditions.pendingStepCallback = step;
1215
+ }
1216
+ else {
1217
+ (_d = (_c = instance.animationConfig).callBack) === null || _d === void 0 ? void 0 : _d.call(_c, { isFinished: false, step });
1218
+ }
993
1219
  }
994
1220
  else {
1221
+ // finishes animation
1222
+ this.applyPendingFloor(instance);
1223
+ // That reached `onFloorChange`, and a consumer removing the route from it
1224
+ // has already had `clearRouteInstance` null `animationConfig` and settle
1225
+ // the status at "NO_ROUTE". Both lines below write through it — the
1226
+ // `?.` is on `callBack`, not on `animationConfig` — so without this the
1227
+ // branch threw a TypeError out of the rAF callback, and overwrote a
1228
+ // finished instance's status with "FINISHED". `animateStep`'s release
1229
+ // path takes the same guard after the same call.
1230
+ if (instance.conditions == null || instance.animationConfig == null)
1231
+ return;
995
1232
  // The status settles before the consumer hears that the route finished,
996
1233
  // for the same reason `startAnimating` and `restartAnimation` order it
997
1234
  // that way. A consumer that relaunches from this callback — looping a
@@ -1005,7 +1242,7 @@ class RouteController {
1005
1242
  }
1006
1243
  }
1007
1244
  animateStep(timestamp, instance) {
1008
- var _a;
1245
+ var _a, _b, _c, _d;
1009
1246
  if (instance.conditions == null || instance.animationConfig == null || instance.route == null) {
1010
1247
  throw new Error("Error: conditions and animationConfig should not be null at this point");
1011
1248
  }
@@ -1022,6 +1259,53 @@ class RouteController {
1022
1259
  instance.conditions.animationId = requestAnimationFrame((time) => this.animateStep(time, instance));
1023
1260
  return;
1024
1261
  }
1262
+ if (instance.conditions.awaitingSources) {
1263
+ let deadline = (_a = instance.conditions.awaitDeadline) !== null && _a !== void 0 ? _a : timestamp + MAX_SOURCE_WAIT_MS;
1264
+ // The deadline is wall clock, and this block returns before the frame-gap
1265
+ // branch below can ever run, so it needs that branch's shift applied here
1266
+ // — the same one `waitStartTime` gets, for the same reason. A backgrounded
1267
+ // tab or a pause longer than the deadline would otherwise release the step
1268
+ // on the first frame back without once asking whether its line is on
1269
+ // screen, which is the desync the wait exists to prevent.
1270
+ if (previousFrameTime != null && timestamp - previousFrameTime > MAX_FRAME_GAP_MS) {
1271
+ deadline += timestamp - previousFrameTime;
1272
+ }
1273
+ instance.conditions.awaitDeadline = deadline;
1274
+ if (!this.stepSourcesReady(instance) && timestamp < deadline) {
1275
+ // Hold the clock: the step starts when its line can be seen.
1276
+ instance.conditions.startTime = timestamp;
1277
+ instance.conditions.animationId = requestAnimationFrame((time) => this.animateStep(time, instance));
1278
+ return;
1279
+ }
1280
+ instance.conditions.awaitingSources = false;
1281
+ instance.conditions.awaitDeadline = undefined;
1282
+ instance.conditions.startTime = timestamp;
1283
+ const heldStep = instance.conditions.pendingStepCallback;
1284
+ instance.conditions.pendingStepCallback = undefined;
1285
+ // The frame that releases the wait draws nothing: it queues its successor
1286
+ // and hands control to the consumer, and that successor does the drawing.
1287
+ //
1288
+ // The queue has to come first. Both calls below reach consumer code —
1289
+ // `applyPendingFloor` through the `onFloorChange` handler, and the held
1290
+ // step callback directly — and a `pauseAnimation()` from either one
1291
+ // cancels whatever id sits in `conditions.animationId`. While that id was
1292
+ // the frame already executing, cancelling it did nothing: the loop kept
1293
+ // running under a `PAUSED` status, and the matching `resumeAnimation()`
1294
+ // passed its status guard and installed a second concurrent rAF chain on
1295
+ // the same instance. Queueing first gives that pause a pending frame to
1296
+ // cancel, and returning afterwards means this frame does not queue a
1297
+ // replacement for the one it just cancelled.
1298
+ instance.conditions.animationId = requestAnimationFrame((time) => this.animateStep(time, instance));
1299
+ this.applyPendingFloor(instance);
1300
+ // `onFloorChange` may have removed the route; everything below reads
1301
+ // `conditions`.
1302
+ if (instance.conditions == null || instance.animationConfig == null)
1303
+ return;
1304
+ if (heldStep) {
1305
+ (_c = (_b = instance.animationConfig).callBack) === null || _c === void 0 ? void 0 : _c.call(_b, { isFinished: false, step: heldStep });
1306
+ }
1307
+ return;
1308
+ }
1025
1309
  if (resetTime) {
1026
1310
  startTime = timestamp - progress;
1027
1311
  instance.conditions.startTime = startTime;
@@ -1094,7 +1378,15 @@ class RouteController {
1094
1378
  return;
1095
1379
  }
1096
1380
  }
1097
- if (step.startInsideFloor !== step.endInsideFloor) {
1381
+ // There has to be a floor to switch *to*. An absent `startInsideFloor` is
1382
+ // meaningful — that is a step entering the building from outdoors — but an
1383
+ // absent `endInsideFloor` is not: venue data routinely omits it on a step
1384
+ // that does not change floor, and reading that as "different" made every
1385
+ // such step stall for `changeFloorTime` and then queue a switch whose
1386
+ // `floorKey` was `undefined`. `updatePlaceFloorForRouting` drops that
1387
+ // silently, but not before it has occupied the `pendingFloor` slot the
1388
+ // step boundary needed for a real one.
1389
+ if (step.endInsideFloor && step.startInsideFloor !== step.endInsideFloor) {
1098
1390
  if (waitStartTime == null) {
1099
1391
  instance.conditions.waitStartTime = timestamp;
1100
1392
  instance.conditions.animationId = requestAnimationFrame((time) => this.animateStep(time, instance));
@@ -1105,9 +1397,21 @@ class RouteController {
1105
1397
  return;
1106
1398
  }
1107
1399
  else {
1108
- this.updatePlaceFloorForRouting(instance, step.endInsidePlace, step.endInsideFloor);
1109
- this.setMarkerLngLat(instance, step.endLocation);
1110
1400
  instance.conditions.waitStartTime = undefined;
1401
+ if (instance.conditions.isStepByStep) {
1402
+ // Step-by-step mode is user-driven: switch right away, as before.
1403
+ this.updatePlaceFloorForRouting(instance, step.endInsidePlace, step.endInsideFloor);
1404
+ this.setMarkerLngLat(instance, step.endLocation);
1405
+ }
1406
+ else {
1407
+ // Defer the switch until the next step's line is uploaded, so floor,
1408
+ // line and marker change together (see applyPendingFloor).
1409
+ instance.conditions.pendingFloor = {
1410
+ placeId: step.endInsidePlace,
1411
+ floorKey: step.endInsideFloor,
1412
+ location: step.endLocation,
1413
+ };
1414
+ }
1111
1415
  onFinish === null || onFinish === void 0 ? void 0 : onFinish(timestamp);
1112
1416
  return;
1113
1417
  }
@@ -1123,22 +1427,17 @@ class RouteController {
1123
1427
  // ignored `stepTime` altogether and collapsed to `vertexCount / fps`.
1124
1428
  // Encoded polylines quantise near 1.1 m, so that regime was the common
1125
1429
  // one, not the corner case.
1430
+ //
1431
+ // The step's whole polyline is already uploaded (see `loadStepGeometry`),
1432
+ // so a frame commits nothing: it only moves the reveal cut-off, which is
1433
+ // the same distance that moves the marker.
1126
1434
  let segmentStart = start;
1127
1435
  let segmentEnd = end;
1128
1436
  let segmentDistance = nav_functions_1.NavFunctions.distance(segmentStart, segmentEnd);
1129
- // Drop the previous frame's interpolated tip before committing anything.
1130
- // Coordinates up to and including the current vertex are settled geometry
1131
- // — exactly `coordinatesIndex + 1` of them — and the tip is a single
1132
- // moving point after them, re-placed at the end of this frame. Appending
1133
- // one per frame instead grew the drawn line with elapsed frames, well
1134
- // past the polyline's own vertex count, and `setData` re-indexed every
1135
- // one of those points on the next frame.
1136
- const coordinates = instance.currentSourceData.geometry.coordinates;
1137
- coordinates.length = Math.min(coordinates.length, instance.conditions.coordinatesIndex + 1);
1138
1437
  while (speed * progressInSeconds > segmentDistance) {
1139
- coordinates.push([segmentEnd.lng, segmentEnd.lat]);
1140
1438
  if (instance.conditions.coordinatesIndex + 2 >= step.coordinates.length) {
1141
- this.flushCurrentSourceData(instance);
1439
+ // `segmentEnd` is the step's last vertex: reveal the line in full.
1440
+ this.applyReveal(instance, this.fractionAlongStep(instance, instance.conditions.coordinatesIndex + 1, 0));
1142
1441
  onFinish === null || onFinish === void 0 ? void 0 : onFinish(timestamp);
1143
1442
  return;
1144
1443
  }
@@ -1167,24 +1466,27 @@ class RouteController {
1167
1466
  instance.conditions.rotatingIcon = true;
1168
1467
  instance.conditions.startTime = timestamp;
1169
1468
  instance.conditions.progress = 0;
1170
- this.flushCurrentSourceData(instance);
1469
+ // The marker stops on the vertex it turns about, so the line does too.
1470
+ this.applyReveal(instance, this.fractionAlongStep(instance, instance.conditions.coordinatesIndex, 0));
1171
1471
  instance.conditions.animationId = requestAnimationFrame((time) => this.animateStep(time, instance));
1172
1472
  return;
1173
1473
  }
1174
1474
  const nextAngle = nav_functions_1.NavFunctions.bearingInDegBetween(segmentEnd, next);
1175
1475
  instance.conditions.iconRotation = nextAngle;
1176
- (_a = instance.routeMarker) === null || _a === void 0 ? void 0 : _a.setRotation(nextAngle);
1476
+ (_d = instance.routeMarker) === null || _d === void 0 ? void 0 : _d.setRotation(nextAngle);
1177
1477
  }
1178
1478
  segmentStart = segmentEnd;
1179
1479
  segmentEnd = next;
1180
1480
  segmentDistance = nav_functions_1.NavFunctions.distance(segmentStart, segmentEnd);
1181
1481
  }
1182
- const fraction = (speed * progressInSeconds) / segmentDistance;
1482
+ const traveled = Math.min(speed * progressInSeconds, segmentDistance);
1183
1483
  const point = segmentDistance === 0
1184
1484
  ? segmentEnd
1185
- : nav_functions_1.NavFunctions.intermediatePointBetween(segmentStart, segmentEnd, Math.min(fraction, 1));
1186
- coordinates.push([point.lng, point.lat]);
1187
- this.flushCurrentSourceData(instance);
1485
+ : nav_functions_1.NavFunctions.intermediatePointBetween(segmentStart, segmentEnd, traveled / segmentDistance);
1486
+ // The same distance drives the reveal cut-off and the marker, and the
1487
+ // reveal is a synchronous paint update rather than a `setData` that lands
1488
+ // several frames later, so the two cannot drift apart.
1489
+ this.applyReveal(instance, this.fractionAlongStep(instance, instance.conditions.coordinatesIndex, traveled));
1188
1490
  this.setMarkerLngLat(instance, point);
1189
1491
  // Only when it has genuinely left the DOM. maplibre's `Marker.addTo`
1190
1492
  // opens with `remove()`, so re-attaching an attached marker tears down
@@ -1213,18 +1515,6 @@ class RouteController {
1213
1515
  return;
1214
1516
  }
1215
1517
  }
1216
- /**
1217
- * Pushes the in-progress segment's geometry to its map source.
1218
- *
1219
- * Called once per frame rather than once per vertex crossed: `setData`
1220
- * rebuilds the whole geojson-vt index and reloads every in-view tile, so its
1221
- * cost is a large fixed one per call and is worth paying only once, with the
1222
- * frame's final geometry.
1223
- */
1224
- flushCurrentSourceData(instance) {
1225
- const source = this.map.getSource(instance.currentId);
1226
- source === null || source === void 0 ? void 0 : source.setData(instance.currentSourceData);
1227
- }
1228
1518
  setMarkerLngLat(instance, point) {
1229
1519
  var _a, _b;
1230
1520
  if ((_a = instance.conditions) === null || _a === void 0 ? void 0 : _a.isStepByStep) {