@seatlayer/core 0.45.0 → 0.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/chunk-AZODENEL.js +97 -0
  2. package/dist/chunk-AZODENEL.js.map +1 -0
  3. package/dist/chunk-FVCOOLJO.js +335 -0
  4. package/dist/chunk-FVCOOLJO.js.map +1 -0
  5. package/dist/{chunk-5MRJCIZP.js → chunk-TCTAS4Z2.js} +88 -14
  6. package/dist/chunk-TCTAS4Z2.js.map +1 -0
  7. package/dist/{de-UHAOVDS3.js → de-ST4H423D.js} +6 -3
  8. package/dist/{de-UHAOVDS3.js.map → de-ST4H423D.js.map} +1 -1
  9. package/dist/{es-6TJS2L7S.js → es-WEC5NHRT.js} +6 -3
  10. package/dist/{es-6TJS2L7S.js.map → es-WEC5NHRT.js.map} +1 -1
  11. package/dist/{fr-MMSY7FPE.js → fr-SGBFNAQV.js} +6 -3
  12. package/dist/{fr-MMSY7FPE.js.map → fr-SGBFNAQV.js.map} +1 -1
  13. package/dist/index.cjs +251 -39
  14. package/dist/index.cjs.map +1 -1
  15. package/dist/index.d.cts +119 -4
  16. package/dist/index.d.ts +119 -4
  17. package/dist/index.js +152 -24
  18. package/dist/index.js.map +1 -1
  19. package/dist/{types-DBnRO2hX.d.cts → types-CE63BK0j.d.cts} +88 -10
  20. package/dist/{types-DBnRO2hX.d.ts → types-CE63BK0j.d.ts} +88 -10
  21. package/dist/view/panoramaDelivery.cjs +126 -0
  22. package/dist/view/panoramaDelivery.cjs.map +1 -0
  23. package/dist/view/panoramaDelivery.d.cts +34 -0
  24. package/dist/view/panoramaDelivery.d.ts +34 -0
  25. package/dist/view/panoramaDelivery.js +17 -0
  26. package/dist/view/panoramaDelivery.js.map +1 -0
  27. package/dist/view3d/crossfade/panorama.cjs +453 -0
  28. package/dist/view3d/crossfade/panorama.cjs.map +1 -0
  29. package/dist/view3d/crossfade/panorama.d.cts +110 -0
  30. package/dist/view3d/crossfade/panorama.d.ts +110 -0
  31. package/dist/view3d/crossfade/panorama.js +30 -0
  32. package/dist/view3d/crossfade/panorama.js.map +1 -0
  33. package/dist/view3d/index.cjs +1161 -128
  34. package/dist/view3d/index.cjs.map +1 -1
  35. package/dist/view3d/index.d.cts +60 -35
  36. package/dist/view3d/index.d.ts +60 -35
  37. package/dist/view3d/index.js +945 -300
  38. package/dist/view3d/index.js.map +1 -1
  39. package/package.json +28 -1
  40. package/dist/chunk-5MRJCIZP.js.map +0 -1
@@ -7,7 +7,20 @@ import {
7
7
  pointInPolygonWithHoles,
8
8
  resolveSection,
9
9
  sectionGeometry
10
- } from "../chunk-5MRJCIZP.js";
10
+ } from "../chunk-TCTAS4Z2.js";
11
+ import {
12
+ MAX_PITCH_DEG,
13
+ VFOV_DEG,
14
+ clampPanoramaFov,
15
+ mountPanorama,
16
+ seatViewDisclosure
17
+ } from "../chunk-FVCOOLJO.js";
18
+ import {
19
+ browserPanoramaConstraints,
20
+ loadPanoramaImage,
21
+ planPanoramaDelivery,
22
+ schedulePanoramaUpgrade
23
+ } from "../chunk-AZODENEL.js";
11
24
 
12
25
  // src/view3d/index.ts
13
26
  import { Quat as Quat2, Vec3 as Vec33 } from "ogl";
@@ -110,6 +123,12 @@ var GLContext = class {
110
123
  this.canvas.style.width = "100%";
111
124
  this.canvas.style.height = "100%";
112
125
  this.canvas.style.touchAction = "none";
126
+ this.canvas.tabIndex = 0;
127
+ this.canvas.setAttribute("role", "application");
128
+ this.canvas.setAttribute(
129
+ "aria-label",
130
+ "Interactive 3D venue. Drag or use arrow keys to look around; pinch, scroll, plus or minus to zoom."
131
+ );
113
132
  this.renderer = new Renderer({
114
133
  canvas: this.canvas,
115
134
  dpr: computeDpr(),
@@ -218,6 +237,10 @@ var OrbitCamera = class {
218
237
  /** Venue centre + radius, so pan can be clamped to somewhere still useful. */
219
238
  this.panAnchor = new Vec3();
220
239
  this.panLimit = 0;
240
+ /** What an unmodified primary-button / one-finger drag does. Section drill-in
241
+ * switches this to pan so a buyer can slide hidden seats into view without
242
+ * discovering a modifier gesture. Shift temporarily inverts the mode. */
243
+ this.primaryDragMode = "orbit";
221
244
  this.camera = new Camera(gl, { fov: FOV, near: 0.1, far: 5e3, aspect: 1 });
222
245
  this.canvas = canvas;
223
246
  this.requestRender = requestRender;
@@ -229,7 +252,9 @@ var OrbitCamera = class {
229
252
  }
230
253
  this.activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
231
254
  if (this.activePointers.size === 1) {
232
- this.panning = e.button === 2 || e.button === 1 || e.shiftKey;
255
+ const secondaryPan = e.button === 2 || e.button === 1;
256
+ const primaryPan = e.button === 0 && (e.shiftKey ? this.primaryDragMode === "orbit" : this.primaryDragMode === "pan");
257
+ this.panning = secondaryPan || primaryPan;
233
258
  this.dragging = !this.panning;
234
259
  this.lastX = e.clientX;
235
260
  this.lastY = e.clientY;
@@ -295,15 +320,38 @@ var OrbitCamera = class {
295
320
  this.onWheel = (e) => {
296
321
  e.preventDefault();
297
322
  const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? 100 : 1;
298
- const norm2 = e.deltaY * unit / 100;
299
- this.dollyBy(Math.exp(norm2 * 0.4));
323
+ const norm2 = Math.max(-2, Math.min(2, e.deltaY * unit / 100));
324
+ this.dollyBy(Math.exp(norm2 * 0.22));
325
+ this.fireGesture();
326
+ };
327
+ this.onKeyDown = (e) => {
328
+ const step = 7 * DEG;
329
+ if (e.shiftKey && (e.key === "ArrowLeft" || e.key === "ArrowRight" || e.key === "ArrowUp" || e.key === "ArrowDown")) {
330
+ this.panBy(
331
+ e.key === "ArrowLeft" ? -32 : e.key === "ArrowRight" ? 32 : 0,
332
+ e.key === "ArrowUp" ? -32 : e.key === "ArrowDown" ? 32 : 0
333
+ );
334
+ } else if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
335
+ this.azT += e.key === "ArrowLeft" ? -step : step;
336
+ } else if (e.key === "ArrowUp" || e.key === "ArrowDown") {
337
+ this.polT = Math.max(POLAR_MIN, Math.min(POLAR_MAX, this.polT + (e.key === "ArrowUp" ? -step : step)));
338
+ } else if (e.key === "+" || e.key === "=") {
339
+ this.dollyBy(0.82);
340
+ } else if (e.key === "-" || e.key === "_") {
341
+ this.dollyBy(1.22);
342
+ } else {
343
+ return;
344
+ }
345
+ e.preventDefault();
300
346
  this.fireGesture();
347
+ this.requestRender();
301
348
  };
302
349
  canvas.addEventListener("pointerdown", this.onPointerDown);
303
350
  canvas.addEventListener("pointermove", this.onPointerMove);
304
351
  canvas.addEventListener("pointerup", this.onPointerUp);
305
352
  canvas.addEventListener("pointercancel", this.onPointerUp);
306
353
  canvas.addEventListener("wheel", this.onWheel, { passive: false });
354
+ canvas.addEventListener("keydown", this.onKeyDown);
307
355
  canvas.addEventListener("contextmenu", this.onContextMenu);
308
356
  }
309
357
  pinchCentroid() {
@@ -371,20 +419,32 @@ var OrbitCamera = class {
371
419
  this.distT = Math.max(this.minDist, Math.min(this.maxDist, this.distT * factor));
372
420
  this.requestRender();
373
421
  }
422
+ /** Programmatic zoom used by the visible camera controls. Keeping it on the
423
+ * same dolly path as wheel, pinch and keyboard preserves all distance limits. */
424
+ zoomBy(factor) {
425
+ this.dollyBy(factor);
426
+ this.fireGesture();
427
+ }
428
+ /** Choose what a normal left/one-finger drag does. Shift temporarily performs
429
+ * the other action. This is intentionally public so the view can make the
430
+ * currently active gesture visible rather than hiding it in documentation. */
431
+ setPrimaryDragMode(mode) {
432
+ this.primaryDragMode = mode;
433
+ }
374
434
  /**
375
435
  * Fit a flattering 3/4 view to the bounds sphere. With `intro`, the camera
376
436
  * STARTS nearly top-down (matching the 2D map's orientation) and further out,
377
437
  * then the damped `update()` eases it up into the 3/4 architectural angle and
378
438
  * dollies in — the venue "stands up" instead of teleporting (~600ms).
379
439
  */
380
- frame(bounds, intro = false, stageAzimuth) {
440
+ frame(bounds, intro = false, stageAzimuth, portraitCrop = false) {
381
441
  this.target.set(bounds.center[0], bounds.center[1], bounds.center[2]);
382
442
  this.targetT.copy(this.target);
383
443
  this.panAnchor.copy(this.target);
384
444
  this.panLimit = Math.max(1, bounds.radius) * 1.5;
385
445
  const r = Math.max(1, bounds.radius);
386
446
  const halfV = this.fovY * DEG / 2;
387
- const aspect = this.camera.aspect || 1;
447
+ const aspect = portraitCrop ? Math.max(0.82, this.camera.aspect || 1) : this.camera.aspect || 1;
388
448
  const halfH = Math.atan(Math.tan(halfV) * aspect);
389
449
  const fit = Math.max(r / Math.tan(halfV), r / Math.tan(halfH));
390
450
  this.azT = stageAzimuth ?? -30 * DEG;
@@ -411,7 +471,7 @@ var OrbitCamera = class {
411
471
  * seat, behind the shell, anywhere): re-pivot on the venue centre without
412
472
  * moving the camera, then glide targets back to the 3/4 architectural pose.
413
473
  */
414
- frameSoft(bounds, stageAzimuth) {
474
+ frameSoft(bounds, stageAzimuth, portraitCrop = false) {
415
475
  this.target.set(bounds.center[0], bounds.center[1], bounds.center[2]);
416
476
  this.targetT.copy(this.target);
417
477
  this.panAnchor.copy(this.target);
@@ -420,7 +480,7 @@ var OrbitCamera = class {
420
480
  this.camera.perspective({ fov: this.fovY, aspect: this.camera.aspect });
421
481
  const r = Math.max(1, bounds.radius);
422
482
  const halfV = this.fovY * DEG / 2;
423
- const aspect = this.camera.aspect || 1;
483
+ const aspect = portraitCrop ? Math.max(0.82, this.camera.aspect || 1) : this.camera.aspect || 1;
424
484
  const halfH = Math.atan(Math.tan(halfV) * aspect);
425
485
  const fit = Math.max(r / Math.tan(halfV), r / Math.tan(halfH));
426
486
  this.azT = stageAzimuth ?? this.azimuth;
@@ -493,6 +553,7 @@ var OrbitCamera = class {
493
553
  this.canvas.removeEventListener("pointerup", this.onPointerUp);
494
554
  this.canvas.removeEventListener("pointercancel", this.onPointerUp);
495
555
  this.canvas.removeEventListener("wheel", this.onWheel);
556
+ this.canvas.removeEventListener("keydown", this.onKeyDown);
496
557
  this.canvas.removeEventListener("contextmenu", this.onContextMenu);
497
558
  this.activePointers.clear();
498
559
  }
@@ -1349,6 +1410,7 @@ function buildSeatInstances(seats, initial, surfaces, seatFloor, categoryColor)
1349
1410
  const iCategory = new Float32Array(count * 3);
1350
1411
  const iMaxRadius = new Float32Array(count);
1351
1412
  const iChairWidth = new Float32Array(count);
1413
+ const iPhysicalSeat = new Float32Array(count);
1352
1414
  const iRing = new Float32Array(count * 3);
1353
1415
  const idToIndex = /* @__PURE__ */ new Map();
1354
1416
  const spacing = nearestNeighbourSpacing(seats);
@@ -1358,6 +1420,7 @@ function buildSeatInstances(seats, initial, surfaces, seatFloor, categoryColor)
1358
1420
  const pitchM = (resolved ?? spacing[i]) * M;
1359
1421
  iMaxRadius[i] = Number.isFinite(pitchM) ? Math.max(0.06, Math.min(SEAT_DOT_RADIUS_M, pitchM * SEAT_PITCH_FRACTION)) : SEAT_DOT_RADIUS_M;
1360
1422
  iChairWidth[i] = chairHalfWidth(Number.isFinite(pitchM) ? pitchM : void 0);
1423
+ iPhysicalSeat[i] = seat.wheelchairSpaceType === "no-seat" ? 0 : 1;
1361
1424
  iPosition[i * 3] = seat.x * M;
1362
1425
  iPosition[i * 3 + 1] = surfaces ? surfaces.seatDeckY(i) : seatSurfaceY(seat);
1363
1426
  iPosition[i * 3 + 2] = seat.y * M;
@@ -1385,6 +1448,7 @@ function buildSeatInstances(seats, initial, surfaces, seatFloor, categoryColor)
1385
1448
  iRing,
1386
1449
  idToIndex,
1387
1450
  iChairWidth,
1451
+ iPhysicalSeat,
1388
1452
  iFloor: seatFloor ?? new Float32Array(count),
1389
1453
  iYaw: new Float32Array(count)
1390
1454
  };
@@ -2420,15 +2484,22 @@ function buildVenueSurfaces(units, seats) {
2420
2484
  }
2421
2485
 
2422
2486
  // src/view3d/scene/sceneModel.ts
2423
- function floorUnits(doc) {
2487
+ function shapeVisibleInConfiguration(shape, activeConfigurationId) {
2488
+ const ids = shape.eventConfigurationIds;
2489
+ return !ids?.length || !!activeConfigurationId && ids.includes(activeConfigurationId);
2490
+ }
2491
+ function visibleObjects(objects, activeConfigurationId) {
2492
+ return objects.filter((object) => object.type !== "shape" || shapeVisibleInConfiguration(object, activeConfigurationId));
2493
+ }
2494
+ function floorUnits(doc, activeConfigurationId) {
2424
2495
  if (doc.floors?.length) {
2425
2496
  return doc.floors.map((f) => ({
2426
- objects: f.objects,
2497
+ objects: visibleObjects(f.objects, activeConfigurationId),
2427
2498
  focal: f.focalPoint ?? doc.focalPoint,
2428
2499
  baseHeightM: f.baseHeightM ?? 0
2429
2500
  }));
2430
2501
  }
2431
- return [{ objects: doc.objects, focal: doc.focalPoint, baseHeightM: 0 }];
2502
+ return [{ objects: visibleObjects(doc.objects, activeConfigurationId), focal: doc.focalPoint, baseHeightM: 0 }];
2432
2503
  }
2433
2504
  var AO = { top: 1, wallBottom: 0.5, bottomCap: 0.4 };
2434
2505
  function tintTop(fill, neutral) {
@@ -2709,23 +2780,114 @@ function buildTable(builder, table, base, fill, S) {
2709
2780
  AO
2710
2781
  );
2711
2782
  }
2783
+ function rotateShapePolygon(poly, degrees) {
2784
+ if (!degrees) return poly;
2785
+ let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
2786
+ for (const point of poly) {
2787
+ minX = Math.min(minX, point.x);
2788
+ maxX = Math.max(maxX, point.x);
2789
+ minY = Math.min(minY, point.y);
2790
+ maxY = Math.max(maxY, point.y);
2791
+ }
2792
+ const cx = (minX + maxX) / 2, cy = (minY + maxY) / 2;
2793
+ const radians = degrees * Math.PI / 180;
2794
+ const cos = Math.cos(radians), sin = Math.sin(radians);
2795
+ return poly.map((point) => {
2796
+ const dx = point.x - cx, dy = point.y - cy;
2797
+ return { x: cx + dx * cos - dy * sin, y: cy + dx * sin + dy * cos };
2798
+ });
2799
+ }
2712
2800
  function shapePolygon(shape) {
2713
- if (shape.kind === "polygon" && shape.points && shape.points.length >= 3) return shape.points;
2801
+ let poly = null;
2802
+ if (shape.kind === "polygon" && shape.points && shape.points.length >= 3) poly = shape.points;
2714
2803
  if (shape.kind === "rect" && shape.width && shape.height) {
2715
- return rectPolygon(shape.x ?? 0, shape.y ?? 0, shape.width, shape.height);
2804
+ poly = rectPolygon(shape.x ?? 0, shape.y ?? 0, shape.width, shape.height);
2716
2805
  }
2717
2806
  if (shape.kind === "ellipse" && shape.width && shape.height) {
2718
2807
  const cx = (shape.x ?? 0) + shape.width / 2;
2719
2808
  const cy = (shape.y ?? 0) + shape.height / 2;
2720
- return ellipsePolygon(cx, cy, shape.width / 2, shape.height / 2);
2809
+ poly = ellipsePolygon(cx, cy, shape.width / 2, shape.height / 2);
2721
2810
  }
2722
- return null;
2811
+ return poly ? rotateShapePolygon(poly, shape.rotation) : null;
2812
+ }
2813
+ function architectureForShape(shape) {
2814
+ const role = shape.role ?? "";
2815
+ const defaults = {
2816
+ stage: { kind: "stage", heightM: 1 },
2817
+ entrance: { kind: "portal", heightM: 2.7 },
2818
+ exit: { kind: "portal", heightM: 2.7 },
2819
+ screen: { kind: "solid", heightM: 6 },
2820
+ wall: { kind: "solid", heightM: 2.8 },
2821
+ rail: { kind: "solid", heightM: 1.1 },
2822
+ suite: { kind: "suite", heightM: 3 },
2823
+ obstruction: { kind: "solid", heightM: 4.5 },
2824
+ sound: { kind: "solid", heightM: 1.45 },
2825
+ restroom: { kind: "solid", heightM: 2.4 },
2826
+ bar: { kind: "solid", heightM: 1.1 },
2827
+ concession: { kind: "solid", heightM: 1.1 },
2828
+ coat: { kind: "solid", heightM: 1.1 }
2829
+ };
2830
+ const resolved = defaults[role] ?? { kind: "plate", heightM: 0.25 };
2831
+ return { ...resolved, heightM: shape.heightM ?? resolved.heightM };
2723
2832
  }
2724
- function buildShape(builder, shape, base, S, stages) {
2833
+ var SHAPE_LAYER_LIFT_M = 0.08;
2834
+ function claimShapeTopOffset(shape, claimed) {
2835
+ const poly = shapePolygon(shape);
2836
+ const architecture = architectureForShape(shape);
2837
+ const defaultTop = architecture.heightM;
2838
+ if (!poly) return defaultTop;
2839
+ if (architecture.kind !== "plate" && architecture.kind !== "stage") return defaultTop;
2840
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
2841
+ for (const p of poly) {
2842
+ minX = Math.min(minX, p.x);
2843
+ minY = Math.min(minY, p.y);
2844
+ maxX = Math.max(maxX, p.x);
2845
+ maxY = Math.max(maxY, p.y);
2846
+ }
2847
+ let topOffsetM = defaultTop;
2848
+ for (const prior of claimed) {
2849
+ const overlaps = maxX > prior.minX && minX < prior.maxX && maxY > prior.minY && minY < prior.maxY;
2850
+ if (overlaps && topOffsetM <= prior.topOffsetM + 1e-6) {
2851
+ topOffsetM = prior.topOffsetM + SHAPE_LAYER_LIFT_M;
2852
+ }
2853
+ }
2854
+ claimed.push({ minX, minY, maxX, maxY, topOffsetM });
2855
+ return topOffsetM;
2856
+ }
2857
+ function lerpPoint(a, b, t) {
2858
+ return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
2859
+ }
2860
+ function orientQuadLong(poly) {
2861
+ if (poly.length !== 4) return poly;
2862
+ const edge0 = Math.hypot(poly[1].x - poly[0].x, poly[1].y - poly[0].y);
2863
+ const edge1 = Math.hypot(poly[2].x - poly[1].x, poly[2].y - poly[1].y);
2864
+ return edge0 >= edge1 ? poly : [poly[1], poly[2], poly[3], poly[0]];
2865
+ }
2866
+ function quadWidthSlice(poly, t0, t1) {
2867
+ return [
2868
+ lerpPoint(poly[0], poly[1], t0),
2869
+ lerpPoint(poly[0], poly[1], t1),
2870
+ lerpPoint(poly[3], poly[2], t1),
2871
+ lerpPoint(poly[3], poly[2], t0)
2872
+ ];
2873
+ }
2874
+ function quadDepthSlice(poly, t0, t1) {
2875
+ return [
2876
+ lerpPoint(poly[0], poly[3], t0),
2877
+ lerpPoint(poly[1], poly[2], t0),
2878
+ lerpPoint(poly[1], poly[2], t1),
2879
+ lerpPoint(poly[0], poly[3], t1)
2880
+ ];
2881
+ }
2882
+ function emitArchitecturalPrism(builder, poly, top, bottom, colTop, colWall) {
2883
+ extrudePrism(builder, poly, void 0, () => top, bottom, colTop, colWall, AO);
2884
+ }
2885
+ function buildShape(builder, shape, base, topOffsetM, S, focal, stages) {
2725
2886
  const poly = shapePolygon(shape);
2726
2887
  if (!poly) return;
2727
2888
  const isStage = shape.role === "stage";
2728
- const height = isStage ? base + 1 : base + 0.25;
2889
+ const architecture = architectureForShape(shape);
2890
+ const height = base + topOffsetM;
2729
2891
  if (isStage && stages) {
2730
2892
  let minX = Infinity, maxX = -Infinity, minZ = Infinity, maxZ = -Infinity;
2731
2893
  for (const pt of poly) {
@@ -2743,8 +2905,29 @@ function buildShape(builder, shape, base, S, stages) {
2743
2905
  });
2744
2906
  }
2745
2907
  const colTop = tintTop(hexToRgb(shape.fill), isStage ? S.stageTop : S.decorTop);
2746
- const colWall = isStage ? S.stageWall : S.decorWall;
2747
- extrudePrism(builder, poly, void 0, () => height, base, colTop, colWall, AO);
2908
+ const colWall = isStage ? S.stageWall : shape.role === "screen" ? [0.24, 0.46, 0.82] : tintTop(hexToRgb(shape.fill), S.decorWall);
2909
+ if (architecture.kind === "portal" && poly.length === 4) {
2910
+ const quad = orientQuadLong(poly);
2911
+ emitArchitecturalPrism(builder, quadWidthSlice(quad, 0, 0.16), height, base, colTop, colWall);
2912
+ emitArchitecturalPrism(builder, quadWidthSlice(quad, 0.84, 1), height, base, colTop, colWall);
2913
+ emitArchitecturalPrism(builder, quad, height, Math.max(base, height - 0.35), colTop, colWall);
2914
+ return;
2915
+ }
2916
+ if (architecture.kind === "suite" && poly.length === 4) {
2917
+ const edgeDistances = poly.map((point, index) => {
2918
+ const next = poly[(index + 1) % 4];
2919
+ const mx = (point.x + next.x) / 2, my = (point.y + next.y) / 2;
2920
+ return Math.hypot(mx - focal.x, my - focal.y);
2921
+ });
2922
+ const front = edgeDistances.indexOf(Math.min(...edgeDistances));
2923
+ const quad = [0, 1, 2, 3].map((offset) => poly[(front + offset) % 4]);
2924
+ emitArchitecturalPrism(builder, quadWidthSlice(quad, 0, 0.08), height, base, colTop, colWall);
2925
+ emitArchitecturalPrism(builder, quadWidthSlice(quad, 0.92, 1), height, base, colTop, colWall);
2926
+ emitArchitecturalPrism(builder, quadDepthSlice(quad, 0.88, 1), height, base, colTop, colWall);
2927
+ emitArchitecturalPrism(builder, quad, height, Math.max(base, height - 0.18), colTop, colWall);
2928
+ return;
2929
+ }
2930
+ emitArchitecturalPrism(builder, poly, height, base, colTop, colWall);
2748
2931
  }
2749
2932
  function decorPolygon(decor) {
2750
2933
  const { x, y, width, height } = decor;
@@ -2842,7 +3025,7 @@ function buildSceneModel(input) {
2842
3025
  const { doc, seats } = input;
2843
3026
  const theme = resolveTheme3D(doc.theme);
2844
3027
  const S = theme.structure;
2845
- const units = floorUnits(doc);
3028
+ const units = floorUnits(doc, input.eventConfigurationId ?? doc.eventConfigurationId);
2846
3029
  const builder = new MeshBuilder();
2847
3030
  const fp = chartFootprint(units, seats);
2848
3031
  const padU = Math.max(60, (fp.maxX - fp.minX + fp.maxY - fp.minY) * 0.06);
@@ -2858,10 +3041,19 @@ function buildSceneModel(input) {
2858
3041
  const unit = units[unitIndex];
2859
3042
  builder.setFloor(unitIndex);
2860
3043
  const claimed = new ClaimedArea();
3044
+ const claimedShapeLayers = [];
2861
3045
  const siblings = unit.objects.filter((o) => o.type === "section" && !!o.outline && o.outline.length >= 3);
2862
3046
  for (const o of unit.objects) {
2863
3047
  if (o.type === "section") buildTier(builder, o, unit, sectionFill(o, sectionFills), surfaces.bySection.get(o.id), claimed, siblings, S);
2864
- else if (o.type === "shape") buildShape(builder, o, unit.baseHeightM, S, stageBounds);
3048
+ else if (o.type === "shape") buildShape(
3049
+ builder,
3050
+ o,
3051
+ unit.baseHeightM,
3052
+ claimShapeTopOffset(o, claimedShapeLayers),
3053
+ S,
3054
+ unit.focal,
3055
+ stageBounds
3056
+ );
2865
3057
  else if (o.type === "gaArea") buildGa(builder, o, unit.baseHeightM, hexToRgb(catColor.get(o.categoryKey)), S);
2866
3058
  else if (o.type === "booth") buildBooth(builder, o, unit.baseHeightM, hexToRgb(catColor.get(o.categoryKey)), S);
2867
3059
  else if (o.type === "table") buildTable(builder, o, unit.baseHeightM, hexToRgb(catColor.get(o.categoryKey)), S);
@@ -2931,6 +3123,7 @@ function buildSceneModel(input) {
2931
3123
  }
2932
3124
  const labels = [];
2933
3125
  const sections = [];
3126
+ const rows = [];
2934
3127
  for (const z of zones) {
2935
3128
  if (z.seatCount === 0) continue;
2936
3129
  labels.push({
@@ -3027,6 +3220,49 @@ function buildSceneModel(input) {
3027
3220
  anchor: [end.seat.x * M, surfaces.seatDeckY(end.index) + ROW_LABEL_LIFT_M, end.seat.y * M]
3028
3221
  });
3029
3222
  }
3223
+ const rowAcc = /* @__PURE__ */ new Map();
3224
+ for (let i = 0; i < seats.length; i++) {
3225
+ const seat = seats[i];
3226
+ const cut = seat.label ? seat.label.lastIndexOf("-") : -1;
3227
+ let acc = rowAcc.get(seat.rowId);
3228
+ if (!acc) {
3229
+ acc = {
3230
+ n: 0,
3231
+ x: 0,
3232
+ y: 0,
3233
+ z: 0,
3234
+ minX: Infinity,
3235
+ maxX: -Infinity,
3236
+ minZ: Infinity,
3237
+ maxZ: -Infinity,
3238
+ label: cut > 0 ? seat.label.slice(0, cut) : seat.rowId,
3239
+ sectionId: seat.sectionId
3240
+ };
3241
+ rowAcc.set(seat.rowId, acc);
3242
+ }
3243
+ const offset = i * 3;
3244
+ const x = seatData.iPosition[offset];
3245
+ const y = seatData.iPosition[offset + 1];
3246
+ const z = seatData.iPosition[offset + 2];
3247
+ acc.n++;
3248
+ acc.x += x;
3249
+ acc.y += y;
3250
+ acc.z += z;
3251
+ acc.minX = Math.min(acc.minX, x);
3252
+ acc.maxX = Math.max(acc.maxX, x);
3253
+ acc.minZ = Math.min(acc.minZ, z);
3254
+ acc.maxZ = Math.max(acc.maxZ, z);
3255
+ }
3256
+ for (const [id, acc] of rowAcc) {
3257
+ rows.push({
3258
+ id,
3259
+ label: acc.label,
3260
+ sectionId: acc.sectionId,
3261
+ seatCount: acc.n,
3262
+ center: [acc.x / acc.n, acc.y / acc.n, acc.z / acc.n],
3263
+ radius: Math.max(0.8, Math.hypot(acc.maxX - acc.minX, acc.maxZ - acc.minZ) * 0.5)
3264
+ });
3265
+ }
3030
3266
  if (seats.length <= SEAT_LABEL_MAX) {
3031
3267
  for (let i = 0; i < seats.length; i++) {
3032
3268
  const s = seats[i];
@@ -3043,7 +3279,18 @@ function buildSceneModel(input) {
3043
3279
  }
3044
3280
  for (const unit of units) {
3045
3281
  for (const o of unit.objects) {
3046
- if (o.type === "text") {
3282
+ if (o.type === "shape" && o.role && ["entrance", "exit", "screen", "suite", "obstruction"].includes(o.role)) {
3283
+ const poly = shapePolygon(o);
3284
+ const centre = poly ? centroidOf(poly) : null;
3285
+ if (!centre || !o.label) continue;
3286
+ labels.push({
3287
+ id: `architecture:${o.id}`,
3288
+ kind: "annotation",
3289
+ text: o.label,
3290
+ anchor: [centre.x * M, unit.baseHeightM + architectureForShape(o).heightM + ANNOTATION_LIFT_M, centre.y * M],
3291
+ color: o.fill
3292
+ });
3293
+ } else if (o.type === "text") {
3047
3294
  if (!o.text) continue;
3048
3295
  labels.push({
3049
3296
  id: `text:${o.id}`,
@@ -3125,6 +3372,7 @@ function buildSceneModel(input) {
3125
3372
  zones,
3126
3373
  stages: stageBounds,
3127
3374
  sections,
3375
+ rows,
3128
3376
  labels,
3129
3377
  floors
3130
3378
  };
@@ -3158,6 +3406,7 @@ var LabelOverlay = class {
3158
3406
  constructor(container, opts = {}) {
3159
3407
  this.nodes = /* @__PURE__ */ new Map();
3160
3408
  this.labels = [];
3409
+ this.forcedDense = false;
3161
3410
  this.opts = opts;
3162
3411
  this.root = document.createElement("div");
3163
3412
  this.root.setAttribute("data-view3d-labels", "");
@@ -3192,6 +3441,11 @@ var LabelOverlay = class {
3192
3441
  }
3193
3442
  }
3194
3443
  }
3444
+ /** Section/row drill-in makes their dense labels explicit, regardless of the
3445
+ * whole-venue distance heuristic used during free orbit. */
3446
+ setForcedDense(enabled) {
3447
+ this.forcedDense = enabled;
3448
+ }
3195
3449
  /**
3196
3450
  * Reposition every label for the current camera.
3197
3451
  *
@@ -3200,6 +3454,10 @@ var LabelOverlay = class {
3200
3454
  update(viewProjection, width, height, cameraDistance, venueRadius, cameraWorld) {
3201
3455
  if (!this.labels.length) return;
3202
3456
  const kinds = visibleLabelKinds(cameraDistance, venueRadius);
3457
+ if (this.forcedDense) {
3458
+ kinds.add("row");
3459
+ kinds.add("seat");
3460
+ }
3203
3461
  const candidates = [];
3204
3462
  for (const label of this.labels) {
3205
3463
  if (!kinds.has(label.kind)) continue;
@@ -3348,6 +3606,7 @@ in vec2 position; // quad corner in [-1,1]
3348
3606
  in vec3 iOffset; // per-instance world position
3349
3607
  in vec3 iColor; // per-instance state colour (resolved CPU-side)
3350
3608
  in float iMaxRadius; // per-instance world-radius ceiling (seat pitch derived)
3609
+ in float iPhysicalSeat;// 1 = chair; 0 = empty wheelchair bay
3351
3610
  in vec3 iRing; // accommodation ring colour; (0,0,0) = not accessible
3352
3611
  in float iFloor; // owning floor index
3353
3612
  uniform mat4 modelViewMatrix;
@@ -3365,6 +3624,7 @@ out float vBudget; // 1 = dot holds its minimum pixel size, <1 = it cannot
3365
3624
  out vec3 vRing;
3366
3625
  out float vDim;
3367
3626
  out float vDotWeight; // 1 = the dot IS this seat, 0 = the chair has taken over
3627
+ out float vPhysicalSeat;
3368
3628
  ${CHAIR_WEIGHT_GLSL}
3369
3629
  void main() {
3370
3630
  vec4 mv = modelViewMatrix * vec4(iOffset, 1.0);
@@ -3394,6 +3654,7 @@ void main() {
3394
3654
  vUv = position;
3395
3655
  vColor = iColor;
3396
3656
  vRing = iRing;
3657
+ vPhysicalSeat = iPhysicalSeat;
3397
3658
  vDim = (uFocusFloor < -0.5 || abs(iFloor - uFocusFloor) < 0.5) ? 0.0 : 1.0;
3398
3659
  gl_Position = projectionMatrix * mv;
3399
3660
  }`
@@ -3408,11 +3669,13 @@ in float vBudget;
3408
3669
  in vec3 vRing;
3409
3670
  in float vDim;
3410
3671
  in float vDotWeight;
3672
+ in float vPhysicalSeat;
3411
3673
  uniform float uSeatFade; // fade toward tier colour with distance (LOD)
3412
3674
  uniform vec3 uFadeColor;
3413
3675
  out vec4 fragColor;
3414
3676
  void main() {
3415
- float d = length(vUv);
3677
+ // Empty wheelchair provision is a square bay, never a round chair marker.
3678
+ float d = mix(max(abs(vUv.x), abs(vUv.y)), length(vUv), vPhysicalSeat);
3416
3679
  if (d > 1.0) discard;
3417
3680
  float alpha = smoothstep(1.0, 0.72, d);
3418
3681
  float shade = 0.80 + 0.28 * (0.5 - vUv.y * 0.5); // subtle top-lit
@@ -3454,6 +3717,7 @@ in float iYaw; // per-instance facing, radians (local +Z -> facing dir)
3454
3717
  in vec3 iRing; // accommodation ring colour; (0,0,0) = not accessible
3455
3718
  in float iFloor;
3456
3719
  in float iSeed; // <0 = seat is empty; else per-person hash in [0,1)
3720
+ in float iPhysicalSeat;// 1 = chair; 0 = empty wheelchair bay
3457
3721
  uniform mat4 modelViewMatrix;
3458
3722
  uniform mat4 projectionMatrix;
3459
3723
  uniform float uChairFull;
@@ -3489,7 +3753,9 @@ void main() {
3489
3753
  float occupant = step(2.5, part);
3490
3754
  float taken = step(0.0, iSeed);
3491
3755
  vOccupant = occupant;
3492
- if (occupant > 0.5) {
3756
+ if (iPhysicalSeat < 0.5) {
3757
+ p = vec3(0.0); // sellable space, but no physical chair/person
3758
+ } else if (occupant > 0.5) {
3493
3759
  if (taken < 0.5) {
3494
3760
  p = vec3(0.0); // empty seat: no person
3495
3761
  } else {
@@ -3632,6 +3898,7 @@ precision highp float;
3632
3898
  in vec2 position;
3633
3899
  in vec3 iOffset;
3634
3900
  in float iMaxRadius;
3901
+ in float iPhysicalSeat;
3635
3902
  uniform mat4 modelViewMatrix;
3636
3903
  uniform mat4 projectionMatrix;
3637
3904
  uniform float uSeatRadius;
@@ -3639,6 +3906,7 @@ uniform float uSeatScale;
3639
3906
  uniform float uMinPixels;
3640
3907
  uniform float uPixelToWorld;
3641
3908
  out vec2 vUv;
3909
+ out float vPhysicalSeat;
3642
3910
  flat out vec3 vPick;
3643
3911
  void main() {
3644
3912
  int id = gl_InstanceID + 1; // 0 reserved for no-hit
@@ -3651,6 +3919,7 @@ void main() {
3651
3919
  // Must match SEAT_VERT exactly, or the hit mask drifts off the drawn dot.
3652
3920
  mv.xy += normalize(vec3(modelViewMatrix * vec4(0.0, 1.0, 0.0, 0.0))).xy * r;
3653
3921
  vUv = position;
3922
+ vPhysicalSeat = iPhysicalSeat;
3654
3923
  gl_Position = projectionMatrix * mv;
3655
3924
  }`
3656
3925
  );
@@ -3659,10 +3928,12 @@ var SEAT_PICK_FRAG = (
3659
3928
  `#version 300 es
3660
3929
  precision highp float;
3661
3930
  in vec2 vUv;
3931
+ in float vPhysicalSeat;
3662
3932
  flat in vec3 vPick;
3663
3933
  out vec4 fragColor;
3664
3934
  void main() {
3665
- if (length(vUv) > 1.0) discard; // round hit-mask matches the dot
3935
+ float d = mix(max(abs(vUv.x), abs(vUv.y)), length(vUv), vPhysicalSeat);
3936
+ if (d > 1.0) discard; // hit-mask matches chair/bay shape
3666
3937
  fragColor = vec4(vPick, 1.0);
3667
3938
  }`
3668
3939
  );
@@ -3824,6 +4095,7 @@ function buildGpuScene(gl, model) {
3824
4095
  // Per-seat world-radius ceiling: what stops distant rows merging into one
3825
4096
  // mass when the shader grows a dot to hold its minimum pixel size.
3826
4097
  iMaxRadius: { size: 1, data: model.seats.iMaxRadius, instanced: 1 },
4098
+ iPhysicalSeat: { size: 1, data: model.seats.iPhysicalSeat, instanced: 1 },
3827
4099
  // Accommodation ring colour; (0,0,0) means the seat carries no access type.
3828
4100
  iRing: { size: 3, data: model.seats.iRing, instanced: 1 },
3829
4101
  iFloor: { size: 1, data: model.seats.iFloor, instanced: 1 }
@@ -3841,6 +4113,7 @@ function buildGpuScene(gl, model) {
3841
4113
  const cYaw = new Float32Array(CAP);
3842
4114
  const cRing = new Float32Array(CAP * 3);
3843
4115
  const cFloor = new Float32Array(CAP);
4116
+ const cPhysicalSeat = new Float32Array(CAP);
3844
4117
  const cSeed = new Float32Array(CAP);
3845
4118
  const chairGeo = new Geometry(gl, {
3846
4119
  position: { size: 3, data: chairBase.position },
@@ -3853,6 +4126,7 @@ function buildGpuScene(gl, model) {
3853
4126
  iYaw: { size: 1, data: cYaw, instanced: 1 },
3854
4127
  iRing: { size: 3, data: cRing, instanced: 1 },
3855
4128
  iFloor: { size: 1, data: cFloor, instanced: 1 },
4129
+ iPhysicalSeat: { size: 1, data: cPhysicalSeat, instanced: 1 },
3856
4130
  iSeed: { size: 1, data: cSeed, instanced: 1 }
3857
4131
  });
3858
4132
  const chairMesh = new Mesh(gl, { geometry: chairGeo, program: chairProg });
@@ -3909,6 +4183,7 @@ function buildGpuScene(gl, model) {
3909
4183
  cRing[k * 3 + 1] = src.iRing[i * 3 + 1];
3910
4184
  cRing[k * 3 + 2] = src.iRing[i * 3 + 2];
3911
4185
  cFloor[k] = src.iFloor[i];
4186
+ cPhysicalSeat[k] = src.iPhysicalSeat[i];
3912
4187
  cSeed[k] = seatOccupantSeed(src.iState[i], i);
3913
4188
  }
3914
4189
  writeChairColors();
@@ -3917,6 +4192,7 @@ function buildGpuScene(gl, model) {
3917
4192
  chairGeo.attributes.iYaw.needsUpdate = true;
3918
4193
  chairGeo.attributes.iRing.needsUpdate = true;
3919
4194
  chairGeo.attributes.iFloor.needsUpdate = true;
4195
+ chairGeo.attributes.iPhysicalSeat.needsUpdate = true;
3920
4196
  chairGeo.attributes.iSeed.needsUpdate = true;
3921
4197
  chairGeo.instancedCount = n;
3922
4198
  if (!chairMesh.parent) chairMesh.setParent(main);
@@ -4247,192 +4523,17 @@ var Cinematic = class {
4247
4523
  }
4248
4524
  };
4249
4525
 
4250
- // src/view3d/crossfade/panorama.ts
4251
- var VFOV_DEG = 70;
4252
- var MAX_PITCH_DEG = 35;
4253
- function bearingToOffsetPx(bearingDeg, viewportW, bgW) {
4254
- const col = (0.5 + bearingDeg / 360) * bgW;
4255
- return viewportW / 2 - col;
4256
- }
4257
- function windowedBgHeight(viewportH, vfovDeg = VFOV_DEG) {
4258
- return viewportH * (180 / vfovDeg);
4259
- }
4260
- function horizonOffsetPy(viewportH, bgH, pitchPx) {
4261
- return (viewportH - bgH) / 2 + clampPitchPx(pitchPx, bgH);
4262
- }
4263
- function clampPitchPx(pitchPx, bgH) {
4264
- const limit = MAX_PITCH_DEG / 180 * bgH;
4265
- return Math.max(-limit, Math.min(limit, pitchPx));
4266
- }
4267
- function mountPanorama(container, view, opts = {}) {
4268
- const fadeMs = opts.fadeMs ?? 400;
4269
- const bearing = view.initialBearingDeg ?? 0;
4270
- const root = document.createElement("div");
4271
- root.setAttribute("role", "dialog");
4272
- root.setAttribute("aria-label", opts.seatLabel ? `View from ${opts.seatLabel}` : "View from seat");
4273
- Object.assign(root.style, {
4274
- position: "absolute",
4275
- inset: "0",
4276
- zIndex: "10",
4277
- opacity: "0",
4278
- transition: `opacity ${fadeMs}ms ease`,
4279
- background: "#05070c",
4280
- overflow: "hidden",
4281
- touchAction: "none"
4282
- });
4283
- const pano = document.createElement("div");
4284
- Object.assign(pano.style, {
4285
- position: "absolute",
4286
- inset: "0",
4287
- backgroundImage: `url("${view.url}")`,
4288
- backgroundRepeat: "repeat-x",
4289
- cursor: "grab"
4290
- });
4291
- root.appendChild(pano);
4292
- const closeBtn = document.createElement("button");
4293
- closeBtn.type = "button";
4294
- closeBtn.setAttribute("aria-label", "Close");
4295
- closeBtn.textContent = "\u2715";
4296
- Object.assign(closeBtn.style, {
4297
- position: "absolute",
4298
- top: "12px",
4299
- right: "12px",
4300
- zIndex: "2",
4301
- width: "34px",
4302
- height: "34px",
4303
- borderRadius: "999px",
4304
- cursor: "pointer",
4305
- border: "1px solid rgba(255,255,255,0.25)",
4306
- background: "rgba(8,12,18,0.6)",
4307
- color: "#e6edf3",
4308
- fontSize: "15px",
4309
- lineHeight: "1"
4310
- });
4311
- root.appendChild(closeBtn);
4312
- const hint = document.createElement("div");
4313
- hint.textContent = "Drag to look around \xB7 Esc to close";
4314
- Object.assign(hint.style, {
4315
- position: "absolute",
4316
- bottom: "12px",
4317
- left: "0",
4318
- right: "0",
4319
- textAlign: "center",
4320
- color: "rgba(230,237,243,0.7)",
4321
- font: "12px ui-sans-serif, system-ui, sans-serif",
4322
- pointerEvents: "none"
4323
- });
4324
- root.appendChild(hint);
4325
- container.appendChild(root);
4326
- let bgW = 0;
4327
- let bgH = 0;
4328
- let posX = 0;
4329
- let pitchPx = 0;
4330
- const layout = () => {
4331
- const vh = root.clientHeight || 1;
4332
- const vw = root.clientWidth || 1;
4333
- const natW = img.naturalWidth || vw * 2;
4334
- const natH = img.naturalHeight || vh;
4335
- bgH = windowedBgHeight(vh);
4336
- bgW = bgH * (natW / natH);
4337
- pano.style.backgroundSize = `${bgW}px ${bgH}px`;
4338
- if (!posInitialised) {
4339
- posX = bearingToOffsetPx(bearing, vw, bgW);
4340
- posInitialised = true;
4341
- }
4342
- pitchPx = clampPitchPx(pitchPx, bgH);
4343
- pano.style.backgroundPosition = `${posX}px ${horizonOffsetPy(vh, bgH, pitchPx)}px`;
4344
- };
4345
- let posInitialised = false;
4346
- const applyPos = () => {
4347
- const vh = root.clientHeight || 1;
4348
- pitchPx = clampPitchPx(pitchPx, bgH);
4349
- pano.style.backgroundPosition = `${posX}px ${horizonOffsetPy(vh, bgH, pitchPx)}px`;
4350
- };
4351
- const img = new Image();
4352
- img.onload = layout;
4353
- img.src = view.url;
4354
- requestAnimationFrame(layout);
4355
- let dragging = false;
4356
- let lastX = 0;
4357
- let lastY = 0;
4358
- const onDown = (e) => {
4359
- dragging = true;
4360
- lastX = e.clientX;
4361
- lastY = e.clientY;
4362
- pano.style.cursor = "grabbing";
4363
- try {
4364
- pano.setPointerCapture?.(e.pointerId);
4365
- } catch {
4366
- }
4367
- };
4368
- const onMove = (e) => {
4369
- if (!dragging) return;
4370
- posX += e.clientX - lastX;
4371
- pitchPx += e.clientY - lastY;
4372
- lastX = e.clientX;
4373
- lastY = e.clientY;
4374
- applyPos();
4375
- };
4376
- const onUp = (e) => {
4377
- dragging = false;
4378
- pano.style.cursor = "grab";
4379
- try {
4380
- pano.releasePointerCapture?.(e.pointerId);
4381
- } catch {
4382
- }
4383
- };
4384
- pano.addEventListener("pointerdown", onDown);
4385
- pano.addEventListener("pointermove", onMove);
4386
- pano.addEventListener("pointerup", onUp);
4387
- pano.addEventListener("pointercancel", onUp);
4388
- let closed = false;
4389
- let disposed = false;
4390
- let fadeTimer = 0;
4391
- const removeListeners = () => {
4392
- pano.removeEventListener("pointerdown", onDown);
4393
- pano.removeEventListener("pointermove", onMove);
4394
- pano.removeEventListener("pointerup", onUp);
4395
- pano.removeEventListener("pointercancel", onUp);
4396
- window.removeEventListener("keydown", onKey);
4397
- };
4398
- const teardown = () => {
4399
- if (fadeTimer) {
4400
- window.clearTimeout(fadeTimer);
4401
- fadeTimer = 0;
4402
- }
4403
- removeListeners();
4404
- if (root.parentNode) root.parentNode.removeChild(root);
4405
- };
4406
- const close = () => {
4407
- if (closed) return;
4408
- closed = true;
4409
- root.style.opacity = "0";
4410
- const done = () => {
4411
- fadeTimer = 0;
4412
- if (disposed) return;
4413
- teardown();
4414
- opts.onClose?.();
4415
- };
4416
- fadeTimer = window.setTimeout(done, fadeMs);
4417
- };
4418
- const onKey = (e) => {
4419
- if (e.key === "Escape") {
4420
- e.stopPropagation();
4421
- close();
4422
- }
4423
- };
4424
- window.addEventListener("keydown", onKey);
4425
- closeBtn.addEventListener("click", close);
4426
- requestAnimationFrame(() => {
4427
- root.style.opacity = "1";
4428
- });
4526
+ // src/view3d/camera/seatViewPose.ts
4527
+ var SEAT_EYE_ABOVE_DECK_M = 1.02;
4528
+ var FOCAL_LOOK_HEIGHT_M = 1.5;
4529
+ function seatViewPose(seatDeckWorld, focalPoint, floorBaseHeightM = 0) {
4429
4530
  return {
4430
- close,
4431
- dispose() {
4432
- closed = true;
4433
- disposed = true;
4434
- teardown();
4435
- }
4531
+ eye: [seatDeckWorld[0], seatDeckWorld[1] + SEAT_EYE_ABOVE_DECK_M, seatDeckWorld[2]],
4532
+ focal: [
4533
+ focalPoint.x * M,
4534
+ floorBaseHeightM + FOCAL_LOOK_HEIGHT_M,
4535
+ focalPoint.y * M
4536
+ ]
4436
4537
  };
4437
4538
  }
4438
4539
 
@@ -4557,6 +4658,10 @@ function createPanoSphere(gl, image) {
4557
4658
  setOpacity(value) {
4558
4659
  program.uniforms.uOpacity.value = Math.max(0, Math.min(1, value));
4559
4660
  },
4661
+ setImage(nextImage) {
4662
+ texture.image = nextImage;
4663
+ texture.needsUpdate = true;
4664
+ },
4560
4665
  dispose() {
4561
4666
  mesh.setParent(null);
4562
4667
  geometry.remove();
@@ -4568,20 +4673,29 @@ function createPanoSphere(gl, image) {
4568
4673
 
4569
4674
  // src/view3d/crossfade/panoramaSphere.ts
4570
4675
  var DEG_PER_PX = 0.15;
4571
- function loadImage(url) {
4572
- return new Promise((resolve, reject) => {
4573
- const img = new Image();
4574
- img.crossOrigin = "anonymous";
4575
- img.onload = () => resolve(img);
4576
- img.onerror = () => reject(new Error("panorama_image_failed"));
4577
- img.src = url;
4578
- });
4579
- }
4580
4676
  async function mountPanoramaSphere(container, view, deps, opts = {}) {
4581
4677
  const fadeMs = opts.fadeMs ?? 400;
4582
4678
  let bearing = view?.initialBearingDeg ?? 0;
4583
- let pitch = 0;
4679
+ let pitch = Math.max(-MAX_PITCH_DEG, Math.min(MAX_PITCH_DEG, view?.initialPitchDeg ?? 0));
4680
+ let viewFov = VFOV_DEG;
4584
4681
  let disposed = false;
4682
+ const loadAbort = new AbortController();
4683
+ const abortFromCaller = () => loadAbort.abort();
4684
+ opts.signal?.addEventListener("abort", abortFromCaller, { once: true });
4685
+ let cancelUpgrade = () => {
4686
+ };
4687
+ const priorPosition = [
4688
+ deps.camera.position.x,
4689
+ deps.camera.position.y,
4690
+ deps.camera.position.z
4691
+ ];
4692
+ if (!view && deps.cameraOriginWorld) {
4693
+ deps.camera.position.set(
4694
+ deps.cameraOriginWorld[0],
4695
+ deps.cameraOriginWorld[1],
4696
+ deps.cameraOriginWorld[2]
4697
+ );
4698
+ }
4585
4699
  if (!view && deps.focalWorld) {
4586
4700
  const p = deps.camera.position;
4587
4701
  const dx = deps.focalWorld[0] - p.x;
@@ -4595,22 +4709,37 @@ async function mountPanoramaSphere(container, view, deps, opts = {}) {
4595
4709
  }
4596
4710
  let sphere = null;
4597
4711
  if (view) {
4712
+ const maxTextureSize = deps.gl.getParameter(deps.gl.MAX_TEXTURE_SIZE);
4713
+ const delivery = planPanoramaDelivery(view, browserPanoramaConstraints(maxTextureSize));
4598
4714
  let image;
4599
4715
  try {
4600
- image = await loadImage(view.url);
4716
+ image = await loadPanoramaImage(delivery.initialUrl, loadAbort.signal);
4601
4717
  } catch {
4718
+ opts.signal?.removeEventListener("abort", abortFromCaller);
4602
4719
  return null;
4603
4720
  }
4604
4721
  try {
4605
4722
  sphere = createPanoSphere(deps.gl, image);
4606
4723
  } catch {
4724
+ loadAbort.abort();
4725
+ opts.signal?.removeEventListener("abort", abortFromCaller);
4607
4726
  return null;
4608
4727
  }
4609
4728
  sphere.mesh.renderOrder = 999;
4610
4729
  sphere.mesh.setParent(deps.scene);
4730
+ if (delivery.upgradeUrl) {
4731
+ cancelUpgrade = schedulePanoramaUpgrade(() => {
4732
+ void loadPanoramaImage(delivery.upgradeUrl, loadAbort.signal).then((full) => {
4733
+ if (disposed || loadAbort.signal.aborted) return;
4734
+ sphere?.setImage(full);
4735
+ deps.requestRender();
4736
+ }).catch(() => {
4737
+ });
4738
+ });
4739
+ }
4611
4740
  }
4612
4741
  const priorFov = deps.camera.fov;
4613
- deps.camera.perspective({ fov: VFOV_DEG, aspect: deps.camera.aspect });
4742
+ deps.camera.perspective({ fov: viewFov, aspect: deps.camera.aspect });
4614
4743
  const priorQuat = deps.camera.quaternion.slice();
4615
4744
  const aim = () => {
4616
4745
  const p = deps.camera.position;
@@ -4621,8 +4750,11 @@ async function mountPanoramaSphere(container, view, deps, opts = {}) {
4621
4750
  };
4622
4751
  aim();
4623
4752
  const root = document.createElement("div");
4753
+ const priorFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
4624
4754
  root.setAttribute("role", "dialog");
4755
+ root.setAttribute("aria-modal", "true");
4625
4756
  root.setAttribute("aria-label", opts.seatLabel ? `View from ${opts.seatLabel}` : "View from seat");
4757
+ root.tabIndex = -1;
4626
4758
  Object.assign(root.style, {
4627
4759
  position: "absolute",
4628
4760
  inset: "0",
@@ -4639,8 +4771,8 @@ async function mountPanoramaSphere(container, view, deps, opts = {}) {
4639
4771
  top: "12px",
4640
4772
  right: "12px",
4641
4773
  zIndex: "2",
4642
- width: "34px",
4643
- height: "34px",
4774
+ width: "44px",
4775
+ height: "44px",
4644
4776
  borderRadius: "999px",
4645
4777
  cursor: "pointer",
4646
4778
  border: "1px solid rgba(255,255,255,0.25)",
@@ -4650,8 +4782,30 @@ async function mountPanoramaSphere(container, view, deps, opts = {}) {
4650
4782
  lineHeight: "1"
4651
4783
  });
4652
4784
  root.appendChild(closeBtn);
4785
+ if (opts.disclosure) {
4786
+ const disclosure = document.createElement("div");
4787
+ disclosure.textContent = `360\xB0 panorama \xB7 ${opts.disclosure}`;
4788
+ Object.assign(disclosure.style, {
4789
+ position: "absolute",
4790
+ top: "12px",
4791
+ left: "12px",
4792
+ zIndex: "2",
4793
+ maxWidth: "calc(100% - 88px)",
4794
+ padding: "8px 11px",
4795
+ borderRadius: "999px",
4796
+ overflow: "hidden",
4797
+ color: "#dce6f8",
4798
+ background: "rgba(8,12,18,0.68)",
4799
+ font: "600 11px/1.2 ui-sans-serif, system-ui, sans-serif",
4800
+ textOverflow: "ellipsis",
4801
+ whiteSpace: "nowrap",
4802
+ pointerEvents: "none",
4803
+ backdropFilter: "blur(6px)"
4804
+ });
4805
+ root.appendChild(disclosure);
4806
+ }
4653
4807
  const hint = document.createElement("div");
4654
- hint.textContent = "Drag to look around \xB7 Esc to close";
4808
+ hint.textContent = "Drag to look \xB7 pinch or scroll to zoom \xB7 Esc to close";
4655
4809
  Object.assign(hint.style, {
4656
4810
  position: "absolute",
4657
4811
  bottom: "12px",
@@ -4664,18 +4818,43 @@ async function mountPanoramaSphere(container, view, deps, opts = {}) {
4664
4818
  });
4665
4819
  root.appendChild(hint);
4666
4820
  container.appendChild(root);
4821
+ closeBtn.focus({ preventScroll: true });
4667
4822
  let dragging = false;
4668
4823
  let lastX = 0;
4669
4824
  let lastY = 0;
4825
+ let pinchDistance = 0;
4826
+ const pointers = /* @__PURE__ */ new Map();
4827
+ const distanceBetweenPointers = () => {
4828
+ const [a, b] = [...pointers.values()];
4829
+ return a && b ? Math.hypot(a.x - b.x, a.y - b.y) : 0;
4830
+ };
4831
+ const setViewFov = (nextFov) => {
4832
+ const clamped = clampPanoramaFov(nextFov);
4833
+ if (clamped === viewFov) return;
4834
+ viewFov = clamped;
4835
+ deps.camera.perspective({ fov: viewFov, aspect: deps.camera.aspect });
4836
+ aim();
4837
+ };
4670
4838
  const onDown = (e) => {
4671
4839
  if (e.target === closeBtn) return;
4672
- dragging = true;
4840
+ pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
4841
+ dragging = pointers.size === 1;
4673
4842
  lastX = e.clientX;
4674
4843
  lastY = e.clientY;
4844
+ if (pointers.size === 2) pinchDistance = distanceBetweenPointers();
4675
4845
  root.setPointerCapture?.(e.pointerId);
4676
4846
  root.style.cursor = "grabbing";
4677
4847
  };
4678
4848
  const onMove = (e) => {
4849
+ if (!pointers.has(e.pointerId)) return;
4850
+ pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
4851
+ if (pointers.size >= 2) {
4852
+ const nextDistance = distanceBetweenPointers();
4853
+ if (pinchDistance > 0 && nextDistance > 0) setViewFov(viewFov * (pinchDistance / nextDistance));
4854
+ pinchDistance = nextDistance;
4855
+ dragging = false;
4856
+ return;
4857
+ }
4679
4858
  if (!dragging) return;
4680
4859
  bearing += (e.clientX - lastX) * DEG_PER_PX;
4681
4860
  pitch = Math.max(-MAX_PITCH_DEG, Math.min(MAX_PITCH_DEG, pitch + (e.clientY - lastY) * DEG_PER_PX));
@@ -4683,18 +4862,58 @@ async function mountPanoramaSphere(container, view, deps, opts = {}) {
4683
4862
  lastY = e.clientY;
4684
4863
  aim();
4685
4864
  };
4686
- const onUp = () => {
4687
- dragging = false;
4688
- root.style.cursor = "grab";
4865
+ const onUp = (e) => {
4866
+ pointers.delete(e.pointerId);
4867
+ pinchDistance = pointers.size === 2 ? distanceBetweenPointers() : 0;
4868
+ const remaining = pointers.values().next().value;
4869
+ dragging = pointers.size === 1;
4870
+ if (remaining) {
4871
+ lastX = remaining.x;
4872
+ lastY = remaining.y;
4873
+ } else root.style.cursor = "grab";
4874
+ try {
4875
+ root.releasePointerCapture?.(e.pointerId);
4876
+ } catch {
4877
+ }
4878
+ };
4879
+ const onWheel = (e) => {
4880
+ e.preventDefault();
4881
+ setViewFov(viewFov + e.deltaY * 0.04);
4689
4882
  };
4690
4883
  root.addEventListener("pointerdown", onDown);
4691
4884
  root.addEventListener("pointermove", onMove);
4692
4885
  root.addEventListener("pointerup", onUp);
4693
4886
  root.addEventListener("pointercancel", onUp);
4887
+ root.addEventListener("wheel", onWheel, { passive: false });
4694
4888
  const onKey = (e) => {
4695
- if (e.key !== "Escape" || disposed) return;
4696
- e.stopPropagation();
4697
- handle.close();
4889
+ if (disposed) return;
4890
+ if (e.key === "Tab") {
4891
+ e.preventDefault();
4892
+ closeBtn.focus({ preventScroll: true });
4893
+ return;
4894
+ }
4895
+ if (e.key === "Escape") {
4896
+ e.stopPropagation();
4897
+ handle.close();
4898
+ return;
4899
+ }
4900
+ if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
4901
+ bearing += e.key === "ArrowLeft" ? -4 : 4;
4902
+ } else if (e.key === "ArrowUp" || e.key === "ArrowDown") {
4903
+ pitch = Math.max(-MAX_PITCH_DEG, Math.min(MAX_PITCH_DEG, pitch + (e.key === "ArrowUp" ? 4 : -4)));
4904
+ } else if (e.key === "+" || e.key === "=") {
4905
+ setViewFov(viewFov - 5);
4906
+ e.preventDefault();
4907
+ return;
4908
+ } else if (e.key === "-") {
4909
+ setViewFov(viewFov + 5);
4910
+ e.preventDefault();
4911
+ return;
4912
+ } else {
4913
+ return;
4914
+ }
4915
+ e.preventDefault();
4916
+ aim();
4698
4917
  };
4699
4918
  window.addEventListener("keydown", onKey);
4700
4919
  let raf = 0;
@@ -4719,14 +4938,20 @@ async function mountPanoramaSphere(container, view, deps, opts = {}) {
4719
4938
  const teardown = () => {
4720
4939
  if (disposed) return;
4721
4940
  disposed = true;
4941
+ cancelUpgrade();
4942
+ loadAbort.abort();
4943
+ opts.signal?.removeEventListener("abort", abortFromCaller);
4722
4944
  cancelAnimationFrame(raf);
4723
4945
  root.removeEventListener("pointerdown", onDown);
4724
4946
  root.removeEventListener("pointermove", onMove);
4725
4947
  root.removeEventListener("pointerup", onUp);
4726
4948
  root.removeEventListener("pointercancel", onUp);
4949
+ root.removeEventListener("wheel", onWheel);
4727
4950
  window.removeEventListener("keydown", onKey);
4728
4951
  root.remove();
4952
+ if (priorFocus?.isConnected) priorFocus.focus({ preventScroll: true });
4729
4953
  sphere?.dispose();
4954
+ deps.camera.position.set(priorPosition[0], priorPosition[1], priorPosition[2]);
4730
4955
  deps.camera.perspective({ fov: priorFov, aspect: deps.camera.aspect });
4731
4956
  deps.camera.quaternion.set(priorQuat[0], priorQuat[1], priorQuat[2], priorQuat[3]);
4732
4957
  deps.requestRender();
@@ -4763,8 +4988,12 @@ var Analytics3D = class {
4763
4988
  } catch {
4764
4989
  }
4765
4990
  }
4766
- opened(seats, hasHeights) {
4767
- this.emit("3d_opened", { seats, hasHeights });
4991
+ opened(seats, hasHeights, buildMs) {
4992
+ this.emit("3d_opened", {
4993
+ seats,
4994
+ hasHeights,
4995
+ ...buildMs === void 0 ? {} : { buildMs: Math.round(buildMs) }
4996
+ });
4768
4997
  }
4769
4998
  /** First user-driven orbit/dolly per mount only (the intro ease is not user
4770
4999
  * input, so callers must gate this on real pointer/wheel gestures). */
@@ -4794,18 +5023,77 @@ var Analytics3D = class {
4794
5023
  this.panoramaOpenedAt = 0;
4795
5024
  this.emit("3d_panorama_closed", { viewMs });
4796
5025
  }
5026
+ panoramaFailed(seatId, phase) {
5027
+ this.emit("3d_panorama_failed", { seatId, phase });
5028
+ }
5029
+ panoramaFallback(seatId) {
5030
+ this.emit("3d_panorama_fallback", { seatId });
5031
+ }
5032
+ contextLost() {
5033
+ this.emit("3d_context_lost");
5034
+ }
5035
+ contextRestored() {
5036
+ this.emit("3d_context_restored");
5037
+ }
4797
5038
  };
4798
5039
 
5040
+ // src/view3d/positioningContext.ts
5041
+ function establishPositioningContext(container) {
5042
+ const originalInline = container.style.position;
5043
+ if (getComputedStyle(container).position !== "static") return () => {
5044
+ };
5045
+ container.style.position = "relative";
5046
+ return () => {
5047
+ if (container.style.position === "relative") container.style.position = originalInline;
5048
+ };
5049
+ }
5050
+
5051
+ // src/view3d/prepareScene.ts
5052
+ function prepareOnMain(input) {
5053
+ const started = performance.now();
5054
+ const model = buildSceneModel(input);
5055
+ return { model, buildMs: performance.now() - started, source: "main" };
5056
+ }
5057
+ function prepareVenue3D(input) {
5058
+ if (typeof Worker === "undefined") return Promise.resolve(prepareOnMain(input));
5059
+ return new Promise((resolve) => {
5060
+ const worker = new Worker(new URL("./scene/scene.worker.ts", import.meta.url), { type: "module" });
5061
+ let settled = false;
5062
+ const finish = (result) => {
5063
+ if (settled) return;
5064
+ settled = true;
5065
+ clearTimeout(timeout);
5066
+ worker.terminate();
5067
+ resolve(result);
5068
+ };
5069
+ const fallback = () => finish(prepareOnMain(input));
5070
+ const timeout = window.setTimeout(fallback, 15e3);
5071
+ worker.onerror = fallback;
5072
+ worker.onmessage = (event) => {
5073
+ finish({ model: event.data.model, buildMs: event.data.buildMs, source: "worker" });
5074
+ };
5075
+ worker.postMessage(input);
5076
+ });
5077
+ }
5078
+
4799
5079
  // src/view3d/index.ts
4800
- var SEAT_EYE_ABOVE_DECK = 1.02;
4801
5080
  var DEG2 = Math.PI / 180;
4802
5081
  var TAP_SLOP = 6;
4803
5082
  var TAP_MS = 500;
4804
5083
  function mountVenue3D(container, input, opts = {}) {
4805
- const model = buildSceneModel(input);
5084
+ const buildStartedAt = performance.now();
5085
+ const model = input.prepared?.model ?? buildSceneModel(input);
4806
5086
  const analytics = new Analytics3D(opts.onAnalytics);
4807
5087
  const seatIdByIndex = new Array(model.seats.count);
4808
5088
  for (const [id, idx] of model.seats.idToIndex) seatIdByIndex[idx] = id;
5089
+ const expandedSeatById = new Map(input.seats.map((seat) => [seat.id, seat]));
5090
+ const seatIdsByRow = /* @__PURE__ */ new Map();
5091
+ for (const seat of input.seats) {
5092
+ if (!seat.rowId) continue;
5093
+ const row = seatIdsByRow.get(seat.rowId);
5094
+ if (row) row.push(seat.id);
5095
+ else seatIdsByRow.set(seat.rowId, [seat.id]);
5096
+ }
4809
5097
  const sectionIdBySeatId = /* @__PURE__ */ new Map();
4810
5098
  for (const s of input.seats) sectionIdBySeatId.set(s.id, s.sectionId);
4811
5099
  const hasHeights = (() => {
@@ -4820,10 +5108,22 @@ function mountVenue3D(container, input, opts = {}) {
4820
5108
  let disposed = false;
4821
5109
  let selection = /* @__PURE__ */ new Map();
4822
5110
  let panorama = null;
5111
+ let panoramaLoadAbort = null;
5112
+ let panoramaPriorZIndex = null;
4823
5113
  const prefetch = /* @__PURE__ */ new Map();
4824
5114
  let flightGen = 0;
4825
5115
  let reducedForced = null;
4826
5116
  let focusedFloor = -1;
5117
+ const raisePanoramaLayer = () => {
5118
+ if (panoramaPriorZIndex !== null) return;
5119
+ panoramaPriorZIndex = container.style.zIndex;
5120
+ container.style.zIndex = "20";
5121
+ };
5122
+ const restorePanoramaLayer = () => {
5123
+ if (panoramaPriorZIndex === null) return;
5124
+ if (container.style.zIndex === "20") container.style.zIndex = panoramaPriorZIndex;
5125
+ panoramaPriorZIndex = null;
5126
+ };
4827
5127
  const rebuildGpu = () => {
4828
5128
  gpu = buildGpuScene(glctx.gl, model);
4829
5129
  pick = new PickPipeline(glctx.renderer, gpu.seatGeometry, gpu.solidGeometry, model.seats.count);
@@ -4857,6 +5157,7 @@ function mountVenue3D(container, input, opts = {}) {
4857
5157
  };
4858
5158
  const glctx = new GLContext(container, {
4859
5159
  onContextLost: () => {
5160
+ if (!disposed) analytics.contextLost();
4860
5161
  contextLost = true;
4861
5162
  loop.stop();
4862
5163
  gpu = null;
@@ -4865,6 +5166,7 @@ function mountVenue3D(container, input, opts = {}) {
4865
5166
  onContextRestored: () => {
4866
5167
  rebuildGpu();
4867
5168
  contextLost = false;
5169
+ if (!disposed) analytics.contextRestored();
4868
5170
  loop.requestRender();
4869
5171
  }
4870
5172
  });
@@ -4881,16 +5183,48 @@ function mountVenue3D(container, input, opts = {}) {
4881
5183
  const dz = model.focalWorld[2] - model.bounds.center[2];
4882
5184
  return Math.hypot(dx, dz) > model.bounds.radius * 0.12 ? Math.atan2(dx, dz) : void 0;
4883
5185
  })();
4884
- orbit.frame(model.bounds, true, stageAzimuth);
5186
+ orbit.frame(model.bounds, true, stageAzimuth, opts.portraitOverviewCrop === true);
4885
5187
  const cinematic = new Cinematic(orbit.camera);
4886
- if (!container.style.position || container.style.position === "static") {
4887
- container.style.position = "relative";
4888
- }
5188
+ const restoreContainerPosition = establishPositioningContext(container);
4889
5189
  const labelOverlay = new LabelOverlay(container, {
4890
5190
  fontFamily: input.doc.theme?.fontFamily,
4891
5191
  ink: input.doc.theme?.textColor
4892
5192
  });
4893
5193
  labelOverlay.setLabels(model.labels);
5194
+ const baseLabels = model.labels;
5195
+ let focusedSectionId = null;
5196
+ const showSectionSeatLabels = (sectionId) => {
5197
+ focusedSectionId = sectionId;
5198
+ labelOverlay.setForcedDense(!!sectionId);
5199
+ if (!sectionId) {
5200
+ labelOverlay.setLabels(baseLabels);
5201
+ return;
5202
+ }
5203
+ const sectionSeats = input.seats.filter((seat) => seat.sectionId === sectionId);
5204
+ const seatIds = new Set(sectionSeats.map((seat) => seat.id));
5205
+ const rowIds = new Set(sectionSeats.map((seat) => seat.rowId));
5206
+ const focusedBase = baseLabels.filter((label) => label.kind !== "row" && label.kind !== "seat" || (label.kind === "row" ? rowIds.has(label.id.slice("row:".length)) : seatIds.has(label.id.slice("seat:".length))));
5207
+ if (model.seatCount <= 6e3) {
5208
+ labelOverlay.setLabels(focusedBase);
5209
+ return;
5210
+ }
5211
+ const labels = sectionSeats.flatMap((seat) => {
5212
+ const index = model.seats.idToIndex.get(seat.id);
5213
+ if (index === void 0) return [];
5214
+ const offset = index * 3;
5215
+ return [{
5216
+ id: `seat:${seat.id}`,
5217
+ kind: "seat",
5218
+ text: seat.displayLabel || seat.label,
5219
+ anchor: [
5220
+ model.seats.iPosition[offset],
5221
+ model.seats.iPosition[offset + 1] + 0.55,
5222
+ model.seats.iPosition[offset + 2]
5223
+ ]
5224
+ }];
5225
+ });
5226
+ labelOverlay.setLabels([...focusedBase, ...labels]);
5227
+ };
4894
5228
  rebuildGpu();
4895
5229
  const loop = new RenderLoop(() => {
4896
5230
  if (contextLost || !gpu || frozen) return false;
@@ -4943,6 +5277,9 @@ function mountVenue3D(container, input, opts = {}) {
4943
5277
  if (!p) {
4944
5278
  p = Promise.resolve(opts.getSeatView(seatId));
4945
5279
  prefetch.set(seatId, p);
5280
+ void p.catch(() => {
5281
+ if (prefetch.get(seatId) === p) prefetch.delete(seatId);
5282
+ });
4946
5283
  while (prefetch.size > PREFETCH_CAP) {
4947
5284
  const oldest = prefetch.keys().next().value;
4948
5285
  if (oldest === void 0) break;
@@ -4951,11 +5288,18 @@ function mountVenue3D(container, input, opts = {}) {
4951
5288
  }
4952
5289
  return p;
4953
5290
  };
4954
- const seatEyeWorld = (idx) => [
4955
- model.seats.iPosition[idx * 3],
4956
- model.seats.iPosition[idx * 3 + 1] + SEAT_EYE_ABOVE_DECK,
4957
- model.seats.iPosition[idx * 3 + 2]
4958
- ];
5291
+ const resolvedSeatViewPose = (seatId, idx) => {
5292
+ const seat = expandedSeatById.get(seatId);
5293
+ const floorIndex = Math.max(0, Math.round(model.seats.iFloor[idx] ?? 0));
5294
+ const floor = input.doc.floors?.[floorIndex];
5295
+ const focalPoint = seat?.focalPoint ?? floor?.focalPoint ?? input.doc.focalPoint;
5296
+ const deck = [
5297
+ model.seats.iPosition[idx * 3],
5298
+ model.seats.iPosition[idx * 3 + 1],
5299
+ model.seats.iPosition[idx * 3 + 2]
5300
+ ];
5301
+ return seatViewPose(deck, focalPoint, floor?.baseHeightM ?? 0);
5302
+ };
4959
5303
  const placeCameraFinal = (finalPos, focal) => {
4960
5304
  orbit.camera.position.set(finalPos[0], finalPos[1], finalPos[2]);
4961
5305
  orbit.camera.lookAt(new Vec33(focal[0], focal[1], focal[2]));
@@ -4963,44 +5307,188 @@ function mountVenue3D(container, input, opts = {}) {
4963
5307
  orbit.camera.updateProjectionMatrix();
4964
5308
  };
4965
5309
  let arriveChip = null;
5310
+ const layoutArriveChip = () => {
5311
+ if (!arriveChip) return;
5312
+ const narrow = (container.clientWidth || glctx.canvas.clientWidth) <= 520;
5313
+ Object.assign(arriveChip.style, narrow ? {
5314
+ left: "12px",
5315
+ right: "12px",
5316
+ bottom: "70px",
5317
+ transform: "none",
5318
+ justifyContent: "center"
5319
+ } : {
5320
+ left: "50%",
5321
+ right: "auto",
5322
+ bottom: "18px",
5323
+ transform: "translateX(-50%)",
5324
+ justifyContent: "initial"
5325
+ });
5326
+ };
4966
5327
  const removeArriveChip = () => {
4967
5328
  arriveChip?.remove();
4968
5329
  arriveChip = null;
4969
5330
  };
4970
- const showArriveChip = (seatId, gen) => {
5331
+ const showArriveChip = (seatId, gen, retry = false) => {
4971
5332
  removeArriveChip();
4972
- if (disposed || !opts.getSeatView) return;
4973
- const chip = document.createElement("button");
4974
- chip.type = "button";
4975
- chip.textContent = "\u25C9 View in 360\xB0";
4976
- chip.setAttribute("aria-label", `Open the 360\xB0 view from seat ${seatId}`);
4977
- Object.assign(chip.style, {
5333
+ if (disposed) return null;
5334
+ const controls = document.createElement("div");
5335
+ controls.setAttribute("role", "group");
5336
+ Object.assign(controls.style, {
4978
5337
  position: "absolute",
4979
- left: "50%",
4980
- bottom: "18px",
4981
- transform: "translateX(-50%)",
4982
- minHeight: "44px",
4983
- padding: "10px 18px",
4984
- borderRadius: "999px",
4985
- background: "rgba(12,18,32,0.78)",
4986
- color: "#eef1f8",
4987
- border: "1px solid rgba(150,165,205,0.4)",
4988
- backdropFilter: "blur(6px)",
4989
- font: "600 13px/1 inherit",
4990
- cursor: "pointer",
5338
+ display: "flex",
5339
+ flexDirection: "column",
5340
+ alignItems: "center",
5341
+ gap: "6px",
4991
5342
  zIndex: "4"
4992
5343
  });
4993
- chip.addEventListener("click", () => {
4994
- if (disposed || gen !== flightGen) {
5344
+ const seat = expandedSeatById.get(seatId);
5345
+ const section = seat?.sectionId ? model.sections.find((candidate) => candidate.id === seat.sectionId) : void 0;
5346
+ const seatLabel = seat?.label ?? seatId;
5347
+ controls.setAttribute("aria-label", `Explore views near ${section?.label ? `${section.label}, ` : ""}seat ${seatLabel}`);
5348
+ const location = document.createElement("span");
5349
+ location.textContent = ["Live 3D seat view", section?.label, seatLabel].filter(Boolean).join(" \xB7 ");
5350
+ Object.assign(location.style, {
5351
+ maxWidth: "min(320px, calc(100vw - 32px))",
5352
+ padding: "5px 10px",
5353
+ borderRadius: "999px",
5354
+ overflow: "hidden",
5355
+ color: "#d9e3f5",
5356
+ background: "rgba(12,18,32,0.72)",
5357
+ font: "600 11px/1.2 inherit",
5358
+ textOverflow: "ellipsis",
5359
+ whiteSpace: "nowrap",
5360
+ backdropFilter: "blur(6px)"
5361
+ });
5362
+ controls.appendChild(location);
5363
+ const actions = document.createElement("div");
5364
+ Object.assign(actions.style, {
5365
+ display: "flex",
5366
+ alignItems: "center",
5367
+ justifyContent: "center",
5368
+ gap: "6px"
5369
+ });
5370
+ controls.appendChild(actions);
5371
+ const row = seat?.rowId ? seatIdsByRow.get(seat.rowId) : void 0;
5372
+ const at = row?.indexOf(seatId) ?? -1;
5373
+ const neighbours = at >= 0 ? [row?.[at - 1], row?.[at + 1]] : [void 0, void 0];
5374
+ const addButton = (label, ariaLabel, action, primary = false) => {
5375
+ const button = document.createElement("button");
5376
+ button.type = "button";
5377
+ button.textContent = label;
5378
+ button.setAttribute("aria-label", ariaLabel);
5379
+ Object.assign(button.style, {
5380
+ minHeight: "44px",
5381
+ padding: primary ? "10px 16px" : "10px",
5382
+ borderRadius: "999px",
5383
+ background: "rgba(12,18,32,0.78)",
5384
+ color: "#eef1f8",
5385
+ border: "1px solid rgba(150,165,205,0.4)",
5386
+ backdropFilter: "blur(6px)",
5387
+ font: "600 13px/1 inherit",
5388
+ cursor: "pointer",
5389
+ whiteSpace: "nowrap"
5390
+ });
5391
+ if (!primary) button.style.minWidth = "44px";
5392
+ if (primary) button.dataset.panoramaTrigger = "";
5393
+ button.addEventListener("click", action);
5394
+ actions.appendChild(button);
5395
+ };
5396
+ if (neighbours[0]) {
5397
+ const label = expandedSeatById.get(neighbours[0])?.label ?? neighbours[0];
5398
+ addButton("\u2039", `View previous seat ${label}`, () => {
5399
+ void flyToSeat(neighbours[0]);
5400
+ });
5401
+ }
5402
+ if (opts.getSeatView) {
5403
+ addButton(retry ? "\u21BB Retry 360\xB0 panorama" : "\u25C9 Open 360\xB0 panorama", `${retry ? "Retry" : "Open"} the 360\xB0 panorama from seat ${seatLabel}`, () => {
5404
+ if (disposed || gen !== flightGen) {
5405
+ removeArriveChip();
5406
+ return;
5407
+ }
4995
5408
  removeArriveChip();
4996
- return;
4997
- }
4998
- removeArriveChip();
4999
- void openPanorama(seatId, 400, gen);
5409
+ void openPanorama(seatId, 400, gen);
5410
+ }, true);
5411
+ }
5412
+ if (neighbours[1]) {
5413
+ const label = expandedSeatById.get(neighbours[1])?.label ?? neighbours[1];
5414
+ addButton("\u203A", `View next seat ${label}`, () => {
5415
+ void flyToSeat(neighbours[1]);
5416
+ });
5417
+ }
5418
+ if (!actions.childElementCount) return null;
5419
+ container.appendChild(controls);
5420
+ arriveChip = controls;
5421
+ layoutArriveChip();
5422
+ return controls;
5423
+ };
5424
+ const navigationModeChip = document.createElement("div");
5425
+ navigationModeChip.setAttribute("role", "group");
5426
+ navigationModeChip.setAttribute("aria-label", "3D navigation mode");
5427
+ Object.assign(navigationModeChip.style, {
5428
+ position: "absolute",
5429
+ left: "14px",
5430
+ top: "62px",
5431
+ display: "flex",
5432
+ gap: "3px",
5433
+ padding: "3px",
5434
+ borderRadius: "999px",
5435
+ zIndex: "4",
5436
+ background: "rgba(12,18,32,0.72)",
5437
+ border: "1px solid rgba(150,165,205,0.35)",
5438
+ backdropFilter: "blur(6px)"
5439
+ });
5440
+ const rotateModeButton = document.createElement("button");
5441
+ rotateModeButton.type = "button";
5442
+ rotateModeButton.textContent = "\u21BB Rotate";
5443
+ rotateModeButton.setAttribute("aria-label", "Drag to rotate the 3D venue");
5444
+ const moveModeButton = document.createElement("button");
5445
+ moveModeButton.type = "button";
5446
+ moveModeButton.textContent = "\u2725 Move";
5447
+ moveModeButton.setAttribute("aria-label", "Drag to move the 3D venue left, right, up or down");
5448
+ const zoomOutButton = document.createElement("button");
5449
+ zoomOutButton.type = "button";
5450
+ zoomOutButton.textContent = "\u2212";
5451
+ zoomOutButton.setAttribute("aria-label", "Zoom out of the 3D venue");
5452
+ zoomOutButton.title = "Zoom out";
5453
+ const zoomInButton = document.createElement("button");
5454
+ zoomInButton.type = "button";
5455
+ zoomInButton.textContent = "+";
5456
+ zoomInButton.setAttribute("aria-label", "Zoom into the 3D venue");
5457
+ zoomInButton.title = "Zoom in";
5458
+ for (const button of [rotateModeButton, moveModeButton, zoomOutButton, zoomInButton]) {
5459
+ Object.assign(button.style, {
5460
+ minHeight: "34px",
5461
+ padding: "6px 10px",
5462
+ border: "0",
5463
+ borderRadius: "999px",
5464
+ color: "#c9d4ea",
5465
+ background: "transparent",
5466
+ font: "600 11px/1 inherit",
5467
+ cursor: "pointer",
5468
+ whiteSpace: "nowrap"
5000
5469
  });
5001
- container.appendChild(chip);
5002
- arriveChip = chip;
5470
+ if (button === zoomOutButton || button === zoomInButton) {
5471
+ button.style.minWidth = "34px";
5472
+ button.style.padding = "6px";
5473
+ button.style.fontSize = "17px";
5474
+ }
5475
+ navigationModeChip.appendChild(button);
5476
+ }
5477
+ const setNavigationMode = (mode) => {
5478
+ orbit.setPrimaryDragMode(mode);
5479
+ const rotateActive = mode === "orbit";
5480
+ rotateModeButton.setAttribute("aria-pressed", String(rotateActive));
5481
+ moveModeButton.setAttribute("aria-pressed", String(!rotateActive));
5482
+ rotateModeButton.style.background = rotateActive ? "rgba(96,110,150,0.48)" : "transparent";
5483
+ moveModeButton.style.background = rotateActive ? "transparent" : "rgba(96,110,150,0.48)";
5484
+ navigationModeChip.title = rotateActive ? "Drag to rotate \xB7 Shift-drag to move" : "Drag to move \xB7 Shift-drag to rotate";
5003
5485
  };
5486
+ rotateModeButton.addEventListener("click", () => setNavigationMode("orbit"));
5487
+ moveModeButton.addEventListener("click", () => setNavigationMode("pan"));
5488
+ zoomOutButton.addEventListener("click", () => orbit.zoomBy(1.22));
5489
+ zoomInButton.addEventListener("click", () => orbit.zoomBy(0.82));
5490
+ setNavigationMode("orbit");
5491
+ container.appendChild(navigationModeChip);
5004
5492
  const overviewChip = document.createElement("button");
5005
5493
  overviewChip.type = "button";
5006
5494
  overviewChip.textContent = "\u2302 Overview";
@@ -5020,17 +5508,46 @@ function mountVenue3D(container, input, opts = {}) {
5020
5508
  cursor: "pointer",
5021
5509
  zIndex: "4"
5022
5510
  });
5023
- overviewChip.addEventListener("click", () => {
5024
- if (disposed || frozen || panorama) return;
5511
+ const focusOverview = () => {
5512
+ if (disposed) return;
5513
+ if (panorama) {
5514
+ panorama.dispose();
5515
+ panorama = null;
5516
+ analytics.panoramaClosed();
5517
+ }
5518
+ panoramaLoadAbort?.abort();
5519
+ panoramaLoadAbort = null;
5520
+ restorePanoramaLayer();
5521
+ overviewChip.style.display = "";
5522
+ labelOverlay.setVisible(true);
5523
+ frozen = false;
5025
5524
  cancelFlight();
5026
5525
  removeArriveChip();
5027
- orbit.frameSoft(model.bounds, stageAzimuth);
5526
+ setNavigationMode("orbit");
5527
+ if (reducedMotion()) orbit.frame(model.bounds, false, stageAzimuth, opts.portraitOverviewCrop === true);
5528
+ else orbit.frameSoft(model.bounds, stageAzimuth, opts.portraitOverviewCrop === true);
5529
+ opts.onViewTargetChange?.(null);
5530
+ showSectionSeatLabels(null);
5531
+ opts.onSectionFocusChange?.(null);
5028
5532
  loop.requestRender();
5029
- });
5533
+ };
5534
+ overviewChip.addEventListener("click", focusOverview);
5030
5535
  container.appendChild(overviewChip);
5031
5536
  const openPanorama = async (seatId, fadeMs, gen) => {
5537
+ panoramaLoadAbort?.abort();
5538
+ const loadAbort = new AbortController();
5539
+ panoramaLoadAbort = loadAbort;
5540
+ const seatIndex = model.seats.idToIndex.get(seatId);
5541
+ if (seatIndex === void 0) {
5542
+ orbit.syncFromCamera();
5543
+ return;
5544
+ }
5545
+ const seatLabel = expandedSeatById.get(seatId)?.label ?? seatId;
5546
+ const seatPose = resolvedSeatViewPose(seatId, seatIndex);
5032
5547
  const viewPromise = ensureSeatView(seatId);
5033
5548
  if (!viewPromise) {
5549
+ loadAbort.abort();
5550
+ if (panoramaLoadAbort === loadAbort) panoramaLoadAbort = null;
5034
5551
  orbit.syncFromCamera();
5035
5552
  return;
5036
5553
  }
@@ -5040,34 +5557,56 @@ function mountVenue3D(container, input, opts = {}) {
5040
5557
  try {
5041
5558
  view = await viewPromise;
5042
5559
  } catch {
5560
+ if (panoramaLoadAbort === loadAbort) panoramaLoadAbort = null;
5561
+ analytics.panoramaFailed(seatId, "source");
5043
5562
  if (!disposed && gen === flightGen) {
5044
5563
  frozen = false;
5045
- orbit.resumeAfterFlight(model.focalWorld);
5564
+ orbit.resumeAfterFlight(seatPose.focal);
5046
5565
  loop.requestRender();
5566
+ const controls = showArriveChip(seatId, gen, true);
5567
+ requestAnimationFrame(() => controls?.querySelector("[data-panorama-trigger]")?.focus());
5047
5568
  }
5048
5569
  return;
5049
5570
  }
5050
- if (disposed || gen !== flightGen) return;
5571
+ if (disposed || gen !== flightGen) {
5572
+ loadAbort.abort();
5573
+ if (panoramaLoadAbort === loadAbort) panoramaLoadAbort = null;
5574
+ return;
5575
+ }
5051
5576
  removeArriveChip();
5052
5577
  const onClose = () => {
5578
+ loadAbort.abort();
5579
+ if (panoramaLoadAbort === loadAbort) panoramaLoadAbort = null;
5053
5580
  panorama = null;
5581
+ restorePanoramaLayer();
5582
+ overviewChip.style.display = "";
5054
5583
  labelOverlay.setVisible(true);
5055
5584
  frozen = false;
5056
5585
  analytics.panoramaClosed();
5057
- orbit.resumeAfterFlight(model.focalWorld);
5586
+ orbit.resumeAfterFlight(seatPose.focal);
5058
5587
  loop.requestRender();
5059
- showArriveChip(seatId, flightGen);
5588
+ const controls = showArriveChip(seatId, flightGen);
5589
+ requestAnimationFrame(() => controls?.querySelector("[data-panorama-trigger]")?.focus());
5060
5590
  };
5061
5591
  const sceneMode = view.generated === true;
5592
+ const disclosure = seatViewDisclosure(view);
5593
+ raisePanoramaLayer();
5594
+ overviewChip.style.display = "none";
5595
+ const effectiveFadeMs = reducedMotion() ? 0 : fadeMs;
5062
5596
  const spherical = gpu && !contextLost ? await mountPanoramaSphere(container, sceneMode ? null : view, {
5063
5597
  gl: glctx.gl,
5064
5598
  scene: gpu.main,
5065
5599
  camera: orbit.camera,
5066
5600
  requestRender: () => loop.requestRender(),
5067
- focalWorld: model.focalWorld
5068
- }, { fadeMs, seatLabel: seatId, onClose }) : null;
5601
+ cameraOriginWorld: sceneMode ? seatPose.eye : void 0,
5602
+ focalWorld: seatPose.focal
5603
+ }, { fadeMs: effectiveFadeMs, seatLabel, disclosure, onClose, signal: loadAbort.signal }) : null;
5069
5604
  if (disposed || gen !== flightGen) {
5605
+ loadAbort.abort();
5606
+ if (panoramaLoadAbort === loadAbort) panoramaLoadAbort = null;
5070
5607
  spherical?.dispose();
5608
+ overviewChip.style.display = "";
5609
+ restorePanoramaLayer();
5071
5610
  return;
5072
5611
  }
5073
5612
  if (spherical) {
@@ -5077,12 +5616,21 @@ function mountVenue3D(container, input, opts = {}) {
5077
5616
  loop.requestRender();
5078
5617
  panorama = spherical;
5079
5618
  } else {
5080
- panorama = mountPanorama(container, view, { fadeMs, seatLabel: seatId, onClose });
5619
+ analytics.panoramaFallback(seatId);
5620
+ panorama = mountPanorama(container, view, {
5621
+ fadeMs: effectiveFadeMs,
5622
+ seatLabel,
5623
+ disclosure,
5624
+ onClose,
5625
+ signal: loadAbort.signal
5626
+ });
5081
5627
  }
5082
5628
  analytics.panoramaOpened();
5083
5629
  };
5084
5630
  const cancelFlight = () => {
5085
5631
  flightGen++;
5632
+ panoramaLoadAbort?.abort();
5633
+ panoramaLoadAbort = null;
5086
5634
  if (cinematic.active) {
5087
5635
  cinematic.cancel();
5088
5636
  orbit.resumeAfterFlight(model.focalWorld);
@@ -5092,15 +5640,19 @@ function mountVenue3D(container, input, opts = {}) {
5092
5640
  if (disposed || !gpu) return Promise.resolve();
5093
5641
  const idx = model.seats.idToIndex.get(seatId);
5094
5642
  if (idx === void 0) return Promise.resolve();
5643
+ opts.onViewTargetChange?.(seatId);
5095
5644
  if (panorama) {
5096
5645
  panorama.dispose();
5097
5646
  panorama = null;
5647
+ overviewChip.style.display = "";
5648
+ restorePanoramaLayer();
5098
5649
  }
5650
+ panoramaLoadAbort?.abort();
5651
+ panoramaLoadAbort = null;
5099
5652
  frozen = false;
5100
5653
  const gen = ++flightGen;
5101
5654
  removeArriveChip();
5102
- const seatEye = seatEyeWorld(idx);
5103
- const focal = model.focalWorld;
5655
+ const { eye: seatEye, focal } = resolvedSeatViewPose(seatId, idx);
5104
5656
  const start = [orbit.camera.position.x, orbit.camera.position.y, orbit.camera.position.z];
5105
5657
  const { waypoints, finalPos } = buildWaypoints(start, seatEye, focal, model.bounds.center, model.bounds.radius);
5106
5658
  if (reducedMotion()) {
@@ -5117,11 +5669,26 @@ function mountVenue3D(container, input, opts = {}) {
5117
5669
  return cinematic.start(waypoints, startQuat, endQuat).then(() => {
5118
5670
  if (disposed || gen !== flightGen) return;
5119
5671
  analytics.cinematicPlayed(FLIGHT_DURATION_MS);
5120
- orbit.resumeAfterFlight(model.focalWorld);
5672
+ orbit.resumeAfterFlight(focal);
5121
5673
  loop.requestRender();
5122
5674
  showArriveChip(seatId, gen);
5123
5675
  });
5124
5676
  };
5677
+ const focusSectionCamera = (sectionId) => {
5678
+ const sec = model.sections.find((candidate) => candidate.id === sectionId);
5679
+ if (!sec || sec.seatCount === 0) return false;
5680
+ cinematic.cancel();
5681
+ removeArriveChip();
5682
+ setNavigationMode("pan");
5683
+ showSectionSeatLabels(sectionId);
5684
+ const dx = sec.focalWorld[0] - sec.center[0];
5685
+ const dz = sec.focalWorld[2] - sec.center[2];
5686
+ const azimuth = Math.hypot(dx, dz) > sec.radius * 0.12 ? Math.atan2(dx, dz) : void 0;
5687
+ orbit.frame({ center: sec.center, radius: Math.max(5, sec.radius * 0.5) }, false, azimuth);
5688
+ opts.onSectionFocusChange?.(sectionId);
5689
+ loop.requestRender();
5690
+ return true;
5691
+ };
5125
5692
  let downX = 0, downY = 0, downT = 0, downId = -1, moved = false, suppressTap = false;
5126
5693
  const onDown = (e) => {
5127
5694
  if (downId !== -1) return;
@@ -5140,6 +5707,50 @@ function mountVenue3D(container, input, opts = {}) {
5140
5707
  if (e.pointerId !== downId) return;
5141
5708
  if (Math.hypot(e.clientX - downX, e.clientY - downY) > TAP_SLOP) moved = true;
5142
5709
  };
5710
+ const pickNearestProjectedSeat = (clientX, clientY, sectionId, maxDistance, bookableOnly) => {
5711
+ const rect = glctx.canvas.getBoundingClientRect();
5712
+ const width = glctx.canvas.clientWidth || rect.width || 1;
5713
+ const height = glctx.canvas.clientHeight || rect.height || 1;
5714
+ const tapX = clientX - rect.left;
5715
+ const tapY = clientY - rect.top;
5716
+ const maxDistanceSq = maxDistance * maxDistance;
5717
+ let bestIndex = -1;
5718
+ let bestDistanceSq = maxDistanceSq;
5719
+ let bestDepth = Infinity;
5720
+ for (const seat of input.seats) {
5721
+ if (sectionId && seat.sectionId !== sectionId) continue;
5722
+ const index = model.seats.idToIndex.get(seat.id);
5723
+ if (index === void 0) continue;
5724
+ if (bookableOnly) {
5725
+ const state = Math.round(model.seats.iState[index] ?? 0);
5726
+ if (state !== 0 && state !== 3) continue;
5727
+ }
5728
+ const offset = index * 3;
5729
+ const screen = projectToScreen(
5730
+ orbit.camera.projectionViewMatrix,
5731
+ [
5732
+ model.seats.iPosition[offset],
5733
+ // Match the visible chair/number rather than the deck-level instance
5734
+ // origin. At strong zoom the vertical difference is dozens of pixels
5735
+ // and a perfectly reasonable click on the chair otherwise misses.
5736
+ model.seats.iPosition[offset + 1] + 0.55,
5737
+ model.seats.iPosition[offset + 2]
5738
+ ],
5739
+ width,
5740
+ height
5741
+ );
5742
+ if (!screen.visible) continue;
5743
+ const dx = screen.x - tapX;
5744
+ const dy = screen.y - tapY;
5745
+ const distanceSq = dx * dx + dy * dy;
5746
+ if (distanceSq < bestDistanceSq || Math.abs(distanceSq - bestDistanceSq) < 1 && screen.depth < bestDepth) {
5747
+ bestIndex = index;
5748
+ bestDistanceSq = distanceSq;
5749
+ bestDepth = screen.depth;
5750
+ }
5751
+ }
5752
+ return bestIndex;
5753
+ };
5143
5754
  const onUp = (e) => {
5144
5755
  if (e.pointerId !== downId) return;
5145
5756
  const isTap = !moved && performance.now() - downT < TAP_MS;
@@ -5149,12 +5760,25 @@ function mountVenue3D(container, input, opts = {}) {
5149
5760
  return;
5150
5761
  }
5151
5762
  if (!isTap || !gpu || !pick) return;
5152
- pick.syncFromSeatProgram(gpu.seatProgram);
5153
- const rect = glctx.canvas.getBoundingClientRect();
5154
- const dpr = glctx.renderer.dpr;
5155
- const { x, y } = pickPixelCoords(e.clientX, e.clientY, rect, dpr, glctx.gl.drawingBufferWidth, glctx.gl.drawingBufferHeight);
5156
- const radius = Math.max(2, Math.round(8 * dpr));
5157
- const idx = pick.pick(orbit.camera, x, y, radius);
5763
+ let idx = focusedSectionId ? pickNearestProjectedSeat(e.clientX, e.clientY, focusedSectionId, 44, true) : -1;
5764
+ if (focusedSectionId && idx < 0) {
5765
+ const sectionIndex = pickNearestProjectedSeat(e.clientX, e.clientY, null, 72, false);
5766
+ const sectionSeatId = sectionIndex >= 0 ? seatIdByIndex[sectionIndex] : void 0;
5767
+ const nextSectionId = sectionSeatId ? sectionIdBySeatId.get(sectionSeatId) : void 0;
5768
+ if (nextSectionId && nextSectionId !== focusedSectionId && focusSectionCamera(nextSectionId)) return;
5769
+ }
5770
+ if (!focusedSectionId) {
5771
+ pick.syncFromSeatProgram(gpu.seatProgram);
5772
+ const rect = glctx.canvas.getBoundingClientRect();
5773
+ const dpr = glctx.renderer.dpr;
5774
+ const { x, y } = pickPixelCoords(e.clientX, e.clientY, rect, dpr, glctx.gl.drawingBufferWidth, glctx.gl.drawingBufferHeight);
5775
+ const radius = Math.max(2, Math.round(8 * dpr));
5776
+ idx = pick.pick(orbit.camera, x, y, radius);
5777
+ if (idx < 0) idx = pickNearestProjectedSeat(e.clientX, e.clientY, null, 42, false);
5778
+ const overviewSeatId = idx >= 0 ? seatIdByIndex[idx] : void 0;
5779
+ const sectionId = overviewSeatId ? sectionIdBySeatId.get(overviewSeatId) : void 0;
5780
+ if (sectionId && focusSectionCamera(sectionId)) return;
5781
+ }
5158
5782
  if (idx < 0 || idx >= seatIdByIndex.length) {
5159
5783
  if (selection.size) setSelection([]);
5160
5784
  return;
@@ -5180,9 +5804,11 @@ function mountVenue3D(container, input, opts = {}) {
5180
5804
  },
5181
5805
  setSelection,
5182
5806
  flyToSeat,
5807
+ focusOverview,
5183
5808
  resize() {
5184
5809
  const { width, height } = glctx.resize();
5185
5810
  orbit.setAspect(width / Math.max(1, height));
5811
+ layoutArriveChip();
5186
5812
  loop.requestRender();
5187
5813
  },
5188
5814
  stats() {
@@ -5207,6 +5833,7 @@ function mountVenue3D(container, input, opts = {}) {
5207
5833
  gpu.chairProgram.uniforms.uFocusFloor.value = value;
5208
5834
  }
5209
5835
  focusedFloor = value;
5836
+ setNavigationMode("orbit");
5210
5837
  if (index !== null) {
5211
5838
  const f = model.floors[index];
5212
5839
  if (f.seatCount > 0) {
@@ -5224,6 +5851,9 @@ function mountVenue3D(container, input, opts = {}) {
5224
5851
  const zone = model.zones.find((z) => z.id === zoneId);
5225
5852
  if (!zone || zone.seatCount === 0) return false;
5226
5853
  cinematic.cancel();
5854
+ setNavigationMode("orbit");
5855
+ showSectionSeatLabels(null);
5856
+ opts.onSectionFocusChange?.(null);
5227
5857
  const dx = zone.focalWorld[0] - zone.center[0];
5228
5858
  const dz = zone.focalWorld[2] - zone.center[2];
5229
5859
  const azimuth = Math.hypot(dx, dz) > zone.radius * 0.12 ? Math.atan2(dx, dz) : void 0;
@@ -5235,13 +5865,24 @@ function mountVenue3D(container, input, opts = {}) {
5235
5865
  return model.sections;
5236
5866
  },
5237
5867
  focusSection(sectionId) {
5238
- const sec = model.sections.find((s) => s.id === sectionId);
5239
- if (!sec || sec.seatCount === 0) return false;
5868
+ return focusSectionCamera(sectionId);
5869
+ },
5870
+ rows(sectionId) {
5871
+ return sectionId ? model.rows.filter((row) => row.sectionId === sectionId) : model.rows;
5872
+ },
5873
+ seatsInRow(rowId) {
5874
+ return input.seats.filter((seat) => seat.rowId === rowId).map((seat) => ({ id: seat.id, label: seat.displayLabel || seat.label }));
5875
+ },
5876
+ focusRow(rowId) {
5877
+ const row = model.rows.find((candidate) => candidate.id === rowId);
5878
+ if (!row || row.seatCount === 0) return false;
5240
5879
  cinematic.cancel();
5241
- const dx = sec.focalWorld[0] - sec.center[0];
5242
- const dz = sec.focalWorld[2] - sec.center[2];
5243
- const azimuth = Math.hypot(dx, dz) > sec.radius * 0.12 ? Math.atan2(dx, dz) : void 0;
5244
- orbit.frame({ center: sec.center, radius: sec.radius * 1.45 }, false, azimuth);
5880
+ setNavigationMode("pan");
5881
+ if (row.sectionId) {
5882
+ showSectionSeatLabels(row.sectionId);
5883
+ opts.onSectionFocusChange?.(row.sectionId);
5884
+ }
5885
+ orbit.frame({ center: row.center, radius: Math.max(2.5, row.radius * 0.65) });
5245
5886
  loop.requestRender();
5246
5887
  return true;
5247
5888
  },
@@ -5252,6 +5893,7 @@ function mountVenue3D(container, input, opts = {}) {
5252
5893
  disposed = true;
5253
5894
  removeArriveChip();
5254
5895
  overviewChip.remove();
5896
+ navigationModeChip.remove();
5255
5897
  cancelFlight();
5256
5898
  loop.stop();
5257
5899
  labelOverlay.dispose();
@@ -5260,6 +5902,8 @@ function mountVenue3D(container, input, opts = {}) {
5260
5902
  panorama.dispose();
5261
5903
  panorama = null;
5262
5904
  }
5905
+ restorePanoramaLayer();
5906
+ restoreContainerPosition();
5263
5907
  glctx.canvas.removeEventListener("pointerdown", onDown);
5264
5908
  glctx.canvas.removeEventListener("pointermove", onMove);
5265
5909
  glctx.canvas.removeEventListener("pointerup", onUp);
@@ -5272,11 +5916,12 @@ function mountVenue3D(container, input, opts = {}) {
5272
5916
  glctx.dispose();
5273
5917
  }
5274
5918
  };
5275
- analytics.opened(model.seatCount, hasHeights);
5919
+ analytics.opened(model.seatCount, hasHeights, input.prepared?.buildMs ?? performance.now() - buildStartedAt);
5276
5920
  return handle;
5277
5921
  }
5278
5922
  export {
5279
5923
  buildSceneModel,
5280
- mountVenue3D
5924
+ mountVenue3D,
5925
+ prepareVenue3D
5281
5926
  };
5282
5927
  //# sourceMappingURL=index.js.map