@seatlayer/core 0.46.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
@@ -31,7 +31,8 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
31
31
  var view3d_exports = {};
32
32
  __export(view3d_exports, {
33
33
  buildSceneModel: () => buildSceneModel,
34
- mountVenue3D: () => mountVenue3D
34
+ mountVenue3D: () => mountVenue3D,
35
+ prepareVenue3D: () => prepareVenue3D
35
36
  });
36
37
  module.exports = __toCommonJS(view3d_exports);
37
38
  var import_ogl8 = require("ogl");
@@ -134,6 +135,12 @@ var GLContext = class {
134
135
  this.canvas.style.width = "100%";
135
136
  this.canvas.style.height = "100%";
136
137
  this.canvas.style.touchAction = "none";
138
+ this.canvas.tabIndex = 0;
139
+ this.canvas.setAttribute("role", "application");
140
+ this.canvas.setAttribute(
141
+ "aria-label",
142
+ "Interactive 3D venue. Drag or use arrow keys to look around; pinch, scroll, plus or minus to zoom."
143
+ );
137
144
  this.renderer = new import_ogl.Renderer({
138
145
  canvas: this.canvas,
139
146
  dpr: computeDpr(),
@@ -242,6 +249,10 @@ var OrbitCamera = class {
242
249
  /** Venue centre + radius, so pan can be clamped to somewhere still useful. */
243
250
  this.panAnchor = new import_ogl2.Vec3();
244
251
  this.panLimit = 0;
252
+ /** What an unmodified primary-button / one-finger drag does. Section drill-in
253
+ * switches this to pan so a buyer can slide hidden seats into view without
254
+ * discovering a modifier gesture. Shift temporarily inverts the mode. */
255
+ this.primaryDragMode = "orbit";
245
256
  this.camera = new import_ogl2.Camera(gl, { fov: FOV, near: 0.1, far: 5e3, aspect: 1 });
246
257
  this.canvas = canvas;
247
258
  this.requestRender = requestRender;
@@ -253,7 +264,9 @@ var OrbitCamera = class {
253
264
  }
254
265
  this.activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
255
266
  if (this.activePointers.size === 1) {
256
- this.panning = e.button === 2 || e.button === 1 || e.shiftKey;
267
+ const secondaryPan = e.button === 2 || e.button === 1;
268
+ const primaryPan = e.button === 0 && (e.shiftKey ? this.primaryDragMode === "orbit" : this.primaryDragMode === "pan");
269
+ this.panning = secondaryPan || primaryPan;
257
270
  this.dragging = !this.panning;
258
271
  this.lastX = e.clientX;
259
272
  this.lastY = e.clientY;
@@ -319,15 +332,38 @@ var OrbitCamera = class {
319
332
  this.onWheel = (e) => {
320
333
  e.preventDefault();
321
334
  const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? 100 : 1;
322
- const norm2 = e.deltaY * unit / 100;
323
- this.dollyBy(Math.exp(norm2 * 0.4));
335
+ const norm2 = Math.max(-2, Math.min(2, e.deltaY * unit / 100));
336
+ this.dollyBy(Math.exp(norm2 * 0.22));
337
+ this.fireGesture();
338
+ };
339
+ this.onKeyDown = (e) => {
340
+ const step = 7 * DEG;
341
+ if (e.shiftKey && (e.key === "ArrowLeft" || e.key === "ArrowRight" || e.key === "ArrowUp" || e.key === "ArrowDown")) {
342
+ this.panBy(
343
+ e.key === "ArrowLeft" ? -32 : e.key === "ArrowRight" ? 32 : 0,
344
+ e.key === "ArrowUp" ? -32 : e.key === "ArrowDown" ? 32 : 0
345
+ );
346
+ } else if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
347
+ this.azT += e.key === "ArrowLeft" ? -step : step;
348
+ } else if (e.key === "ArrowUp" || e.key === "ArrowDown") {
349
+ this.polT = Math.max(POLAR_MIN, Math.min(POLAR_MAX, this.polT + (e.key === "ArrowUp" ? -step : step)));
350
+ } else if (e.key === "+" || e.key === "=") {
351
+ this.dollyBy(0.82);
352
+ } else if (e.key === "-" || e.key === "_") {
353
+ this.dollyBy(1.22);
354
+ } else {
355
+ return;
356
+ }
357
+ e.preventDefault();
324
358
  this.fireGesture();
359
+ this.requestRender();
325
360
  };
326
361
  canvas.addEventListener("pointerdown", this.onPointerDown);
327
362
  canvas.addEventListener("pointermove", this.onPointerMove);
328
363
  canvas.addEventListener("pointerup", this.onPointerUp);
329
364
  canvas.addEventListener("pointercancel", this.onPointerUp);
330
365
  canvas.addEventListener("wheel", this.onWheel, { passive: false });
366
+ canvas.addEventListener("keydown", this.onKeyDown);
331
367
  canvas.addEventListener("contextmenu", this.onContextMenu);
332
368
  }
333
369
  pinchCentroid() {
@@ -395,20 +431,32 @@ var OrbitCamera = class {
395
431
  this.distT = Math.max(this.minDist, Math.min(this.maxDist, this.distT * factor));
396
432
  this.requestRender();
397
433
  }
434
+ /** Programmatic zoom used by the visible camera controls. Keeping it on the
435
+ * same dolly path as wheel, pinch and keyboard preserves all distance limits. */
436
+ zoomBy(factor) {
437
+ this.dollyBy(factor);
438
+ this.fireGesture();
439
+ }
440
+ /** Choose what a normal left/one-finger drag does. Shift temporarily performs
441
+ * the other action. This is intentionally public so the view can make the
442
+ * currently active gesture visible rather than hiding it in documentation. */
443
+ setPrimaryDragMode(mode) {
444
+ this.primaryDragMode = mode;
445
+ }
398
446
  /**
399
447
  * Fit a flattering 3/4 view to the bounds sphere. With `intro`, the camera
400
448
  * STARTS nearly top-down (matching the 2D map's orientation) and further out,
401
449
  * then the damped `update()` eases it up into the 3/4 architectural angle and
402
450
  * dollies in — the venue "stands up" instead of teleporting (~600ms).
403
451
  */
404
- frame(bounds, intro = false, stageAzimuth) {
452
+ frame(bounds, intro = false, stageAzimuth, portraitCrop = false) {
405
453
  this.target.set(bounds.center[0], bounds.center[1], bounds.center[2]);
406
454
  this.targetT.copy(this.target);
407
455
  this.panAnchor.copy(this.target);
408
456
  this.panLimit = Math.max(1, bounds.radius) * 1.5;
409
457
  const r = Math.max(1, bounds.radius);
410
458
  const halfV = this.fovY * DEG / 2;
411
- const aspect = this.camera.aspect || 1;
459
+ const aspect = portraitCrop ? Math.max(0.82, this.camera.aspect || 1) : this.camera.aspect || 1;
412
460
  const halfH = Math.atan(Math.tan(halfV) * aspect);
413
461
  const fit = Math.max(r / Math.tan(halfV), r / Math.tan(halfH));
414
462
  this.azT = stageAzimuth ?? -30 * DEG;
@@ -435,7 +483,7 @@ var OrbitCamera = class {
435
483
  * seat, behind the shell, anywhere): re-pivot on the venue centre without
436
484
  * moving the camera, then glide targets back to the 3/4 architectural pose.
437
485
  */
438
- frameSoft(bounds, stageAzimuth) {
486
+ frameSoft(bounds, stageAzimuth, portraitCrop = false) {
439
487
  this.target.set(bounds.center[0], bounds.center[1], bounds.center[2]);
440
488
  this.targetT.copy(this.target);
441
489
  this.panAnchor.copy(this.target);
@@ -444,7 +492,7 @@ var OrbitCamera = class {
444
492
  this.camera.perspective({ fov: this.fovY, aspect: this.camera.aspect });
445
493
  const r = Math.max(1, bounds.radius);
446
494
  const halfV = this.fovY * DEG / 2;
447
- const aspect = this.camera.aspect || 1;
495
+ const aspect = portraitCrop ? Math.max(0.82, this.camera.aspect || 1) : this.camera.aspect || 1;
448
496
  const halfH = Math.atan(Math.tan(halfV) * aspect);
449
497
  const fit = Math.max(r / Math.tan(halfV), r / Math.tan(halfH));
450
498
  this.azT = stageAzimuth ?? this.azimuth;
@@ -517,6 +565,7 @@ var OrbitCamera = class {
517
565
  this.canvas.removeEventListener("pointerup", this.onPointerUp);
518
566
  this.canvas.removeEventListener("pointercancel", this.onPointerUp);
519
567
  this.canvas.removeEventListener("wheel", this.onWheel);
568
+ this.canvas.removeEventListener("keydown", this.onKeyDown);
520
569
  this.canvas.removeEventListener("contextmenu", this.onContextMenu);
521
570
  this.activePointers.clear();
522
571
  }
@@ -731,7 +780,10 @@ var SURROUNDINGS_SHAPE_ROLES = [
731
780
  "sound",
732
781
  "concession",
733
782
  "coat",
734
- "wall"
783
+ "wall",
784
+ "rail",
785
+ "suite",
786
+ "obstruction"
735
787
  ];
736
788
  var SURROUNDINGS_SHAPE_ROLE_SET = new Set(SURROUNDINGS_SHAPE_ROLES);
737
789
 
@@ -1455,6 +1507,7 @@ function buildSeatInstances(seats, initial, surfaces, seatFloor, categoryColor)
1455
1507
  const iCategory = new Float32Array(count * 3);
1456
1508
  const iMaxRadius = new Float32Array(count);
1457
1509
  const iChairWidth = new Float32Array(count);
1510
+ const iPhysicalSeat = new Float32Array(count);
1458
1511
  const iRing = new Float32Array(count * 3);
1459
1512
  const idToIndex = /* @__PURE__ */ new Map();
1460
1513
  const spacing = nearestNeighbourSpacing(seats);
@@ -1464,6 +1517,7 @@ function buildSeatInstances(seats, initial, surfaces, seatFloor, categoryColor)
1464
1517
  const pitchM = (resolved ?? spacing[i]) * M;
1465
1518
  iMaxRadius[i] = Number.isFinite(pitchM) ? Math.max(0.06, Math.min(SEAT_DOT_RADIUS_M, pitchM * SEAT_PITCH_FRACTION)) : SEAT_DOT_RADIUS_M;
1466
1519
  iChairWidth[i] = chairHalfWidth(Number.isFinite(pitchM) ? pitchM : void 0);
1520
+ iPhysicalSeat[i] = seat.wheelchairSpaceType === "no-seat" ? 0 : 1;
1467
1521
  iPosition[i * 3] = seat.x * M;
1468
1522
  iPosition[i * 3 + 1] = surfaces ? surfaces.seatDeckY(i) : seatSurfaceY(seat);
1469
1523
  iPosition[i * 3 + 2] = seat.y * M;
@@ -1491,6 +1545,7 @@ function buildSeatInstances(seats, initial, surfaces, seatFloor, categoryColor)
1491
1545
  iRing,
1492
1546
  idToIndex,
1493
1547
  iChairWidth,
1548
+ iPhysicalSeat,
1494
1549
  iFloor: seatFloor ?? new Float32Array(count),
1495
1550
  iYaw: new Float32Array(count)
1496
1551
  };
@@ -2893,15 +2948,22 @@ function buildVenueSurfaces(units, seats) {
2893
2948
  }
2894
2949
 
2895
2950
  // src/view3d/scene/sceneModel.ts
2896
- function floorUnits(doc) {
2951
+ function shapeVisibleInConfiguration(shape, activeConfigurationId) {
2952
+ const ids = shape.eventConfigurationIds;
2953
+ return !ids?.length || !!activeConfigurationId && ids.includes(activeConfigurationId);
2954
+ }
2955
+ function visibleObjects(objects, activeConfigurationId) {
2956
+ return objects.filter((object) => object.type !== "shape" || shapeVisibleInConfiguration(object, activeConfigurationId));
2957
+ }
2958
+ function floorUnits(doc, activeConfigurationId) {
2897
2959
  if (doc.floors?.length) {
2898
2960
  return doc.floors.map((f) => ({
2899
- objects: f.objects,
2961
+ objects: visibleObjects(f.objects, activeConfigurationId),
2900
2962
  focal: f.focalPoint ?? doc.focalPoint,
2901
2963
  baseHeightM: f.baseHeightM ?? 0
2902
2964
  }));
2903
2965
  }
2904
- return [{ objects: doc.objects, focal: doc.focalPoint, baseHeightM: 0 }];
2966
+ return [{ objects: visibleObjects(doc.objects, activeConfigurationId), focal: doc.focalPoint, baseHeightM: 0 }];
2905
2967
  }
2906
2968
  var AO = { top: 1, wallBottom: 0.5, bottomCap: 0.4 };
2907
2969
  function tintTop(fill, neutral) {
@@ -3182,23 +3244,114 @@ function buildTable(builder, table, base, fill, S) {
3182
3244
  AO
3183
3245
  );
3184
3246
  }
3247
+ function rotateShapePolygon(poly, degrees) {
3248
+ if (!degrees) return poly;
3249
+ let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
3250
+ for (const point of poly) {
3251
+ minX = Math.min(minX, point.x);
3252
+ maxX = Math.max(maxX, point.x);
3253
+ minY = Math.min(minY, point.y);
3254
+ maxY = Math.max(maxY, point.y);
3255
+ }
3256
+ const cx = (minX + maxX) / 2, cy = (minY + maxY) / 2;
3257
+ const radians = degrees * Math.PI / 180;
3258
+ const cos = Math.cos(radians), sin = Math.sin(radians);
3259
+ return poly.map((point) => {
3260
+ const dx = point.x - cx, dy = point.y - cy;
3261
+ return { x: cx + dx * cos - dy * sin, y: cy + dx * sin + dy * cos };
3262
+ });
3263
+ }
3185
3264
  function shapePolygon(shape) {
3186
- if (shape.kind === "polygon" && shape.points && shape.points.length >= 3) return shape.points;
3265
+ let poly = null;
3266
+ if (shape.kind === "polygon" && shape.points && shape.points.length >= 3) poly = shape.points;
3187
3267
  if (shape.kind === "rect" && shape.width && shape.height) {
3188
- return rectPolygon(shape.x ?? 0, shape.y ?? 0, shape.width, shape.height);
3268
+ poly = rectPolygon(shape.x ?? 0, shape.y ?? 0, shape.width, shape.height);
3189
3269
  }
3190
3270
  if (shape.kind === "ellipse" && shape.width && shape.height) {
3191
3271
  const cx = (shape.x ?? 0) + shape.width / 2;
3192
3272
  const cy = (shape.y ?? 0) + shape.height / 2;
3193
- return ellipsePolygon(cx, cy, shape.width / 2, shape.height / 2);
3273
+ poly = ellipsePolygon(cx, cy, shape.width / 2, shape.height / 2);
3274
+ }
3275
+ return poly ? rotateShapePolygon(poly, shape.rotation) : null;
3276
+ }
3277
+ function architectureForShape(shape) {
3278
+ const role = shape.role ?? "";
3279
+ const defaults = {
3280
+ stage: { kind: "stage", heightM: 1 },
3281
+ entrance: { kind: "portal", heightM: 2.7 },
3282
+ exit: { kind: "portal", heightM: 2.7 },
3283
+ screen: { kind: "solid", heightM: 6 },
3284
+ wall: { kind: "solid", heightM: 2.8 },
3285
+ rail: { kind: "solid", heightM: 1.1 },
3286
+ suite: { kind: "suite", heightM: 3 },
3287
+ obstruction: { kind: "solid", heightM: 4.5 },
3288
+ sound: { kind: "solid", heightM: 1.45 },
3289
+ restroom: { kind: "solid", heightM: 2.4 },
3290
+ bar: { kind: "solid", heightM: 1.1 },
3291
+ concession: { kind: "solid", heightM: 1.1 },
3292
+ coat: { kind: "solid", heightM: 1.1 }
3293
+ };
3294
+ const resolved = defaults[role] ?? { kind: "plate", heightM: 0.25 };
3295
+ return { ...resolved, heightM: shape.heightM ?? resolved.heightM };
3296
+ }
3297
+ var SHAPE_LAYER_LIFT_M = 0.08;
3298
+ function claimShapeTopOffset(shape, claimed) {
3299
+ const poly = shapePolygon(shape);
3300
+ const architecture = architectureForShape(shape);
3301
+ const defaultTop = architecture.heightM;
3302
+ if (!poly) return defaultTop;
3303
+ if (architecture.kind !== "plate" && architecture.kind !== "stage") return defaultTop;
3304
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
3305
+ for (const p of poly) {
3306
+ minX = Math.min(minX, p.x);
3307
+ minY = Math.min(minY, p.y);
3308
+ maxX = Math.max(maxX, p.x);
3309
+ maxY = Math.max(maxY, p.y);
3310
+ }
3311
+ let topOffsetM = defaultTop;
3312
+ for (const prior of claimed) {
3313
+ const overlaps = maxX > prior.minX && minX < prior.maxX && maxY > prior.minY && minY < prior.maxY;
3314
+ if (overlaps && topOffsetM <= prior.topOffsetM + 1e-6) {
3315
+ topOffsetM = prior.topOffsetM + SHAPE_LAYER_LIFT_M;
3316
+ }
3194
3317
  }
3195
- return null;
3318
+ claimed.push({ minX, minY, maxX, maxY, topOffsetM });
3319
+ return topOffsetM;
3196
3320
  }
3197
- function buildShape(builder, shape, base, S, stages) {
3321
+ function lerpPoint(a, b, t) {
3322
+ return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
3323
+ }
3324
+ function orientQuadLong(poly) {
3325
+ if (poly.length !== 4) return poly;
3326
+ const edge0 = Math.hypot(poly[1].x - poly[0].x, poly[1].y - poly[0].y);
3327
+ const edge1 = Math.hypot(poly[2].x - poly[1].x, poly[2].y - poly[1].y);
3328
+ return edge0 >= edge1 ? poly : [poly[1], poly[2], poly[3], poly[0]];
3329
+ }
3330
+ function quadWidthSlice(poly, t0, t1) {
3331
+ return [
3332
+ lerpPoint(poly[0], poly[1], t0),
3333
+ lerpPoint(poly[0], poly[1], t1),
3334
+ lerpPoint(poly[3], poly[2], t1),
3335
+ lerpPoint(poly[3], poly[2], t0)
3336
+ ];
3337
+ }
3338
+ function quadDepthSlice(poly, t0, t1) {
3339
+ return [
3340
+ lerpPoint(poly[0], poly[3], t0),
3341
+ lerpPoint(poly[1], poly[2], t0),
3342
+ lerpPoint(poly[1], poly[2], t1),
3343
+ lerpPoint(poly[0], poly[3], t1)
3344
+ ];
3345
+ }
3346
+ function emitArchitecturalPrism(builder, poly, top, bottom, colTop, colWall) {
3347
+ extrudePrism(builder, poly, void 0, () => top, bottom, colTop, colWall, AO);
3348
+ }
3349
+ function buildShape(builder, shape, base, topOffsetM, S, focal, stages) {
3198
3350
  const poly = shapePolygon(shape);
3199
3351
  if (!poly) return;
3200
3352
  const isStage = shape.role === "stage";
3201
- const height = isStage ? base + 1 : base + 0.25;
3353
+ const architecture = architectureForShape(shape);
3354
+ const height = base + topOffsetM;
3202
3355
  if (isStage && stages) {
3203
3356
  let minX = Infinity, maxX = -Infinity, minZ = Infinity, maxZ = -Infinity;
3204
3357
  for (const pt of poly) {
@@ -3216,8 +3369,29 @@ function buildShape(builder, shape, base, S, stages) {
3216
3369
  });
3217
3370
  }
3218
3371
  const colTop = tintTop(hexToRgb(shape.fill), isStage ? S.stageTop : S.decorTop);
3219
- const colWall = isStage ? S.stageWall : S.decorWall;
3220
- extrudePrism(builder, poly, void 0, () => height, base, colTop, colWall, AO);
3372
+ const colWall = isStage ? S.stageWall : shape.role === "screen" ? [0.24, 0.46, 0.82] : tintTop(hexToRgb(shape.fill), S.decorWall);
3373
+ if (architecture.kind === "portal" && poly.length === 4) {
3374
+ const quad = orientQuadLong(poly);
3375
+ emitArchitecturalPrism(builder, quadWidthSlice(quad, 0, 0.16), height, base, colTop, colWall);
3376
+ emitArchitecturalPrism(builder, quadWidthSlice(quad, 0.84, 1), height, base, colTop, colWall);
3377
+ emitArchitecturalPrism(builder, quad, height, Math.max(base, height - 0.35), colTop, colWall);
3378
+ return;
3379
+ }
3380
+ if (architecture.kind === "suite" && poly.length === 4) {
3381
+ const edgeDistances = poly.map((point, index) => {
3382
+ const next = poly[(index + 1) % 4];
3383
+ const mx = (point.x + next.x) / 2, my = (point.y + next.y) / 2;
3384
+ return Math.hypot(mx - focal.x, my - focal.y);
3385
+ });
3386
+ const front = edgeDistances.indexOf(Math.min(...edgeDistances));
3387
+ const quad = [0, 1, 2, 3].map((offset) => poly[(front + offset) % 4]);
3388
+ emitArchitecturalPrism(builder, quadWidthSlice(quad, 0, 0.08), height, base, colTop, colWall);
3389
+ emitArchitecturalPrism(builder, quadWidthSlice(quad, 0.92, 1), height, base, colTop, colWall);
3390
+ emitArchitecturalPrism(builder, quadDepthSlice(quad, 0.88, 1), height, base, colTop, colWall);
3391
+ emitArchitecturalPrism(builder, quad, height, Math.max(base, height - 0.18), colTop, colWall);
3392
+ return;
3393
+ }
3394
+ emitArchitecturalPrism(builder, poly, height, base, colTop, colWall);
3221
3395
  }
3222
3396
  function decorPolygon(decor) {
3223
3397
  const { x, y, width, height } = decor;
@@ -3315,7 +3489,7 @@ function buildSceneModel(input) {
3315
3489
  const { doc, seats } = input;
3316
3490
  const theme = resolveTheme3D(doc.theme);
3317
3491
  const S = theme.structure;
3318
- const units = floorUnits(doc);
3492
+ const units = floorUnits(doc, input.eventConfigurationId ?? doc.eventConfigurationId);
3319
3493
  const builder = new MeshBuilder();
3320
3494
  const fp = chartFootprint(units, seats);
3321
3495
  const padU = Math.max(60, (fp.maxX - fp.minX + fp.maxY - fp.minY) * 0.06);
@@ -3331,10 +3505,19 @@ function buildSceneModel(input) {
3331
3505
  const unit = units[unitIndex];
3332
3506
  builder.setFloor(unitIndex);
3333
3507
  const claimed = new ClaimedArea();
3508
+ const claimedShapeLayers = [];
3334
3509
  const siblings = unit.objects.filter((o) => o.type === "section" && !!o.outline && o.outline.length >= 3);
3335
3510
  for (const o of unit.objects) {
3336
3511
  if (o.type === "section") buildTier(builder, o, unit, sectionFill(o, sectionFills), surfaces.bySection.get(o.id), claimed, siblings, S);
3337
- else if (o.type === "shape") buildShape(builder, o, unit.baseHeightM, S, stageBounds);
3512
+ else if (o.type === "shape") buildShape(
3513
+ builder,
3514
+ o,
3515
+ unit.baseHeightM,
3516
+ claimShapeTopOffset(o, claimedShapeLayers),
3517
+ S,
3518
+ unit.focal,
3519
+ stageBounds
3520
+ );
3338
3521
  else if (o.type === "gaArea") buildGa(builder, o, unit.baseHeightM, hexToRgb(catColor.get(o.categoryKey)), S);
3339
3522
  else if (o.type === "booth") buildBooth(builder, o, unit.baseHeightM, hexToRgb(catColor.get(o.categoryKey)), S);
3340
3523
  else if (o.type === "table") buildTable(builder, o, unit.baseHeightM, hexToRgb(catColor.get(o.categoryKey)), S);
@@ -3404,6 +3587,7 @@ function buildSceneModel(input) {
3404
3587
  }
3405
3588
  const labels = [];
3406
3589
  const sections = [];
3590
+ const rows = [];
3407
3591
  for (const z of zones) {
3408
3592
  if (z.seatCount === 0) continue;
3409
3593
  labels.push({
@@ -3500,6 +3684,49 @@ function buildSceneModel(input) {
3500
3684
  anchor: [end.seat.x * M, surfaces.seatDeckY(end.index) + ROW_LABEL_LIFT_M, end.seat.y * M]
3501
3685
  });
3502
3686
  }
3687
+ const rowAcc = /* @__PURE__ */ new Map();
3688
+ for (let i = 0; i < seats.length; i++) {
3689
+ const seat = seats[i];
3690
+ const cut = seat.label ? seat.label.lastIndexOf("-") : -1;
3691
+ let acc = rowAcc.get(seat.rowId);
3692
+ if (!acc) {
3693
+ acc = {
3694
+ n: 0,
3695
+ x: 0,
3696
+ y: 0,
3697
+ z: 0,
3698
+ minX: Infinity,
3699
+ maxX: -Infinity,
3700
+ minZ: Infinity,
3701
+ maxZ: -Infinity,
3702
+ label: cut > 0 ? seat.label.slice(0, cut) : seat.rowId,
3703
+ sectionId: seat.sectionId
3704
+ };
3705
+ rowAcc.set(seat.rowId, acc);
3706
+ }
3707
+ const offset = i * 3;
3708
+ const x = seatData.iPosition[offset];
3709
+ const y = seatData.iPosition[offset + 1];
3710
+ const z = seatData.iPosition[offset + 2];
3711
+ acc.n++;
3712
+ acc.x += x;
3713
+ acc.y += y;
3714
+ acc.z += z;
3715
+ acc.minX = Math.min(acc.minX, x);
3716
+ acc.maxX = Math.max(acc.maxX, x);
3717
+ acc.minZ = Math.min(acc.minZ, z);
3718
+ acc.maxZ = Math.max(acc.maxZ, z);
3719
+ }
3720
+ for (const [id, acc] of rowAcc) {
3721
+ rows.push({
3722
+ id,
3723
+ label: acc.label,
3724
+ sectionId: acc.sectionId,
3725
+ seatCount: acc.n,
3726
+ center: [acc.x / acc.n, acc.y / acc.n, acc.z / acc.n],
3727
+ radius: Math.max(0.8, Math.hypot(acc.maxX - acc.minX, acc.maxZ - acc.minZ) * 0.5)
3728
+ });
3729
+ }
3503
3730
  if (seats.length <= SEAT_LABEL_MAX) {
3504
3731
  for (let i = 0; i < seats.length; i++) {
3505
3732
  const s = seats[i];
@@ -3516,7 +3743,18 @@ function buildSceneModel(input) {
3516
3743
  }
3517
3744
  for (const unit of units) {
3518
3745
  for (const o of unit.objects) {
3519
- if (o.type === "text") {
3746
+ if (o.type === "shape" && o.role && ["entrance", "exit", "screen", "suite", "obstruction"].includes(o.role)) {
3747
+ const poly = shapePolygon(o);
3748
+ const centre = poly ? centroidOf(poly) : null;
3749
+ if (!centre || !o.label) continue;
3750
+ labels.push({
3751
+ id: `architecture:${o.id}`,
3752
+ kind: "annotation",
3753
+ text: o.label,
3754
+ anchor: [centre.x * M, unit.baseHeightM + architectureForShape(o).heightM + ANNOTATION_LIFT_M, centre.y * M],
3755
+ color: o.fill
3756
+ });
3757
+ } else if (o.type === "text") {
3520
3758
  if (!o.text) continue;
3521
3759
  labels.push({
3522
3760
  id: `text:${o.id}`,
@@ -3598,6 +3836,7 @@ function buildSceneModel(input) {
3598
3836
  zones,
3599
3837
  stages: stageBounds,
3600
3838
  sections,
3839
+ rows,
3601
3840
  labels,
3602
3841
  floors
3603
3842
  };
@@ -3631,6 +3870,7 @@ var LabelOverlay = class {
3631
3870
  constructor(container, opts = {}) {
3632
3871
  this.nodes = /* @__PURE__ */ new Map();
3633
3872
  this.labels = [];
3873
+ this.forcedDense = false;
3634
3874
  this.opts = opts;
3635
3875
  this.root = document.createElement("div");
3636
3876
  this.root.setAttribute("data-view3d-labels", "");
@@ -3665,6 +3905,11 @@ var LabelOverlay = class {
3665
3905
  }
3666
3906
  }
3667
3907
  }
3908
+ /** Section/row drill-in makes their dense labels explicit, regardless of the
3909
+ * whole-venue distance heuristic used during free orbit. */
3910
+ setForcedDense(enabled) {
3911
+ this.forcedDense = enabled;
3912
+ }
3668
3913
  /**
3669
3914
  * Reposition every label for the current camera.
3670
3915
  *
@@ -3673,6 +3918,10 @@ var LabelOverlay = class {
3673
3918
  update(viewProjection, width, height, cameraDistance, venueRadius, cameraWorld) {
3674
3919
  if (!this.labels.length) return;
3675
3920
  const kinds = visibleLabelKinds(cameraDistance, venueRadius);
3921
+ if (this.forcedDense) {
3922
+ kinds.add("row");
3923
+ kinds.add("seat");
3924
+ }
3676
3925
  const candidates = [];
3677
3926
  for (const label of this.labels) {
3678
3927
  if (!kinds.has(label.kind)) continue;
@@ -3821,6 +4070,7 @@ in vec2 position; // quad corner in [-1,1]
3821
4070
  in vec3 iOffset; // per-instance world position
3822
4071
  in vec3 iColor; // per-instance state colour (resolved CPU-side)
3823
4072
  in float iMaxRadius; // per-instance world-radius ceiling (seat pitch derived)
4073
+ in float iPhysicalSeat;// 1 = chair; 0 = empty wheelchair bay
3824
4074
  in vec3 iRing; // accommodation ring colour; (0,0,0) = not accessible
3825
4075
  in float iFloor; // owning floor index
3826
4076
  uniform mat4 modelViewMatrix;
@@ -3838,6 +4088,7 @@ out float vBudget; // 1 = dot holds its minimum pixel size, <1 = it cannot
3838
4088
  out vec3 vRing;
3839
4089
  out float vDim;
3840
4090
  out float vDotWeight; // 1 = the dot IS this seat, 0 = the chair has taken over
4091
+ out float vPhysicalSeat;
3841
4092
  ${CHAIR_WEIGHT_GLSL}
3842
4093
  void main() {
3843
4094
  vec4 mv = modelViewMatrix * vec4(iOffset, 1.0);
@@ -3867,6 +4118,7 @@ void main() {
3867
4118
  vUv = position;
3868
4119
  vColor = iColor;
3869
4120
  vRing = iRing;
4121
+ vPhysicalSeat = iPhysicalSeat;
3870
4122
  vDim = (uFocusFloor < -0.5 || abs(iFloor - uFocusFloor) < 0.5) ? 0.0 : 1.0;
3871
4123
  gl_Position = projectionMatrix * mv;
3872
4124
  }`
@@ -3881,11 +4133,13 @@ in float vBudget;
3881
4133
  in vec3 vRing;
3882
4134
  in float vDim;
3883
4135
  in float vDotWeight;
4136
+ in float vPhysicalSeat;
3884
4137
  uniform float uSeatFade; // fade toward tier colour with distance (LOD)
3885
4138
  uniform vec3 uFadeColor;
3886
4139
  out vec4 fragColor;
3887
4140
  void main() {
3888
- float d = length(vUv);
4141
+ // Empty wheelchair provision is a square bay, never a round chair marker.
4142
+ float d = mix(max(abs(vUv.x), abs(vUv.y)), length(vUv), vPhysicalSeat);
3889
4143
  if (d > 1.0) discard;
3890
4144
  float alpha = smoothstep(1.0, 0.72, d);
3891
4145
  float shade = 0.80 + 0.28 * (0.5 - vUv.y * 0.5); // subtle top-lit
@@ -3927,6 +4181,7 @@ in float iYaw; // per-instance facing, radians (local +Z -> facing dir)
3927
4181
  in vec3 iRing; // accommodation ring colour; (0,0,0) = not accessible
3928
4182
  in float iFloor;
3929
4183
  in float iSeed; // <0 = seat is empty; else per-person hash in [0,1)
4184
+ in float iPhysicalSeat;// 1 = chair; 0 = empty wheelchair bay
3930
4185
  uniform mat4 modelViewMatrix;
3931
4186
  uniform mat4 projectionMatrix;
3932
4187
  uniform float uChairFull;
@@ -3962,7 +4217,9 @@ void main() {
3962
4217
  float occupant = step(2.5, part);
3963
4218
  float taken = step(0.0, iSeed);
3964
4219
  vOccupant = occupant;
3965
- if (occupant > 0.5) {
4220
+ if (iPhysicalSeat < 0.5) {
4221
+ p = vec3(0.0); // sellable space, but no physical chair/person
4222
+ } else if (occupant > 0.5) {
3966
4223
  if (taken < 0.5) {
3967
4224
  p = vec3(0.0); // empty seat: no person
3968
4225
  } else {
@@ -4105,6 +4362,7 @@ precision highp float;
4105
4362
  in vec2 position;
4106
4363
  in vec3 iOffset;
4107
4364
  in float iMaxRadius;
4365
+ in float iPhysicalSeat;
4108
4366
  uniform mat4 modelViewMatrix;
4109
4367
  uniform mat4 projectionMatrix;
4110
4368
  uniform float uSeatRadius;
@@ -4112,6 +4370,7 @@ uniform float uSeatScale;
4112
4370
  uniform float uMinPixels;
4113
4371
  uniform float uPixelToWorld;
4114
4372
  out vec2 vUv;
4373
+ out float vPhysicalSeat;
4115
4374
  flat out vec3 vPick;
4116
4375
  void main() {
4117
4376
  int id = gl_InstanceID + 1; // 0 reserved for no-hit
@@ -4124,6 +4383,7 @@ void main() {
4124
4383
  // Must match SEAT_VERT exactly, or the hit mask drifts off the drawn dot.
4125
4384
  mv.xy += normalize(vec3(modelViewMatrix * vec4(0.0, 1.0, 0.0, 0.0))).xy * r;
4126
4385
  vUv = position;
4386
+ vPhysicalSeat = iPhysicalSeat;
4127
4387
  gl_Position = projectionMatrix * mv;
4128
4388
  }`
4129
4389
  );
@@ -4132,10 +4392,12 @@ var SEAT_PICK_FRAG = (
4132
4392
  `#version 300 es
4133
4393
  precision highp float;
4134
4394
  in vec2 vUv;
4395
+ in float vPhysicalSeat;
4135
4396
  flat in vec3 vPick;
4136
4397
  out vec4 fragColor;
4137
4398
  void main() {
4138
- if (length(vUv) > 1.0) discard; // round hit-mask matches the dot
4399
+ float d = mix(max(abs(vUv.x), abs(vUv.y)), length(vUv), vPhysicalSeat);
4400
+ if (d > 1.0) discard; // hit-mask matches chair/bay shape
4139
4401
  fragColor = vec4(vPick, 1.0);
4140
4402
  }`
4141
4403
  );
@@ -4297,6 +4559,7 @@ function buildGpuScene(gl, model) {
4297
4559
  // Per-seat world-radius ceiling: what stops distant rows merging into one
4298
4560
  // mass when the shader grows a dot to hold its minimum pixel size.
4299
4561
  iMaxRadius: { size: 1, data: model.seats.iMaxRadius, instanced: 1 },
4562
+ iPhysicalSeat: { size: 1, data: model.seats.iPhysicalSeat, instanced: 1 },
4300
4563
  // Accommodation ring colour; (0,0,0) means the seat carries no access type.
4301
4564
  iRing: { size: 3, data: model.seats.iRing, instanced: 1 },
4302
4565
  iFloor: { size: 1, data: model.seats.iFloor, instanced: 1 }
@@ -4314,6 +4577,7 @@ function buildGpuScene(gl, model) {
4314
4577
  const cYaw = new Float32Array(CAP);
4315
4578
  const cRing = new Float32Array(CAP * 3);
4316
4579
  const cFloor = new Float32Array(CAP);
4580
+ const cPhysicalSeat = new Float32Array(CAP);
4317
4581
  const cSeed = new Float32Array(CAP);
4318
4582
  const chairGeo = new import_ogl4.Geometry(gl, {
4319
4583
  position: { size: 3, data: chairBase.position },
@@ -4326,6 +4590,7 @@ function buildGpuScene(gl, model) {
4326
4590
  iYaw: { size: 1, data: cYaw, instanced: 1 },
4327
4591
  iRing: { size: 3, data: cRing, instanced: 1 },
4328
4592
  iFloor: { size: 1, data: cFloor, instanced: 1 },
4593
+ iPhysicalSeat: { size: 1, data: cPhysicalSeat, instanced: 1 },
4329
4594
  iSeed: { size: 1, data: cSeed, instanced: 1 }
4330
4595
  });
4331
4596
  const chairMesh = new import_ogl4.Mesh(gl, { geometry: chairGeo, program: chairProg });
@@ -4382,6 +4647,7 @@ function buildGpuScene(gl, model) {
4382
4647
  cRing[k * 3 + 1] = src.iRing[i * 3 + 1];
4383
4648
  cRing[k * 3 + 2] = src.iRing[i * 3 + 2];
4384
4649
  cFloor[k] = src.iFloor[i];
4650
+ cPhysicalSeat[k] = src.iPhysicalSeat[i];
4385
4651
  cSeed[k] = seatOccupantSeed(src.iState[i], i);
4386
4652
  }
4387
4653
  writeChairColors();
@@ -4390,6 +4656,7 @@ function buildGpuScene(gl, model) {
4390
4656
  chairGeo.attributes.iYaw.needsUpdate = true;
4391
4657
  chairGeo.attributes.iRing.needsUpdate = true;
4392
4658
  chairGeo.attributes.iFloor.needsUpdate = true;
4659
+ chairGeo.attributes.iPhysicalSeat.needsUpdate = true;
4393
4660
  chairGeo.attributes.iSeed.needsUpdate = true;
4394
4661
  chairGeo.instancedCount = n;
4395
4662
  if (!chairMesh.parent) chairMesh.setParent(main);
@@ -4720,8 +4987,120 @@ var Cinematic = class {
4720
4987
  }
4721
4988
  };
4722
4989
 
4990
+ // src/view3d/camera/seatViewPose.ts
4991
+ var SEAT_EYE_ABOVE_DECK_M = 1.02;
4992
+ var FOCAL_LOOK_HEIGHT_M = 1.5;
4993
+ function seatViewPose(seatDeckWorld, focalPoint, floorBaseHeightM = 0) {
4994
+ return {
4995
+ eye: [seatDeckWorld[0], seatDeckWorld[1] + SEAT_EYE_ABOVE_DECK_M, seatDeckWorld[2]],
4996
+ focal: [
4997
+ focalPoint.x * M,
4998
+ floorBaseHeightM + FOCAL_LOOK_HEIGHT_M,
4999
+ focalPoint.y * M
5000
+ ]
5001
+ };
5002
+ }
5003
+
5004
+ // src/view/panoramaDelivery.ts
5005
+ function estimatePanoramaTextureBytes(width, height) {
5006
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return 0;
5007
+ return Math.ceil(width * height * 4 * (4 / 3));
5008
+ }
5009
+ function panoramaTextureBudgetBytes(deviceMemoryGb) {
5010
+ const mib = deviceMemoryGb !== void 0 && deviceMemoryGb <= 2 ? 32 : deviceMemoryGb !== void 0 && deviceMemoryGb >= 8 ? 192 : 96;
5011
+ return mib * 1024 * 1024;
5012
+ }
5013
+ function browserPanoramaConstraints(maxTextureSize) {
5014
+ const nav = typeof navigator === "undefined" ? void 0 : navigator;
5015
+ return {
5016
+ saveData: nav?.connection?.saveData === true,
5017
+ maxTextureSize,
5018
+ maxDecodedBytes: panoramaTextureBudgetBytes(nav?.deviceMemory)
5019
+ };
5020
+ }
5021
+ function planPanoramaDelivery(source, constraints = {}) {
5022
+ const previewUrl = source.previewUrl?.trim();
5023
+ if (!previewUrl || previewUrl === source.url) {
5024
+ return {
5025
+ initialUrl: source.url,
5026
+ initialWidth: source.sourceWidth,
5027
+ initialHeight: source.sourceHeight,
5028
+ reason: "full-only"
5029
+ };
5030
+ }
5031
+ const initial = {
5032
+ initialUrl: previewUrl,
5033
+ initialWidth: source.previewWidth,
5034
+ initialHeight: source.previewHeight
5035
+ };
5036
+ if (constraints.saveData) return { ...initial, reason: "save-data" };
5037
+ if (constraints.maxTextureSize !== void 0 && source.sourceWidth !== void 0 && source.sourceHeight !== void 0 && Math.max(source.sourceWidth, source.sourceHeight) > constraints.maxTextureSize) {
5038
+ return { ...initial, reason: "texture-limit" };
5039
+ }
5040
+ if (constraints.maxDecodedBytes !== void 0 && source.sourceWidth !== void 0 && source.sourceHeight !== void 0 && estimatePanoramaTextureBytes(source.sourceWidth, source.sourceHeight) > constraints.maxDecodedBytes) {
5041
+ return { ...initial, reason: "memory-limit" };
5042
+ }
5043
+ return { ...initial, upgradeUrl: source.url, reason: "progressive" };
5044
+ }
5045
+ function abortError() {
5046
+ return new DOMException("Panorama image load aborted", "AbortError");
5047
+ }
5048
+ function loadPanoramaImage(url, signal) {
5049
+ return new Promise((resolve, reject) => {
5050
+ if (signal?.aborted) {
5051
+ reject(abortError());
5052
+ return;
5053
+ }
5054
+ const image = new Image();
5055
+ image.crossOrigin = "anonymous";
5056
+ let settled = false;
5057
+ const cleanup = () => {
5058
+ image.onload = null;
5059
+ image.onerror = null;
5060
+ signal?.removeEventListener("abort", onAbort);
5061
+ };
5062
+ const finish = (error) => {
5063
+ if (settled) return;
5064
+ settled = true;
5065
+ cleanup();
5066
+ if (error) reject(error);
5067
+ else resolve(image);
5068
+ };
5069
+ const onAbort = () => {
5070
+ image.src = "";
5071
+ finish(abortError());
5072
+ };
5073
+ image.onload = () => {
5074
+ const decoded = typeof image.decode === "function" ? image.decode() : Promise.resolve();
5075
+ void decoded.then(() => finish(), () => finish());
5076
+ };
5077
+ image.onerror = () => finish(new Error("panorama_image_failed"));
5078
+ signal?.addEventListener("abort", onAbort, { once: true });
5079
+ image.src = url;
5080
+ });
5081
+ }
5082
+ function schedulePanoramaUpgrade(work) {
5083
+ const host = globalThis;
5084
+ if (host.requestIdleCallback) {
5085
+ const id2 = host.requestIdleCallback(work, { timeout: 1200 });
5086
+ return () => host.cancelIdleCallback?.(id2);
5087
+ }
5088
+ const id = globalThis.setTimeout(work, 32);
5089
+ return () => globalThis.clearTimeout(id);
5090
+ }
5091
+
4723
5092
  // src/view3d/crossfade/panorama.ts
5093
+ function seatViewDisclosure(view) {
5094
+ const coverage = view.generated ? "Live 3D \xB7 exact seat-eye" : view.coverage === "exact-seat" ? "Exact seat photo" : view.coverage === "row-representative" ? "Representative row view" : view.coverage === "section-representative" ? "Representative section view" : view.coverage === "venue-representative" ? "Representative venue view" : "Venue photo";
5095
+ const year = view.capturedAt && /^\d{4}/.test(view.capturedAt) ? view.capturedAt.slice(0, 4) : "";
5096
+ return [coverage, year ? `captured ${year}` : "", view.sourceLabel ?? ""].filter(Boolean).join(" \xB7 ");
5097
+ }
4724
5098
  var VFOV_DEG = 70;
5099
+ var MIN_VFOV_DEG = 35;
5100
+ var MAX_VFOV_DEG = 90;
5101
+ function clampPanoramaFov(fovDeg) {
5102
+ return Math.max(MIN_VFOV_DEG, Math.min(MAX_VFOV_DEG, fovDeg));
5103
+ }
4725
5104
  var MAX_PITCH_DEG = 35;
4726
5105
  function bearingToOffsetPx(bearingDeg, viewportW, bgW) {
4727
5106
  const col = (0.5 + bearingDeg / 360) * bgW;
@@ -4740,9 +5119,16 @@ function clampPitchPx(pitchPx, bgH) {
4740
5119
  function mountPanorama(container, view, opts = {}) {
4741
5120
  const fadeMs = opts.fadeMs ?? 400;
4742
5121
  const bearing = view.initialBearingDeg ?? 0;
5122
+ const delivery = planPanoramaDelivery(view, browserPanoramaConstraints());
5123
+ const loadAbort = new AbortController();
5124
+ const abortFromCaller = () => loadAbort.abort();
5125
+ opts.signal?.addEventListener("abort", abortFromCaller, { once: true });
4743
5126
  const root = document.createElement("div");
5127
+ const priorFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
4744
5128
  root.setAttribute("role", "dialog");
5129
+ root.setAttribute("aria-modal", "true");
4745
5130
  root.setAttribute("aria-label", opts.seatLabel ? `View from ${opts.seatLabel}` : "View from seat");
5131
+ root.tabIndex = -1;
4746
5132
  Object.assign(root.style, {
4747
5133
  position: "absolute",
4748
5134
  inset: "0",
@@ -4757,7 +5143,7 @@ function mountPanorama(container, view, opts = {}) {
4757
5143
  Object.assign(pano.style, {
4758
5144
  position: "absolute",
4759
5145
  inset: "0",
4760
- backgroundImage: `url("${view.url}")`,
5146
+ backgroundImage: `url("${delivery.initialUrl}")`,
4761
5147
  backgroundRepeat: "repeat-x",
4762
5148
  cursor: "grab"
4763
5149
  });
@@ -4771,8 +5157,8 @@ function mountPanorama(container, view, opts = {}) {
4771
5157
  top: "12px",
4772
5158
  right: "12px",
4773
5159
  zIndex: "2",
4774
- width: "34px",
4775
- height: "34px",
5160
+ width: "44px",
5161
+ height: "44px",
4776
5162
  borderRadius: "999px",
4777
5163
  cursor: "pointer",
4778
5164
  border: "1px solid rgba(255,255,255,0.25)",
@@ -4782,8 +5168,30 @@ function mountPanorama(container, view, opts = {}) {
4782
5168
  lineHeight: "1"
4783
5169
  });
4784
5170
  root.appendChild(closeBtn);
5171
+ if (opts.disclosure) {
5172
+ const disclosure = document.createElement("div");
5173
+ disclosure.textContent = `360\xB0 panorama \xB7 ${opts.disclosure}`;
5174
+ Object.assign(disclosure.style, {
5175
+ position: "absolute",
5176
+ top: "12px",
5177
+ left: "12px",
5178
+ zIndex: "2",
5179
+ maxWidth: "calc(100% - 88px)",
5180
+ padding: "8px 11px",
5181
+ borderRadius: "999px",
5182
+ overflow: "hidden",
5183
+ color: "#dce6f8",
5184
+ background: "rgba(8,12,18,0.68)",
5185
+ font: "600 11px/1.2 ui-sans-serif, system-ui, sans-serif",
5186
+ textOverflow: "ellipsis",
5187
+ whiteSpace: "nowrap",
5188
+ pointerEvents: "none",
5189
+ backdropFilter: "blur(6px)"
5190
+ });
5191
+ root.appendChild(disclosure);
5192
+ }
4785
5193
  const hint = document.createElement("div");
4786
- hint.textContent = "Drag to look around \xB7 Esc to close";
5194
+ hint.textContent = "Drag to look \xB7 pinch or scroll to zoom \xB7 Esc to close";
4787
5195
  Object.assign(hint.style, {
4788
5196
  position: "absolute",
4789
5197
  bottom: "12px",
@@ -4796,23 +5204,28 @@ function mountPanorama(container, view, opts = {}) {
4796
5204
  });
4797
5205
  root.appendChild(hint);
4798
5206
  container.appendChild(root);
5207
+ closeBtn.focus({ preventScroll: true });
4799
5208
  let bgW = 0;
4800
5209
  let bgH = 0;
4801
5210
  let posX = 0;
4802
5211
  let pitchPx = 0;
5212
+ let viewFov = VFOV_DEG;
4803
5213
  const layout = () => {
4804
5214
  const vh = root.clientHeight || 1;
4805
5215
  const vw = root.clientWidth || 1;
4806
5216
  const natW = img.naturalWidth || vw * 2;
4807
5217
  const natH = img.naturalHeight || vh;
4808
- bgH = windowedBgHeight(vh);
5218
+ const centredImageRatio = bgW > 0 ? (vw / 2 - posX) / bgW : 0;
5219
+ const pitchRatio = bgH > 0 ? pitchPx / bgH : 0;
5220
+ bgH = windowedBgHeight(vh, viewFov);
4809
5221
  bgW = bgH * (natW / natH);
4810
5222
  pano.style.backgroundSize = `${bgW}px ${bgH}px`;
4811
5223
  if (!posInitialised) {
4812
5224
  posX = bearingToOffsetPx(bearing, vw, bgW);
5225
+ pitchPx = clampPitchPx((view.initialPitchDeg ?? 0) / 180 * bgH, bgH);
4813
5226
  posInitialised = true;
4814
- }
4815
- pitchPx = clampPitchPx(pitchPx, bgH);
5227
+ } else posX = vw / 2 - centredImageRatio * bgW;
5228
+ pitchPx = clampPitchPx(pitchRatio * bgH, bgH);
4816
5229
  pano.style.backgroundPosition = `${posX}px ${horizonOffsetPy(vh, bgH, pitchPx)}px`;
4817
5230
  };
4818
5231
  let posInitialised = false;
@@ -4821,17 +5234,44 @@ function mountPanorama(container, view, opts = {}) {
4821
5234
  pitchPx = clampPitchPx(pitchPx, bgH);
4822
5235
  pano.style.backgroundPosition = `${posX}px ${horizonOffsetPy(vh, bgH, pitchPx)}px`;
4823
5236
  };
4824
- const img = new Image();
5237
+ let img = new Image();
4825
5238
  img.onload = layout;
4826
- img.src = view.url;
5239
+ img.src = delivery.initialUrl;
5240
+ let cancelUpgrade = () => {
5241
+ };
5242
+ if (delivery.upgradeUrl) {
5243
+ cancelUpgrade = schedulePanoramaUpgrade(() => {
5244
+ void loadPanoramaImage(delivery.upgradeUrl, loadAbort.signal).then((full) => {
5245
+ if (disposed || loadAbort.signal.aborted) return;
5246
+ img = full;
5247
+ pano.style.backgroundImage = `url("${delivery.upgradeUrl}")`;
5248
+ layout();
5249
+ }).catch(() => {
5250
+ });
5251
+ });
5252
+ }
4827
5253
  requestAnimationFrame(layout);
4828
5254
  let dragging = false;
4829
5255
  let lastX = 0;
4830
5256
  let lastY = 0;
5257
+ let pinchDistance = 0;
5258
+ const pointers = /* @__PURE__ */ new Map();
5259
+ const distanceBetweenPointers = () => {
5260
+ const [a, b] = [...pointers.values()];
5261
+ return a && b ? Math.hypot(a.x - b.x, a.y - b.y) : 0;
5262
+ };
5263
+ const setViewFov = (nextFov) => {
5264
+ const clamped = clampPanoramaFov(nextFov);
5265
+ if (clamped === viewFov) return;
5266
+ viewFov = clamped;
5267
+ layout();
5268
+ };
4831
5269
  const onDown = (e) => {
4832
- dragging = true;
5270
+ pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
5271
+ dragging = pointers.size === 1;
4833
5272
  lastX = e.clientX;
4834
5273
  lastY = e.clientY;
5274
+ if (pointers.size === 2) pinchDistance = distanceBetweenPointers();
4835
5275
  pano.style.cursor = "grabbing";
4836
5276
  try {
4837
5277
  pano.setPointerCapture?.(e.pointerId);
@@ -4839,6 +5279,15 @@ function mountPanorama(container, view, opts = {}) {
4839
5279
  }
4840
5280
  };
4841
5281
  const onMove = (e) => {
5282
+ if (!pointers.has(e.pointerId)) return;
5283
+ pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
5284
+ if (pointers.size >= 2) {
5285
+ const nextDistance = distanceBetweenPointers();
5286
+ if (pinchDistance > 0 && nextDistance > 0) setViewFov(viewFov * (pinchDistance / nextDistance));
5287
+ pinchDistance = nextDistance;
5288
+ dragging = false;
5289
+ return;
5290
+ }
4842
5291
  if (!dragging) return;
4843
5292
  posX += e.clientX - lastX;
4844
5293
  pitchPx += e.clientY - lastY;
@@ -4847,17 +5296,28 @@ function mountPanorama(container, view, opts = {}) {
4847
5296
  applyPos();
4848
5297
  };
4849
5298
  const onUp = (e) => {
4850
- dragging = false;
4851
- pano.style.cursor = "grab";
5299
+ pointers.delete(e.pointerId);
5300
+ pinchDistance = pointers.size === 2 ? distanceBetweenPointers() : 0;
5301
+ const remaining = pointers.values().next().value;
5302
+ dragging = pointers.size === 1;
5303
+ if (remaining) {
5304
+ lastX = remaining.x;
5305
+ lastY = remaining.y;
5306
+ } else pano.style.cursor = "grab";
4852
5307
  try {
4853
5308
  pano.releasePointerCapture?.(e.pointerId);
4854
5309
  } catch {
4855
5310
  }
4856
5311
  };
5312
+ const onWheel = (e) => {
5313
+ e.preventDefault();
5314
+ setViewFov(viewFov + e.deltaY * 0.04);
5315
+ };
4857
5316
  pano.addEventListener("pointerdown", onDown);
4858
5317
  pano.addEventListener("pointermove", onMove);
4859
5318
  pano.addEventListener("pointerup", onUp);
4860
5319
  pano.addEventListener("pointercancel", onUp);
5320
+ pano.addEventListener("wheel", onWheel, { passive: false });
4861
5321
  let closed = false;
4862
5322
  let disposed = false;
4863
5323
  let fadeTimer = 0;
@@ -4866,15 +5326,20 @@ function mountPanorama(container, view, opts = {}) {
4866
5326
  pano.removeEventListener("pointermove", onMove);
4867
5327
  pano.removeEventListener("pointerup", onUp);
4868
5328
  pano.removeEventListener("pointercancel", onUp);
5329
+ pano.removeEventListener("wheel", onWheel);
4869
5330
  window.removeEventListener("keydown", onKey);
4870
5331
  };
4871
5332
  const teardown = () => {
5333
+ cancelUpgrade();
5334
+ loadAbort.abort();
5335
+ opts.signal?.removeEventListener("abort", abortFromCaller);
4872
5336
  if (fadeTimer) {
4873
5337
  window.clearTimeout(fadeTimer);
4874
5338
  fadeTimer = 0;
4875
5339
  }
4876
5340
  removeListeners();
4877
5341
  if (root.parentNode) root.parentNode.removeChild(root);
5342
+ if (priorFocus?.isConnected) priorFocus.focus({ preventScroll: true });
4878
5343
  };
4879
5344
  const close = () => {
4880
5345
  if (closed) return;
@@ -4889,10 +5354,33 @@ function mountPanorama(container, view, opts = {}) {
4889
5354
  fadeTimer = window.setTimeout(done, fadeMs);
4890
5355
  };
4891
5356
  const onKey = (e) => {
5357
+ if (e.key === "Tab") {
5358
+ e.preventDefault();
5359
+ closeBtn.focus({ preventScroll: true });
5360
+ return;
5361
+ }
4892
5362
  if (e.key === "Escape") {
4893
5363
  e.stopPropagation();
4894
5364
  close();
5365
+ return;
4895
5366
  }
5367
+ if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
5368
+ posX += e.key === "ArrowLeft" ? -32 : 32;
5369
+ } else if (e.key === "ArrowUp" || e.key === "ArrowDown") {
5370
+ pitchPx += (e.key === "ArrowUp" ? 4 : -4) * (bgH / 180);
5371
+ } else if (e.key === "+" || e.key === "=") {
5372
+ setViewFov(viewFov - 5);
5373
+ e.preventDefault();
5374
+ return;
5375
+ } else if (e.key === "-") {
5376
+ setViewFov(viewFov + 5);
5377
+ e.preventDefault();
5378
+ return;
5379
+ } else {
5380
+ return;
5381
+ }
5382
+ e.preventDefault();
5383
+ applyPos();
4896
5384
  };
4897
5385
  window.addEventListener("keydown", onKey);
4898
5386
  closeBtn.addEventListener("click", close);
@@ -5030,6 +5518,10 @@ function createPanoSphere(gl, image) {
5030
5518
  setOpacity(value) {
5031
5519
  program.uniforms.uOpacity.value = Math.max(0, Math.min(1, value));
5032
5520
  },
5521
+ setImage(nextImage) {
5522
+ texture.image = nextImage;
5523
+ texture.needsUpdate = true;
5524
+ },
5033
5525
  dispose() {
5034
5526
  mesh.setParent(null);
5035
5527
  geometry.remove();
@@ -5041,20 +5533,29 @@ function createPanoSphere(gl, image) {
5041
5533
 
5042
5534
  // src/view3d/crossfade/panoramaSphere.ts
5043
5535
  var DEG_PER_PX = 0.15;
5044
- function loadImage(url) {
5045
- return new Promise((resolve, reject) => {
5046
- const img = new Image();
5047
- img.crossOrigin = "anonymous";
5048
- img.onload = () => resolve(img);
5049
- img.onerror = () => reject(new Error("panorama_image_failed"));
5050
- img.src = url;
5051
- });
5052
- }
5053
5536
  async function mountPanoramaSphere(container, view, deps, opts = {}) {
5054
5537
  const fadeMs = opts.fadeMs ?? 400;
5055
5538
  let bearing = view?.initialBearingDeg ?? 0;
5056
- let pitch = 0;
5539
+ let pitch = Math.max(-MAX_PITCH_DEG, Math.min(MAX_PITCH_DEG, view?.initialPitchDeg ?? 0));
5540
+ let viewFov = VFOV_DEG;
5057
5541
  let disposed = false;
5542
+ const loadAbort = new AbortController();
5543
+ const abortFromCaller = () => loadAbort.abort();
5544
+ opts.signal?.addEventListener("abort", abortFromCaller, { once: true });
5545
+ let cancelUpgrade = () => {
5546
+ };
5547
+ const priorPosition = [
5548
+ deps.camera.position.x,
5549
+ deps.camera.position.y,
5550
+ deps.camera.position.z
5551
+ ];
5552
+ if (!view && deps.cameraOriginWorld) {
5553
+ deps.camera.position.set(
5554
+ deps.cameraOriginWorld[0],
5555
+ deps.cameraOriginWorld[1],
5556
+ deps.cameraOriginWorld[2]
5557
+ );
5558
+ }
5058
5559
  if (!view && deps.focalWorld) {
5059
5560
  const p = deps.camera.position;
5060
5561
  const dx = deps.focalWorld[0] - p.x;
@@ -5068,22 +5569,37 @@ async function mountPanoramaSphere(container, view, deps, opts = {}) {
5068
5569
  }
5069
5570
  let sphere = null;
5070
5571
  if (view) {
5572
+ const maxTextureSize = deps.gl.getParameter(deps.gl.MAX_TEXTURE_SIZE);
5573
+ const delivery = planPanoramaDelivery(view, browserPanoramaConstraints(maxTextureSize));
5071
5574
  let image;
5072
5575
  try {
5073
- image = await loadImage(view.url);
5576
+ image = await loadPanoramaImage(delivery.initialUrl, loadAbort.signal);
5074
5577
  } catch {
5578
+ opts.signal?.removeEventListener("abort", abortFromCaller);
5075
5579
  return null;
5076
5580
  }
5077
5581
  try {
5078
5582
  sphere = createPanoSphere(deps.gl, image);
5079
5583
  } catch {
5584
+ loadAbort.abort();
5585
+ opts.signal?.removeEventListener("abort", abortFromCaller);
5080
5586
  return null;
5081
5587
  }
5082
5588
  sphere.mesh.renderOrder = 999;
5083
5589
  sphere.mesh.setParent(deps.scene);
5590
+ if (delivery.upgradeUrl) {
5591
+ cancelUpgrade = schedulePanoramaUpgrade(() => {
5592
+ void loadPanoramaImage(delivery.upgradeUrl, loadAbort.signal).then((full) => {
5593
+ if (disposed || loadAbort.signal.aborted) return;
5594
+ sphere?.setImage(full);
5595
+ deps.requestRender();
5596
+ }).catch(() => {
5597
+ });
5598
+ });
5599
+ }
5084
5600
  }
5085
5601
  const priorFov = deps.camera.fov;
5086
- deps.camera.perspective({ fov: VFOV_DEG, aspect: deps.camera.aspect });
5602
+ deps.camera.perspective({ fov: viewFov, aspect: deps.camera.aspect });
5087
5603
  const priorQuat = deps.camera.quaternion.slice();
5088
5604
  const aim = () => {
5089
5605
  const p = deps.camera.position;
@@ -5094,8 +5610,11 @@ async function mountPanoramaSphere(container, view, deps, opts = {}) {
5094
5610
  };
5095
5611
  aim();
5096
5612
  const root = document.createElement("div");
5613
+ const priorFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
5097
5614
  root.setAttribute("role", "dialog");
5615
+ root.setAttribute("aria-modal", "true");
5098
5616
  root.setAttribute("aria-label", opts.seatLabel ? `View from ${opts.seatLabel}` : "View from seat");
5617
+ root.tabIndex = -1;
5099
5618
  Object.assign(root.style, {
5100
5619
  position: "absolute",
5101
5620
  inset: "0",
@@ -5112,8 +5631,8 @@ async function mountPanoramaSphere(container, view, deps, opts = {}) {
5112
5631
  top: "12px",
5113
5632
  right: "12px",
5114
5633
  zIndex: "2",
5115
- width: "34px",
5116
- height: "34px",
5634
+ width: "44px",
5635
+ height: "44px",
5117
5636
  borderRadius: "999px",
5118
5637
  cursor: "pointer",
5119
5638
  border: "1px solid rgba(255,255,255,0.25)",
@@ -5123,8 +5642,30 @@ async function mountPanoramaSphere(container, view, deps, opts = {}) {
5123
5642
  lineHeight: "1"
5124
5643
  });
5125
5644
  root.appendChild(closeBtn);
5645
+ if (opts.disclosure) {
5646
+ const disclosure = document.createElement("div");
5647
+ disclosure.textContent = `360\xB0 panorama \xB7 ${opts.disclosure}`;
5648
+ Object.assign(disclosure.style, {
5649
+ position: "absolute",
5650
+ top: "12px",
5651
+ left: "12px",
5652
+ zIndex: "2",
5653
+ maxWidth: "calc(100% - 88px)",
5654
+ padding: "8px 11px",
5655
+ borderRadius: "999px",
5656
+ overflow: "hidden",
5657
+ color: "#dce6f8",
5658
+ background: "rgba(8,12,18,0.68)",
5659
+ font: "600 11px/1.2 ui-sans-serif, system-ui, sans-serif",
5660
+ textOverflow: "ellipsis",
5661
+ whiteSpace: "nowrap",
5662
+ pointerEvents: "none",
5663
+ backdropFilter: "blur(6px)"
5664
+ });
5665
+ root.appendChild(disclosure);
5666
+ }
5126
5667
  const hint = document.createElement("div");
5127
- hint.textContent = "Drag to look around \xB7 Esc to close";
5668
+ hint.textContent = "Drag to look \xB7 pinch or scroll to zoom \xB7 Esc to close";
5128
5669
  Object.assign(hint.style, {
5129
5670
  position: "absolute",
5130
5671
  bottom: "12px",
@@ -5137,18 +5678,43 @@ async function mountPanoramaSphere(container, view, deps, opts = {}) {
5137
5678
  });
5138
5679
  root.appendChild(hint);
5139
5680
  container.appendChild(root);
5681
+ closeBtn.focus({ preventScroll: true });
5140
5682
  let dragging = false;
5141
5683
  let lastX = 0;
5142
5684
  let lastY = 0;
5685
+ let pinchDistance = 0;
5686
+ const pointers = /* @__PURE__ */ new Map();
5687
+ const distanceBetweenPointers = () => {
5688
+ const [a, b] = [...pointers.values()];
5689
+ return a && b ? Math.hypot(a.x - b.x, a.y - b.y) : 0;
5690
+ };
5691
+ const setViewFov = (nextFov) => {
5692
+ const clamped = clampPanoramaFov(nextFov);
5693
+ if (clamped === viewFov) return;
5694
+ viewFov = clamped;
5695
+ deps.camera.perspective({ fov: viewFov, aspect: deps.camera.aspect });
5696
+ aim();
5697
+ };
5143
5698
  const onDown = (e) => {
5144
5699
  if (e.target === closeBtn) return;
5145
- dragging = true;
5700
+ pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
5701
+ dragging = pointers.size === 1;
5146
5702
  lastX = e.clientX;
5147
5703
  lastY = e.clientY;
5704
+ if (pointers.size === 2) pinchDistance = distanceBetweenPointers();
5148
5705
  root.setPointerCapture?.(e.pointerId);
5149
5706
  root.style.cursor = "grabbing";
5150
5707
  };
5151
5708
  const onMove = (e) => {
5709
+ if (!pointers.has(e.pointerId)) return;
5710
+ pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
5711
+ if (pointers.size >= 2) {
5712
+ const nextDistance = distanceBetweenPointers();
5713
+ if (pinchDistance > 0 && nextDistance > 0) setViewFov(viewFov * (pinchDistance / nextDistance));
5714
+ pinchDistance = nextDistance;
5715
+ dragging = false;
5716
+ return;
5717
+ }
5152
5718
  if (!dragging) return;
5153
5719
  bearing += (e.clientX - lastX) * DEG_PER_PX;
5154
5720
  pitch = Math.max(-MAX_PITCH_DEG, Math.min(MAX_PITCH_DEG, pitch + (e.clientY - lastY) * DEG_PER_PX));
@@ -5156,18 +5722,58 @@ async function mountPanoramaSphere(container, view, deps, opts = {}) {
5156
5722
  lastY = e.clientY;
5157
5723
  aim();
5158
5724
  };
5159
- const onUp = () => {
5160
- dragging = false;
5161
- root.style.cursor = "grab";
5725
+ const onUp = (e) => {
5726
+ pointers.delete(e.pointerId);
5727
+ pinchDistance = pointers.size === 2 ? distanceBetweenPointers() : 0;
5728
+ const remaining = pointers.values().next().value;
5729
+ dragging = pointers.size === 1;
5730
+ if (remaining) {
5731
+ lastX = remaining.x;
5732
+ lastY = remaining.y;
5733
+ } else root.style.cursor = "grab";
5734
+ try {
5735
+ root.releasePointerCapture?.(e.pointerId);
5736
+ } catch {
5737
+ }
5738
+ };
5739
+ const onWheel = (e) => {
5740
+ e.preventDefault();
5741
+ setViewFov(viewFov + e.deltaY * 0.04);
5162
5742
  };
5163
5743
  root.addEventListener("pointerdown", onDown);
5164
5744
  root.addEventListener("pointermove", onMove);
5165
5745
  root.addEventListener("pointerup", onUp);
5166
5746
  root.addEventListener("pointercancel", onUp);
5747
+ root.addEventListener("wheel", onWheel, { passive: false });
5167
5748
  const onKey = (e) => {
5168
- if (e.key !== "Escape" || disposed) return;
5169
- e.stopPropagation();
5170
- handle.close();
5749
+ if (disposed) return;
5750
+ if (e.key === "Tab") {
5751
+ e.preventDefault();
5752
+ closeBtn.focus({ preventScroll: true });
5753
+ return;
5754
+ }
5755
+ if (e.key === "Escape") {
5756
+ e.stopPropagation();
5757
+ handle.close();
5758
+ return;
5759
+ }
5760
+ if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
5761
+ bearing += e.key === "ArrowLeft" ? -4 : 4;
5762
+ } else if (e.key === "ArrowUp" || e.key === "ArrowDown") {
5763
+ pitch = Math.max(-MAX_PITCH_DEG, Math.min(MAX_PITCH_DEG, pitch + (e.key === "ArrowUp" ? 4 : -4)));
5764
+ } else if (e.key === "+" || e.key === "=") {
5765
+ setViewFov(viewFov - 5);
5766
+ e.preventDefault();
5767
+ return;
5768
+ } else if (e.key === "-") {
5769
+ setViewFov(viewFov + 5);
5770
+ e.preventDefault();
5771
+ return;
5772
+ } else {
5773
+ return;
5774
+ }
5775
+ e.preventDefault();
5776
+ aim();
5171
5777
  };
5172
5778
  window.addEventListener("keydown", onKey);
5173
5779
  let raf = 0;
@@ -5192,14 +5798,20 @@ async function mountPanoramaSphere(container, view, deps, opts = {}) {
5192
5798
  const teardown = () => {
5193
5799
  if (disposed) return;
5194
5800
  disposed = true;
5801
+ cancelUpgrade();
5802
+ loadAbort.abort();
5803
+ opts.signal?.removeEventListener("abort", abortFromCaller);
5195
5804
  cancelAnimationFrame(raf);
5196
5805
  root.removeEventListener("pointerdown", onDown);
5197
5806
  root.removeEventListener("pointermove", onMove);
5198
5807
  root.removeEventListener("pointerup", onUp);
5199
5808
  root.removeEventListener("pointercancel", onUp);
5809
+ root.removeEventListener("wheel", onWheel);
5200
5810
  window.removeEventListener("keydown", onKey);
5201
5811
  root.remove();
5812
+ if (priorFocus?.isConnected) priorFocus.focus({ preventScroll: true });
5202
5813
  sphere?.dispose();
5814
+ deps.camera.position.set(priorPosition[0], priorPosition[1], priorPosition[2]);
5203
5815
  deps.camera.perspective({ fov: priorFov, aspect: deps.camera.aspect });
5204
5816
  deps.camera.quaternion.set(priorQuat[0], priorQuat[1], priorQuat[2], priorQuat[3]);
5205
5817
  deps.requestRender();
@@ -5236,8 +5848,12 @@ var Analytics3D = class {
5236
5848
  } catch {
5237
5849
  }
5238
5850
  }
5239
- opened(seats, hasHeights) {
5240
- this.emit("3d_opened", { seats, hasHeights });
5851
+ opened(seats, hasHeights, buildMs) {
5852
+ this.emit("3d_opened", {
5853
+ seats,
5854
+ hasHeights,
5855
+ ...buildMs === void 0 ? {} : { buildMs: Math.round(buildMs) }
5856
+ });
5241
5857
  }
5242
5858
  /** First user-driven orbit/dolly per mount only (the intro ease is not user
5243
5859
  * input, so callers must gate this on real pointer/wheel gestures). */
@@ -5267,18 +5883,78 @@ var Analytics3D = class {
5267
5883
  this.panoramaOpenedAt = 0;
5268
5884
  this.emit("3d_panorama_closed", { viewMs });
5269
5885
  }
5886
+ panoramaFailed(seatId, phase) {
5887
+ this.emit("3d_panorama_failed", { seatId, phase });
5888
+ }
5889
+ panoramaFallback(seatId) {
5890
+ this.emit("3d_panorama_fallback", { seatId });
5891
+ }
5892
+ contextLost() {
5893
+ this.emit("3d_context_lost");
5894
+ }
5895
+ contextRestored() {
5896
+ this.emit("3d_context_restored");
5897
+ }
5270
5898
  };
5271
5899
 
5900
+ // src/view3d/positioningContext.ts
5901
+ function establishPositioningContext(container) {
5902
+ const originalInline = container.style.position;
5903
+ if (getComputedStyle(container).position !== "static") return () => {
5904
+ };
5905
+ container.style.position = "relative";
5906
+ return () => {
5907
+ if (container.style.position === "relative") container.style.position = originalInline;
5908
+ };
5909
+ }
5910
+
5911
+ // src/view3d/prepareScene.ts
5912
+ var import_meta = {};
5913
+ function prepareOnMain(input) {
5914
+ const started = performance.now();
5915
+ const model = buildSceneModel(input);
5916
+ return { model, buildMs: performance.now() - started, source: "main" };
5917
+ }
5918
+ function prepareVenue3D(input) {
5919
+ if (typeof Worker === "undefined") return Promise.resolve(prepareOnMain(input));
5920
+ return new Promise((resolve) => {
5921
+ const worker = new Worker(new URL("./scene/scene.worker.ts", import_meta.url), { type: "module" });
5922
+ let settled = false;
5923
+ const finish = (result) => {
5924
+ if (settled) return;
5925
+ settled = true;
5926
+ clearTimeout(timeout);
5927
+ worker.terminate();
5928
+ resolve(result);
5929
+ };
5930
+ const fallback = () => finish(prepareOnMain(input));
5931
+ const timeout = window.setTimeout(fallback, 15e3);
5932
+ worker.onerror = fallback;
5933
+ worker.onmessage = (event) => {
5934
+ finish({ model: event.data.model, buildMs: event.data.buildMs, source: "worker" });
5935
+ };
5936
+ worker.postMessage(input);
5937
+ });
5938
+ }
5939
+
5272
5940
  // src/view3d/index.ts
5273
- var SEAT_EYE_ABOVE_DECK = 1.02;
5274
5941
  var DEG3 = Math.PI / 180;
5275
5942
  var TAP_SLOP = 6;
5276
5943
  var TAP_MS = 500;
5277
5944
  function mountVenue3D(container, input, opts = {}) {
5278
- const model = buildSceneModel(input);
5945
+ const buildStartedAt = performance.now();
5946
+ const model = input.prepared?.model ?? buildSceneModel(input);
5279
5947
  const analytics = new Analytics3D(opts.onAnalytics);
5280
5948
  const seatIdByIndex = new Array(model.seats.count);
5281
5949
  for (const [id, idx] of model.seats.idToIndex) seatIdByIndex[idx] = id;
5950
+ const expandedSeatById = new Map(input.seats.map((seat) => [seat.id, seat]));
5951
+ const seatIdsByRow = /* @__PURE__ */ new Map();
5952
+ for (const seat of input.seats) {
5953
+ if (!seat.rowId) continue;
5954
+ const row = seatIdsByRow.get(seat.rowId);
5955
+ if (row) row.push(seat.id);
5956
+ else seatIdsByRow.set(seat.rowId, [seat.id]);
5957
+ }
5282
5958
  const sectionIdBySeatId = /* @__PURE__ */ new Map();
5283
5959
  for (const s of input.seats) sectionIdBySeatId.set(s.id, s.sectionId);
5284
5960
  const hasHeights = (() => {
@@ -5293,10 +5969,22 @@ function mountVenue3D(container, input, opts = {}) {
5293
5969
  let disposed = false;
5294
5970
  let selection = /* @__PURE__ */ new Map();
5295
5971
  let panorama = null;
5972
+ let panoramaLoadAbort = null;
5973
+ let panoramaPriorZIndex = null;
5296
5974
  const prefetch = /* @__PURE__ */ new Map();
5297
5975
  let flightGen = 0;
5298
5976
  let reducedForced = null;
5299
5977
  let focusedFloor = -1;
5978
+ const raisePanoramaLayer = () => {
5979
+ if (panoramaPriorZIndex !== null) return;
5980
+ panoramaPriorZIndex = container.style.zIndex;
5981
+ container.style.zIndex = "20";
5982
+ };
5983
+ const restorePanoramaLayer = () => {
5984
+ if (panoramaPriorZIndex === null) return;
5985
+ if (container.style.zIndex === "20") container.style.zIndex = panoramaPriorZIndex;
5986
+ panoramaPriorZIndex = null;
5987
+ };
5300
5988
  const rebuildGpu = () => {
5301
5989
  gpu = buildGpuScene(glctx.gl, model);
5302
5990
  pick = new PickPipeline(glctx.renderer, gpu.seatGeometry, gpu.solidGeometry, model.seats.count);
@@ -5330,6 +6018,7 @@ function mountVenue3D(container, input, opts = {}) {
5330
6018
  };
5331
6019
  const glctx = new GLContext(container, {
5332
6020
  onContextLost: () => {
6021
+ if (!disposed) analytics.contextLost();
5333
6022
  contextLost = true;
5334
6023
  loop.stop();
5335
6024
  gpu = null;
@@ -5338,6 +6027,7 @@ function mountVenue3D(container, input, opts = {}) {
5338
6027
  onContextRestored: () => {
5339
6028
  rebuildGpu();
5340
6029
  contextLost = false;
6030
+ if (!disposed) analytics.contextRestored();
5341
6031
  loop.requestRender();
5342
6032
  }
5343
6033
  });
@@ -5354,16 +6044,48 @@ function mountVenue3D(container, input, opts = {}) {
5354
6044
  const dz = model.focalWorld[2] - model.bounds.center[2];
5355
6045
  return Math.hypot(dx, dz) > model.bounds.radius * 0.12 ? Math.atan2(dx, dz) : void 0;
5356
6046
  })();
5357
- orbit.frame(model.bounds, true, stageAzimuth);
6047
+ orbit.frame(model.bounds, true, stageAzimuth, opts.portraitOverviewCrop === true);
5358
6048
  const cinematic = new Cinematic(orbit.camera);
5359
- if (!container.style.position || container.style.position === "static") {
5360
- container.style.position = "relative";
5361
- }
6049
+ const restoreContainerPosition = establishPositioningContext(container);
5362
6050
  const labelOverlay = new LabelOverlay(container, {
5363
6051
  fontFamily: input.doc.theme?.fontFamily,
5364
6052
  ink: input.doc.theme?.textColor
5365
6053
  });
5366
6054
  labelOverlay.setLabels(model.labels);
6055
+ const baseLabels = model.labels;
6056
+ let focusedSectionId = null;
6057
+ const showSectionSeatLabels = (sectionId) => {
6058
+ focusedSectionId = sectionId;
6059
+ labelOverlay.setForcedDense(!!sectionId);
6060
+ if (!sectionId) {
6061
+ labelOverlay.setLabels(baseLabels);
6062
+ return;
6063
+ }
6064
+ const sectionSeats = input.seats.filter((seat) => seat.sectionId === sectionId);
6065
+ const seatIds = new Set(sectionSeats.map((seat) => seat.id));
6066
+ const rowIds = new Set(sectionSeats.map((seat) => seat.rowId));
6067
+ 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))));
6068
+ if (model.seatCount <= 6e3) {
6069
+ labelOverlay.setLabels(focusedBase);
6070
+ return;
6071
+ }
6072
+ const labels = sectionSeats.flatMap((seat) => {
6073
+ const index = model.seats.idToIndex.get(seat.id);
6074
+ if (index === void 0) return [];
6075
+ const offset = index * 3;
6076
+ return [{
6077
+ id: `seat:${seat.id}`,
6078
+ kind: "seat",
6079
+ text: seat.displayLabel || seat.label,
6080
+ anchor: [
6081
+ model.seats.iPosition[offset],
6082
+ model.seats.iPosition[offset + 1] + 0.55,
6083
+ model.seats.iPosition[offset + 2]
6084
+ ]
6085
+ }];
6086
+ });
6087
+ labelOverlay.setLabels([...focusedBase, ...labels]);
6088
+ };
5367
6089
  rebuildGpu();
5368
6090
  const loop = new RenderLoop(() => {
5369
6091
  if (contextLost || !gpu || frozen) return false;
@@ -5416,6 +6138,9 @@ function mountVenue3D(container, input, opts = {}) {
5416
6138
  if (!p) {
5417
6139
  p = Promise.resolve(opts.getSeatView(seatId));
5418
6140
  prefetch.set(seatId, p);
6141
+ void p.catch(() => {
6142
+ if (prefetch.get(seatId) === p) prefetch.delete(seatId);
6143
+ });
5419
6144
  while (prefetch.size > PREFETCH_CAP) {
5420
6145
  const oldest = prefetch.keys().next().value;
5421
6146
  if (oldest === void 0) break;
@@ -5424,11 +6149,18 @@ function mountVenue3D(container, input, opts = {}) {
5424
6149
  }
5425
6150
  return p;
5426
6151
  };
5427
- const seatEyeWorld = (idx) => [
5428
- model.seats.iPosition[idx * 3],
5429
- model.seats.iPosition[idx * 3 + 1] + SEAT_EYE_ABOVE_DECK,
5430
- model.seats.iPosition[idx * 3 + 2]
5431
- ];
6152
+ const resolvedSeatViewPose = (seatId, idx) => {
6153
+ const seat = expandedSeatById.get(seatId);
6154
+ const floorIndex = Math.max(0, Math.round(model.seats.iFloor[idx] ?? 0));
6155
+ const floor = input.doc.floors?.[floorIndex];
6156
+ const focalPoint = seat?.focalPoint ?? floor?.focalPoint ?? input.doc.focalPoint;
6157
+ const deck = [
6158
+ model.seats.iPosition[idx * 3],
6159
+ model.seats.iPosition[idx * 3 + 1],
6160
+ model.seats.iPosition[idx * 3 + 2]
6161
+ ];
6162
+ return seatViewPose(deck, focalPoint, floor?.baseHeightM ?? 0);
6163
+ };
5432
6164
  const placeCameraFinal = (finalPos, focal) => {
5433
6165
  orbit.camera.position.set(finalPos[0], finalPos[1], finalPos[2]);
5434
6166
  orbit.camera.lookAt(new import_ogl8.Vec3(focal[0], focal[1], focal[2]));
@@ -5436,44 +6168,188 @@ function mountVenue3D(container, input, opts = {}) {
5436
6168
  orbit.camera.updateProjectionMatrix();
5437
6169
  };
5438
6170
  let arriveChip = null;
6171
+ const layoutArriveChip = () => {
6172
+ if (!arriveChip) return;
6173
+ const narrow = (container.clientWidth || glctx.canvas.clientWidth) <= 520;
6174
+ Object.assign(arriveChip.style, narrow ? {
6175
+ left: "12px",
6176
+ right: "12px",
6177
+ bottom: "70px",
6178
+ transform: "none",
6179
+ justifyContent: "center"
6180
+ } : {
6181
+ left: "50%",
6182
+ right: "auto",
6183
+ bottom: "18px",
6184
+ transform: "translateX(-50%)",
6185
+ justifyContent: "initial"
6186
+ });
6187
+ };
5439
6188
  const removeArriveChip = () => {
5440
6189
  arriveChip?.remove();
5441
6190
  arriveChip = null;
5442
6191
  };
5443
- const showArriveChip = (seatId, gen) => {
6192
+ const showArriveChip = (seatId, gen, retry = false) => {
5444
6193
  removeArriveChip();
5445
- if (disposed || !opts.getSeatView) return;
5446
- const chip = document.createElement("button");
5447
- chip.type = "button";
5448
- chip.textContent = "\u25C9 View in 360\xB0";
5449
- chip.setAttribute("aria-label", `Open the 360\xB0 view from seat ${seatId}`);
5450
- Object.assign(chip.style, {
6194
+ if (disposed) return null;
6195
+ const controls = document.createElement("div");
6196
+ controls.setAttribute("role", "group");
6197
+ Object.assign(controls.style, {
5451
6198
  position: "absolute",
5452
- left: "50%",
5453
- bottom: "18px",
5454
- transform: "translateX(-50%)",
5455
- minHeight: "44px",
5456
- padding: "10px 18px",
5457
- borderRadius: "999px",
5458
- background: "rgba(12,18,32,0.78)",
5459
- color: "#eef1f8",
5460
- border: "1px solid rgba(150,165,205,0.4)",
5461
- backdropFilter: "blur(6px)",
5462
- font: "600 13px/1 inherit",
5463
- cursor: "pointer",
6199
+ display: "flex",
6200
+ flexDirection: "column",
6201
+ alignItems: "center",
6202
+ gap: "6px",
5464
6203
  zIndex: "4"
5465
6204
  });
5466
- chip.addEventListener("click", () => {
5467
- if (disposed || gen !== flightGen) {
6205
+ const seat = expandedSeatById.get(seatId);
6206
+ const section = seat?.sectionId ? model.sections.find((candidate) => candidate.id === seat.sectionId) : void 0;
6207
+ const seatLabel = seat?.label ?? seatId;
6208
+ controls.setAttribute("aria-label", `Explore views near ${section?.label ? `${section.label}, ` : ""}seat ${seatLabel}`);
6209
+ const location = document.createElement("span");
6210
+ location.textContent = ["Live 3D seat view", section?.label, seatLabel].filter(Boolean).join(" \xB7 ");
6211
+ Object.assign(location.style, {
6212
+ maxWidth: "min(320px, calc(100vw - 32px))",
6213
+ padding: "5px 10px",
6214
+ borderRadius: "999px",
6215
+ overflow: "hidden",
6216
+ color: "#d9e3f5",
6217
+ background: "rgba(12,18,32,0.72)",
6218
+ font: "600 11px/1.2 inherit",
6219
+ textOverflow: "ellipsis",
6220
+ whiteSpace: "nowrap",
6221
+ backdropFilter: "blur(6px)"
6222
+ });
6223
+ controls.appendChild(location);
6224
+ const actions = document.createElement("div");
6225
+ Object.assign(actions.style, {
6226
+ display: "flex",
6227
+ alignItems: "center",
6228
+ justifyContent: "center",
6229
+ gap: "6px"
6230
+ });
6231
+ controls.appendChild(actions);
6232
+ const row = seat?.rowId ? seatIdsByRow.get(seat.rowId) : void 0;
6233
+ const at = row?.indexOf(seatId) ?? -1;
6234
+ const neighbours = at >= 0 ? [row?.[at - 1], row?.[at + 1]] : [void 0, void 0];
6235
+ const addButton = (label, ariaLabel, action, primary = false) => {
6236
+ const button = document.createElement("button");
6237
+ button.type = "button";
6238
+ button.textContent = label;
6239
+ button.setAttribute("aria-label", ariaLabel);
6240
+ Object.assign(button.style, {
6241
+ minHeight: "44px",
6242
+ padding: primary ? "10px 16px" : "10px",
6243
+ borderRadius: "999px",
6244
+ background: "rgba(12,18,32,0.78)",
6245
+ color: "#eef1f8",
6246
+ border: "1px solid rgba(150,165,205,0.4)",
6247
+ backdropFilter: "blur(6px)",
6248
+ font: "600 13px/1 inherit",
6249
+ cursor: "pointer",
6250
+ whiteSpace: "nowrap"
6251
+ });
6252
+ if (!primary) button.style.minWidth = "44px";
6253
+ if (primary) button.dataset.panoramaTrigger = "";
6254
+ button.addEventListener("click", action);
6255
+ actions.appendChild(button);
6256
+ };
6257
+ if (neighbours[0]) {
6258
+ const label = expandedSeatById.get(neighbours[0])?.label ?? neighbours[0];
6259
+ addButton("\u2039", `View previous seat ${label}`, () => {
6260
+ void flyToSeat(neighbours[0]);
6261
+ });
6262
+ }
6263
+ if (opts.getSeatView) {
6264
+ addButton(retry ? "\u21BB Retry 360\xB0 panorama" : "\u25C9 Open 360\xB0 panorama", `${retry ? "Retry" : "Open"} the 360\xB0 panorama from seat ${seatLabel}`, () => {
6265
+ if (disposed || gen !== flightGen) {
6266
+ removeArriveChip();
6267
+ return;
6268
+ }
5468
6269
  removeArriveChip();
5469
- return;
5470
- }
5471
- removeArriveChip();
5472
- void openPanorama(seatId, 400, gen);
6270
+ void openPanorama(seatId, 400, gen);
6271
+ }, true);
6272
+ }
6273
+ if (neighbours[1]) {
6274
+ const label = expandedSeatById.get(neighbours[1])?.label ?? neighbours[1];
6275
+ addButton("\u203A", `View next seat ${label}`, () => {
6276
+ void flyToSeat(neighbours[1]);
6277
+ });
6278
+ }
6279
+ if (!actions.childElementCount) return null;
6280
+ container.appendChild(controls);
6281
+ arriveChip = controls;
6282
+ layoutArriveChip();
6283
+ return controls;
6284
+ };
6285
+ const navigationModeChip = document.createElement("div");
6286
+ navigationModeChip.setAttribute("role", "group");
6287
+ navigationModeChip.setAttribute("aria-label", "3D navigation mode");
6288
+ Object.assign(navigationModeChip.style, {
6289
+ position: "absolute",
6290
+ left: "14px",
6291
+ top: "62px",
6292
+ display: "flex",
6293
+ gap: "3px",
6294
+ padding: "3px",
6295
+ borderRadius: "999px",
6296
+ zIndex: "4",
6297
+ background: "rgba(12,18,32,0.72)",
6298
+ border: "1px solid rgba(150,165,205,0.35)",
6299
+ backdropFilter: "blur(6px)"
6300
+ });
6301
+ const rotateModeButton = document.createElement("button");
6302
+ rotateModeButton.type = "button";
6303
+ rotateModeButton.textContent = "\u21BB Rotate";
6304
+ rotateModeButton.setAttribute("aria-label", "Drag to rotate the 3D venue");
6305
+ const moveModeButton = document.createElement("button");
6306
+ moveModeButton.type = "button";
6307
+ moveModeButton.textContent = "\u2725 Move";
6308
+ moveModeButton.setAttribute("aria-label", "Drag to move the 3D venue left, right, up or down");
6309
+ const zoomOutButton = document.createElement("button");
6310
+ zoomOutButton.type = "button";
6311
+ zoomOutButton.textContent = "\u2212";
6312
+ zoomOutButton.setAttribute("aria-label", "Zoom out of the 3D venue");
6313
+ zoomOutButton.title = "Zoom out";
6314
+ const zoomInButton = document.createElement("button");
6315
+ zoomInButton.type = "button";
6316
+ zoomInButton.textContent = "+";
6317
+ zoomInButton.setAttribute("aria-label", "Zoom into the 3D venue");
6318
+ zoomInButton.title = "Zoom in";
6319
+ for (const button of [rotateModeButton, moveModeButton, zoomOutButton, zoomInButton]) {
6320
+ Object.assign(button.style, {
6321
+ minHeight: "34px",
6322
+ padding: "6px 10px",
6323
+ border: "0",
6324
+ borderRadius: "999px",
6325
+ color: "#c9d4ea",
6326
+ background: "transparent",
6327
+ font: "600 11px/1 inherit",
6328
+ cursor: "pointer",
6329
+ whiteSpace: "nowrap"
5473
6330
  });
5474
- container.appendChild(chip);
5475
- arriveChip = chip;
6331
+ if (button === zoomOutButton || button === zoomInButton) {
6332
+ button.style.minWidth = "34px";
6333
+ button.style.padding = "6px";
6334
+ button.style.fontSize = "17px";
6335
+ }
6336
+ navigationModeChip.appendChild(button);
6337
+ }
6338
+ const setNavigationMode = (mode) => {
6339
+ orbit.setPrimaryDragMode(mode);
6340
+ const rotateActive = mode === "orbit";
6341
+ rotateModeButton.setAttribute("aria-pressed", String(rotateActive));
6342
+ moveModeButton.setAttribute("aria-pressed", String(!rotateActive));
6343
+ rotateModeButton.style.background = rotateActive ? "rgba(96,110,150,0.48)" : "transparent";
6344
+ moveModeButton.style.background = rotateActive ? "transparent" : "rgba(96,110,150,0.48)";
6345
+ navigationModeChip.title = rotateActive ? "Drag to rotate \xB7 Shift-drag to move" : "Drag to move \xB7 Shift-drag to rotate";
5476
6346
  };
6347
+ rotateModeButton.addEventListener("click", () => setNavigationMode("orbit"));
6348
+ moveModeButton.addEventListener("click", () => setNavigationMode("pan"));
6349
+ zoomOutButton.addEventListener("click", () => orbit.zoomBy(1.22));
6350
+ zoomInButton.addEventListener("click", () => orbit.zoomBy(0.82));
6351
+ setNavigationMode("orbit");
6352
+ container.appendChild(navigationModeChip);
5477
6353
  const overviewChip = document.createElement("button");
5478
6354
  overviewChip.type = "button";
5479
6355
  overviewChip.textContent = "\u2302 Overview";
@@ -5493,17 +6369,46 @@ function mountVenue3D(container, input, opts = {}) {
5493
6369
  cursor: "pointer",
5494
6370
  zIndex: "4"
5495
6371
  });
5496
- overviewChip.addEventListener("click", () => {
5497
- if (disposed || frozen || panorama) return;
6372
+ const focusOverview = () => {
6373
+ if (disposed) return;
6374
+ if (panorama) {
6375
+ panorama.dispose();
6376
+ panorama = null;
6377
+ analytics.panoramaClosed();
6378
+ }
6379
+ panoramaLoadAbort?.abort();
6380
+ panoramaLoadAbort = null;
6381
+ restorePanoramaLayer();
6382
+ overviewChip.style.display = "";
6383
+ labelOverlay.setVisible(true);
6384
+ frozen = false;
5498
6385
  cancelFlight();
5499
6386
  removeArriveChip();
5500
- orbit.frameSoft(model.bounds, stageAzimuth);
6387
+ setNavigationMode("orbit");
6388
+ if (reducedMotion()) orbit.frame(model.bounds, false, stageAzimuth, opts.portraitOverviewCrop === true);
6389
+ else orbit.frameSoft(model.bounds, stageAzimuth, opts.portraitOverviewCrop === true);
6390
+ opts.onViewTargetChange?.(null);
6391
+ showSectionSeatLabels(null);
6392
+ opts.onSectionFocusChange?.(null);
5501
6393
  loop.requestRender();
5502
- });
6394
+ };
6395
+ overviewChip.addEventListener("click", focusOverview);
5503
6396
  container.appendChild(overviewChip);
5504
6397
  const openPanorama = async (seatId, fadeMs, gen) => {
6398
+ panoramaLoadAbort?.abort();
6399
+ const loadAbort = new AbortController();
6400
+ panoramaLoadAbort = loadAbort;
6401
+ const seatIndex = model.seats.idToIndex.get(seatId);
6402
+ if (seatIndex === void 0) {
6403
+ orbit.syncFromCamera();
6404
+ return;
6405
+ }
6406
+ const seatLabel = expandedSeatById.get(seatId)?.label ?? seatId;
6407
+ const seatPose = resolvedSeatViewPose(seatId, seatIndex);
5505
6408
  const viewPromise = ensureSeatView(seatId);
5506
6409
  if (!viewPromise) {
6410
+ loadAbort.abort();
6411
+ if (panoramaLoadAbort === loadAbort) panoramaLoadAbort = null;
5507
6412
  orbit.syncFromCamera();
5508
6413
  return;
5509
6414
  }
@@ -5513,34 +6418,56 @@ function mountVenue3D(container, input, opts = {}) {
5513
6418
  try {
5514
6419
  view = await viewPromise;
5515
6420
  } catch {
6421
+ if (panoramaLoadAbort === loadAbort) panoramaLoadAbort = null;
6422
+ analytics.panoramaFailed(seatId, "source");
5516
6423
  if (!disposed && gen === flightGen) {
5517
6424
  frozen = false;
5518
- orbit.resumeAfterFlight(model.focalWorld);
6425
+ orbit.resumeAfterFlight(seatPose.focal);
5519
6426
  loop.requestRender();
6427
+ const controls = showArriveChip(seatId, gen, true);
6428
+ requestAnimationFrame(() => controls?.querySelector("[data-panorama-trigger]")?.focus());
5520
6429
  }
5521
6430
  return;
5522
6431
  }
5523
- if (disposed || gen !== flightGen) return;
6432
+ if (disposed || gen !== flightGen) {
6433
+ loadAbort.abort();
6434
+ if (panoramaLoadAbort === loadAbort) panoramaLoadAbort = null;
6435
+ return;
6436
+ }
5524
6437
  removeArriveChip();
5525
6438
  const onClose = () => {
6439
+ loadAbort.abort();
6440
+ if (panoramaLoadAbort === loadAbort) panoramaLoadAbort = null;
5526
6441
  panorama = null;
6442
+ restorePanoramaLayer();
6443
+ overviewChip.style.display = "";
5527
6444
  labelOverlay.setVisible(true);
5528
6445
  frozen = false;
5529
6446
  analytics.panoramaClosed();
5530
- orbit.resumeAfterFlight(model.focalWorld);
6447
+ orbit.resumeAfterFlight(seatPose.focal);
5531
6448
  loop.requestRender();
5532
- showArriveChip(seatId, flightGen);
6449
+ const controls = showArriveChip(seatId, flightGen);
6450
+ requestAnimationFrame(() => controls?.querySelector("[data-panorama-trigger]")?.focus());
5533
6451
  };
5534
6452
  const sceneMode = view.generated === true;
6453
+ const disclosure = seatViewDisclosure(view);
6454
+ raisePanoramaLayer();
6455
+ overviewChip.style.display = "none";
6456
+ const effectiveFadeMs = reducedMotion() ? 0 : fadeMs;
5535
6457
  const spherical = gpu && !contextLost ? await mountPanoramaSphere(container, sceneMode ? null : view, {
5536
6458
  gl: glctx.gl,
5537
6459
  scene: gpu.main,
5538
6460
  camera: orbit.camera,
5539
6461
  requestRender: () => loop.requestRender(),
5540
- focalWorld: model.focalWorld
5541
- }, { fadeMs, seatLabel: seatId, onClose }) : null;
6462
+ cameraOriginWorld: sceneMode ? seatPose.eye : void 0,
6463
+ focalWorld: seatPose.focal
6464
+ }, { fadeMs: effectiveFadeMs, seatLabel, disclosure, onClose, signal: loadAbort.signal }) : null;
5542
6465
  if (disposed || gen !== flightGen) {
6466
+ loadAbort.abort();
6467
+ if (panoramaLoadAbort === loadAbort) panoramaLoadAbort = null;
5543
6468
  spherical?.dispose();
6469
+ overviewChip.style.display = "";
6470
+ restorePanoramaLayer();
5544
6471
  return;
5545
6472
  }
5546
6473
  if (spherical) {
@@ -5550,12 +6477,21 @@ function mountVenue3D(container, input, opts = {}) {
5550
6477
  loop.requestRender();
5551
6478
  panorama = spherical;
5552
6479
  } else {
5553
- panorama = mountPanorama(container, view, { fadeMs, seatLabel: seatId, onClose });
6480
+ analytics.panoramaFallback(seatId);
6481
+ panorama = mountPanorama(container, view, {
6482
+ fadeMs: effectiveFadeMs,
6483
+ seatLabel,
6484
+ disclosure,
6485
+ onClose,
6486
+ signal: loadAbort.signal
6487
+ });
5554
6488
  }
5555
6489
  analytics.panoramaOpened();
5556
6490
  };
5557
6491
  const cancelFlight = () => {
5558
6492
  flightGen++;
6493
+ panoramaLoadAbort?.abort();
6494
+ panoramaLoadAbort = null;
5559
6495
  if (cinematic.active) {
5560
6496
  cinematic.cancel();
5561
6497
  orbit.resumeAfterFlight(model.focalWorld);
@@ -5565,15 +6501,19 @@ function mountVenue3D(container, input, opts = {}) {
5565
6501
  if (disposed || !gpu) return Promise.resolve();
5566
6502
  const idx = model.seats.idToIndex.get(seatId);
5567
6503
  if (idx === void 0) return Promise.resolve();
6504
+ opts.onViewTargetChange?.(seatId);
5568
6505
  if (panorama) {
5569
6506
  panorama.dispose();
5570
6507
  panorama = null;
6508
+ overviewChip.style.display = "";
6509
+ restorePanoramaLayer();
5571
6510
  }
6511
+ panoramaLoadAbort?.abort();
6512
+ panoramaLoadAbort = null;
5572
6513
  frozen = false;
5573
6514
  const gen = ++flightGen;
5574
6515
  removeArriveChip();
5575
- const seatEye = seatEyeWorld(idx);
5576
- const focal = model.focalWorld;
6516
+ const { eye: seatEye, focal } = resolvedSeatViewPose(seatId, idx);
5577
6517
  const start = [orbit.camera.position.x, orbit.camera.position.y, orbit.camera.position.z];
5578
6518
  const { waypoints, finalPos } = buildWaypoints(start, seatEye, focal, model.bounds.center, model.bounds.radius);
5579
6519
  if (reducedMotion()) {
@@ -5590,11 +6530,26 @@ function mountVenue3D(container, input, opts = {}) {
5590
6530
  return cinematic.start(waypoints, startQuat, endQuat).then(() => {
5591
6531
  if (disposed || gen !== flightGen) return;
5592
6532
  analytics.cinematicPlayed(FLIGHT_DURATION_MS);
5593
- orbit.resumeAfterFlight(model.focalWorld);
6533
+ orbit.resumeAfterFlight(focal);
5594
6534
  loop.requestRender();
5595
6535
  showArriveChip(seatId, gen);
5596
6536
  });
5597
6537
  };
6538
+ const focusSectionCamera = (sectionId) => {
6539
+ const sec = model.sections.find((candidate) => candidate.id === sectionId);
6540
+ if (!sec || sec.seatCount === 0) return false;
6541
+ cinematic.cancel();
6542
+ removeArriveChip();
6543
+ setNavigationMode("pan");
6544
+ showSectionSeatLabels(sectionId);
6545
+ const dx = sec.focalWorld[0] - sec.center[0];
6546
+ const dz = sec.focalWorld[2] - sec.center[2];
6547
+ const azimuth = Math.hypot(dx, dz) > sec.radius * 0.12 ? Math.atan2(dx, dz) : void 0;
6548
+ orbit.frame({ center: sec.center, radius: Math.max(5, sec.radius * 0.5) }, false, azimuth);
6549
+ opts.onSectionFocusChange?.(sectionId);
6550
+ loop.requestRender();
6551
+ return true;
6552
+ };
5598
6553
  let downX = 0, downY = 0, downT = 0, downId = -1, moved = false, suppressTap = false;
5599
6554
  const onDown = (e) => {
5600
6555
  if (downId !== -1) return;
@@ -5613,6 +6568,50 @@ function mountVenue3D(container, input, opts = {}) {
5613
6568
  if (e.pointerId !== downId) return;
5614
6569
  if (Math.hypot(e.clientX - downX, e.clientY - downY) > TAP_SLOP) moved = true;
5615
6570
  };
6571
+ const pickNearestProjectedSeat = (clientX, clientY, sectionId, maxDistance, bookableOnly) => {
6572
+ const rect = glctx.canvas.getBoundingClientRect();
6573
+ const width = glctx.canvas.clientWidth || rect.width || 1;
6574
+ const height = glctx.canvas.clientHeight || rect.height || 1;
6575
+ const tapX = clientX - rect.left;
6576
+ const tapY = clientY - rect.top;
6577
+ const maxDistanceSq = maxDistance * maxDistance;
6578
+ let bestIndex = -1;
6579
+ let bestDistanceSq = maxDistanceSq;
6580
+ let bestDepth = Infinity;
6581
+ for (const seat of input.seats) {
6582
+ if (sectionId && seat.sectionId !== sectionId) continue;
6583
+ const index = model.seats.idToIndex.get(seat.id);
6584
+ if (index === void 0) continue;
6585
+ if (bookableOnly) {
6586
+ const state = Math.round(model.seats.iState[index] ?? 0);
6587
+ if (state !== 0 && state !== 3) continue;
6588
+ }
6589
+ const offset = index * 3;
6590
+ const screen = projectToScreen(
6591
+ orbit.camera.projectionViewMatrix,
6592
+ [
6593
+ model.seats.iPosition[offset],
6594
+ // Match the visible chair/number rather than the deck-level instance
6595
+ // origin. At strong zoom the vertical difference is dozens of pixels
6596
+ // and a perfectly reasonable click on the chair otherwise misses.
6597
+ model.seats.iPosition[offset + 1] + 0.55,
6598
+ model.seats.iPosition[offset + 2]
6599
+ ],
6600
+ width,
6601
+ height
6602
+ );
6603
+ if (!screen.visible) continue;
6604
+ const dx = screen.x - tapX;
6605
+ const dy = screen.y - tapY;
6606
+ const distanceSq = dx * dx + dy * dy;
6607
+ if (distanceSq < bestDistanceSq || Math.abs(distanceSq - bestDistanceSq) < 1 && screen.depth < bestDepth) {
6608
+ bestIndex = index;
6609
+ bestDistanceSq = distanceSq;
6610
+ bestDepth = screen.depth;
6611
+ }
6612
+ }
6613
+ return bestIndex;
6614
+ };
5616
6615
  const onUp = (e) => {
5617
6616
  if (e.pointerId !== downId) return;
5618
6617
  const isTap = !moved && performance.now() - downT < TAP_MS;
@@ -5622,12 +6621,25 @@ function mountVenue3D(container, input, opts = {}) {
5622
6621
  return;
5623
6622
  }
5624
6623
  if (!isTap || !gpu || !pick) return;
5625
- pick.syncFromSeatProgram(gpu.seatProgram);
5626
- const rect = glctx.canvas.getBoundingClientRect();
5627
- const dpr = glctx.renderer.dpr;
5628
- const { x, y } = pickPixelCoords(e.clientX, e.clientY, rect, dpr, glctx.gl.drawingBufferWidth, glctx.gl.drawingBufferHeight);
5629
- const radius = Math.max(2, Math.round(8 * dpr));
5630
- const idx = pick.pick(orbit.camera, x, y, radius);
6624
+ let idx = focusedSectionId ? pickNearestProjectedSeat(e.clientX, e.clientY, focusedSectionId, 44, true) : -1;
6625
+ if (focusedSectionId && idx < 0) {
6626
+ const sectionIndex = pickNearestProjectedSeat(e.clientX, e.clientY, null, 72, false);
6627
+ const sectionSeatId = sectionIndex >= 0 ? seatIdByIndex[sectionIndex] : void 0;
6628
+ const nextSectionId = sectionSeatId ? sectionIdBySeatId.get(sectionSeatId) : void 0;
6629
+ if (nextSectionId && nextSectionId !== focusedSectionId && focusSectionCamera(nextSectionId)) return;
6630
+ }
6631
+ if (!focusedSectionId) {
6632
+ pick.syncFromSeatProgram(gpu.seatProgram);
6633
+ const rect = glctx.canvas.getBoundingClientRect();
6634
+ const dpr = glctx.renderer.dpr;
6635
+ const { x, y } = pickPixelCoords(e.clientX, e.clientY, rect, dpr, glctx.gl.drawingBufferWidth, glctx.gl.drawingBufferHeight);
6636
+ const radius = Math.max(2, Math.round(8 * dpr));
6637
+ idx = pick.pick(orbit.camera, x, y, radius);
6638
+ if (idx < 0) idx = pickNearestProjectedSeat(e.clientX, e.clientY, null, 42, false);
6639
+ const overviewSeatId = idx >= 0 ? seatIdByIndex[idx] : void 0;
6640
+ const sectionId = overviewSeatId ? sectionIdBySeatId.get(overviewSeatId) : void 0;
6641
+ if (sectionId && focusSectionCamera(sectionId)) return;
6642
+ }
5631
6643
  if (idx < 0 || idx >= seatIdByIndex.length) {
5632
6644
  if (selection.size) setSelection([]);
5633
6645
  return;
@@ -5653,9 +6665,11 @@ function mountVenue3D(container, input, opts = {}) {
5653
6665
  },
5654
6666
  setSelection,
5655
6667
  flyToSeat,
6668
+ focusOverview,
5656
6669
  resize() {
5657
6670
  const { width, height } = glctx.resize();
5658
6671
  orbit.setAspect(width / Math.max(1, height));
6672
+ layoutArriveChip();
5659
6673
  loop.requestRender();
5660
6674
  },
5661
6675
  stats() {
@@ -5680,6 +6694,7 @@ function mountVenue3D(container, input, opts = {}) {
5680
6694
  gpu.chairProgram.uniforms.uFocusFloor.value = value;
5681
6695
  }
5682
6696
  focusedFloor = value;
6697
+ setNavigationMode("orbit");
5683
6698
  if (index !== null) {
5684
6699
  const f = model.floors[index];
5685
6700
  if (f.seatCount > 0) {
@@ -5697,6 +6712,9 @@ function mountVenue3D(container, input, opts = {}) {
5697
6712
  const zone = model.zones.find((z) => z.id === zoneId);
5698
6713
  if (!zone || zone.seatCount === 0) return false;
5699
6714
  cinematic.cancel();
6715
+ setNavigationMode("orbit");
6716
+ showSectionSeatLabels(null);
6717
+ opts.onSectionFocusChange?.(null);
5700
6718
  const dx = zone.focalWorld[0] - zone.center[0];
5701
6719
  const dz = zone.focalWorld[2] - zone.center[2];
5702
6720
  const azimuth = Math.hypot(dx, dz) > zone.radius * 0.12 ? Math.atan2(dx, dz) : void 0;
@@ -5708,13 +6726,24 @@ function mountVenue3D(container, input, opts = {}) {
5708
6726
  return model.sections;
5709
6727
  },
5710
6728
  focusSection(sectionId) {
5711
- const sec = model.sections.find((s) => s.id === sectionId);
5712
- if (!sec || sec.seatCount === 0) return false;
6729
+ return focusSectionCamera(sectionId);
6730
+ },
6731
+ rows(sectionId) {
6732
+ return sectionId ? model.rows.filter((row) => row.sectionId === sectionId) : model.rows;
6733
+ },
6734
+ seatsInRow(rowId) {
6735
+ return input.seats.filter((seat) => seat.rowId === rowId).map((seat) => ({ id: seat.id, label: seat.displayLabel || seat.label }));
6736
+ },
6737
+ focusRow(rowId) {
6738
+ const row = model.rows.find((candidate) => candidate.id === rowId);
6739
+ if (!row || row.seatCount === 0) return false;
5713
6740
  cinematic.cancel();
5714
- const dx = sec.focalWorld[0] - sec.center[0];
5715
- const dz = sec.focalWorld[2] - sec.center[2];
5716
- const azimuth = Math.hypot(dx, dz) > sec.radius * 0.12 ? Math.atan2(dx, dz) : void 0;
5717
- orbit.frame({ center: sec.center, radius: sec.radius * 1.45 }, false, azimuth);
6741
+ setNavigationMode("pan");
6742
+ if (row.sectionId) {
6743
+ showSectionSeatLabels(row.sectionId);
6744
+ opts.onSectionFocusChange?.(row.sectionId);
6745
+ }
6746
+ orbit.frame({ center: row.center, radius: Math.max(2.5, row.radius * 0.65) });
5718
6747
  loop.requestRender();
5719
6748
  return true;
5720
6749
  },
@@ -5725,6 +6754,7 @@ function mountVenue3D(container, input, opts = {}) {
5725
6754
  disposed = true;
5726
6755
  removeArriveChip();
5727
6756
  overviewChip.remove();
6757
+ navigationModeChip.remove();
5728
6758
  cancelFlight();
5729
6759
  loop.stop();
5730
6760
  labelOverlay.dispose();
@@ -5733,6 +6763,8 @@ function mountVenue3D(container, input, opts = {}) {
5733
6763
  panorama.dispose();
5734
6764
  panorama = null;
5735
6765
  }
6766
+ restorePanoramaLayer();
6767
+ restoreContainerPosition();
5736
6768
  glctx.canvas.removeEventListener("pointerdown", onDown);
5737
6769
  glctx.canvas.removeEventListener("pointermove", onMove);
5738
6770
  glctx.canvas.removeEventListener("pointerup", onUp);
@@ -5745,12 +6777,13 @@ function mountVenue3D(container, input, opts = {}) {
5745
6777
  glctx.dispose();
5746
6778
  }
5747
6779
  };
5748
- analytics.opened(model.seatCount, hasHeights);
6780
+ analytics.opened(model.seatCount, hasHeights, input.prepared?.buildMs ?? performance.now() - buildStartedAt);
5749
6781
  return handle;
5750
6782
  }
5751
6783
  // Annotate the CommonJS export names for ESM import in node:
5752
6784
  0 && (module.exports = {
5753
6785
  buildSceneModel,
5754
- mountVenue3D
6786
+ mountVenue3D,
6787
+ prepareVenue3D
5755
6788
  });
5756
6789
  //# sourceMappingURL=index.cjs.map