@seatlayer/js 0.28.4 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,5 +1,10 @@
1
1
  // src/SeatingChart.ts
2
- import { PickerController, loadLocale, setStringOverrides, t } from "@seatlayer/core";
2
+ import {
3
+ PickerController,
4
+ loadLocale,
5
+ setStringOverrides,
6
+ t
7
+ } from "@seatlayer/core";
3
8
 
4
9
  // src/api.ts
5
10
  var ApiError = class extends Error {
@@ -161,6 +166,7 @@ var SeatingChart = class {
161
166
  this.rendered = false;
162
167
  return this;
163
168
  }
169
+ this.controller.setViewMode(this.opts.initialView ?? "flat");
164
170
  this.mode_ = info.mode === "test" ? "test" : "live";
165
171
  if (this.opts.seatTooltip !== false) {
166
172
  const tip = document.createElement("div");
@@ -341,7 +347,14 @@ var SeatingChart = class {
341
347
  /** @internal Throwing variant of {@link bestAvailable} for the native host adapter. See {@link holdOrThrow}. */
342
348
  async bestAvailableOrThrow(qty, categoryKey, options = {}) {
343
349
  const h = await this.controller.bestAvailable(qty, categoryKey, options);
344
- return h ? { holdId: h.holdId, expiresAt: h.expiresAt, labels: h.labels, seats: h.seats, items: h.items } : null;
350
+ return h ? {
351
+ holdId: h.holdId,
352
+ expiresAt: h.expiresAt,
353
+ labels: h.labels,
354
+ seats: h.seats,
355
+ items: h.items,
356
+ ...options.zoneId ? { zoneId: options.zoneId } : {}
357
+ } : null;
345
358
  }
346
359
  /**
347
360
  * Choose a ticket tier for a selected seat (e.g. Adult → Child). The seat's
@@ -373,6 +386,14 @@ var SeatingChart = class {
373
386
  setColorblindSafe(on) {
374
387
  this.controller.setColorblindSafe(on);
375
388
  }
389
+ /** Switch between flat, isometric and exact-seat Perspective 2.5D views. */
390
+ setViewMode(mode) {
391
+ this.controller.setViewMode(mode);
392
+ }
393
+ /** Current canvas projection. */
394
+ getViewMode() {
395
+ return this.controller.getViewMode();
396
+ }
376
397
  /** Zoom in one step (same increment as the wheel/pinch gesture). */
377
398
  zoomIn() {
378
399
  this.controller.zoomIn();
@@ -1082,6 +1103,41 @@ function escapeOption(value) {
1082
1103
  "'": "'"
1083
1104
  })[character]);
1084
1105
  }
1106
+ var SEATLAYER_MODULE_URL = (() => {
1107
+ if (typeof document !== "undefined" && document.currentScript instanceof HTMLScriptElement && document.currentScript.src) {
1108
+ return document.currentScript.src;
1109
+ }
1110
+ try {
1111
+ const u = import.meta.url;
1112
+ if (typeof u === "string" && u) return u;
1113
+ } catch {
1114
+ }
1115
+ return void 0;
1116
+ })();
1117
+ var _webgl2Cache = null;
1118
+ function hasWebGL2() {
1119
+ if (_webgl2Cache !== null) return _webgl2Cache;
1120
+ try {
1121
+ if (typeof document === "undefined") return _webgl2Cache = false;
1122
+ const canvas = document.createElement("canvas");
1123
+ _webgl2Cache = !!canvas.getContext("webgl2");
1124
+ } catch {
1125
+ _webgl2Cache = false;
1126
+ }
1127
+ return _webgl2Cache;
1128
+ }
1129
+ async function loadVenue3d() {
1130
+ if (typeof __SEATLAYER_CDN__ !== "undefined" && __SEATLAYER_CDN__) {
1131
+ const base = SEATLAYER_MODULE_URL ?? (typeof location !== "undefined" ? location.href : void 0);
1132
+ if (!base) throw new Error("seatlayer: cannot resolve the 3D view chunk URL");
1133
+ const url = new URL("./seatlayer-view3d.mjs", base).href;
1134
+ return import(
1135
+ /* @vite-ignore */
1136
+ url
1137
+ );
1138
+ }
1139
+ return import("@seatlayer/core/view3d");
1140
+ }
1085
1141
  var STYLE_ID = "seatlayer-picker-style";
1086
1142
  var CSS = `
1087
1143
  .sl-picker{position:relative;display:flex;flex-direction:column;width:100%;height:100%;min-height:420px;overflow:hidden;
@@ -1613,6 +1669,45 @@ var CSS = `
1613
1669
  .sl-projection button.on{background:var(--sl-accent);color:var(--sl-accent-ink)}
1614
1670
  .sl-picker[data-layout="narrow"] .sl-projection button{min-width:38px;min-height:32px;padding:5px 8px;font-size:9.5px}
1615
1671
 
1672
+ /* 3D venue overlay \u2014 mounts over the (paused) Konva stage inside the map host.
1673
+ Carries its own gradient so it paints instantly before the scene builds, and
1674
+ cross-fades on enter/exit via a compositor-only opacity transition. Sits below
1675
+ the anchored chrome (z-index:5) and the confirm card (z-index:10) so the
1676
+ Map|3D toggle and the seat confirm both stay usable over it. */
1677
+ .sl-view3d{position:absolute;inset:0;z-index:4;opacity:0;touch-action:none;
1678
+ transition:opacity .3s ease;background:radial-gradient(120% 120% at 50% 0%,#191f28 0%,#0d1014 70%)}
1679
+ .sl-view3d canvas{display:block;width:100%;height:100%}
1680
+ [data-view3d=on] .sl-chips,[data-view3d=on] .sl-rungs{display:none}
1681
+ .sl-view3d-back{position:absolute;top:12px;left:12px;z-index:2;display:inline-flex;align-items:center;gap:6px;
1682
+ padding:7px 13px 7px 9px;border-radius:999px;font-size:11px;font-weight:800;letter-spacing:.03em;
1683
+ color:#e6edf3;background:rgba(10,14,20,.62);border:1px solid rgba(255,255,255,.22);backdrop-filter:blur(6px)}
1684
+ .sl-view3d-back:hover,.sl-view3d-back:focus-visible{background:rgba(16,22,30,.82);border-color:rgba(255,255,255,.4)}
1685
+ .sl-view3d-back svg{width:15px;height:15px;stroke:currentColor;stroke-width:2.4;fill:none;stroke-linecap:round;stroke-linejoin:round}
1686
+ /* While immersed, the 2D-only chrome is meaningless \u2014 hide it, keep Map|3D. */
1687
+ .sl-picker[data-view3d="on"] .sl-rungs,
1688
+ .sl-picker[data-view3d="on"] .sl-floors,
1689
+ .sl-picker[data-view3d="on"] .sl-zoom,
1690
+ .sl-picker[data-view3d="on"] .sl-seccard,
1691
+ .sl-picker[data-view3d="on"] .sl-minimap{display:none!important}
1692
+ /* The confirm card bottom-sheets over 3D (no 2D screen anchor to track). */
1693
+ .sl-picker[data-view3d="on"] .sl-confirm{left:50%!important;top:auto!important;bottom:16px;
1694
+ transform:translateX(-50%);width:min(342px,calc(100% - 24px))}
1695
+ .sl-picker[data-view3d="on"] .sl-confirm[data-placement]{transform:translateX(-50%)}
1696
+
1697
+ /* Plain "View from here" action shown when no real photo exists (the synthetic
1698
+ thumb is suppressed at card size \u2014 full-screen is where it earns its keep). */
1699
+ .sl-confirm-viewbtn{width:100%;margin-top:9px;padding:8px;border-radius:8px;border:1px solid var(--sl-line);
1700
+ display:flex;align-items:center;justify-content:center;gap:7px;font-size:12px;font-weight:800;
1701
+ color:var(--sl-text);background:transparent;transition:border-color .15s,background .15s}
1702
+ .sl-confirm-viewbtn:hover,.sl-confirm-viewbtn:focus-visible{border-color:var(--sl-accent);background:color-mix(in srgb,var(--sl-accent) 10%,transparent)}
1703
+ /* confirm-card "See it in 3D" / "View from this seat" action \u2014 the purchase-
1704
+ moment bridge into the cinematic. Styled like the view-from-seat button. */
1705
+ .sl-confirm-3d{width:100%;margin-top:9px;padding:8px;border-radius:8px;border:1px solid var(--sl-line);
1706
+ display:flex;align-items:center;justify-content:center;gap:7px;font-size:12px;font-weight:800;
1707
+ color:var(--sl-text);background:color-mix(in srgb,var(--sl-accent) 12%,transparent);transition:border-color .15s,background .15s}
1708
+ .sl-confirm-3d:hover,.sl-confirm-3d:focus-visible{border-color:var(--sl-accent);background:color-mix(in srgb,var(--sl-accent) 20%,transparent)}
1709
+ .sl-confirm-3d svg{width:15px;height:15px;stroke:currentColor;stroke-width:1.9;fill:none;stroke-linecap:round;stroke-linejoin:round}
1710
+
1616
1711
  /* multi-floor switcher (flows within the left-rail region) */
1617
1712
  .sl-floors{display:none;flex-direction:column;gap:6px;max-width:100%}
1618
1713
  .sl-floors.on{display:flex}
@@ -1840,6 +1935,15 @@ var SeatPicker = class _SeatPicker {
1840
1935
  // arena / multi-floor / seat-view chrome
1841
1936
  this.rungsEl = null;
1842
1937
  this.projectionEl = null;
1938
+ // --- 3D venue view (Map | 3D) ---
1939
+ this.buyerView = "map";
1940
+ this.view3dEl = null;
1941
+ this.view3dHandle = null;
1942
+ /** Monotonic token so a stale async mount (buyer left before OGL finished
1943
+ * loading) never installs its handle over a newer state. */
1944
+ this.view3dGen = 0;
1945
+ /** Seat whose 2D confirm card launched "See it in 3D"; re-shown on return. */
1946
+ this.view3dReturnSeat = null;
1843
1947
  this.floorsEl = null;
1844
1948
  this.secCardEl = null;
1845
1949
  this.viewEl = null;
@@ -1890,6 +1994,9 @@ var SeatPicker = class _SeatPicker {
1890
1994
  this.lastAvailFloorId = "";
1891
1995
  this.availQuietUntil = 0;
1892
1996
  this.liveTimer = null;
1997
+ /** `perspective` (2.5D) is retired from the buyer surface — accept it for
1998
+ * source compatibility but coerce to `flat` with a one-time deprecation warn. */
1999
+ this.perspectiveWarned = false;
1893
2000
  if (!options || typeof options !== "object") throw new Error("seatmap: options object is required");
1894
2001
  if (!options.event || typeof options.event !== "string") throw new Error("seatmap: `event` key is required");
1895
2002
  if (!options.container) throw new Error("seatmap: `container` is required (or use SeatPicker.open())");
@@ -1908,12 +2015,14 @@ var SeatPicker = class _SeatPicker {
1908
2015
  onSelectionChange: () => {
1909
2016
  this.syncTray();
1910
2017
  if (this.committedSelection().length) this.collapseSectionCard();
2018
+ this.syncSelectionTo3d();
1911
2019
  },
1912
2020
  onStatusChange: () => {
1913
2021
  this.syncPrices();
1914
2022
  this.evictTakenSelections();
1915
2023
  this.detectBooked();
1916
2024
  this.refreshMinimap();
2025
+ this.pushAvailabilityTo3d();
1917
2026
  },
1918
2027
  onHoldExpired: () => {
1919
2028
  this.hold = null;
@@ -2002,19 +2111,26 @@ var SeatPicker = class _SeatPicker {
2002
2111
  const realPhoto = seat.viewUrl ?? "";
2003
2112
  const hasStage = this.chartHasStage();
2004
2113
  if (!realPhoto && !hasStage) return "";
2005
- let url = realPhoto;
2006
2114
  let distance = null;
2007
- if (!url) {
2115
+ if (!realPhoto) {
2008
2116
  try {
2009
2117
  const thumb = generateSeatThumb(seat, seat.focalPoint ?? doc.focalPoint);
2010
- url = thumb.url;
2011
2118
  distance = thumb.distanceM ?? null;
2012
2119
  } catch {
2013
2120
  return "";
2014
2121
  }
2015
2122
  }
2016
2123
  const sightHtml = hasStage ? `<div class="sl-confirm-sight"><span aria-hidden="true">\u2713</span>${distance != null ? t2("picker.sightline", { m: distance }) : this.tf("picker.sightlineClear", "Clear sightline")}</div>` : "";
2017
- return `<button type="button" class="sl-confirm-view sl-confirm-thumbwrap" aria-label="${t2("picker.viewFromSeat", { label: seat.label })}"><img class="sl-confirm-thumb" src="${url}" alt="" /><span class="sl-confirm-thumb-badge">\u{1F52D} ${this.tf("picker.viewFromHere", "View from here")}</span></button>` + sightHtml;
2124
+ const viewBtn = realPhoto ? `<button type="button" class="sl-confirm-view sl-confirm-thumbwrap" aria-label="${t2("picker.viewFromSeat", { label: seat.label })}"><img class="sl-confirm-thumb" src="${realPhoto}" alt="" /><span class="sl-confirm-thumb-badge">\u{1F52D} ${this.tf("picker.viewFromHere", "View from here")}</span></button>` : `<button type="button" class="sl-confirm-view sl-confirm-viewbtn" aria-label="${t2("picker.viewFromSeat", { label: seat.label })}"><span aria-hidden="true">\u{1F52D}</span><span>${this.tf("picker.viewFromHere", "View from here")}</span></button>`;
2125
+ return viewBtn + sightHtml;
2126
+ }
2127
+ /** "See it in 3D" (2D) / "View from this seat" (already in 3D) action for the
2128
+ * confirm card. Only when 3D is available — the purchase-moment bridge into
2129
+ * the cinematic that reaches buyers who never press the Map | 3D toggle. */
2130
+ see3dConfirmHtml() {
2131
+ if (!this.canOffer3d()) return "";
2132
+ const label = this.buyerView === "venue3d" ? this.tf("picker.viewFromThisSeat", "View from this seat") : this.tf("picker.seeItIn3d", "See it in 3D");
2133
+ return `<button type="button" class="sl-confirm-3d" aria-label="${label}"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2l9 5v10l-9 5-9-5V7z"/><path d="M12 12l9-5M12 12v10M12 12L3 7"/></svg><span>${label}</span></button>`;
2018
2134
  }
2019
2135
  /** Minimal HTML/attribute escaper for buyer-authored commercial text (notes). */
2020
2136
  escCx(value) {
@@ -2431,7 +2547,7 @@ var SeatPicker = class _SeatPicker {
2431
2547
  }
2432
2548
  this.els.boot.remove();
2433
2549
  this.salesClosed = !!info.salesClosed;
2434
- this.controller.setViewMode(this.opts.initialView ?? "flat");
2550
+ this.controller.setViewMode(this.normalizeInitialView(this.opts.initialView));
2435
2551
  this.buildRegions();
2436
2552
  this.regions["bottom-right"].appendChild(this.els.zoom);
2437
2553
  this.regions["bottom-center"].appendChild(this.els.toast);
@@ -3115,15 +3231,15 @@ var SeatPicker = class _SeatPicker {
3115
3231
  const doc = this.controller.doc;
3116
3232
  if (!doc || !this.els.map) return;
3117
3233
  const hasSections = doc.objects.some((o) => o.type === "section") || (doc.floors ?? []).some((f) => f.objects.some((o) => o.type === "section"));
3118
- if (hasSections) {
3234
+ if (this.canOffer3d()) {
3119
3235
  const projection = document.createElement("div");
3120
3236
  projection.className = "sl-projection";
3121
3237
  projection.setAttribute("role", "group");
3122
- projection.setAttribute("aria-label", "Map projection");
3123
- projection.innerHTML = '<button type="button" data-projection="flat" aria-pressed="false" title="Flat 2D map">2D</button><button type="button" data-projection="perspective" aria-pressed="false" title="Perspective 2.5D map">2.5D</button>';
3238
+ projection.setAttribute("aria-label", "Venue view");
3239
+ projection.innerHTML = '<button type="button" data-view="map" aria-pressed="true" title="Flat 2D map">Map</button><button type="button" data-view="venue3d" aria-pressed="false" title="Interactive 3D venue view">3D</button>';
3124
3240
  projection.querySelectorAll("button").forEach((button) => {
3125
3241
  button.addEventListener("click", () => {
3126
- this.setViewMode(button.dataset.projection);
3242
+ this.setBuyerView(button.dataset.view);
3127
3243
  });
3128
3244
  });
3129
3245
  this.regions["top-right"].appendChild(projection);
@@ -3193,13 +3309,17 @@ var SeatPicker = class _SeatPicker {
3193
3309
  }
3194
3310
  syncProjection() {
3195
3311
  if (!this.projectionEl) return;
3196
- const active = this.controller.getViewMode();
3197
3312
  this.projectionEl.querySelectorAll("button").forEach((button) => {
3198
- const on = button.dataset.projection === active;
3313
+ const on = button.dataset.view === this.buyerView;
3199
3314
  button.classList.toggle("on", on);
3200
3315
  button.setAttribute("aria-pressed", String(on));
3201
3316
  });
3202
3317
  }
3318
+ /** Can this picker offer the 3D venue view? Requires the option (default on)
3319
+ * and WebGL2, and a chart to render. */
3320
+ canOffer3d() {
3321
+ return this.opts.enable3D !== false && hasWebGL2() && !!this.controller.doc;
3322
+ }
3203
3323
  /** Reflect the active floor onto the switcher rail. */
3204
3324
  syncFloors() {
3205
3325
  if (!this.floorsEl) return;
@@ -3504,17 +3624,27 @@ var SeatPicker = class _SeatPicker {
3504
3624
  el.setAttribute("aria-modal", "true");
3505
3625
  el.setAttribute("aria-label", `Confirm seat ${seat.label}`);
3506
3626
  el.style.setProperty("--sl-cat", cat?.color ?? "#6e7bff");
3507
- el.innerHTML = `<div class="sl-confirm-grid">` + identityFields + `</div><div class="sl-confirm-cat"><span class="sl-dot" style="background:${cat?.color ?? "#6e7bff"}"></span><span class="sl-confirm-cat-name">${safe(details?.categoryLabel ?? cat?.label ?? seat.categoryKey)}</span>` + (price != null ? `<span class="sl-confirm-price">${this.money(price)}</span>` : "") + `</div><div class="sl-confirm-body">` + this.wheelchairConfirmHtml(details?.wheelchairSpaceType) + this.commercialConfirmHtml(seat.commercial) + (this.seatViewEnabled() ? this.confirmThumbHtml(seat) : "") + `<div class="sl-confirm-row"><button type="button" class="sl-confirm-cancel">Cancel</button><button type="button" class="sl-confirm-add"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12.5l4 4L19 7"/></svg>Select</button></div></div>`;
3627
+ el.innerHTML = `<div class="sl-confirm-grid">` + identityFields + `</div><div class="sl-confirm-cat"><span class="sl-dot" style="background:${cat?.color ?? "#6e7bff"}"></span><span class="sl-confirm-cat-name">${safe(details?.categoryLabel ?? cat?.label ?? seat.categoryKey)}</span>` + (price != null ? `<span class="sl-confirm-price">${this.money(price)}</span>` : "") + `</div><div class="sl-confirm-body">` + this.wheelchairConfirmHtml(details?.wheelchairSpaceType) + this.commercialConfirmHtml(seat.commercial) + (this.seatViewEnabled() ? this.confirmThumbHtml(seat) : "") + this.see3dConfirmHtml() + `<div class="sl-confirm-row"><button type="button" class="sl-confirm-cancel">Cancel</button><button type="button" class="sl-confirm-add"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12.5l4 4L19 7"/></svg>Select</button></div></div>`;
3508
3628
  this.els.map.appendChild(el);
3509
3629
  this.confirmEl = el;
3510
3630
  this.reanchorConfirm();
3511
3631
  el.querySelector(".sl-confirm-view")?.addEventListener("click", () => this.openSeatView(seat));
3632
+ el.querySelector(".sl-confirm-3d")?.addEventListener("click", () => {
3633
+ if (this.buyerView === "venue3d") {
3634
+ void this.view3dHandle?.flyToSeat(seat.id);
3635
+ } else {
3636
+ this.view3dReturnSeat = seat;
3637
+ this.dismissConfirm();
3638
+ void this.enter3d(seat.id);
3639
+ }
3640
+ });
3512
3641
  el.querySelector(".sl-confirm-add").addEventListener("click", () => this.commitConfirm());
3513
3642
  el.querySelector(".sl-confirm-cancel").addEventListener("click", () => this.cancelConfirm());
3514
3643
  requestAnimationFrame(() => el.querySelector(".sl-confirm-add")?.focus());
3515
3644
  }
3516
3645
  reanchorConfirm() {
3517
3646
  if (!this.confirmEl || !this.confirmSeat) return;
3647
+ if (this.root?.dataset.view3d === "on") return;
3518
3648
  const p = this.controller.worldToScreen({ x: this.confirmSeat.x, y: this.confirmSeat.y });
3519
3649
  if (this.root?.dataset.layout === "narrow") return;
3520
3650
  const mapWidth = this.els.map.clientWidth;
@@ -3597,16 +3727,21 @@ var SeatPicker = class _SeatPicker {
3597
3727
  this.viewEl = el;
3598
3728
  const pano = el.querySelector(".sl-view-pano");
3599
3729
  pano.style.backgroundImage = `url("${panoUrl}")`;
3600
- let zoom = 1.2;
3601
- let posX = 0;
3730
+ const VFOV_DEG = 70;
3731
+ const MAX_PITCH_DEG = 35;
3732
+ let zoom = 1;
3733
+ const vh0 = pano.clientHeight || 1;
3734
+ const vw0 = pano.clientWidth || 1;
3735
+ let posX = -(vh0 * (180 / VFOV_DEG) * 2 / 2 - vw0 / 2);
3602
3736
  let posY = 0;
3603
3737
  const apply = () => {
3604
3738
  const h = pano.clientHeight || 1;
3605
- const bgH = h * zoom;
3739
+ const bgH = h * (180 / VFOV_DEG) * zoom;
3606
3740
  const overV = Math.max(0, bgH - h);
3607
- posY = Math.min(overV / 2, Math.max(-overV / 2, posY));
3741
+ const pitchLimit = Math.min(overV / 2, MAX_PITCH_DEG / 180 * bgH);
3742
+ posY = Math.min(pitchLimit, Math.max(-pitchLimit, posY));
3608
3743
  pano.style.backgroundSize = `auto ${bgH}px`;
3609
- pano.style.backgroundPosition = `${posX}px ${posY + overV / 2}px`;
3744
+ pano.style.backgroundPosition = `${posX}px ${posY - overV / 2}px`;
3610
3745
  };
3611
3746
  apply();
3612
3747
  let dragging = false;
@@ -4361,9 +4496,10 @@ var SeatPicker = class _SeatPicker {
4361
4496
  this.controller.setColorblindSafe(on);
4362
4497
  writeStoredColorblind(on);
4363
4498
  }
4364
- /** Switch the buyer canvas between flat, isometric and Perspective 2.5D. */
4499
+ /** Switch the underlying 2D renderer projection (flat / isometric). The buyer
4500
+ * UI no longer exposes `perspective`; it is coerced to `flat`. */
4365
4501
  setViewMode(mode) {
4366
- this.controller.setViewMode(mode);
4502
+ this.controller.setViewMode(this.normalizeInitialView(mode));
4367
4503
  this.syncProjection();
4368
4504
  this.dismissConfirm();
4369
4505
  }
@@ -4371,6 +4507,178 @@ var SeatPicker = class _SeatPicker {
4371
4507
  getViewMode() {
4372
4508
  return this.controller.getViewMode();
4373
4509
  }
4510
+ normalizeInitialView(mode) {
4511
+ if (mode === "perspective") {
4512
+ if (!this.perspectiveWarned) {
4513
+ this.perspectiveWarned = true;
4514
+ console.warn(
4515
+ "[seatlayer] initialView:'perspective' (2.5D) is deprecated for the buyer picker and was coerced to 'flat'. Use the Map | 3D control for the immersive view."
4516
+ );
4517
+ }
4518
+ return "flat";
4519
+ }
4520
+ return mode ?? "flat";
4521
+ }
4522
+ // ---- 3D venue view ---------------------------------------------------------
4523
+ /** Current buyer view: the flat map, or the interactive 3D venue. */
4524
+ getBuyerView() {
4525
+ return this.buyerView;
4526
+ }
4527
+ /** Toggle between the flat Map and the 3D venue view. No-op when unchanged or
4528
+ * when 3D is unavailable. */
4529
+ setBuyerView(view) {
4530
+ if (view === this.buyerView) return;
4531
+ if (view === "venue3d") void this.enter3d();
4532
+ else this.exit3d();
4533
+ }
4534
+ /** SeatStatus → the view3d palette state. Selection is layered separately. */
4535
+ seatState3dFor(id) {
4536
+ switch (this.controller.getStatus(id)) {
4537
+ case "held":
4538
+ return "held";
4539
+ case "booked":
4540
+ return "sold";
4541
+ case "not_for_sale":
4542
+ return "dimmed";
4543
+ default:
4544
+ return "available";
4545
+ }
4546
+ }
4547
+ /** Push the full live availability snapshot into the 3D handle (selection is
4548
+ * preserved inside the module). Cheap enough per status delta. */
4549
+ pushAvailabilityTo3d() {
4550
+ if (!this.view3dHandle) return;
4551
+ const updates = this.allSeats().map((s) => ({ seatId: s.id, state: this.seatState3dFor(s.id) }));
4552
+ this.view3dHandle.setAvailability(updates);
4553
+ }
4554
+ /** Mirror the authoritative widget selection into the 3D handle. */
4555
+ syncSelectionTo3d() {
4556
+ if (!this.view3dHandle) return;
4557
+ this.view3dHandle.setSelection(this.controller.getSelection().map((s) => s.id));
4558
+ }
4559
+ /** Build the view-from-seat panorama the cinematic dissolves into — reuses the
4560
+ * exact input path as the 2D `openSeatView` (organizer photo, else generated). */
4561
+ seatViewFor3d(seatId) {
4562
+ const seat = this.allSeats().find((s) => s.id === seatId);
4563
+ if (!seat) return null;
4564
+ if (seat.viewUrl) return { url: seat.viewUrl };
4565
+ const doc = this.controller.doc;
4566
+ if (!doc) return null;
4567
+ const activeId = this.controller.getActiveFloorId();
4568
+ const focal = seat.focalPoint ?? doc.floors?.find((f) => f.id === activeId)?.focalPoint ?? doc.focalPoint ?? { x: 0, y: 0 };
4569
+ try {
4570
+ return { url: generateSeatPanorama(seat, focal, this.allSeats()).url };
4571
+ } catch {
4572
+ return null;
4573
+ }
4574
+ }
4575
+ /** Route the module's decoupled analytics into the host callback, tagged buyer. */
4576
+ emit3dAnalytics(event, props) {
4577
+ try {
4578
+ this.opts.onAnalytics?.(event, { ...props, surface: "buyer" });
4579
+ } catch {
4580
+ }
4581
+ }
4582
+ /** A 3D seat tap runs the SAME selection path as a 2D tap: toggle through the
4583
+ * controller, then raise the shared confirm card (bottom-sheeted in 3D). */
4584
+ onView3dSeatPick(seatId) {
4585
+ if (this.salesClosed) {
4586
+ this.toast(this.tf("picker.salesClosedToast", "Sales are closed for this event."), "warning");
4587
+ this.syncSelectionTo3d();
4588
+ return;
4589
+ }
4590
+ const seat = this.allSeats().find((s) => s.id === seatId);
4591
+ if (!seat) return;
4592
+ const already = this.committedSelection().some((s) => s.id === seatId);
4593
+ if (already) {
4594
+ this.controller.deselect([seatId]);
4595
+ this.dismissConfirm();
4596
+ return;
4597
+ }
4598
+ this.flashPickedSeat(seatId);
4599
+ this.controller.select([seatId]);
4600
+ if (this.opts.confirmSelection !== false) this.showConfirm(seat);
4601
+ else this.syncTray();
4602
+ }
4603
+ async enter3d(flySeatId) {
4604
+ if (this.view3dEl || !this.canOffer3d() || !this.els.map) return;
4605
+ const doc = this.controller.doc;
4606
+ if (!doc) return;
4607
+ this.buyerView = "venue3d";
4608
+ this.root?.setAttribute("data-view3d", "on");
4609
+ this.dismissConfirm();
4610
+ this.syncProjection();
4611
+ const overlay = document.createElement("div");
4612
+ overlay.className = "sl-view3d";
4613
+ overlay.setAttribute("role", "group");
4614
+ overlay.setAttribute("aria-label", "Interactive 3D venue view");
4615
+ const back = document.createElement("button");
4616
+ back.type = "button";
4617
+ back.className = "sl-view3d-back";
4618
+ back.innerHTML = `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M15 18l-6-6 6-6"/></svg><span>${this.tf("picker.backToMap", "Back to map")}</span>`;
4619
+ back.addEventListener("click", () => this.exit3d());
4620
+ overlay.appendChild(back);
4621
+ this.els.map.appendChild(overlay);
4622
+ this.view3dEl = overlay;
4623
+ requestAnimationFrame(() => {
4624
+ overlay.style.opacity = "1";
4625
+ });
4626
+ const gen = ++this.view3dGen;
4627
+ try {
4628
+ const seats = expandChart(doc);
4629
+ const mod = await loadVenue3d();
4630
+ if (gen !== this.view3dGen || this.buyerView !== "venue3d" || this.view3dEl !== overlay) return;
4631
+ const handle = mod.mountVenue3D(overlay, { doc, seats }, {
4632
+ onSeatPick: (id) => this.onView3dSeatPick(id),
4633
+ // Deferred off the tap gesture: generateSeatPanorama walks every seat
4634
+ // (O(n) on a 13k chart), and the module prefetches at pick — by the
4635
+ // time the flight lands (~2.5s) the idle render has long finished. A
4636
+ // null view REJECTS so the module's no-panorama path keeps the buyer
4637
+ // in orbit instead of dissolving into an empty overlay.
4638
+ getSeatView: (id) => new Promise((resolve, reject) => {
4639
+ const run = () => {
4640
+ const view = this.seatViewFor3d(id);
4641
+ if (view) resolve(view);
4642
+ else reject(new Error("seat_view_unavailable"));
4643
+ };
4644
+ const ric = globalThis.requestIdleCallback;
4645
+ if (typeof ric === "function") ric(run, { timeout: 1500 });
4646
+ else setTimeout(run, 50);
4647
+ }),
4648
+ onAnalytics: (event, props) => this.emit3dAnalytics(event, props)
4649
+ });
4650
+ this.view3dHandle = handle;
4651
+ this.pushAvailabilityTo3d();
4652
+ this.syncSelectionTo3d();
4653
+ if (flySeatId) void handle.flyToSeat(flySeatId);
4654
+ } catch (err) {
4655
+ this.opts.onError?.(err);
4656
+ this.exit3d();
4657
+ }
4658
+ }
4659
+ exit3d() {
4660
+ if (this.buyerView !== "venue3d" && !this.view3dEl) return;
4661
+ this.view3dGen++;
4662
+ this.buyerView = "map";
4663
+ this.root?.removeAttribute("data-view3d");
4664
+ try {
4665
+ this.view3dHandle?.dispose();
4666
+ } catch {
4667
+ }
4668
+ this.view3dHandle = null;
4669
+ const overlay = this.view3dEl;
4670
+ this.view3dEl = null;
4671
+ if (overlay) {
4672
+ overlay.style.opacity = "0";
4673
+ setTimeout(() => overlay.remove(), 320);
4674
+ }
4675
+ this.syncProjection();
4676
+ const seat = this.view3dReturnSeat;
4677
+ this.view3dReturnSeat = null;
4678
+ if (seat && this.committedSelection().some((s) => s.id === seat.id) && this.opts.confirmSelection !== false) {
4679
+ this.showConfirm(seat);
4680
+ }
4681
+ }
4374
4682
  /** Current active/restored hold reflected in the tray. */
4375
4683
  getCurrentHold() {
4376
4684
  return this.hold;
@@ -4462,6 +4770,7 @@ var SeatPicker = class _SeatPicker {
4462
4770
  this.closeConfirm();
4463
4771
  this.dismissTableDialog(false);
4464
4772
  this.closeSeatView();
4773
+ this.exit3d();
4465
4774
  this.stopHoldTimer();
4466
4775
  if (this.toastTimer) clearTimeout(this.toastTimer);
4467
4776
  if (this.liveTimer) clearTimeout(this.liveTimer);
@@ -6118,7 +6427,7 @@ var SeatManager = class {
6118
6427
  const category = this.doc?.categories.find((item) => item.key === seat.categoryKey);
6119
6428
  const sectionMetric = this.controlRoomSnapshot?.revenue.bySection.find((row) => row.sectionId === sectionId);
6120
6429
  const object = this.doc?.objects.find((item) => item.id === seat.rowId);
6121
- const location = object?.type === "row" ? { label: "Row", value: object.label } : object?.type === "table" ? { label: "Table", value: object.label } : seat.kind === "booth" ? { label: "Type", value: "Booth" } : null;
6430
+ const location2 = object?.type === "row" ? { label: "Row", value: object.label } : object?.type === "table" ? { label: "Table", value: object.label } : seat.kind === "booth" ? { label: "Type", value: "Booth" } : null;
6122
6431
  const itemKind = seat.kind === "booth" ? "Booth" : "Seat";
6123
6432
  this.els.rail.innerHTML = `
6124
6433
  <p class="slm-eyebrow">${itemKind} details</p>
@@ -6128,7 +6437,7 @@ var SeatManager = class {
6128
6437
  <div class="slm-inspect-grid">
6129
6438
  <div><span>Status</span><b>${statusLabel[status]}</b></div>
6130
6439
  <div><span>Section</span><b>${esc(sectionLabel)}</b></div>
6131
- ${location ? `<div><span>${location.label}</span><b>${esc(location.value)}</b></div>` : ""}
6440
+ ${location2 ? `<div><span>${location2.label}</span><b>${esc(location2.value)}</b></div>` : ""}
6132
6441
  <div><span>Category</span><b>${esc(category?.label ?? seat.categoryKey)}</b></div>
6133
6442
  <div><span>Sold in section</span><b>${sectionMetric ? `${sectionMetric.booked} of ${sectionMetric.total}` : "\u2014"}</b></div>
6134
6443
  <div><span>Section revenue</span><b>${sectionMetric && this.controlRoomSnapshot ? fmtMoney(sectionMetric.bookedRevenue, this.controlRoomSnapshot.currency) : "\u2014"}</b></div>