@mapvx/web-js 3.5.0-dev.3 → 3.5.0-dev.4

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