@seatlayer/core 0.46.0 → 0.47.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +1176 -128
  34. package/dist/view3d/index.cjs.map +1 -1
  35. package/dist/view3d/index.d.cts +73 -35
  36. package/dist/view3d/index.d.ts +73 -35
  37. package/dist/view3d/index.js +960 -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,13 @@ 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, prepSource) {
4992
+ this.emit("3d_opened", {
4993
+ seats,
4994
+ hasHeights,
4995
+ ...buildMs === void 0 ? {} : { buildMs: Math.round(buildMs) },
4996
+ ...prepSource === void 0 ? {} : { prepSource }
4997
+ });
4768
4998
  }
4769
4999
  /** First user-driven orbit/dolly per mount only (the intro ease is not user
4770
5000
  * input, so callers must gate this on real pointer/wheel gestures). */
@@ -4794,18 +5024,77 @@ var Analytics3D = class {
4794
5024
  this.panoramaOpenedAt = 0;
4795
5025
  this.emit("3d_panorama_closed", { viewMs });
4796
5026
  }
5027
+ panoramaFailed(seatId, phase) {
5028
+ this.emit("3d_panorama_failed", { seatId, phase });
5029
+ }
5030
+ panoramaFallback(seatId) {
5031
+ this.emit("3d_panorama_fallback", { seatId });
5032
+ }
5033
+ contextLost() {
5034
+ this.emit("3d_context_lost");
5035
+ }
5036
+ contextRestored() {
5037
+ this.emit("3d_context_restored");
5038
+ }
4797
5039
  };
4798
5040
 
5041
+ // src/view3d/positioningContext.ts
5042
+ function establishPositioningContext(container) {
5043
+ const originalInline = container.style.position;
5044
+ if (getComputedStyle(container).position !== "static") return () => {
5045
+ };
5046
+ container.style.position = "relative";
5047
+ return () => {
5048
+ if (container.style.position === "relative") container.style.position = originalInline;
5049
+ };
5050
+ }
5051
+
5052
+ // src/view3d/prepareScene.ts
5053
+ function prepareOnMain(input) {
5054
+ const started = performance.now();
5055
+ const model = buildSceneModel(input);
5056
+ return { model, buildMs: performance.now() - started, source: "main" };
5057
+ }
5058
+ function prepareVenue3D(input) {
5059
+ if (typeof Worker === "undefined") return Promise.resolve(prepareOnMain(input));
5060
+ return new Promise((resolve) => {
5061
+ const worker = new Worker(new URL("./scene/scene.worker.ts", import.meta.url), { type: "module" });
5062
+ let settled = false;
5063
+ const finish = (result) => {
5064
+ if (settled) return;
5065
+ settled = true;
5066
+ clearTimeout(timeout);
5067
+ worker.terminate();
5068
+ resolve(result);
5069
+ };
5070
+ const fallback = () => finish(prepareOnMain(input));
5071
+ const timeout = window.setTimeout(fallback, 15e3);
5072
+ worker.onerror = fallback;
5073
+ worker.onmessage = (event) => {
5074
+ finish({ model: event.data.model, buildMs: event.data.buildMs, source: "worker" });
5075
+ };
5076
+ worker.postMessage(input);
5077
+ });
5078
+ }
5079
+
4799
5080
  // src/view3d/index.ts
4800
- var SEAT_EYE_ABOVE_DECK = 1.02;
4801
5081
  var DEG2 = Math.PI / 180;
4802
5082
  var TAP_SLOP = 6;
4803
5083
  var TAP_MS = 500;
4804
5084
  function mountVenue3D(container, input, opts = {}) {
4805
- const model = buildSceneModel(input);
5085
+ const buildStartedAt = performance.now();
5086
+ const model = input.prepared?.model ?? buildSceneModel(input);
4806
5087
  const analytics = new Analytics3D(opts.onAnalytics);
4807
5088
  const seatIdByIndex = new Array(model.seats.count);
4808
5089
  for (const [id, idx] of model.seats.idToIndex) seatIdByIndex[idx] = id;
5090
+ const expandedSeatById = new Map(input.seats.map((seat) => [seat.id, seat]));
5091
+ const seatIdsByRow = /* @__PURE__ */ new Map();
5092
+ for (const seat of input.seats) {
5093
+ if (!seat.rowId) continue;
5094
+ const row = seatIdsByRow.get(seat.rowId);
5095
+ if (row) row.push(seat.id);
5096
+ else seatIdsByRow.set(seat.rowId, [seat.id]);
5097
+ }
4809
5098
  const sectionIdBySeatId = /* @__PURE__ */ new Map();
4810
5099
  for (const s of input.seats) sectionIdBySeatId.set(s.id, s.sectionId);
4811
5100
  const hasHeights = (() => {
@@ -4820,10 +5109,22 @@ function mountVenue3D(container, input, opts = {}) {
4820
5109
  let disposed = false;
4821
5110
  let selection = /* @__PURE__ */ new Map();
4822
5111
  let panorama = null;
5112
+ let panoramaLoadAbort = null;
5113
+ let panoramaPriorZIndex = null;
4823
5114
  const prefetch = /* @__PURE__ */ new Map();
4824
5115
  let flightGen = 0;
4825
5116
  let reducedForced = null;
4826
5117
  let focusedFloor = -1;
5118
+ const raisePanoramaLayer = () => {
5119
+ if (panoramaPriorZIndex !== null) return;
5120
+ panoramaPriorZIndex = container.style.zIndex;
5121
+ container.style.zIndex = "20";
5122
+ };
5123
+ const restorePanoramaLayer = () => {
5124
+ if (panoramaPriorZIndex === null) return;
5125
+ if (container.style.zIndex === "20") container.style.zIndex = panoramaPriorZIndex;
5126
+ panoramaPriorZIndex = null;
5127
+ };
4827
5128
  const rebuildGpu = () => {
4828
5129
  gpu = buildGpuScene(glctx.gl, model);
4829
5130
  pick = new PickPipeline(glctx.renderer, gpu.seatGeometry, gpu.solidGeometry, model.seats.count);
@@ -4857,6 +5158,7 @@ function mountVenue3D(container, input, opts = {}) {
4857
5158
  };
4858
5159
  const glctx = new GLContext(container, {
4859
5160
  onContextLost: () => {
5161
+ if (!disposed) analytics.contextLost();
4860
5162
  contextLost = true;
4861
5163
  loop.stop();
4862
5164
  gpu = null;
@@ -4865,6 +5167,7 @@ function mountVenue3D(container, input, opts = {}) {
4865
5167
  onContextRestored: () => {
4866
5168
  rebuildGpu();
4867
5169
  contextLost = false;
5170
+ if (!disposed) analytics.contextRestored();
4868
5171
  loop.requestRender();
4869
5172
  }
4870
5173
  });
@@ -4881,16 +5184,48 @@ function mountVenue3D(container, input, opts = {}) {
4881
5184
  const dz = model.focalWorld[2] - model.bounds.center[2];
4882
5185
  return Math.hypot(dx, dz) > model.bounds.radius * 0.12 ? Math.atan2(dx, dz) : void 0;
4883
5186
  })();
4884
- orbit.frame(model.bounds, true, stageAzimuth);
5187
+ orbit.frame(model.bounds, true, stageAzimuth, opts.portraitOverviewCrop === true);
4885
5188
  const cinematic = new Cinematic(orbit.camera);
4886
- if (!container.style.position || container.style.position === "static") {
4887
- container.style.position = "relative";
4888
- }
5189
+ const restoreContainerPosition = establishPositioningContext(container);
4889
5190
  const labelOverlay = new LabelOverlay(container, {
4890
5191
  fontFamily: input.doc.theme?.fontFamily,
4891
5192
  ink: input.doc.theme?.textColor
4892
5193
  });
4893
5194
  labelOverlay.setLabels(model.labels);
5195
+ const baseLabels = model.labels;
5196
+ let focusedSectionId = null;
5197
+ const showSectionSeatLabels = (sectionId) => {
5198
+ focusedSectionId = sectionId;
5199
+ labelOverlay.setForcedDense(!!sectionId);
5200
+ if (!sectionId) {
5201
+ labelOverlay.setLabels(baseLabels);
5202
+ return;
5203
+ }
5204
+ const sectionSeats = input.seats.filter((seat) => seat.sectionId === sectionId);
5205
+ const seatIds = new Set(sectionSeats.map((seat) => seat.id));
5206
+ const rowIds = new Set(sectionSeats.map((seat) => seat.rowId));
5207
+ 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))));
5208
+ if (model.seatCount <= 6e3) {
5209
+ labelOverlay.setLabels(focusedBase);
5210
+ return;
5211
+ }
5212
+ const labels = sectionSeats.flatMap((seat) => {
5213
+ const index = model.seats.idToIndex.get(seat.id);
5214
+ if (index === void 0) return [];
5215
+ const offset = index * 3;
5216
+ return [{
5217
+ id: `seat:${seat.id}`,
5218
+ kind: "seat",
5219
+ text: seat.displayLabel || seat.label,
5220
+ anchor: [
5221
+ model.seats.iPosition[offset],
5222
+ model.seats.iPosition[offset + 1] + 0.55,
5223
+ model.seats.iPosition[offset + 2]
5224
+ ]
5225
+ }];
5226
+ });
5227
+ labelOverlay.setLabels([...focusedBase, ...labels]);
5228
+ };
4894
5229
  rebuildGpu();
4895
5230
  const loop = new RenderLoop(() => {
4896
5231
  if (contextLost || !gpu || frozen) return false;
@@ -4943,6 +5278,9 @@ function mountVenue3D(container, input, opts = {}) {
4943
5278
  if (!p) {
4944
5279
  p = Promise.resolve(opts.getSeatView(seatId));
4945
5280
  prefetch.set(seatId, p);
5281
+ void p.catch(() => {
5282
+ if (prefetch.get(seatId) === p) prefetch.delete(seatId);
5283
+ });
4946
5284
  while (prefetch.size > PREFETCH_CAP) {
4947
5285
  const oldest = prefetch.keys().next().value;
4948
5286
  if (oldest === void 0) break;
@@ -4951,56 +5289,212 @@ function mountVenue3D(container, input, opts = {}) {
4951
5289
  }
4952
5290
  return p;
4953
5291
  };
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
- ];
5292
+ const resolvedSeatViewPose = (seatId, idx) => {
5293
+ const seat = expandedSeatById.get(seatId);
5294
+ const floorIndex = Math.max(0, Math.round(model.seats.iFloor[idx] ?? 0));
5295
+ const floor = input.doc.floors?.[floorIndex];
5296
+ const focalPoint = seat?.focalPoint ?? floor?.focalPoint ?? input.doc.focalPoint;
5297
+ const deck = [
5298
+ model.seats.iPosition[idx * 3],
5299
+ model.seats.iPosition[idx * 3 + 1],
5300
+ model.seats.iPosition[idx * 3 + 2]
5301
+ ];
5302
+ return seatViewPose(deck, focalPoint, floor?.baseHeightM ?? 0);
5303
+ };
4959
5304
  const placeCameraFinal = (finalPos, focal) => {
4960
5305
  orbit.camera.position.set(finalPos[0], finalPos[1], finalPos[2]);
4961
5306
  orbit.camera.lookAt(new Vec33(focal[0], focal[1], focal[2]));
4962
5307
  orbit.camera.fov = FOV_END;
4963
5308
  orbit.camera.updateProjectionMatrix();
4964
5309
  };
5310
+ const isNarrow = () => (container.clientWidth || glctx.canvas.clientWidth) <= 520;
4965
5311
  let arriveChip = null;
5312
+ const layoutArriveChip = () => {
5313
+ if (!arriveChip) return;
5314
+ const narrow = isNarrow();
5315
+ Object.assign(arriveChip.style, narrow ? {
5316
+ left: "12px",
5317
+ right: "12px",
5318
+ bottom: "70px",
5319
+ transform: "none",
5320
+ justifyContent: "center"
5321
+ } : {
5322
+ left: "50%",
5323
+ right: "auto",
5324
+ bottom: "18px",
5325
+ transform: "translateX(-50%)",
5326
+ justifyContent: "initial"
5327
+ });
5328
+ };
4966
5329
  const removeArriveChip = () => {
4967
5330
  arriveChip?.remove();
4968
5331
  arriveChip = null;
4969
5332
  };
4970
- const showArriveChip = (seatId, gen) => {
5333
+ const showArriveChip = (seatId, gen, retry = false) => {
4971
5334
  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, {
5335
+ if (disposed) return null;
5336
+ const controls = document.createElement("div");
5337
+ controls.setAttribute("role", "group");
5338
+ Object.assign(controls.style, {
4978
5339
  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",
5340
+ display: "flex",
5341
+ flexDirection: "column",
5342
+ alignItems: "center",
5343
+ gap: "6px",
4991
5344
  zIndex: "4"
4992
5345
  });
4993
- chip.addEventListener("click", () => {
4994
- if (disposed || gen !== flightGen) {
5346
+ const seat = expandedSeatById.get(seatId);
5347
+ const section = seat?.sectionId ? model.sections.find((candidate) => candidate.id === seat.sectionId) : void 0;
5348
+ const seatLabel = seat?.label ?? seatId;
5349
+ controls.setAttribute("aria-label", `Explore views near ${section?.label ? `${section.label}, ` : ""}seat ${seatLabel}`);
5350
+ const location = document.createElement("span");
5351
+ location.textContent = ["Live 3D seat view", section?.label, seatLabel].filter(Boolean).join(" \xB7 ");
5352
+ Object.assign(location.style, {
5353
+ maxWidth: "min(320px, calc(100vw - 32px))",
5354
+ padding: "5px 10px",
5355
+ borderRadius: "999px",
5356
+ overflow: "hidden",
5357
+ color: "#d9e3f5",
5358
+ background: "rgba(12,18,32,0.72)",
5359
+ font: "600 11px/1.2 inherit",
5360
+ textOverflow: "ellipsis",
5361
+ whiteSpace: "nowrap",
5362
+ backdropFilter: "blur(6px)"
5363
+ });
5364
+ controls.appendChild(location);
5365
+ const actions = document.createElement("div");
5366
+ Object.assign(actions.style, {
5367
+ display: "flex",
5368
+ alignItems: "center",
5369
+ justifyContent: "center",
5370
+ gap: "6px"
5371
+ });
5372
+ controls.appendChild(actions);
5373
+ const row = seat?.rowId ? seatIdsByRow.get(seat.rowId) : void 0;
5374
+ const at = row?.indexOf(seatId) ?? -1;
5375
+ const neighbours = at >= 0 ? [row?.[at - 1], row?.[at + 1]] : [void 0, void 0];
5376
+ const addButton = (label, ariaLabel, action, primary = false) => {
5377
+ const button = document.createElement("button");
5378
+ button.type = "button";
5379
+ button.textContent = label;
5380
+ button.setAttribute("aria-label", ariaLabel);
5381
+ Object.assign(button.style, {
5382
+ minHeight: "44px",
5383
+ padding: primary ? "10px 16px" : "10px",
5384
+ borderRadius: "999px",
5385
+ background: "rgba(12,18,32,0.78)",
5386
+ color: "#eef1f8",
5387
+ border: "1px solid rgba(150,165,205,0.4)",
5388
+ backdropFilter: "blur(6px)",
5389
+ font: "600 13px/1 inherit",
5390
+ cursor: "pointer",
5391
+ whiteSpace: "nowrap"
5392
+ });
5393
+ if (!primary) button.style.minWidth = "44px";
5394
+ if (primary) button.dataset.panoramaTrigger = "";
5395
+ button.addEventListener("click", action);
5396
+ actions.appendChild(button);
5397
+ };
5398
+ if (neighbours[0]) {
5399
+ const label = expandedSeatById.get(neighbours[0])?.label ?? neighbours[0];
5400
+ addButton("\u2039", `View previous seat ${label}`, () => {
5401
+ void flyToSeat(neighbours[0]);
5402
+ });
5403
+ }
5404
+ if (opts.getSeatView) {
5405
+ addButton(retry ? "\u21BB Retry 360\xB0 panorama" : "\u25C9 Open 360\xB0 panorama", `${retry ? "Retry" : "Open"} the 360\xB0 panorama from seat ${seatLabel}`, () => {
5406
+ if (disposed || gen !== flightGen) {
5407
+ removeArriveChip();
5408
+ return;
5409
+ }
4995
5410
  removeArriveChip();
4996
- return;
4997
- }
4998
- removeArriveChip();
4999
- void openPanorama(seatId, 400, gen);
5411
+ void openPanorama(seatId, 400, gen);
5412
+ }, true);
5413
+ }
5414
+ if (neighbours[1]) {
5415
+ const label = expandedSeatById.get(neighbours[1])?.label ?? neighbours[1];
5416
+ addButton("\u203A", `View next seat ${label}`, () => {
5417
+ void flyToSeat(neighbours[1]);
5418
+ });
5419
+ }
5420
+ if (!actions.childElementCount) return null;
5421
+ container.appendChild(controls);
5422
+ arriveChip = controls;
5423
+ layoutArriveChip();
5424
+ return controls;
5425
+ };
5426
+ const navigationModeChip = document.createElement("div");
5427
+ navigationModeChip.setAttribute("role", "group");
5428
+ navigationModeChip.setAttribute("aria-label", "3D navigation mode");
5429
+ Object.assign(navigationModeChip.style, {
5430
+ position: "absolute",
5431
+ left: "14px",
5432
+ top: "62px",
5433
+ display: "flex",
5434
+ gap: "3px",
5435
+ padding: "3px",
5436
+ borderRadius: "999px",
5437
+ zIndex: "4",
5438
+ background: "rgba(12,18,32,0.72)",
5439
+ border: "1px solid rgba(150,165,205,0.35)",
5440
+ backdropFilter: "blur(6px)"
5441
+ });
5442
+ const rotateModeButton = document.createElement("button");
5443
+ rotateModeButton.type = "button";
5444
+ rotateModeButton.textContent = "\u21BB Rotate";
5445
+ rotateModeButton.setAttribute("aria-label", "Drag to rotate the 3D venue");
5446
+ const moveModeButton = document.createElement("button");
5447
+ moveModeButton.type = "button";
5448
+ moveModeButton.textContent = "\u2725 Move";
5449
+ moveModeButton.setAttribute("aria-label", "Drag to move the 3D venue left, right, up or down");
5450
+ const zoomOutButton = document.createElement("button");
5451
+ zoomOutButton.type = "button";
5452
+ zoomOutButton.textContent = "\u2212";
5453
+ zoomOutButton.setAttribute("aria-label", "Zoom out of the 3D venue");
5454
+ zoomOutButton.title = "Zoom out";
5455
+ const zoomInButton = document.createElement("button");
5456
+ zoomInButton.type = "button";
5457
+ zoomInButton.textContent = "+";
5458
+ zoomInButton.setAttribute("aria-label", "Zoom into the 3D venue");
5459
+ zoomInButton.title = "Zoom in";
5460
+ for (const button of [rotateModeButton, moveModeButton, zoomOutButton, zoomInButton]) {
5461
+ Object.assign(button.style, {
5462
+ minHeight: "34px",
5463
+ padding: "6px 10px",
5464
+ border: "0",
5465
+ borderRadius: "999px",
5466
+ color: "#c9d4ea",
5467
+ background: "transparent",
5468
+ font: "600 11px/1 inherit",
5469
+ cursor: "pointer",
5470
+ whiteSpace: "nowrap"
5000
5471
  });
5001
- container.appendChild(chip);
5002
- arriveChip = chip;
5472
+ if (button === zoomOutButton || button === zoomInButton) {
5473
+ button.style.minWidth = "34px";
5474
+ button.style.padding = "6px";
5475
+ button.style.fontSize = "17px";
5476
+ }
5477
+ navigationModeChip.appendChild(button);
5478
+ }
5479
+ const setNavigationMode = (mode) => {
5480
+ orbit.setPrimaryDragMode(mode);
5481
+ const rotateActive = mode === "orbit";
5482
+ rotateModeButton.setAttribute("aria-pressed", String(rotateActive));
5483
+ moveModeButton.setAttribute("aria-pressed", String(!rotateActive));
5484
+ rotateModeButton.style.background = rotateActive ? "rgba(96,110,150,0.48)" : "transparent";
5485
+ moveModeButton.style.background = rotateActive ? "transparent" : "rgba(96,110,150,0.48)";
5486
+ navigationModeChip.title = rotateActive ? "Drag to rotate \xB7 Shift-drag to move" : "Drag to move \xB7 Shift-drag to rotate";
5487
+ };
5488
+ rotateModeButton.addEventListener("click", () => setNavigationMode("orbit"));
5489
+ moveModeButton.addEventListener("click", () => setNavigationMode("pan"));
5490
+ zoomOutButton.addEventListener("click", () => orbit.zoomBy(1.22));
5491
+ zoomInButton.addEventListener("click", () => orbit.zoomBy(0.82));
5492
+ setNavigationMode("orbit");
5493
+ container.appendChild(navigationModeChip);
5494
+ const layoutNavigationChip = () => {
5495
+ navigationModeChip.style.display = isNarrow() ? "none" : "flex";
5003
5496
  };
5497
+ layoutNavigationChip();
5004
5498
  const overviewChip = document.createElement("button");
5005
5499
  overviewChip.type = "button";
5006
5500
  overviewChip.textContent = "\u2302 Overview";
@@ -5020,17 +5514,46 @@ function mountVenue3D(container, input, opts = {}) {
5020
5514
  cursor: "pointer",
5021
5515
  zIndex: "4"
5022
5516
  });
5023
- overviewChip.addEventListener("click", () => {
5024
- if (disposed || frozen || panorama) return;
5517
+ const focusOverview = () => {
5518
+ if (disposed) return;
5519
+ if (panorama) {
5520
+ panorama.dispose();
5521
+ panorama = null;
5522
+ analytics.panoramaClosed();
5523
+ }
5524
+ panoramaLoadAbort?.abort();
5525
+ panoramaLoadAbort = null;
5526
+ restorePanoramaLayer();
5527
+ overviewChip.style.display = "";
5528
+ labelOverlay.setVisible(true);
5529
+ frozen = false;
5025
5530
  cancelFlight();
5026
5531
  removeArriveChip();
5027
- orbit.frameSoft(model.bounds, stageAzimuth);
5532
+ setNavigationMode("orbit");
5533
+ if (reducedMotion()) orbit.frame(model.bounds, false, stageAzimuth, opts.portraitOverviewCrop === true);
5534
+ else orbit.frameSoft(model.bounds, stageAzimuth, opts.portraitOverviewCrop === true);
5535
+ opts.onViewTargetChange?.(null);
5536
+ showSectionSeatLabels(null);
5537
+ opts.onSectionFocusChange?.(null);
5028
5538
  loop.requestRender();
5029
- });
5539
+ };
5540
+ overviewChip.addEventListener("click", focusOverview);
5030
5541
  container.appendChild(overviewChip);
5031
5542
  const openPanorama = async (seatId, fadeMs, gen) => {
5543
+ panoramaLoadAbort?.abort();
5544
+ const loadAbort = new AbortController();
5545
+ panoramaLoadAbort = loadAbort;
5546
+ const seatIndex = model.seats.idToIndex.get(seatId);
5547
+ if (seatIndex === void 0) {
5548
+ orbit.syncFromCamera();
5549
+ return;
5550
+ }
5551
+ const seatLabel = expandedSeatById.get(seatId)?.label ?? seatId;
5552
+ const seatPose = resolvedSeatViewPose(seatId, seatIndex);
5032
5553
  const viewPromise = ensureSeatView(seatId);
5033
5554
  if (!viewPromise) {
5555
+ loadAbort.abort();
5556
+ if (panoramaLoadAbort === loadAbort) panoramaLoadAbort = null;
5034
5557
  orbit.syncFromCamera();
5035
5558
  return;
5036
5559
  }
@@ -5040,34 +5563,56 @@ function mountVenue3D(container, input, opts = {}) {
5040
5563
  try {
5041
5564
  view = await viewPromise;
5042
5565
  } catch {
5566
+ if (panoramaLoadAbort === loadAbort) panoramaLoadAbort = null;
5567
+ analytics.panoramaFailed(seatId, "source");
5043
5568
  if (!disposed && gen === flightGen) {
5044
5569
  frozen = false;
5045
- orbit.resumeAfterFlight(model.focalWorld);
5570
+ orbit.resumeAfterFlight(seatPose.focal);
5046
5571
  loop.requestRender();
5572
+ const controls = showArriveChip(seatId, gen, true);
5573
+ requestAnimationFrame(() => controls?.querySelector("[data-panorama-trigger]")?.focus());
5047
5574
  }
5048
5575
  return;
5049
5576
  }
5050
- if (disposed || gen !== flightGen) return;
5577
+ if (disposed || gen !== flightGen) {
5578
+ loadAbort.abort();
5579
+ if (panoramaLoadAbort === loadAbort) panoramaLoadAbort = null;
5580
+ return;
5581
+ }
5051
5582
  removeArriveChip();
5052
5583
  const onClose = () => {
5584
+ loadAbort.abort();
5585
+ if (panoramaLoadAbort === loadAbort) panoramaLoadAbort = null;
5053
5586
  panorama = null;
5587
+ restorePanoramaLayer();
5588
+ overviewChip.style.display = "";
5054
5589
  labelOverlay.setVisible(true);
5055
5590
  frozen = false;
5056
5591
  analytics.panoramaClosed();
5057
- orbit.resumeAfterFlight(model.focalWorld);
5592
+ orbit.resumeAfterFlight(seatPose.focal);
5058
5593
  loop.requestRender();
5059
- showArriveChip(seatId, flightGen);
5594
+ const controls = showArriveChip(seatId, flightGen);
5595
+ requestAnimationFrame(() => controls?.querySelector("[data-panorama-trigger]")?.focus());
5060
5596
  };
5061
5597
  const sceneMode = view.generated === true;
5598
+ const disclosure = seatViewDisclosure(view);
5599
+ raisePanoramaLayer();
5600
+ overviewChip.style.display = "none";
5601
+ const effectiveFadeMs = reducedMotion() ? 0 : fadeMs;
5062
5602
  const spherical = gpu && !contextLost ? await mountPanoramaSphere(container, sceneMode ? null : view, {
5063
5603
  gl: glctx.gl,
5064
5604
  scene: gpu.main,
5065
5605
  camera: orbit.camera,
5066
5606
  requestRender: () => loop.requestRender(),
5067
- focalWorld: model.focalWorld
5068
- }, { fadeMs, seatLabel: seatId, onClose }) : null;
5607
+ cameraOriginWorld: sceneMode ? seatPose.eye : void 0,
5608
+ focalWorld: seatPose.focal
5609
+ }, { fadeMs: effectiveFadeMs, seatLabel, disclosure, onClose, signal: loadAbort.signal }) : null;
5069
5610
  if (disposed || gen !== flightGen) {
5611
+ loadAbort.abort();
5612
+ if (panoramaLoadAbort === loadAbort) panoramaLoadAbort = null;
5070
5613
  spherical?.dispose();
5614
+ overviewChip.style.display = "";
5615
+ restorePanoramaLayer();
5071
5616
  return;
5072
5617
  }
5073
5618
  if (spherical) {
@@ -5077,12 +5622,21 @@ function mountVenue3D(container, input, opts = {}) {
5077
5622
  loop.requestRender();
5078
5623
  panorama = spherical;
5079
5624
  } else {
5080
- panorama = mountPanorama(container, view, { fadeMs, seatLabel: seatId, onClose });
5625
+ analytics.panoramaFallback(seatId);
5626
+ panorama = mountPanorama(container, view, {
5627
+ fadeMs: effectiveFadeMs,
5628
+ seatLabel,
5629
+ disclosure,
5630
+ onClose,
5631
+ signal: loadAbort.signal
5632
+ });
5081
5633
  }
5082
5634
  analytics.panoramaOpened();
5083
5635
  };
5084
5636
  const cancelFlight = () => {
5085
5637
  flightGen++;
5638
+ panoramaLoadAbort?.abort();
5639
+ panoramaLoadAbort = null;
5086
5640
  if (cinematic.active) {
5087
5641
  cinematic.cancel();
5088
5642
  orbit.resumeAfterFlight(model.focalWorld);
@@ -5092,15 +5646,19 @@ function mountVenue3D(container, input, opts = {}) {
5092
5646
  if (disposed || !gpu) return Promise.resolve();
5093
5647
  const idx = model.seats.idToIndex.get(seatId);
5094
5648
  if (idx === void 0) return Promise.resolve();
5649
+ opts.onViewTargetChange?.(seatId);
5095
5650
  if (panorama) {
5096
5651
  panorama.dispose();
5097
5652
  panorama = null;
5653
+ overviewChip.style.display = "";
5654
+ restorePanoramaLayer();
5098
5655
  }
5656
+ panoramaLoadAbort?.abort();
5657
+ panoramaLoadAbort = null;
5099
5658
  frozen = false;
5100
5659
  const gen = ++flightGen;
5101
5660
  removeArriveChip();
5102
- const seatEye = seatEyeWorld(idx);
5103
- const focal = model.focalWorld;
5661
+ const { eye: seatEye, focal } = resolvedSeatViewPose(seatId, idx);
5104
5662
  const start = [orbit.camera.position.x, orbit.camera.position.y, orbit.camera.position.z];
5105
5663
  const { waypoints, finalPos } = buildWaypoints(start, seatEye, focal, model.bounds.center, model.bounds.radius);
5106
5664
  if (reducedMotion()) {
@@ -5117,11 +5675,26 @@ function mountVenue3D(container, input, opts = {}) {
5117
5675
  return cinematic.start(waypoints, startQuat, endQuat).then(() => {
5118
5676
  if (disposed || gen !== flightGen) return;
5119
5677
  analytics.cinematicPlayed(FLIGHT_DURATION_MS);
5120
- orbit.resumeAfterFlight(model.focalWorld);
5678
+ orbit.resumeAfterFlight(focal);
5121
5679
  loop.requestRender();
5122
5680
  showArriveChip(seatId, gen);
5123
5681
  });
5124
5682
  };
5683
+ const focusSectionCamera = (sectionId) => {
5684
+ const sec = model.sections.find((candidate) => candidate.id === sectionId);
5685
+ if (!sec || sec.seatCount === 0) return false;
5686
+ cinematic.cancel();
5687
+ removeArriveChip();
5688
+ setNavigationMode("pan");
5689
+ showSectionSeatLabels(sectionId);
5690
+ const dx = sec.focalWorld[0] - sec.center[0];
5691
+ const dz = sec.focalWorld[2] - sec.center[2];
5692
+ const azimuth = Math.hypot(dx, dz) > sec.radius * 0.12 ? Math.atan2(dx, dz) : void 0;
5693
+ orbit.frame({ center: sec.center, radius: Math.max(5, sec.radius * 0.5) }, false, azimuth);
5694
+ opts.onSectionFocusChange?.(sectionId);
5695
+ loop.requestRender();
5696
+ return true;
5697
+ };
5125
5698
  let downX = 0, downY = 0, downT = 0, downId = -1, moved = false, suppressTap = false;
5126
5699
  const onDown = (e) => {
5127
5700
  if (downId !== -1) return;
@@ -5140,6 +5713,50 @@ function mountVenue3D(container, input, opts = {}) {
5140
5713
  if (e.pointerId !== downId) return;
5141
5714
  if (Math.hypot(e.clientX - downX, e.clientY - downY) > TAP_SLOP) moved = true;
5142
5715
  };
5716
+ const pickNearestProjectedSeat = (clientX, clientY, sectionId, maxDistance, bookableOnly) => {
5717
+ const rect = glctx.canvas.getBoundingClientRect();
5718
+ const width = glctx.canvas.clientWidth || rect.width || 1;
5719
+ const height = glctx.canvas.clientHeight || rect.height || 1;
5720
+ const tapX = clientX - rect.left;
5721
+ const tapY = clientY - rect.top;
5722
+ const maxDistanceSq = maxDistance * maxDistance;
5723
+ let bestIndex = -1;
5724
+ let bestDistanceSq = maxDistanceSq;
5725
+ let bestDepth = Infinity;
5726
+ for (const seat of input.seats) {
5727
+ if (sectionId && seat.sectionId !== sectionId) continue;
5728
+ const index = model.seats.idToIndex.get(seat.id);
5729
+ if (index === void 0) continue;
5730
+ if (bookableOnly) {
5731
+ const state = Math.round(model.seats.iState[index] ?? 0);
5732
+ if (state !== 0 && state !== 3) continue;
5733
+ }
5734
+ const offset = index * 3;
5735
+ const screen = projectToScreen(
5736
+ orbit.camera.projectionViewMatrix,
5737
+ [
5738
+ model.seats.iPosition[offset],
5739
+ // Match the visible chair/number rather than the deck-level instance
5740
+ // origin. At strong zoom the vertical difference is dozens of pixels
5741
+ // and a perfectly reasonable click on the chair otherwise misses.
5742
+ model.seats.iPosition[offset + 1] + 0.55,
5743
+ model.seats.iPosition[offset + 2]
5744
+ ],
5745
+ width,
5746
+ height
5747
+ );
5748
+ if (!screen.visible) continue;
5749
+ const dx = screen.x - tapX;
5750
+ const dy = screen.y - tapY;
5751
+ const distanceSq = dx * dx + dy * dy;
5752
+ if (distanceSq < bestDistanceSq || Math.abs(distanceSq - bestDistanceSq) < 1 && screen.depth < bestDepth) {
5753
+ bestIndex = index;
5754
+ bestDistanceSq = distanceSq;
5755
+ bestDepth = screen.depth;
5756
+ }
5757
+ }
5758
+ return bestIndex;
5759
+ };
5143
5760
  const onUp = (e) => {
5144
5761
  if (e.pointerId !== downId) return;
5145
5762
  const isTap = !moved && performance.now() - downT < TAP_MS;
@@ -5149,12 +5766,25 @@ function mountVenue3D(container, input, opts = {}) {
5149
5766
  return;
5150
5767
  }
5151
5768
  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);
5769
+ let idx = focusedSectionId ? pickNearestProjectedSeat(e.clientX, e.clientY, focusedSectionId, 44, true) : -1;
5770
+ if (focusedSectionId && idx < 0) {
5771
+ const sectionIndex = pickNearestProjectedSeat(e.clientX, e.clientY, null, 72, false);
5772
+ const sectionSeatId = sectionIndex >= 0 ? seatIdByIndex[sectionIndex] : void 0;
5773
+ const nextSectionId = sectionSeatId ? sectionIdBySeatId.get(sectionSeatId) : void 0;
5774
+ if (nextSectionId && nextSectionId !== focusedSectionId && focusSectionCamera(nextSectionId)) return;
5775
+ }
5776
+ if (!focusedSectionId) {
5777
+ pick.syncFromSeatProgram(gpu.seatProgram);
5778
+ const rect = glctx.canvas.getBoundingClientRect();
5779
+ const dpr = glctx.renderer.dpr;
5780
+ const { x, y } = pickPixelCoords(e.clientX, e.clientY, rect, dpr, glctx.gl.drawingBufferWidth, glctx.gl.drawingBufferHeight);
5781
+ const radius = Math.max(2, Math.round(8 * dpr));
5782
+ idx = pick.pick(orbit.camera, x, y, radius);
5783
+ if (idx < 0) idx = pickNearestProjectedSeat(e.clientX, e.clientY, null, 42, false);
5784
+ const overviewSeatId = idx >= 0 ? seatIdByIndex[idx] : void 0;
5785
+ const sectionId = overviewSeatId ? sectionIdBySeatId.get(overviewSeatId) : void 0;
5786
+ if (sectionId && focusSectionCamera(sectionId)) return;
5787
+ }
5158
5788
  if (idx < 0 || idx >= seatIdByIndex.length) {
5159
5789
  if (selection.size) setSelection([]);
5160
5790
  return;
@@ -5180,9 +5810,12 @@ function mountVenue3D(container, input, opts = {}) {
5180
5810
  },
5181
5811
  setSelection,
5182
5812
  flyToSeat,
5813
+ focusOverview,
5183
5814
  resize() {
5184
5815
  const { width, height } = glctx.resize();
5185
5816
  orbit.setAspect(width / Math.max(1, height));
5817
+ layoutArriveChip();
5818
+ layoutNavigationChip();
5186
5819
  loop.requestRender();
5187
5820
  },
5188
5821
  stats() {
@@ -5207,6 +5840,7 @@ function mountVenue3D(container, input, opts = {}) {
5207
5840
  gpu.chairProgram.uniforms.uFocusFloor.value = value;
5208
5841
  }
5209
5842
  focusedFloor = value;
5843
+ setNavigationMode("orbit");
5210
5844
  if (index !== null) {
5211
5845
  const f = model.floors[index];
5212
5846
  if (f.seatCount > 0) {
@@ -5224,6 +5858,9 @@ function mountVenue3D(container, input, opts = {}) {
5224
5858
  const zone = model.zones.find((z) => z.id === zoneId);
5225
5859
  if (!zone || zone.seatCount === 0) return false;
5226
5860
  cinematic.cancel();
5861
+ setNavigationMode("orbit");
5862
+ showSectionSeatLabels(null);
5863
+ opts.onSectionFocusChange?.(null);
5227
5864
  const dx = zone.focalWorld[0] - zone.center[0];
5228
5865
  const dz = zone.focalWorld[2] - zone.center[2];
5229
5866
  const azimuth = Math.hypot(dx, dz) > zone.radius * 0.12 ? Math.atan2(dx, dz) : void 0;
@@ -5235,13 +5872,24 @@ function mountVenue3D(container, input, opts = {}) {
5235
5872
  return model.sections;
5236
5873
  },
5237
5874
  focusSection(sectionId) {
5238
- const sec = model.sections.find((s) => s.id === sectionId);
5239
- if (!sec || sec.seatCount === 0) return false;
5875
+ return focusSectionCamera(sectionId);
5876
+ },
5877
+ rows(sectionId) {
5878
+ return sectionId ? model.rows.filter((row) => row.sectionId === sectionId) : model.rows;
5879
+ },
5880
+ seatsInRow(rowId) {
5881
+ return input.seats.filter((seat) => seat.rowId === rowId).map((seat) => ({ id: seat.id, label: seat.displayLabel || seat.label }));
5882
+ },
5883
+ focusRow(rowId) {
5884
+ const row = model.rows.find((candidate) => candidate.id === rowId);
5885
+ if (!row || row.seatCount === 0) return false;
5240
5886
  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);
5887
+ setNavigationMode("pan");
5888
+ if (row.sectionId) {
5889
+ showSectionSeatLabels(row.sectionId);
5890
+ opts.onSectionFocusChange?.(row.sectionId);
5891
+ }
5892
+ orbit.frame({ center: row.center, radius: Math.max(2.5, row.radius * 0.65) });
5245
5893
  loop.requestRender();
5246
5894
  return true;
5247
5895
  },
@@ -5252,6 +5900,7 @@ function mountVenue3D(container, input, opts = {}) {
5252
5900
  disposed = true;
5253
5901
  removeArriveChip();
5254
5902
  overviewChip.remove();
5903
+ navigationModeChip.remove();
5255
5904
  cancelFlight();
5256
5905
  loop.stop();
5257
5906
  labelOverlay.dispose();
@@ -5260,6 +5909,8 @@ function mountVenue3D(container, input, opts = {}) {
5260
5909
  panorama.dispose();
5261
5910
  panorama = null;
5262
5911
  }
5912
+ restorePanoramaLayer();
5913
+ restoreContainerPosition();
5263
5914
  glctx.canvas.removeEventListener("pointerdown", onDown);
5264
5915
  glctx.canvas.removeEventListener("pointermove", onMove);
5265
5916
  glctx.canvas.removeEventListener("pointerup", onUp);
@@ -5272,11 +5923,20 @@ function mountVenue3D(container, input, opts = {}) {
5272
5923
  glctx.dispose();
5273
5924
  }
5274
5925
  };
5275
- analytics.opened(model.seatCount, hasHeights);
5926
+ analytics.opened(
5927
+ model.seatCount,
5928
+ hasHeights,
5929
+ input.prepared?.buildMs ?? performance.now() - buildStartedAt,
5930
+ // Known synchronously at mount: either the caller handed us a scene
5931
+ // prepared by `prepareVenue3D` (which reports worker vs. main-thread
5932
+ // fallback) or we compiled it inline here. Never delays the event.
5933
+ input.prepared?.source ?? "inline"
5934
+ );
5276
5935
  return handle;
5277
5936
  }
5278
5937
  export {
5279
5938
  buildSceneModel,
5280
- mountVenue3D
5939
+ mountVenue3D,
5940
+ prepareVenue3D
5281
5941
  };
5282
5942
  //# sourceMappingURL=index.js.map