@seatlayer/js 0.30.0 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.cts CHANGED
@@ -667,6 +667,16 @@ interface SeatPickerOptions {
667
667
  * dynamically imported on first use). Set false for embed hosts that must
668
668
  * stay strictly 2D. */
669
669
  enable3D?: boolean;
670
+ /**
671
+ * Seat-count ceiling above which 3D is not offered. Default: the measured-good
672
+ * ceiling of 15,000 seats (14,142 renders at 58 fps), halved to 7,500 on a
673
+ * device that reports itself as small/low-core.
674
+ *
675
+ * The 3D scene holds EVERY seat resident — there is no streaming rung yet — so
676
+ * a 53,000-seat chart builds its scene in ~780 ms on a desktop and much worse
677
+ * on a phone. Offering 3D there is offering a stall. A host that knows its
678
+ * audience can raise this; the ceiling goes away when seat streaming lands. */
679
+ max3DSeats?: number;
670
680
  /**
671
681
  * Optional analytics sink for the widget's own journey events. Currently emits
672
682
  * the 3D venue-view journey (`3d_opened`, `3d_orbit_engaged`, `3d_seat_picked`,
@@ -821,6 +831,8 @@ declare class SeatPicker {
821
831
  private miniTf;
822
832
  private priceBandKeys;
823
833
  private focusedCatKey;
834
+ /** "Hide limited-view seats" — mirrored into 3D by `seatState3dFor`. */
835
+ private limitedViewFilter;
824
836
  private pricesExpanded;
825
837
  /** Last surfaced section summary (re-rendered when the price band changes). */
826
838
  private lastSection;
@@ -1046,9 +1058,15 @@ declare class SeatPicker {
1046
1058
  /** Reflect the engine's current LOD rung onto the pill group. */
1047
1059
  private syncRung;
1048
1060
  private syncProjection;
1049
- /** Can this picker offer the 3D venue view? Requires the option (default on)
1050
- * and WebGL2, and a chart to render. */
1061
+ /** Can this picker offer the 3D venue view? Requires the option (default on),
1062
+ * WebGL2, a chart to render, and a chart small enough to render WELL. */
1051
1063
  private canOffer3d;
1064
+ /**
1065
+ * The seat ceiling for offering 3D. See `max3DSeats` — an explicit host value
1066
+ * always wins; otherwise a small/low-core device gets half the desktop budget,
1067
+ * because it is the device that turns "slow" into "stalled".
1068
+ */
1069
+ private max3dSeats;
1052
1070
  /** Reflect the active floor onto the switcher rail. */
1053
1071
  private syncFloors;
1054
1072
  /** Show (or clear, on null) the tapped-section summary card. */
@@ -1220,6 +1238,21 @@ declare class SeatPicker {
1220
1238
  /** A 3D seat tap runs the SAME selection path as a 2D tap: toggle through the
1221
1239
  * controller, then raise the shared confirm card (bottom-sheeted in 3D). */
1222
1240
  private onView3dSeatPick;
1241
+ /**
1242
+ * The venue-navigation rail inside 3D: levels and areas.
1243
+ *
1244
+ * In 2D a buyer moves through the venue by floor switcher and by the LOD
1245
+ * rungs. Both are hidden while immersed, and the 3D module's own chips only
1246
+ * cover "go home" and "see the 360" — so on a multi-floor or multi-zone chart
1247
+ * the buyer entered 3D and LOST the ability to reach the level or area they
1248
+ * were booking. The camera moves for both already exist on the handle
1249
+ * (`focusFloor` / `focusZone`); this is the surface that offers them.
1250
+ *
1251
+ * Levels are a TOGGLE (a floor stays isolated until you pick another), areas
1252
+ * are an ACTION (the camera flies there and you are then free to orbit), which
1253
+ * is why only the level pills carry a pressed state.
1254
+ */
1255
+ private buildView3dNav;
1223
1256
  private enter3d;
1224
1257
  private exit3d;
1225
1258
  /** Current active/restored hold reflected in the tray. */
package/dist/index.d.ts CHANGED
@@ -667,6 +667,16 @@ interface SeatPickerOptions {
667
667
  * dynamically imported on first use). Set false for embed hosts that must
668
668
  * stay strictly 2D. */
669
669
  enable3D?: boolean;
670
+ /**
671
+ * Seat-count ceiling above which 3D is not offered. Default: the measured-good
672
+ * ceiling of 15,000 seats (14,142 renders at 58 fps), halved to 7,500 on a
673
+ * device that reports itself as small/low-core.
674
+ *
675
+ * The 3D scene holds EVERY seat resident — there is no streaming rung yet — so
676
+ * a 53,000-seat chart builds its scene in ~780 ms on a desktop and much worse
677
+ * on a phone. Offering 3D there is offering a stall. A host that knows its
678
+ * audience can raise this; the ceiling goes away when seat streaming lands. */
679
+ max3DSeats?: number;
670
680
  /**
671
681
  * Optional analytics sink for the widget's own journey events. Currently emits
672
682
  * the 3D venue-view journey (`3d_opened`, `3d_orbit_engaged`, `3d_seat_picked`,
@@ -821,6 +831,8 @@ declare class SeatPicker {
821
831
  private miniTf;
822
832
  private priceBandKeys;
823
833
  private focusedCatKey;
834
+ /** "Hide limited-view seats" — mirrored into 3D by `seatState3dFor`. */
835
+ private limitedViewFilter;
824
836
  private pricesExpanded;
825
837
  /** Last surfaced section summary (re-rendered when the price band changes). */
826
838
  private lastSection;
@@ -1046,9 +1058,15 @@ declare class SeatPicker {
1046
1058
  /** Reflect the engine's current LOD rung onto the pill group. */
1047
1059
  private syncRung;
1048
1060
  private syncProjection;
1049
- /** Can this picker offer the 3D venue view? Requires the option (default on)
1050
- * and WebGL2, and a chart to render. */
1061
+ /** Can this picker offer the 3D venue view? Requires the option (default on),
1062
+ * WebGL2, a chart to render, and a chart small enough to render WELL. */
1051
1063
  private canOffer3d;
1064
+ /**
1065
+ * The seat ceiling for offering 3D. See `max3DSeats` — an explicit host value
1066
+ * always wins; otherwise a small/low-core device gets half the desktop budget,
1067
+ * because it is the device that turns "slow" into "stalled".
1068
+ */
1069
+ private max3dSeats;
1052
1070
  /** Reflect the active floor onto the switcher rail. */
1053
1071
  private syncFloors;
1054
1072
  /** Show (or clear, on null) the tapped-section summary card. */
@@ -1220,6 +1238,21 @@ declare class SeatPicker {
1220
1238
  /** A 3D seat tap runs the SAME selection path as a 2D tap: toggle through the
1221
1239
  * controller, then raise the shared confirm card (bottom-sheeted in 3D). */
1222
1240
  private onView3dSeatPick;
1241
+ /**
1242
+ * The venue-navigation rail inside 3D: levels and areas.
1243
+ *
1244
+ * In 2D a buyer moves through the venue by floor switcher and by the LOD
1245
+ * rungs. Both are hidden while immersed, and the 3D module's own chips only
1246
+ * cover "go home" and "see the 360" — so on a multi-floor or multi-zone chart
1247
+ * the buyer entered 3D and LOST the ability to reach the level or area they
1248
+ * were booking. The camera moves for both already exist on the handle
1249
+ * (`focusFloor` / `focusZone`); this is the surface that offers them.
1250
+ *
1251
+ * Levels are a TOGGLE (a floor stays isolated until you pick another), areas
1252
+ * are an ACTION (the camera flies there and you are then free to orbit), which
1253
+ * is why only the level pills carry a pressed state.
1254
+ */
1255
+ private buildView3dNav;
1223
1256
  private enter3d;
1224
1257
  private exit3d;
1225
1258
  /** Current active/restored hold reflected in the tray. */
package/dist/index.js CHANGED
@@ -93,6 +93,9 @@ var PubApi = class {
93
93
  }
94
94
  };
95
95
 
96
+ // src/seatLayerBrand.ts
97
+ var SEATLAYER_ATTRIBUTION_MARK_SVG = '<svg viewBox="0 0 64 56" width="12" height="11" fill="none" aria-hidden="true" focusable="false" style="display:block"><path d="M4 13 Q16 6 29 7 L28.5 17 Q17 16.5 7 22 Z" fill="#f4b740"/><path d="M4 13 Q16 6 29 7 L28.5 17 Q17 16.5 7 22 Z" fill="#f4b740" transform="translate(64 0) scale(-1 1)"/><path d="M8 28 Q18 22 28.6 23 L28.2 32 Q18 28.5 10 36 Z" fill="#fcf7ee"/><path d="M8 28 Q18 22 28.6 23 L28.2 32 Q18 28.5 10 36 Z" fill="#fcf7ee" transform="translate(64 0) scale(-1 1)"/><path d="M11 41 Q19 35 28.2 36 L27.8 45 Q19.5 42 13.5 49 Z" fill="#fcf7ee"/><path d="M11 41 Q19 35 28.2 36 L27.8 45 Q19.5 42 13.5 49 Z" fill="#fcf7ee" transform="translate(64 0) scale(-1 1)"/></svg>';
98
+
96
99
  // src/SeatingChart.ts
97
100
  var DEFAULT_API_BASE = "https://api.seatlayer.io";
98
101
  var DEFAULT_MAX_SELECTION = 10;
@@ -208,7 +211,7 @@ var SeatingChart = class {
208
211
  badge.rel = "noopener noreferrer";
209
212
  badge.setAttribute("aria-label", t("picker.poweredBy"));
210
213
  badge.style.cssText = 'position:absolute;bottom:10px;right:12px;z-index:5;display:inline-flex;align-items:center;gap:6px;padding:5px 9px;border-radius:999px;background:rgba(255,255,255,.92);color:#4a5163;text-decoration:none;font:600 11px/1 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;letter-spacing:.02em;box-shadow:0 2px 8px rgba(0,0,0,.12);';
211
- badge.innerHTML = `<span aria-hidden="true" style="width:16px;height:16px;border-radius:4px;flex:none;display:flex;align-items:center;justify-content:center;background:#f4b740;color:#1a1200"><svg viewBox="0 0 24 24" style="width:11px;height:11px;fill:currentColor"><path d="M4 15c0-1.1.9-2 2-2h12a2 2 0 0 1 2 2v3h-3v-2H7v2H4v-3Z"/><rect x="7" y="7" width="10" height="5" rx="1.6"/></svg></span><span>${t("picker.poweredBy")}</span>`;
214
+ badge.innerHTML = '<span aria-hidden="true" style="width:16px;height:16px;border-radius:4px;flex:none;display:flex;align-items:center;justify-content:center;background:#0c1220;color:#fcf7ee">' + SEATLAYER_ATTRIBUTION_MARK_SVG + `</span><span>${t("picker.poweredBy")}</span>`;
212
215
  host.appendChild(badge);
213
216
  }
214
217
  placeTooltip() {
@@ -1078,6 +1081,8 @@ import {
1078
1081
  var DEFAULT_API_BASE2 = "https://api.seatlayer.io";
1079
1082
  var DEFAULT_MAX_SELECTION2 = 10;
1080
1083
  var EXTEND_PROMPT_MS = 6e4;
1084
+ var MAX_3D_SEATS_DEFAULT = 15e3;
1085
+ var MAX_3D_SECTION_PILLS = 12;
1081
1086
  function pointInPolygon(x, y, poly) {
1082
1087
  let inside = false;
1083
1088
  for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
@@ -1497,14 +1502,14 @@ var CSS = `
1497
1502
  .sl-closed-pill.on{display:inline-flex}
1498
1503
  .sl-closed-pill svg{width:13px;height:13px;stroke:currentColor;stroke-width:2;fill:none;stroke-linecap:round;stroke-linejoin:round}
1499
1504
 
1500
- /* "Powered by SeatLayer" attribution badge (side-panel foot) \u2014 the small gold
1501
- rounded logo mark + wordmark. Hidden when the host opts out or the org's paid
1505
+ /* "Powered by SeatLayer" attribution badge (side-panel foot) \u2014 the canonical
1506
+ Layered Rows mark + wordmark. Hidden when the host opts out or the org's paid
1502
1507
  theme sets hideBadge. */
1503
1508
  .sl-powered{display:flex;align-items:center;justify-content:center;gap:6px;margin-top:10px;
1504
1509
  font-size:11px;letter-spacing:.03em;color:var(--sl-muted)}
1505
1510
  .sl-powered-mark{width:16px;height:16px;border-radius:4px;flex:none;display:flex;align-items:center;justify-content:center;
1506
- background:var(--sl-accent);color:var(--sl-accent-ink)}
1507
- .sl-powered-mark svg{width:11px;height:11px;fill:currentColor}
1511
+ background:#0c1220;color:#fcf7ee}
1512
+ .sl-powered-mark svg{width:12px;height:11px}
1508
1513
 
1509
1514
  /* a11y filter chips (flow within the top-left region) */
1510
1515
  .sl-chips{display:flex;gap:6px;flex-wrap:wrap}
@@ -1686,6 +1691,20 @@ var CSS = `
1686
1691
  color:#e6edf3;background:rgba(10,14,20,.62);border:1px solid rgba(255,255,255,.22);backdrop-filter:blur(6px)}
1687
1692
  .sl-view3d-back:hover,.sl-view3d-back:focus-visible{background:rgba(16,22,30,.82);border-color:rgba(255,255,255,.4)}
1688
1693
  .sl-view3d-back svg{width:15px;height:15px;stroke:currentColor;stroke-width:2.4;fill:none;stroke-linecap:round;stroke-linejoin:round}
1694
+ /* Venue navigation inside 3D: levels (isolate a floor) and areas (fly to a zone).
1695
+ Bottom-LEFT, clear of the module's own chips (Overview bottom-right, 360
1696
+ bottom-centre) and of the bottom-sheeted confirm card. Horizontally scrollable
1697
+ so a venue with many zones never pushes the rail off a phone screen. */
1698
+ .sl-view3d-nav{position:absolute;left:12px;bottom:16px;z-index:3;display:flex;flex-direction:column;gap:6px;
1699
+ max-width:calc(100% - 24px);pointer-events:none}
1700
+ .sl-view3d-nav > div{display:flex;gap:6px;overflow-x:auto;scrollbar-width:none;pointer-events:auto;
1701
+ padding:1px;-webkit-overflow-scrolling:touch}
1702
+ .sl-view3d-nav > div::-webkit-scrollbar{display:none}
1703
+ .sl-view3d-nav button{flex:0 0 auto;min-height:32px;padding:7px 12px;border-radius:999px;white-space:nowrap;
1704
+ font-size:11.5px;font-weight:700;color:#c9d4ea;background:rgba(12,18,32,.72);
1705
+ border:1px solid rgba(150,165,205,.35);backdrop-filter:blur(6px);cursor:pointer}
1706
+ .sl-view3d-nav button:hover,.sl-view3d-nav button:focus-visible{color:#eef1f8;border-color:rgba(190,205,240,.6)}
1707
+ .sl-view3d-nav button[aria-pressed="true"]{background:var(--sl-accent);color:var(--sl-accent-ink);border-color:transparent}
1689
1708
  /* While immersed, the 2D-only chrome is meaningless \u2014 hide it, keep Map|3D. */
1690
1709
  .sl-picker[data-view3d="on"] .sl-rungs,
1691
1710
  .sl-picker[data-view3d="on"] .sl-floors,
@@ -1959,6 +1978,8 @@ var SeatPicker = class _SeatPicker {
1959
1978
  // F4 price-band filter — active band's category keys (null = all prices)
1960
1979
  this.priceBandKeys = null;
1961
1980
  this.focusedCatKey = null;
1981
+ /** "Hide limited-view seats" — mirrored into 3D by `seatState3dFor`. */
1982
+ this.limitedViewFilter = false;
1962
1983
  this.pricesExpanded = false;
1963
1984
  /** Last surfaced section summary (re-rendered when the price band changes). */
1964
1985
  this.lastSection = null;
@@ -2627,12 +2648,13 @@ var SeatPicker = class _SeatPicker {
2627
2648
  limited.setAttribute("aria-pressed", "false");
2628
2649
  limited.innerHTML = `\u25D0 ${this.tf("picker.hideLimitedView", "Hide limited-view seats")}`;
2629
2650
  chips.appendChild(limited);
2630
- let limitedOn = false;
2631
2651
  limited.addEventListener("click", () => {
2632
- limitedOn = !limitedOn;
2652
+ const limitedOn = !this.limitedViewFilter;
2653
+ this.limitedViewFilter = limitedOn;
2633
2654
  limited.classList.toggle("on", limitedOn);
2634
2655
  limited.setAttribute("aria-pressed", String(limitedOn));
2635
2656
  this.controller.setCommercialLimitedFilter(limitedOn);
2657
+ this.pushAvailabilityTo3d();
2636
2658
  if (limitedOn) focusSeatsForFilter();
2637
2659
  });
2638
2660
  }
@@ -2790,7 +2812,7 @@ var SeatPicker = class _SeatPicker {
2790
2812
  if (!foot) return;
2791
2813
  const el = document.createElement("div");
2792
2814
  el.className = "sl-powered";
2793
- el.innerHTML = `<span class="sl-powered-mark" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M4 15c0-1.1.9-2 2-2h12a2 2 0 0 1 2 2v3h-3v-2H7v2H4v-3Z"/><rect x="7" y="7" width="10" height="5" rx="1.6"/></svg></span><span>${this.tf("picker.poweredBy", "Powered by SeatLayer")}</span>`;
2815
+ el.innerHTML = `<span class="sl-powered-mark" aria-hidden="true">` + SEATLAYER_ATTRIBUTION_MARK_SVG + `</span><span>${this.tf("picker.poweredBy", "Powered by SeatLayer")}</span>`;
2794
2816
  foot.appendChild(el);
2795
2817
  }
2796
2818
  // ---- Feature 6: chrome anchor regions -------------------------------------
@@ -3221,6 +3243,7 @@ var SeatPicker = class _SeatPicker {
3221
3243
  this.priceBandKeys = keys ? new Set(keys) : null;
3222
3244
  this.controller.setCategoryFilter(keys);
3223
3245
  this.controller.focusCategoryFilter(keys);
3246
+ this.pushAvailabilityTo3d();
3224
3247
  this.syncFloors();
3225
3248
  this.syncRung();
3226
3249
  this.refreshMinimap();
@@ -3318,10 +3341,23 @@ var SeatPicker = class _SeatPicker {
3318
3341
  button.setAttribute("aria-pressed", String(on));
3319
3342
  });
3320
3343
  }
3321
- /** Can this picker offer the 3D venue view? Requires the option (default on)
3322
- * and WebGL2, and a chart to render. */
3344
+ /** Can this picker offer the 3D venue view? Requires the option (default on),
3345
+ * WebGL2, a chart to render, and a chart small enough to render WELL. */
3323
3346
  canOffer3d() {
3324
- return this.opts.enable3D !== false && hasWebGL2() && !!this.controller.doc;
3347
+ if (this.opts.enable3D === false || !this.controller.doc || !hasWebGL2()) return false;
3348
+ return this.allSeats().length <= this.max3dSeats();
3349
+ }
3350
+ /**
3351
+ * The seat ceiling for offering 3D. See `max3DSeats` — an explicit host value
3352
+ * always wins; otherwise a small/low-core device gets half the desktop budget,
3353
+ * because it is the device that turns "slow" into "stalled".
3354
+ */
3355
+ max3dSeats() {
3356
+ const authored = this.opts.max3DSeats;
3357
+ if (typeof authored === "number" && authored > 0) return authored;
3358
+ const nav = globalThis.navigator;
3359
+ const small = (nav?.hardwareConcurrency ?? 8) <= 4 || (nav?.deviceMemory ?? 8) <= 4 || (globalThis.matchMedia?.("(pointer: coarse)").matches ?? false);
3360
+ return small ? MAX_3D_SEATS_DEFAULT / 2 : MAX_3D_SEATS_DEFAULT;
3325
3361
  }
3326
3362
  /** Reflect the active floor onto the switcher rail. */
3327
3363
  syncFloors() {
@@ -3874,6 +3910,7 @@ var SeatPicker = class _SeatPicker {
3874
3910
  if (select) select.value = "all";
3875
3911
  this.controller.setCategoryFilter(next ? [next] : null);
3876
3912
  this.controller.focusCategoryFilter(next ? [next] : null);
3913
+ this.pushAvailabilityTo3d();
3877
3914
  this.syncFloors();
3878
3915
  this.syncRung();
3879
3916
  this.refreshMinimap();
@@ -4565,8 +4602,8 @@ var SeatPicker = class _SeatPicker {
4565
4602
  void this.enter3d(flyToSeatId);
4566
4603
  }
4567
4604
  /** SeatStatus → the view3d palette state. Selection is layered separately. */
4568
- seatState3dFor(id) {
4569
- switch (this.controller.getStatus(id)) {
4605
+ seatState3dFor(seat) {
4606
+ switch (this.controller.getStatus(seat.id)) {
4570
4607
  case "held":
4571
4608
  return "held";
4572
4609
  case "booked":
@@ -4574,14 +4611,19 @@ var SeatPicker = class _SeatPicker {
4574
4611
  case "not_for_sale":
4575
4612
  return "dimmed";
4576
4613
  default:
4577
- return "available";
4614
+ break;
4615
+ }
4616
+ if (this.priceBandKeys != null && !this.priceBandKeys.has(seat.categoryKey)) return "dimmed";
4617
+ if (this.limitedViewFilter && (seat.commercial?.restrictedView || seat.commercial?.obstructedView)) {
4618
+ return "dimmed";
4578
4619
  }
4620
+ return "available";
4579
4621
  }
4580
4622
  /** Push the full live availability snapshot into the 3D handle (selection is
4581
4623
  * preserved inside the module). Cheap enough per status delta. */
4582
4624
  pushAvailabilityTo3d() {
4583
4625
  if (!this.view3dHandle) return;
4584
- const updates = this.allSeats().map((s) => ({ seatId: s.id, state: this.seatState3dFor(s.id) }));
4626
+ const updates = this.allSeats().map((s) => ({ seatId: s.id, state: this.seatState3dFor(s) }));
4585
4627
  this.view3dHandle.setAvailability(updates);
4586
4628
  }
4587
4629
  /** Mirror the authoritative widget selection into the 3D handle. */
@@ -4633,6 +4675,75 @@ var SeatPicker = class _SeatPicker {
4633
4675
  if (this.opts.confirmSelection !== false) this.showConfirm(seat);
4634
4676
  else this.syncTray();
4635
4677
  }
4678
+ /**
4679
+ * The venue-navigation rail inside 3D: levels and areas.
4680
+ *
4681
+ * In 2D a buyer moves through the venue by floor switcher and by the LOD
4682
+ * rungs. Both are hidden while immersed, and the 3D module's own chips only
4683
+ * cover "go home" and "see the 360" — so on a multi-floor or multi-zone chart
4684
+ * the buyer entered 3D and LOST the ability to reach the level or area they
4685
+ * were booking. The camera moves for both already exist on the handle
4686
+ * (`focusFloor` / `focusZone`); this is the surface that offers them.
4687
+ *
4688
+ * Levels are a TOGGLE (a floor stays isolated until you pick another), areas
4689
+ * are an ACTION (the camera flies there and you are then free to orbit), which
4690
+ * is why only the level pills carry a pressed state.
4691
+ */
4692
+ buildView3dNav(overlay, handle) {
4693
+ const floors = handle.floors();
4694
+ const zones = handle.zones().filter((z) => z.seatCount > 0);
4695
+ const sections = zones.length > 1 ? [] : handle.sections().filter((s) => s.seatCount > 0).slice(0, MAX_3D_SECTION_PILLS);
4696
+ const wantFloors = floors.length > 1;
4697
+ const wantZones = zones.length > 1;
4698
+ const wantSections = sections.length > 1;
4699
+ if (!wantFloors && !wantZones && !wantSections) return;
4700
+ const nav = document.createElement("div");
4701
+ nav.className = "sl-view3d-nav";
4702
+ if (wantFloors) {
4703
+ const row = document.createElement("div");
4704
+ row.setAttribute("role", "group");
4705
+ row.setAttribute("aria-label", this.tf("picker.levels", "Levels"));
4706
+ const pills = [];
4707
+ const select = (index) => {
4708
+ if (!handle.focusFloor(index)) return;
4709
+ pills.forEach((p) => {
4710
+ p.setAttribute("aria-pressed", String((p.dataset.floor === "" ? null : Number(p.dataset.floor)) === index));
4711
+ });
4712
+ };
4713
+ const add = (label, index) => {
4714
+ const b = document.createElement("button");
4715
+ b.type = "button";
4716
+ b.textContent = label;
4717
+ b.dataset.floor = index === null ? "" : String(index);
4718
+ b.setAttribute("aria-pressed", String(index === null));
4719
+ b.addEventListener("click", () => select(index));
4720
+ pills.push(b);
4721
+ row.appendChild(b);
4722
+ };
4723
+ add(this.tf("picker.allLevels", "All levels"), null);
4724
+ for (const f of floors) add(f.label || `Level ${f.index + 1}`, f.index);
4725
+ nav.appendChild(row);
4726
+ }
4727
+ if (wantZones || wantSections) {
4728
+ const row = document.createElement("div");
4729
+ row.setAttribute("role", "group");
4730
+ row.setAttribute("aria-label", this.tf("picker.areas", "Areas"));
4731
+ const entries = wantZones ? zones.map((z) => ({ id: z.id, label: z.label || z.id, go: () => {
4732
+ handle.focusZone(z.id);
4733
+ } })) : sections.map((s) => ({ id: s.id, label: s.label || s.id, go: () => {
4734
+ handle.focusSection(s.id);
4735
+ } }));
4736
+ for (const e of entries) {
4737
+ const b = document.createElement("button");
4738
+ b.type = "button";
4739
+ b.textContent = e.label;
4740
+ b.addEventListener("click", e.go);
4741
+ row.appendChild(b);
4742
+ }
4743
+ nav.appendChild(row);
4744
+ }
4745
+ overlay.appendChild(nav);
4746
+ }
4636
4747
  async enter3d(flySeatId) {
4637
4748
  if (this.view3dEl || !this.canOffer3d() || !this.els.map) return;
4638
4749
  const doc = this.controller.doc;
@@ -4683,6 +4794,7 @@ var SeatPicker = class _SeatPicker {
4683
4794
  this.view3dHandle = handle;
4684
4795
  this.pushAvailabilityTo3d();
4685
4796
  this.syncSelectionTo3d();
4797
+ this.buildView3dNav(overlay, handle);
4686
4798
  if (flySeatId) void handle.flyToSeat(flySeatId);
4687
4799
  } catch (err) {
4688
4800
  this.opts.onError?.(err);