@mapvx/web-js 3.4.0-dev.2 → 3.5.0-dev.1

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 (51) hide show
  1. package/dist/cjs/domain/models/campaign.js +73 -0
  2. package/dist/cjs/domain/models/campaign.js.map +1 -0
  3. package/dist/cjs/index.js +4 -1
  4. package/dist/cjs/index.js.map +1 -1
  5. package/dist/cjs/logger/sentry.js +1 -1
  6. package/dist/cjs/map/map.js +380 -14
  7. package/dist/cjs/map/map.js.map +1 -1
  8. package/dist/cjs/map/scene3DController.js +36 -0
  9. package/dist/cjs/map/scene3DController.js.map +1 -1
  10. package/dist/cjs/repository/repository.js +65 -4
  11. package/dist/cjs/repository/repository.js.map +1 -1
  12. package/dist/cjs/repository/requester.js +81 -9
  13. package/dist/cjs/repository/requester.js.map +1 -1
  14. package/dist/cjs/sdk.js +12 -0
  15. package/dist/cjs/sdk.js.map +1 -1
  16. package/dist/es/config/sdkConfig.d.ts +15 -0
  17. package/dist/es/config/sdkConfig.d.ts.map +1 -1
  18. package/dist/es/domain/models/campaign.d.ts +116 -0
  19. package/dist/es/domain/models/campaign.d.ts.map +1 -0
  20. package/dist/es/domain/models/campaign.js +69 -0
  21. package/dist/es/domain/models/campaign.js.map +1 -0
  22. package/dist/es/domain/models/mapConfig.d.ts +6 -0
  23. package/dist/es/domain/models/mapConfig.d.ts.map +1 -1
  24. package/dist/es/index.d.ts +2 -0
  25. package/dist/es/index.d.ts.map +1 -1
  26. package/dist/es/index.js +2 -0
  27. package/dist/es/index.js.map +1 -1
  28. package/dist/es/logger/sentry.js +1 -1
  29. package/dist/es/map/map.d.ts +145 -0
  30. package/dist/es/map/map.d.ts.map +1 -1
  31. package/dist/es/map/map.js +380 -14
  32. package/dist/es/map/map.js.map +1 -1
  33. package/dist/es/map/scene3DController.d.ts +20 -0
  34. package/dist/es/map/scene3DController.d.ts.map +1 -1
  35. package/dist/es/map/scene3DController.js +36 -0
  36. package/dist/es/map/scene3DController.js.map +1 -1
  37. package/dist/es/repository/repository.d.ts +16 -1
  38. package/dist/es/repository/repository.d.ts.map +1 -1
  39. package/dist/es/repository/repository.js +65 -4
  40. package/dist/es/repository/repository.js.map +1 -1
  41. package/dist/es/repository/requester.d.ts +30 -1
  42. package/dist/es/repository/requester.d.ts.map +1 -1
  43. package/dist/es/repository/requester.js +81 -9
  44. package/dist/es/repository/requester.js.map +1 -1
  45. package/dist/es/sdk.d.ts +11 -1
  46. package/dist/es/sdk.d.ts.map +1 -1
  47. package/dist/es/sdk.js +12 -0
  48. package/dist/es/sdk.js.map +1 -1
  49. package/dist/umd/index.js +1688 -838
  50. package/dist/umd/index.js.map +1 -1
  51. package/package.json +1 -1
@@ -27,6 +27,13 @@ const MAX_TILE_RETRIES = 5;
27
27
  * often instead of once per timer tick.
28
28
  */
29
29
  const TILE_RETRY_BASE_DELAY_MS = 1000;
30
+ /**
31
+ * Safety deadline for {@link InternalMapVXMap.setStyle}'s `onStyleReady` callback. The
32
+ * callback normally fires on the map's `idle` event, but `idle` may never arrive (the
33
+ * style request failed, the swap never settles). Callers hide the map behind a loading
34
+ * overlay until the callback arrives, so it must be released no matter what.
35
+ */
36
+ const STYLE_READY_TIMEOUT_MS = 4000;
30
37
  /** Fill/line layer ids for one circle render-order bucket. */
31
38
  function circleLayerIdsFor(order) {
32
39
  const suffix = CIRCLE_LAYER_SUFFIXES[order];
@@ -171,7 +178,7 @@ export class InternalMapVXMap extends Loggeable {
171
178
  * @returns A new instance of MapVXMap.
172
179
  */
173
180
  constructor(mapConfig, container, token) {
174
- var _a, _b, _c;
181
+ var _a, _b, _c, _d;
175
182
  super();
176
183
  /**
177
184
  * Monotonic counter for {@link addLiveRoute} calls. Refreshes overlap (each one
@@ -185,6 +192,34 @@ export class InternalMapVXMap extends Loggeable {
185
192
  this.subPlaces = [];
186
193
  this.currentFloor = "";
187
194
  this.baseFilters = {};
195
+ /**
196
+ * Layer ids whose declared (base) filter has already been resolved — either captured
197
+ * into {@link baseFilters} or confirmed absent.
198
+ *
199
+ * `baseFilters[id] === undefined` cannot carry that information on its own: it means
200
+ * both "not looked at yet" and "looked at, the layer declares no filter". Without the
201
+ * distinction, a later pass re-reads the layer's LIVE filter — which by then is the
202
+ * one this class wrote — and stores our own floor/public-place filter as if it were
203
+ * the style's, so the layer keeps filtering by a stale floor forever.
204
+ *
205
+ * An id lands here the moment its declared filter is read from the style spec, or the
206
+ * moment this class writes a filter to that layer, whichever happens first. Layers
207
+ * added asynchronously after a `setStyle` swap are in neither situation, so their
208
+ * genuinely pristine live filter is still captured on first touch.
209
+ */
210
+ this.capturedBaseFilters = new Set();
211
+ /**
212
+ * Monotonic id of the most recently STARTED style swap. Every {@link setStyle} call
213
+ * captures its own value and checks it again in each async continuation, so a
214
+ * superseded swap can neither touch the shared bookkeeping (`styleReadyTimer`,
215
+ * `pendingStyleSwapListener`) nor apply its obsolete style to the live map.
216
+ *
217
+ * A per-call `readied` flag is not enough: the timer and the listener slot are
218
+ * single-valued instance state, so two overlapping swaps otherwise clobber each
219
+ * other's — one caller's overlay is released by the other's failure, or an
220
+ * abandoned listener re-seeds filters from layer ids the current style lacks.
221
+ */
222
+ this.styleSwapGeneration = 0;
188
223
  this.markers = [];
189
224
  this.circles = [];
190
225
  this.enableHover = false;
@@ -206,15 +241,18 @@ export class InternalMapVXMap extends Loggeable {
206
241
  routeApiUrl: mapConfig.routeApiUrl,
207
242
  healthApiUrl: mapConfig.healthApiUrl,
208
243
  legacyApiUrl: mapConfig.legacyApiUrl,
244
+ portalApiUrl: mapConfig.portalApiUrl,
209
245
  });
210
246
  this.token = token;
247
+ this.authToken = mapConfig.authToken;
248
+ this.currentLang = (_c = mapConfig.lang) !== null && _c !== void 0 ? _c : "en";
211
249
  this.parentPlaceId = mapConfig.parentPlaceId;
212
250
  this.routeController = new RouteController(this);
213
251
  this.userLocationMarker = undefined;
214
252
  this.watchPositionID = undefined;
215
253
  this.onFloorChange = mapConfig.onFloorChange;
216
254
  this.onParentPlaceChange = mapConfig.onParentPlaceChange;
217
- this.mode = (_c = mapConfig.mode) !== null && _c !== void 0 ? _c : "2D";
255
+ this.mode = (_d = mapConfig.mode) !== null && _d !== void 0 ? _d : "2D";
218
256
  this.scene3DConfig = mapConfig.scene3D;
219
257
  this.initialCenter = mapConfig.center;
220
258
  this.tileCacheConfig = (() => {
@@ -397,6 +435,17 @@ export class InternalMapVXMap extends Loggeable {
397
435
  style.layers
398
436
  .filter((layer) => layer.id.startsWith("indoor-"))
399
437
  .forEach((layer) => {
438
+ // Seed from the STYLE SPEC — the only pristine source — and do it BEFORE the
439
+ // setFilter below overwrites the live filter. Reading it back afterwards would
440
+ // return our own `publicPlaceFilter` and store it as the layer's declared one,
441
+ // turning `public_place` from an OR into an AND and hiding every private
442
+ // feature on the selected floor. Every indoor layer is covered, not just
443
+ // fill/line/symbol: a `circle` layer is discriminated by its filter just the
444
+ // same, and dropping it makes the layer repaint every feature.
445
+ // `filter` is absent from the union member for `background` layers, hence the
446
+ // structural read rather than `layer.filter`.
447
+ const declared = layer.filter;
448
+ this.captureDeclaredBaseFilter(layer.id, declared);
400
449
  const baseFilter = this.baseFilters[layer.id];
401
450
  if (baseFilter != null) {
402
451
  const finalFilter = ["all", baseFilter, publicPlaceFilter];
@@ -405,20 +454,29 @@ export class InternalMapVXMap extends Loggeable {
405
454
  else {
406
455
  this.map.setFilter(layer.id, publicPlaceFilter);
407
456
  }
408
- switch (layer.type) {
409
- case "line":
410
- case "symbol":
411
- case "fill-extrusion":
412
- case "fill":
413
- if (layer.filter != null) {
414
- this.baseFilters[layer.id] = layer.filter;
415
- }
416
- break;
417
- default:
418
- break;
419
- }
420
457
  });
421
458
  }
459
+ /**
460
+ * Records a layer's declared (base) filter the first time the layer is seen, from an
461
+ * explicit value (a style-spec `filter`) when one is available, otherwise by reading
462
+ * the live filter — which is only pristine for layers this class has never written to.
463
+ *
464
+ * Marking happens even when no filter exists, so the layer is never revisited: on a
465
+ * second visit its live filter is one we produced, and storing that as the "declared"
466
+ * filter is what freezes a layer to a stale floor.
467
+ *
468
+ * @param layerId Id of the layer whose base filter should be resolved.
469
+ * @param declared Declared filter straight from the style spec, when the caller has it.
470
+ */
471
+ captureDeclaredBaseFilter(layerId, declared) {
472
+ if (this.capturedBaseFilters.has(layerId))
473
+ return;
474
+ this.capturedBaseFilters.add(layerId);
475
+ const baseFilter = declared !== null && declared !== void 0 ? declared : this.safeGetFilter(layerId);
476
+ if (baseFilter != null) {
477
+ this.baseFilters[layerId] = baseFilter;
478
+ }
479
+ }
422
480
  setLayersForLanguage(lang) {
423
481
  var _a, _b;
424
482
  // outdoor layers
@@ -500,6 +558,14 @@ export class InternalMapVXMap extends Loggeable {
500
558
  var _a;
501
559
  this.circles = [];
502
560
  this.markerLayoutController.destroy();
561
+ // Drop any in-flight `setStyle` bookkeeping while the map still exists: the
562
+ // listener has to be detached before `map.remove()`, and the safety timer must not
563
+ // outlive the map and call `onStyleReady` for a map that is already gone.
564
+ this.clearPendingStyleSwapListener();
565
+ if (this.styleReadyTimer) {
566
+ clearTimeout(this.styleReadyTimer);
567
+ this.styleReadyTimer = undefined;
568
+ }
503
569
  // Dispose the 3D scene's GPU resources before the map (and its shared WebGL context) go away —
504
570
  // MapLibre's own teardown never calls the custom GLB layer's `onRemove`.
505
571
  (_a = this.scene3D) === null || _a === void 0 ? void 0 : _a.dispose();
@@ -574,6 +640,13 @@ export class InternalMapVXMap extends Loggeable {
574
640
  const useServiceWorkerCaching = this.tileCacheConfig.enabled && this.tileCacheConfig.persistToServiceWorker;
575
641
  const finalStyle = useServiceWorkerCaching ? this.transformStyleForCaching(style) : style;
576
642
  this.map.setStyle(finalStyle);
643
+ // The new place's style declares its own filters for the same `indoor-*` layer
644
+ // ids. Without this reset every id is still marked captured from the previous
645
+ // place, so `captureDeclaredBaseFilter` early-returns for all of them and the
646
+ // previous place's expressions keep filtering the new style forever. (The
647
+ // `switch (layer.type)` block that used to re-read them on every style load was
648
+ // removed when capture became once-per-id, which is what made this necessary.)
649
+ this.resetBaseFilters();
577
650
  this.whenStyleUpdates(finalStyle);
578
651
  onStyleReady === null || onStyleReady === void 0 ? void 0 : onStyleReady();
579
652
  })
@@ -613,6 +686,7 @@ export class InternalMapVXMap extends Loggeable {
613
686
  }
614
687
  setLang(lang) {
615
688
  this.repository.setLang(lang);
689
+ this.currentLang = lang;
616
690
  if (rtlLanguages.includes(lang)) {
617
691
  this.setRTLSupport();
618
692
  }
@@ -630,6 +704,201 @@ export class InternalMapVXMap extends Loggeable {
630
704
  setParentPlace(place, updateStyle, onStyleReady) {
631
705
  this.changeParentPlaceTo(place, updateStyle, onStyleReady);
632
706
  }
707
+ setStyle(styleRef, styleTheme, onStyleReady) {
708
+ // Every swap gets a generation. All shared bookkeeping below is touched only while
709
+ // this swap is still the current one, so two overlapping calls cannot release each
710
+ // other's overlay or apply an obsolete style. `readied` stays per-call because each
711
+ // caller's callback must still fire exactly once.
712
+ const generation = ++this.styleSwapGeneration;
713
+ const isCurrent = () => this.styleSwapGeneration === generation;
714
+ let readied = false;
715
+ const reportReady = (error) => {
716
+ if (readied)
717
+ return;
718
+ readied = true;
719
+ // Only the current swap owns the shared timer; a superseded swap clearing it here
720
+ // would leave the live swap with no safety net (and its caller hidden forever).
721
+ if (isCurrent() && this.styleReadyTimer) {
722
+ clearTimeout(this.styleReadyTimer);
723
+ this.styleReadyTimer = undefined;
724
+ }
725
+ onStyleReady === null || onStyleReady === void 0 ? void 0 : onStyleReady(error);
726
+ };
727
+ // `onStyleReady` must fire on EVERY path — success, a rejected style request, or a
728
+ // swap that never settles — because callers keep the map hidden behind a loading
729
+ // overlay until it arrives. The safety timer is therefore armed before the request
730
+ // instead of inside `.then()`.
731
+ if (this.styleReadyTimer)
732
+ clearTimeout(this.styleReadyTimer);
733
+ this.styleReadyTimer = setTimeout(reportReady, STYLE_READY_TIMEOUT_MS);
734
+ // A previous swap may still hold a `styledata` listener whose style never reached the
735
+ // loaded state. Drop it before starting a new one: it captured an obsolete style and
736
+ // would otherwise fire on this swap, wiping `baseFilters` and calling `setFilter`
737
+ // with layer ids the new style does not have.
738
+ this.clearPendingStyleSwapListener();
739
+ // `this.map` is created asynchronously (see `onMapStyleLoaded`), so a `setStyle` call
740
+ // made right after `createMap` can land before it exists. Fail loudly instead of
741
+ // letting `this.map.setStyle` throw into `.catch`, which reported READY and left the
742
+ // caller revealing the previous style as if the swap had succeeded.
743
+ if (!this.map) {
744
+ const error = new Error("setStyle called before the map finished initializing; wait for the map's load event");
745
+ this.logError(error, { functionName: "setStyle" });
746
+ reportReady(error);
747
+ return;
748
+ }
749
+ // When a user bearer token is present, the style is resolved through the
750
+ // bearer-authenticated endpoint, which authorizes by the user's client access
751
+ // rather than the api key's scopes — so a client's branded style can be
752
+ // previewed/applied without granting that place to a shared api key.
753
+ // Api-key consumers keep the `/api/sdk/v2/mapStyleJson` path.
754
+ const stylePromise = this.authToken
755
+ ? this.repository.fetchAndParseMapStyleByBearer(this.parentPlaceId, this.authToken, styleRef, styleTheme)
756
+ : this.repository.fetchAndParseMapStyle(this.parentPlaceId, styleRef, styleTheme);
757
+ stylePromise
758
+ .then((style) => {
759
+ // A newer swap started while this style was in flight. Applying this one now
760
+ // would fight the newer style layer by layer, so drop it and let the newer swap
761
+ // own the map and its caller's callback.
762
+ if (!isCurrent())
763
+ return;
764
+ const useServiceWorkerCaching = this.tileCacheConfig.enabled && this.tileCacheConfig.persistToServiceWorker;
765
+ const finalStyle = useServiceWorkerCaching ? this.transformStyleForCaching(style) : style;
766
+ // Full (non-diff) swap so the previewed style completely replaces the base —
767
+ // a diff would keep old base layers and reappear on the next floor change.
768
+ this.map.setStyle(finalStyle, { diff: false });
769
+ // Drop the previous style's base filters SYNCHRONOUSLY, right where the layers
770
+ // they describe stop existing. They are keyed by layer id and persist across
771
+ // swaps, so any code reading them before the apply pass below (the `idle`
772
+ // handler's `filterByFloorKey`, a floor change racing the swap) would AND the
773
+ // previous style's discriminator into the new style's layers — the stale-filter
774
+ // washout this reset exists to prevent. Gating it on `isStyleLoaded` left exactly
775
+ // that window open. Fresh declared filters are re-captured lazily in
776
+ // `applyLayerFilters` as each indoor layer is first touched.
777
+ this.resetBaseFilters();
778
+ // Re-apply floor filters, route/circle layers and markers ONLY once the new
779
+ // style's sources/layers have finished loading; running them mid-reload throws
780
+ // (`getSource` on an undefined style) and the floor filter would not stick.
781
+ const applyOnceLoaded = () => {
782
+ // Superseded: detach and leave the map to the newer swap. Without this the
783
+ // listener stays attached (the slot it was tracked in has been overwritten)
784
+ // and re-seeds filters from layer ids the current style does not have, which
785
+ // makes maplibre throw and aborts the newer swap's own listener.
786
+ if (!isCurrent()) {
787
+ this.map.off("styledata", applyOnceLoaded);
788
+ return;
789
+ }
790
+ if (!this.hasLoadedStyle())
791
+ return;
792
+ this.map.off("styledata", applyOnceLoaded);
793
+ if (this.pendingStyleSwapListener === applyOnceLoaded) {
794
+ this.pendingStyleSwapListener = undefined;
795
+ }
796
+ this.whenStyleUpdates(finalStyle);
797
+ // A non-diff swap replaces every layer, so anything applied ON TOP of the
798
+ // style — label language, caller-requested place highlights, the 3D scene's
799
+ // GLB layer and hidden-layer overrides — is gone and has to be replayed.
800
+ // `whenStyleUpdates` covers only floor filters, routes and circles.
801
+ this.restoreStyleOverlays();
802
+ this.logEvent("setStyle");
803
+ };
804
+ this.pendingStyleSwapListener = applyOnceLoaded;
805
+ this.map.on("styledata", applyOnceLoaded);
806
+ applyOnceLoaded();
807
+ // Re-apply the floor filter and report ready once the map is idle. This is
808
+ // intentionally OUTSIDE applyOnceLoaded's `isStyleLoaded` gate: the indoor
809
+ // polygon layers settle a beat after the swap (so the floor filter in
810
+ // whenStyleUpdates misses them and the venue paints every floor at once), and
811
+ // `idle` only fires once the style is loaded and its first tiles rendered — so
812
+ // filterByFloorKey is safe here and does not depend on the sometimes-flaky
813
+ // isStyleLoaded check. onStyleReady lets callers reveal the map only once it is
814
+ // settled; the timer armed above just clears a hanging overlay if idle never fires.
815
+ this.map.once("idle", () => {
816
+ // `once` cannot be cancelled, so a superseded swap's handler still fires —
817
+ // filtering here keeps it from re-filtering the newer style's floors and from
818
+ // reporting the newer swap's caller ready ahead of time.
819
+ if (!isCurrent())
820
+ return;
821
+ this.filterByFloorKey(this.currentFloor);
822
+ reportReady();
823
+ });
824
+ })
825
+ .catch((error) => {
826
+ // A superseded swap must not tear down the listener the CURRENT swap installed.
827
+ if (isCurrent()) {
828
+ this.clearPendingStyleSwapListener();
829
+ }
830
+ this.logError(error, { functionName: "setStyle" });
831
+ // Release the caller immediately instead of making it wait out the safety timer:
832
+ // the style will never arrive, so the overlay has nothing to wait for. The error
833
+ // is handed to the callback so a caller can tell a failed swap from an applied
834
+ // one — the previous style is still on screen either way.
835
+ reportReady(error instanceof Error ? error : new Error(String(error)));
836
+ });
837
+ }
838
+ /**
839
+ * Forgets every captured base filter so the next style's declared filters are read
840
+ * fresh from that style instead of inherited from the previous one.
841
+ *
842
+ * Both halves must move together: `baseFilters` holds the expressions and
843
+ * `capturedBaseFilters` records which layer ids have already been resolved, so
844
+ * clearing only the first would leave every id marked "already captured" and the new
845
+ * style's filters would never be read at all.
846
+ */
847
+ resetBaseFilters() {
848
+ this.baseFilters = {};
849
+ this.capturedBaseFilters.clear();
850
+ }
851
+ /**
852
+ * Replays everything that lives ON TOP of the map style after a full (non-diff) swap
853
+ * has deleted it: label language expressions, caller-requested place highlights and
854
+ * the 3D scene's GLB layer / hidden-layer overrides.
855
+ *
856
+ * These are deliberately not part of `whenStyleUpdates`, which restores only
857
+ * style-derived state. These are decisions the SDK's callers made through `setLang`,
858
+ * `setPlacesAsSelected`, `addBorderToPlaces` and the 3D config, and nothing else would
859
+ * ever restore them.
860
+ */
861
+ restoreStyleOverlays() {
862
+ // Each step is isolated: the style itself has already been applied by the time we get
863
+ // here, so one failing restore must neither skip the others nor bubble out into
864
+ // `setStyle`'s `.catch`, which would report the successful swap to `onStyleReady` as
865
+ // a failure. `setPlacesAsSelected` / `addBorderToPlaces` throw by contract.
866
+ const restore = (what, step) => {
867
+ try {
868
+ step();
869
+ }
870
+ catch (error) {
871
+ this.logError(error, { functionName: "restoreStyleOverlays", step: what });
872
+ }
873
+ };
874
+ restore("language", () => this.setLayersForLanguage(this.currentLang));
875
+ if (this.coloredPlacesRequest) {
876
+ const { placeIds, color } = this.coloredPlacesRequest;
877
+ restore("coloredPlaces", () => this.setPlacesAsSelected(placeIds, color));
878
+ }
879
+ if (this.borderedPlacesRequest) {
880
+ const { placeIds, color, width } = this.borderedPlacesRequest;
881
+ restore("borderedPlaces", () => this.addBorderToPlaces(placeIds, color, width));
882
+ }
883
+ // The swap removed the GLB custom layer, and MapLibre called its `onRemove`, which
884
+ // disposes the whole three.js scene. Nothing else re-adds it, so without this every
885
+ // 3D model disappears permanently and `hideLayers` stops being applied.
886
+ restore("scene3D", () => { var _a; return (_a = this.scene3D) === null || _a === void 0 ? void 0 : _a.reattachToStyle(); });
887
+ }
888
+ /**
889
+ * Removes the `styledata` listener left by an in-flight {@link setStyle} swap, if any.
890
+ * Called before starting a new swap and when one fails, so a listener holding an
891
+ * obsolete style can never fire against a different style's layers.
892
+ */
893
+ clearPendingStyleSwapListener() {
894
+ var _a;
895
+ if (!this.pendingStyleSwapListener)
896
+ return;
897
+ // `this.map?` — a listener can only have been installed while the map existed, but
898
+ // this also runs from `destroyMap` and from `setStyle` before the map is created.
899
+ (_a = this.map) === null || _a === void 0 ? void 0 : _a.off("styledata", this.pendingStyleSwapListener);
900
+ this.pendingStyleSwapListener = undefined;
901
+ }
633
902
  /**
634
903
  * Removes current parent place and updates style to default
635
904
  */
@@ -1146,6 +1415,15 @@ export class InternalMapVXMap extends Loggeable {
1146
1415
  });
1147
1416
  const baseFloorId = (_c = this.innerFloors.find((floor) => floor.key === floorId)) === null || _c === void 0 ? void 0 : _c.baseFloor;
1148
1417
  const indoorLayers = layers.filter((layer) => layer.id.startsWith("indoor-"));
1418
+ // Resolve each indoor layer's declared (base) filter before we start folding floor
1419
+ // filters into it — and before cloning it into a `base-indoor-*` layer, which copies
1420
+ // the base filter across. Layers added asynchronously after a `setStyle` swap are
1421
+ // missing when `setBaseFilters` runs, so this is where their still-pristine filter
1422
+ // is captured; without it the layer loses its own discrimination and repaints every
1423
+ // feature (the washed-out / blue-outline look after changing floors). Layers already
1424
+ // resolved are skipped, so a filter this class wrote is never mistaken for a
1425
+ // declared one.
1426
+ indoorLayers.forEach((layer) => this.captureDeclaredBaseFilter(layer.id));
1149
1427
  if (baseFloorId != undefined) {
1150
1428
  const firstLayerId = (_d = indoorLayers[0]) === null || _d === void 0 ? void 0 : _d.id;
1151
1429
  indoorLayers
@@ -1162,8 +1440,33 @@ export class InternalMapVXMap extends Loggeable {
1162
1440
  this.applyLayerFilters(layer, floorId);
1163
1441
  });
1164
1442
  }
1443
+ /**
1444
+ * Reads a layer's current filter, tolerating the layer having been removed or the
1445
+ * style being mid-swap (maplibre `getFilter` throws in those cases). Returns
1446
+ * `undefined` instead of throwing so base-filter capture never aborts floor filtering.
1447
+ */
1448
+ safeGetFilter(layerId) {
1449
+ try {
1450
+ return this.map.getFilter(layerId);
1451
+ }
1452
+ catch (_a) {
1453
+ return undefined;
1454
+ }
1455
+ }
1165
1456
  applyLayerFilters(layer, floorId) {
1166
1457
  var _a;
1458
+ // Resolve the layer's declared (base) filter the first time we touch it. Indoor
1459
+ // layers such as `indoor-polygon-room` share one source and are told apart only by
1460
+ // their own filter (e.g. `["in","room",["get","tags"]]`); we must fold that into the
1461
+ // floor filter or the layer repaints every feature. Some of these layers are added
1462
+ // asynchronously after a `setStyle` swap, so they are absent when `setBaseFilters`
1463
+ // runs — capturing here, while their live filter is still pristine, is what keeps a
1464
+ // floor change from wiping the style (the washed-out, blue-outline look).
1465
+ // `base-indoor-*` clones inherit their base filter from the layer they were cloned
1466
+ // from, so they are never read back.
1467
+ if (!layer.id.startsWith("base-indoor-")) {
1468
+ this.captureDeclaredBaseFilter(layer.id);
1469
+ }
1167
1470
  let baseFilter = this.baseFilters[layer.id];
1168
1471
  const floorIdString = floorId !== null && floorId !== void 0 ? floorId : "";
1169
1472
  const floorFilter = [
@@ -1196,6 +1499,10 @@ export class InternalMapVXMap extends Loggeable {
1196
1499
  (layer.type == "fill" || layer.type === "fill-extrusion")) {
1197
1500
  customFilters.push(["!", ["in", this.hoveredId, ["get", "ref"]]]);
1198
1501
  }
1502
+ // From here on this layer's live filter is ours, never the style's declared one.
1503
+ // Marking it keeps any later pass from reading it back as a base filter — that is
1504
+ // what used to AND the previous floor into the next one and blank the layer out.
1505
+ this.capturedBaseFilters.add(layer.id);
1199
1506
  if (baseFilter != null) {
1200
1507
  const finalFilter = ["all", baseFilter, customFilters];
1201
1508
  this.map.setFilter(layer.id, finalFilter);
@@ -1622,6 +1929,7 @@ export class InternalMapVXMap extends Loggeable {
1622
1929
  }
1623
1930
  setPlacesAsSelected(placeIds, color) {
1624
1931
  try {
1932
+ this.coloredPlacesRequest = { placeIds: [...placeIds], color };
1625
1933
  if (!this.hasLayer("indoor-colored-places")) {
1626
1934
  this.createColoredPlacesLayer();
1627
1935
  }
@@ -1633,6 +1941,9 @@ export class InternalMapVXMap extends Loggeable {
1633
1941
  const placeFilter = ["any", ...coloredPlacesFilters];
1634
1942
  const condition = ["all", placeFilter, basicFilter];
1635
1943
  this.map.setFilter("indoor-colored-places", condition);
1944
+ // Caller-defined base filter: mark it resolved so no later capture can
1945
+ // overwrite this customization with the layer's style-declared filter.
1946
+ this.capturedBaseFilters.add("indoor-colored-places");
1636
1947
  this.baseFilters["indoor-colored-places"] = placeFilter;
1637
1948
  if (color !== undefined) {
1638
1949
  const layer = this.map.getLayer("indoor-colored-places");
@@ -1716,8 +2027,12 @@ export class InternalMapVXMap extends Loggeable {
1716
2027
  return layer !== undefined;
1717
2028
  }
1718
2029
  clearColoredPlaces() {
2030
+ this.coloredPlacesRequest = undefined;
1719
2031
  if (this.hasLayer("indoor-colored-places")) {
1720
2032
  const condition = ["==", ["get", "ref"], "unselected"];
2033
+ // Caller-defined base filter: mark it resolved so no later capture can
2034
+ // overwrite this customization with the layer's style-declared filter.
2035
+ this.capturedBaseFilters.add("indoor-colored-places");
1721
2036
  this.baseFilters["indoor-colored-places"] = condition;
1722
2037
  this.map.setFilter("indoor-colored-places", condition);
1723
2038
  }
@@ -1912,6 +2227,7 @@ export class InternalMapVXMap extends Loggeable {
1912
2227
  }
1913
2228
  addBorderToPlaces(placeIds, color, width) {
1914
2229
  try {
2230
+ this.borderedPlacesRequest = { placeIds: [...placeIds], color, width };
1915
2231
  if (!this.hasLayer("indoor-bordered-places")) {
1916
2232
  this.createBorderedPlacesLayer();
1917
2233
  }
@@ -1923,6 +2239,9 @@ export class InternalMapVXMap extends Loggeable {
1923
2239
  const placeFilter = ["any", ...coloredPlacesFilters];
1924
2240
  const condition = ["all", placeFilter, basicFilter];
1925
2241
  this.map.setFilter("indoor-bordered-places", condition);
2242
+ // Caller-defined base filter: mark it resolved so no later capture can
2243
+ // overwrite this customization with the layer's style-declared filter.
2244
+ this.capturedBaseFilters.add("indoor-bordered-places");
1926
2245
  this.baseFilters["indoor-bordered-places"] = placeFilter;
1927
2246
  if (color !== undefined && width !== undefined) {
1928
2247
  this.map.setPaintProperty("indoor-bordered-places", "line-color", color);
@@ -1948,8 +2267,12 @@ export class InternalMapVXMap extends Loggeable {
1948
2267
  this.map.addLayer(layer);
1949
2268
  }
1950
2269
  clearBorderedPlaces() {
2270
+ this.borderedPlacesRequest = undefined;
1951
2271
  if (this.hasLayer("indoor-bordered-places")) {
1952
2272
  const condition = ["==", ["get", "ref"], "unselected"];
2273
+ // Caller-defined base filter: mark it resolved so no later capture can
2274
+ // overwrite this customization with the layer's style-declared filter.
2275
+ this.capturedBaseFilters.add("indoor-bordered-places");
1953
2276
  this.baseFilters["indoor-bordered-places"] = condition;
1954
2277
  this.map.setFilter("indoor-bordered-places", condition);
1955
2278
  }
@@ -1994,6 +2317,38 @@ export class InternalMapVXMap extends Loggeable {
1994
2317
  this.failedTiles.delete(e.tile);
1995
2318
  }
1996
2319
  }
2320
+ /**
2321
+ * True when a style object is attached to the map. During a full `setStyle` swap the
2322
+ * map is momentarily style-less and any `getSource`/`getFilter` call throws
2323
+ * ("Cannot read properties of undefined"), so call sites that only need the style to
2324
+ * exist should gate on this rather than on `isStyleLoaded()`.
2325
+ */
2326
+ hasStyle() {
2327
+ var _a;
2328
+ // `map.getStyle()` is the PUBLIC probe: it returns the serialized style, or throws
2329
+ // while the map is momentarily style-less mid-swap. Reading maplibre's internal
2330
+ // `style` field instead (it is absent from the public typings, so it needed a double
2331
+ // cast) would fail closed and silently forever the moment a maplibre upgrade renames
2332
+ // or privatizes it — disabling both the tile-retry loop and `setStyle`'s apply pass
2333
+ // with no throw and no log. `safeGetFilter` already uses this try/catch shape.
2334
+ try {
2335
+ return ((_a = this.map) === null || _a === void 0 ? void 0 : _a.getStyle()) != null;
2336
+ }
2337
+ catch (_b) {
2338
+ return false;
2339
+ }
2340
+ }
2341
+ /**
2342
+ * True when the map has a style object attached and fully loaded. Unlike
2343
+ * `map.isStyleLoaded()`, this does not emit maplibre's "There is no style
2344
+ * added to the map" warning when the style is momentarily absent (e.g. during
2345
+ * a `setStyle` swap), because it checks for the style object first.
2346
+ */
2347
+ hasLoadedStyle() {
2348
+ if (!this.hasStyle())
2349
+ return false;
2350
+ return this.map.isStyleLoaded() === true;
2351
+ }
1997
2352
  subscribeToFailedTiles() {
1998
2353
  if (this.retryTimer)
1999
2354
  return;
@@ -2003,6 +2358,17 @@ export class InternalMapVXMap extends Loggeable {
2003
2358
  this.boundOnWebGLContextLost = this.onWebGLContextLost.bind(this);
2004
2359
  this.boundOnWebGLContextRestored = this.onWebGLContextRestored.bind(this);
2005
2360
  this.retryTimer = setInterval(() => {
2361
+ // During a full style swap (`setStyle` with `diff: false`) the map can
2362
+ // momentarily have no style object attached. Calling `getSource` then
2363
+ // throws ("Cannot read properties of undefined (reading 'getSource')").
2364
+ // Only the style's PRESENCE is checked here, never `isStyleLoaded()`:
2365
+ // that is false while any source still has tiles in flight, which is
2366
+ // exactly when tiles fail — gating on it would skip every tick during a
2367
+ // bad-network burst. The queue is left untouched so the tiles queued
2368
+ // mid-swap are still retried on the next tick.
2369
+ if (!this.hasStyle()) {
2370
+ return;
2371
+ }
2006
2372
  if (!this.map.getSource("indoorequal")) {
2007
2373
  this.failedTiles.clear();
2008
2374
  return;