@seatlayer/core 0.46.0 → 0.47.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/chunk-AZODENEL.js +97 -0
  2. package/dist/chunk-AZODENEL.js.map +1 -0
  3. package/dist/chunk-FVCOOLJO.js +335 -0
  4. package/dist/chunk-FVCOOLJO.js.map +1 -0
  5. package/dist/{chunk-5MRJCIZP.js → chunk-TCTAS4Z2.js} +88 -14
  6. package/dist/chunk-TCTAS4Z2.js.map +1 -0
  7. package/dist/{de-UHAOVDS3.js → de-ST4H423D.js} +6 -3
  8. package/dist/{de-UHAOVDS3.js.map → de-ST4H423D.js.map} +1 -1
  9. package/dist/{es-6TJS2L7S.js → es-WEC5NHRT.js} +6 -3
  10. package/dist/{es-6TJS2L7S.js.map → es-WEC5NHRT.js.map} +1 -1
  11. package/dist/{fr-MMSY7FPE.js → fr-SGBFNAQV.js} +6 -3
  12. package/dist/{fr-MMSY7FPE.js.map → fr-SGBFNAQV.js.map} +1 -1
  13. package/dist/index.cjs +251 -39
  14. package/dist/index.cjs.map +1 -1
  15. package/dist/index.d.cts +119 -4
  16. package/dist/index.d.ts +119 -4
  17. package/dist/index.js +152 -24
  18. package/dist/index.js.map +1 -1
  19. package/dist/{types-DBnRO2hX.d.cts → types-CE63BK0j.d.cts} +88 -10
  20. package/dist/{types-DBnRO2hX.d.ts → types-CE63BK0j.d.ts} +88 -10
  21. package/dist/view/panoramaDelivery.cjs +126 -0
  22. package/dist/view/panoramaDelivery.cjs.map +1 -0
  23. package/dist/view/panoramaDelivery.d.cts +34 -0
  24. package/dist/view/panoramaDelivery.d.ts +34 -0
  25. package/dist/view/panoramaDelivery.js +17 -0
  26. package/dist/view/panoramaDelivery.js.map +1 -0
  27. package/dist/view3d/crossfade/panorama.cjs +453 -0
  28. package/dist/view3d/crossfade/panorama.cjs.map +1 -0
  29. package/dist/view3d/crossfade/panorama.d.cts +110 -0
  30. package/dist/view3d/crossfade/panorama.d.ts +110 -0
  31. package/dist/view3d/crossfade/panorama.js +30 -0
  32. package/dist/view3d/crossfade/panorama.js.map +1 -0
  33. package/dist/view3d/index.cjs +1176 -128
  34. package/dist/view3d/index.cjs.map +1 -1
  35. package/dist/view3d/index.d.cts +73 -35
  36. package/dist/view3d/index.d.ts +73 -35
  37. package/dist/view3d/index.js +960 -300
  38. package/dist/view3d/index.js.map +1 -1
  39. package/package.json +28 -1
  40. package/dist/chunk-5MRJCIZP.js.map +0 -1
@@ -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,13 @@ 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, prepSource) {
5852
+ this.emit("3d_opened", {
5853
+ seats,
5854
+ hasHeights,
5855
+ ...buildMs === void 0 ? {} : { buildMs: Math.round(buildMs) },
5856
+ ...prepSource === void 0 ? {} : { prepSource }
5857
+ });
5241
5858
  }
5242
5859
  /** First user-driven orbit/dolly per mount only (the intro ease is not user
5243
5860
  * input, so callers must gate this on real pointer/wheel gestures). */
@@ -5267,18 +5884,78 @@ var Analytics3D = class {
5267
5884
  this.panoramaOpenedAt = 0;
5268
5885
  this.emit("3d_panorama_closed", { viewMs });
5269
5886
  }
5887
+ panoramaFailed(seatId, phase) {
5888
+ this.emit("3d_panorama_failed", { seatId, phase });
5889
+ }
5890
+ panoramaFallback(seatId) {
5891
+ this.emit("3d_panorama_fallback", { seatId });
5892
+ }
5893
+ contextLost() {
5894
+ this.emit("3d_context_lost");
5895
+ }
5896
+ contextRestored() {
5897
+ this.emit("3d_context_restored");
5898
+ }
5270
5899
  };
5271
5900
 
5901
+ // src/view3d/positioningContext.ts
5902
+ function establishPositioningContext(container) {
5903
+ const originalInline = container.style.position;
5904
+ if (getComputedStyle(container).position !== "static") return () => {
5905
+ };
5906
+ container.style.position = "relative";
5907
+ return () => {
5908
+ if (container.style.position === "relative") container.style.position = originalInline;
5909
+ };
5910
+ }
5911
+
5912
+ // src/view3d/prepareScene.ts
5913
+ var import_meta = {};
5914
+ function prepareOnMain(input) {
5915
+ const started = performance.now();
5916
+ const model = buildSceneModel(input);
5917
+ return { model, buildMs: performance.now() - started, source: "main" };
5918
+ }
5919
+ function prepareVenue3D(input) {
5920
+ if (typeof Worker === "undefined") return Promise.resolve(prepareOnMain(input));
5921
+ return new Promise((resolve) => {
5922
+ const worker = new Worker(new URL("./scene/scene.worker.ts", import_meta.url), { type: "module" });
5923
+ let settled = false;
5924
+ const finish = (result) => {
5925
+ if (settled) return;
5926
+ settled = true;
5927
+ clearTimeout(timeout);
5928
+ worker.terminate();
5929
+ resolve(result);
5930
+ };
5931
+ const fallback = () => finish(prepareOnMain(input));
5932
+ const timeout = window.setTimeout(fallback, 15e3);
5933
+ worker.onerror = fallback;
5934
+ worker.onmessage = (event) => {
5935
+ finish({ model: event.data.model, buildMs: event.data.buildMs, source: "worker" });
5936
+ };
5937
+ worker.postMessage(input);
5938
+ });
5939
+ }
5940
+
5272
5941
  // src/view3d/index.ts
5273
- var SEAT_EYE_ABOVE_DECK = 1.02;
5274
5942
  var DEG3 = Math.PI / 180;
5275
5943
  var TAP_SLOP = 6;
5276
5944
  var TAP_MS = 500;
5277
5945
  function mountVenue3D(container, input, opts = {}) {
5278
- const model = buildSceneModel(input);
5946
+ const buildStartedAt = performance.now();
5947
+ const model = input.prepared?.model ?? buildSceneModel(input);
5279
5948
  const analytics = new Analytics3D(opts.onAnalytics);
5280
5949
  const seatIdByIndex = new Array(model.seats.count);
5281
5950
  for (const [id, idx] of model.seats.idToIndex) seatIdByIndex[idx] = id;
5951
+ const expandedSeatById = new Map(input.seats.map((seat) => [seat.id, seat]));
5952
+ const seatIdsByRow = /* @__PURE__ */ new Map();
5953
+ for (const seat of input.seats) {
5954
+ if (!seat.rowId) continue;
5955
+ const row = seatIdsByRow.get(seat.rowId);
5956
+ if (row) row.push(seat.id);
5957
+ else seatIdsByRow.set(seat.rowId, [seat.id]);
5958
+ }
5282
5959
  const sectionIdBySeatId = /* @__PURE__ */ new Map();
5283
5960
  for (const s of input.seats) sectionIdBySeatId.set(s.id, s.sectionId);
5284
5961
  const hasHeights = (() => {
@@ -5293,10 +5970,22 @@ function mountVenue3D(container, input, opts = {}) {
5293
5970
  let disposed = false;
5294
5971
  let selection = /* @__PURE__ */ new Map();
5295
5972
  let panorama = null;
5973
+ let panoramaLoadAbort = null;
5974
+ let panoramaPriorZIndex = null;
5296
5975
  const prefetch = /* @__PURE__ */ new Map();
5297
5976
  let flightGen = 0;
5298
5977
  let reducedForced = null;
5299
5978
  let focusedFloor = -1;
5979
+ const raisePanoramaLayer = () => {
5980
+ if (panoramaPriorZIndex !== null) return;
5981
+ panoramaPriorZIndex = container.style.zIndex;
5982
+ container.style.zIndex = "20";
5983
+ };
5984
+ const restorePanoramaLayer = () => {
5985
+ if (panoramaPriorZIndex === null) return;
5986
+ if (container.style.zIndex === "20") container.style.zIndex = panoramaPriorZIndex;
5987
+ panoramaPriorZIndex = null;
5988
+ };
5300
5989
  const rebuildGpu = () => {
5301
5990
  gpu = buildGpuScene(glctx.gl, model);
5302
5991
  pick = new PickPipeline(glctx.renderer, gpu.seatGeometry, gpu.solidGeometry, model.seats.count);
@@ -5330,6 +6019,7 @@ function mountVenue3D(container, input, opts = {}) {
5330
6019
  };
5331
6020
  const glctx = new GLContext(container, {
5332
6021
  onContextLost: () => {
6022
+ if (!disposed) analytics.contextLost();
5333
6023
  contextLost = true;
5334
6024
  loop.stop();
5335
6025
  gpu = null;
@@ -5338,6 +6028,7 @@ function mountVenue3D(container, input, opts = {}) {
5338
6028
  onContextRestored: () => {
5339
6029
  rebuildGpu();
5340
6030
  contextLost = false;
6031
+ if (!disposed) analytics.contextRestored();
5341
6032
  loop.requestRender();
5342
6033
  }
5343
6034
  });
@@ -5354,16 +6045,48 @@ function mountVenue3D(container, input, opts = {}) {
5354
6045
  const dz = model.focalWorld[2] - model.bounds.center[2];
5355
6046
  return Math.hypot(dx, dz) > model.bounds.radius * 0.12 ? Math.atan2(dx, dz) : void 0;
5356
6047
  })();
5357
- orbit.frame(model.bounds, true, stageAzimuth);
6048
+ orbit.frame(model.bounds, true, stageAzimuth, opts.portraitOverviewCrop === true);
5358
6049
  const cinematic = new Cinematic(orbit.camera);
5359
- if (!container.style.position || container.style.position === "static") {
5360
- container.style.position = "relative";
5361
- }
6050
+ const restoreContainerPosition = establishPositioningContext(container);
5362
6051
  const labelOverlay = new LabelOverlay(container, {
5363
6052
  fontFamily: input.doc.theme?.fontFamily,
5364
6053
  ink: input.doc.theme?.textColor
5365
6054
  });
5366
6055
  labelOverlay.setLabels(model.labels);
6056
+ const baseLabels = model.labels;
6057
+ let focusedSectionId = null;
6058
+ const showSectionSeatLabels = (sectionId) => {
6059
+ focusedSectionId = sectionId;
6060
+ labelOverlay.setForcedDense(!!sectionId);
6061
+ if (!sectionId) {
6062
+ labelOverlay.setLabels(baseLabels);
6063
+ return;
6064
+ }
6065
+ const sectionSeats = input.seats.filter((seat) => seat.sectionId === sectionId);
6066
+ const seatIds = new Set(sectionSeats.map((seat) => seat.id));
6067
+ const rowIds = new Set(sectionSeats.map((seat) => seat.rowId));
6068
+ 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))));
6069
+ if (model.seatCount <= 6e3) {
6070
+ labelOverlay.setLabels(focusedBase);
6071
+ return;
6072
+ }
6073
+ const labels = sectionSeats.flatMap((seat) => {
6074
+ const index = model.seats.idToIndex.get(seat.id);
6075
+ if (index === void 0) return [];
6076
+ const offset = index * 3;
6077
+ return [{
6078
+ id: `seat:${seat.id}`,
6079
+ kind: "seat",
6080
+ text: seat.displayLabel || seat.label,
6081
+ anchor: [
6082
+ model.seats.iPosition[offset],
6083
+ model.seats.iPosition[offset + 1] + 0.55,
6084
+ model.seats.iPosition[offset + 2]
6085
+ ]
6086
+ }];
6087
+ });
6088
+ labelOverlay.setLabels([...focusedBase, ...labels]);
6089
+ };
5367
6090
  rebuildGpu();
5368
6091
  const loop = new RenderLoop(() => {
5369
6092
  if (contextLost || !gpu || frozen) return false;
@@ -5416,6 +6139,9 @@ function mountVenue3D(container, input, opts = {}) {
5416
6139
  if (!p) {
5417
6140
  p = Promise.resolve(opts.getSeatView(seatId));
5418
6141
  prefetch.set(seatId, p);
6142
+ void p.catch(() => {
6143
+ if (prefetch.get(seatId) === p) prefetch.delete(seatId);
6144
+ });
5419
6145
  while (prefetch.size > PREFETCH_CAP) {
5420
6146
  const oldest = prefetch.keys().next().value;
5421
6147
  if (oldest === void 0) break;
@@ -5424,56 +6150,212 @@ function mountVenue3D(container, input, opts = {}) {
5424
6150
  }
5425
6151
  return p;
5426
6152
  };
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
- ];
6153
+ const resolvedSeatViewPose = (seatId, idx) => {
6154
+ const seat = expandedSeatById.get(seatId);
6155
+ const floorIndex = Math.max(0, Math.round(model.seats.iFloor[idx] ?? 0));
6156
+ const floor = input.doc.floors?.[floorIndex];
6157
+ const focalPoint = seat?.focalPoint ?? floor?.focalPoint ?? input.doc.focalPoint;
6158
+ const deck = [
6159
+ model.seats.iPosition[idx * 3],
6160
+ model.seats.iPosition[idx * 3 + 1],
6161
+ model.seats.iPosition[idx * 3 + 2]
6162
+ ];
6163
+ return seatViewPose(deck, focalPoint, floor?.baseHeightM ?? 0);
6164
+ };
5432
6165
  const placeCameraFinal = (finalPos, focal) => {
5433
6166
  orbit.camera.position.set(finalPos[0], finalPos[1], finalPos[2]);
5434
6167
  orbit.camera.lookAt(new import_ogl8.Vec3(focal[0], focal[1], focal[2]));
5435
6168
  orbit.camera.fov = FOV_END;
5436
6169
  orbit.camera.updateProjectionMatrix();
5437
6170
  };
6171
+ const isNarrow = () => (container.clientWidth || glctx.canvas.clientWidth) <= 520;
5438
6172
  let arriveChip = null;
6173
+ const layoutArriveChip = () => {
6174
+ if (!arriveChip) return;
6175
+ const narrow = isNarrow();
6176
+ Object.assign(arriveChip.style, narrow ? {
6177
+ left: "12px",
6178
+ right: "12px",
6179
+ bottom: "70px",
6180
+ transform: "none",
6181
+ justifyContent: "center"
6182
+ } : {
6183
+ left: "50%",
6184
+ right: "auto",
6185
+ bottom: "18px",
6186
+ transform: "translateX(-50%)",
6187
+ justifyContent: "initial"
6188
+ });
6189
+ };
5439
6190
  const removeArriveChip = () => {
5440
6191
  arriveChip?.remove();
5441
6192
  arriveChip = null;
5442
6193
  };
5443
- const showArriveChip = (seatId, gen) => {
6194
+ const showArriveChip = (seatId, gen, retry = false) => {
5444
6195
  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, {
6196
+ if (disposed) return null;
6197
+ const controls = document.createElement("div");
6198
+ controls.setAttribute("role", "group");
6199
+ Object.assign(controls.style, {
5451
6200
  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",
6201
+ display: "flex",
6202
+ flexDirection: "column",
6203
+ alignItems: "center",
6204
+ gap: "6px",
5464
6205
  zIndex: "4"
5465
6206
  });
5466
- chip.addEventListener("click", () => {
5467
- if (disposed || gen !== flightGen) {
6207
+ const seat = expandedSeatById.get(seatId);
6208
+ const section = seat?.sectionId ? model.sections.find((candidate) => candidate.id === seat.sectionId) : void 0;
6209
+ const seatLabel = seat?.label ?? seatId;
6210
+ controls.setAttribute("aria-label", `Explore views near ${section?.label ? `${section.label}, ` : ""}seat ${seatLabel}`);
6211
+ const location = document.createElement("span");
6212
+ location.textContent = ["Live 3D seat view", section?.label, seatLabel].filter(Boolean).join(" \xB7 ");
6213
+ Object.assign(location.style, {
6214
+ maxWidth: "min(320px, calc(100vw - 32px))",
6215
+ padding: "5px 10px",
6216
+ borderRadius: "999px",
6217
+ overflow: "hidden",
6218
+ color: "#d9e3f5",
6219
+ background: "rgba(12,18,32,0.72)",
6220
+ font: "600 11px/1.2 inherit",
6221
+ textOverflow: "ellipsis",
6222
+ whiteSpace: "nowrap",
6223
+ backdropFilter: "blur(6px)"
6224
+ });
6225
+ controls.appendChild(location);
6226
+ const actions = document.createElement("div");
6227
+ Object.assign(actions.style, {
6228
+ display: "flex",
6229
+ alignItems: "center",
6230
+ justifyContent: "center",
6231
+ gap: "6px"
6232
+ });
6233
+ controls.appendChild(actions);
6234
+ const row = seat?.rowId ? seatIdsByRow.get(seat.rowId) : void 0;
6235
+ const at = row?.indexOf(seatId) ?? -1;
6236
+ const neighbours = at >= 0 ? [row?.[at - 1], row?.[at + 1]] : [void 0, void 0];
6237
+ const addButton = (label, ariaLabel, action, primary = false) => {
6238
+ const button = document.createElement("button");
6239
+ button.type = "button";
6240
+ button.textContent = label;
6241
+ button.setAttribute("aria-label", ariaLabel);
6242
+ Object.assign(button.style, {
6243
+ minHeight: "44px",
6244
+ padding: primary ? "10px 16px" : "10px",
6245
+ borderRadius: "999px",
6246
+ background: "rgba(12,18,32,0.78)",
6247
+ color: "#eef1f8",
6248
+ border: "1px solid rgba(150,165,205,0.4)",
6249
+ backdropFilter: "blur(6px)",
6250
+ font: "600 13px/1 inherit",
6251
+ cursor: "pointer",
6252
+ whiteSpace: "nowrap"
6253
+ });
6254
+ if (!primary) button.style.minWidth = "44px";
6255
+ if (primary) button.dataset.panoramaTrigger = "";
6256
+ button.addEventListener("click", action);
6257
+ actions.appendChild(button);
6258
+ };
6259
+ if (neighbours[0]) {
6260
+ const label = expandedSeatById.get(neighbours[0])?.label ?? neighbours[0];
6261
+ addButton("\u2039", `View previous seat ${label}`, () => {
6262
+ void flyToSeat(neighbours[0]);
6263
+ });
6264
+ }
6265
+ if (opts.getSeatView) {
6266
+ addButton(retry ? "\u21BB Retry 360\xB0 panorama" : "\u25C9 Open 360\xB0 panorama", `${retry ? "Retry" : "Open"} the 360\xB0 panorama from seat ${seatLabel}`, () => {
6267
+ if (disposed || gen !== flightGen) {
6268
+ removeArriveChip();
6269
+ return;
6270
+ }
5468
6271
  removeArriveChip();
5469
- return;
5470
- }
5471
- removeArriveChip();
5472
- void openPanorama(seatId, 400, gen);
6272
+ void openPanorama(seatId, 400, gen);
6273
+ }, true);
6274
+ }
6275
+ if (neighbours[1]) {
6276
+ const label = expandedSeatById.get(neighbours[1])?.label ?? neighbours[1];
6277
+ addButton("\u203A", `View next seat ${label}`, () => {
6278
+ void flyToSeat(neighbours[1]);
6279
+ });
6280
+ }
6281
+ if (!actions.childElementCount) return null;
6282
+ container.appendChild(controls);
6283
+ arriveChip = controls;
6284
+ layoutArriveChip();
6285
+ return controls;
6286
+ };
6287
+ const navigationModeChip = document.createElement("div");
6288
+ navigationModeChip.setAttribute("role", "group");
6289
+ navigationModeChip.setAttribute("aria-label", "3D navigation mode");
6290
+ Object.assign(navigationModeChip.style, {
6291
+ position: "absolute",
6292
+ left: "14px",
6293
+ top: "62px",
6294
+ display: "flex",
6295
+ gap: "3px",
6296
+ padding: "3px",
6297
+ borderRadius: "999px",
6298
+ zIndex: "4",
6299
+ background: "rgba(12,18,32,0.72)",
6300
+ border: "1px solid rgba(150,165,205,0.35)",
6301
+ backdropFilter: "blur(6px)"
6302
+ });
6303
+ const rotateModeButton = document.createElement("button");
6304
+ rotateModeButton.type = "button";
6305
+ rotateModeButton.textContent = "\u21BB Rotate";
6306
+ rotateModeButton.setAttribute("aria-label", "Drag to rotate the 3D venue");
6307
+ const moveModeButton = document.createElement("button");
6308
+ moveModeButton.type = "button";
6309
+ moveModeButton.textContent = "\u2725 Move";
6310
+ moveModeButton.setAttribute("aria-label", "Drag to move the 3D venue left, right, up or down");
6311
+ const zoomOutButton = document.createElement("button");
6312
+ zoomOutButton.type = "button";
6313
+ zoomOutButton.textContent = "\u2212";
6314
+ zoomOutButton.setAttribute("aria-label", "Zoom out of the 3D venue");
6315
+ zoomOutButton.title = "Zoom out";
6316
+ const zoomInButton = document.createElement("button");
6317
+ zoomInButton.type = "button";
6318
+ zoomInButton.textContent = "+";
6319
+ zoomInButton.setAttribute("aria-label", "Zoom into the 3D venue");
6320
+ zoomInButton.title = "Zoom in";
6321
+ for (const button of [rotateModeButton, moveModeButton, zoomOutButton, zoomInButton]) {
6322
+ Object.assign(button.style, {
6323
+ minHeight: "34px",
6324
+ padding: "6px 10px",
6325
+ border: "0",
6326
+ borderRadius: "999px",
6327
+ color: "#c9d4ea",
6328
+ background: "transparent",
6329
+ font: "600 11px/1 inherit",
6330
+ cursor: "pointer",
6331
+ whiteSpace: "nowrap"
5473
6332
  });
5474
- container.appendChild(chip);
5475
- arriveChip = chip;
6333
+ if (button === zoomOutButton || button === zoomInButton) {
6334
+ button.style.minWidth = "34px";
6335
+ button.style.padding = "6px";
6336
+ button.style.fontSize = "17px";
6337
+ }
6338
+ navigationModeChip.appendChild(button);
6339
+ }
6340
+ const setNavigationMode = (mode) => {
6341
+ orbit.setPrimaryDragMode(mode);
6342
+ const rotateActive = mode === "orbit";
6343
+ rotateModeButton.setAttribute("aria-pressed", String(rotateActive));
6344
+ moveModeButton.setAttribute("aria-pressed", String(!rotateActive));
6345
+ rotateModeButton.style.background = rotateActive ? "rgba(96,110,150,0.48)" : "transparent";
6346
+ moveModeButton.style.background = rotateActive ? "transparent" : "rgba(96,110,150,0.48)";
6347
+ navigationModeChip.title = rotateActive ? "Drag to rotate \xB7 Shift-drag to move" : "Drag to move \xB7 Shift-drag to rotate";
5476
6348
  };
6349
+ rotateModeButton.addEventListener("click", () => setNavigationMode("orbit"));
6350
+ moveModeButton.addEventListener("click", () => setNavigationMode("pan"));
6351
+ zoomOutButton.addEventListener("click", () => orbit.zoomBy(1.22));
6352
+ zoomInButton.addEventListener("click", () => orbit.zoomBy(0.82));
6353
+ setNavigationMode("orbit");
6354
+ container.appendChild(navigationModeChip);
6355
+ const layoutNavigationChip = () => {
6356
+ navigationModeChip.style.display = isNarrow() ? "none" : "flex";
6357
+ };
6358
+ layoutNavigationChip();
5477
6359
  const overviewChip = document.createElement("button");
5478
6360
  overviewChip.type = "button";
5479
6361
  overviewChip.textContent = "\u2302 Overview";
@@ -5493,17 +6375,46 @@ function mountVenue3D(container, input, opts = {}) {
5493
6375
  cursor: "pointer",
5494
6376
  zIndex: "4"
5495
6377
  });
5496
- overviewChip.addEventListener("click", () => {
5497
- if (disposed || frozen || panorama) return;
6378
+ const focusOverview = () => {
6379
+ if (disposed) return;
6380
+ if (panorama) {
6381
+ panorama.dispose();
6382
+ panorama = null;
6383
+ analytics.panoramaClosed();
6384
+ }
6385
+ panoramaLoadAbort?.abort();
6386
+ panoramaLoadAbort = null;
6387
+ restorePanoramaLayer();
6388
+ overviewChip.style.display = "";
6389
+ labelOverlay.setVisible(true);
6390
+ frozen = false;
5498
6391
  cancelFlight();
5499
6392
  removeArriveChip();
5500
- orbit.frameSoft(model.bounds, stageAzimuth);
6393
+ setNavigationMode("orbit");
6394
+ if (reducedMotion()) orbit.frame(model.bounds, false, stageAzimuth, opts.portraitOverviewCrop === true);
6395
+ else orbit.frameSoft(model.bounds, stageAzimuth, opts.portraitOverviewCrop === true);
6396
+ opts.onViewTargetChange?.(null);
6397
+ showSectionSeatLabels(null);
6398
+ opts.onSectionFocusChange?.(null);
5501
6399
  loop.requestRender();
5502
- });
6400
+ };
6401
+ overviewChip.addEventListener("click", focusOverview);
5503
6402
  container.appendChild(overviewChip);
5504
6403
  const openPanorama = async (seatId, fadeMs, gen) => {
6404
+ panoramaLoadAbort?.abort();
6405
+ const loadAbort = new AbortController();
6406
+ panoramaLoadAbort = loadAbort;
6407
+ const seatIndex = model.seats.idToIndex.get(seatId);
6408
+ if (seatIndex === void 0) {
6409
+ orbit.syncFromCamera();
6410
+ return;
6411
+ }
6412
+ const seatLabel = expandedSeatById.get(seatId)?.label ?? seatId;
6413
+ const seatPose = resolvedSeatViewPose(seatId, seatIndex);
5505
6414
  const viewPromise = ensureSeatView(seatId);
5506
6415
  if (!viewPromise) {
6416
+ loadAbort.abort();
6417
+ if (panoramaLoadAbort === loadAbort) panoramaLoadAbort = null;
5507
6418
  orbit.syncFromCamera();
5508
6419
  return;
5509
6420
  }
@@ -5513,34 +6424,56 @@ function mountVenue3D(container, input, opts = {}) {
5513
6424
  try {
5514
6425
  view = await viewPromise;
5515
6426
  } catch {
6427
+ if (panoramaLoadAbort === loadAbort) panoramaLoadAbort = null;
6428
+ analytics.panoramaFailed(seatId, "source");
5516
6429
  if (!disposed && gen === flightGen) {
5517
6430
  frozen = false;
5518
- orbit.resumeAfterFlight(model.focalWorld);
6431
+ orbit.resumeAfterFlight(seatPose.focal);
5519
6432
  loop.requestRender();
6433
+ const controls = showArriveChip(seatId, gen, true);
6434
+ requestAnimationFrame(() => controls?.querySelector("[data-panorama-trigger]")?.focus());
5520
6435
  }
5521
6436
  return;
5522
6437
  }
5523
- if (disposed || gen !== flightGen) return;
6438
+ if (disposed || gen !== flightGen) {
6439
+ loadAbort.abort();
6440
+ if (panoramaLoadAbort === loadAbort) panoramaLoadAbort = null;
6441
+ return;
6442
+ }
5524
6443
  removeArriveChip();
5525
6444
  const onClose = () => {
6445
+ loadAbort.abort();
6446
+ if (panoramaLoadAbort === loadAbort) panoramaLoadAbort = null;
5526
6447
  panorama = null;
6448
+ restorePanoramaLayer();
6449
+ overviewChip.style.display = "";
5527
6450
  labelOverlay.setVisible(true);
5528
6451
  frozen = false;
5529
6452
  analytics.panoramaClosed();
5530
- orbit.resumeAfterFlight(model.focalWorld);
6453
+ orbit.resumeAfterFlight(seatPose.focal);
5531
6454
  loop.requestRender();
5532
- showArriveChip(seatId, flightGen);
6455
+ const controls = showArriveChip(seatId, flightGen);
6456
+ requestAnimationFrame(() => controls?.querySelector("[data-panorama-trigger]")?.focus());
5533
6457
  };
5534
6458
  const sceneMode = view.generated === true;
6459
+ const disclosure = seatViewDisclosure(view);
6460
+ raisePanoramaLayer();
6461
+ overviewChip.style.display = "none";
6462
+ const effectiveFadeMs = reducedMotion() ? 0 : fadeMs;
5535
6463
  const spherical = gpu && !contextLost ? await mountPanoramaSphere(container, sceneMode ? null : view, {
5536
6464
  gl: glctx.gl,
5537
6465
  scene: gpu.main,
5538
6466
  camera: orbit.camera,
5539
6467
  requestRender: () => loop.requestRender(),
5540
- focalWorld: model.focalWorld
5541
- }, { fadeMs, seatLabel: seatId, onClose }) : null;
6468
+ cameraOriginWorld: sceneMode ? seatPose.eye : void 0,
6469
+ focalWorld: seatPose.focal
6470
+ }, { fadeMs: effectiveFadeMs, seatLabel, disclosure, onClose, signal: loadAbort.signal }) : null;
5542
6471
  if (disposed || gen !== flightGen) {
6472
+ loadAbort.abort();
6473
+ if (panoramaLoadAbort === loadAbort) panoramaLoadAbort = null;
5543
6474
  spherical?.dispose();
6475
+ overviewChip.style.display = "";
6476
+ restorePanoramaLayer();
5544
6477
  return;
5545
6478
  }
5546
6479
  if (spherical) {
@@ -5550,12 +6483,21 @@ function mountVenue3D(container, input, opts = {}) {
5550
6483
  loop.requestRender();
5551
6484
  panorama = spherical;
5552
6485
  } else {
5553
- panorama = mountPanorama(container, view, { fadeMs, seatLabel: seatId, onClose });
6486
+ analytics.panoramaFallback(seatId);
6487
+ panorama = mountPanorama(container, view, {
6488
+ fadeMs: effectiveFadeMs,
6489
+ seatLabel,
6490
+ disclosure,
6491
+ onClose,
6492
+ signal: loadAbort.signal
6493
+ });
5554
6494
  }
5555
6495
  analytics.panoramaOpened();
5556
6496
  };
5557
6497
  const cancelFlight = () => {
5558
6498
  flightGen++;
6499
+ panoramaLoadAbort?.abort();
6500
+ panoramaLoadAbort = null;
5559
6501
  if (cinematic.active) {
5560
6502
  cinematic.cancel();
5561
6503
  orbit.resumeAfterFlight(model.focalWorld);
@@ -5565,15 +6507,19 @@ function mountVenue3D(container, input, opts = {}) {
5565
6507
  if (disposed || !gpu) return Promise.resolve();
5566
6508
  const idx = model.seats.idToIndex.get(seatId);
5567
6509
  if (idx === void 0) return Promise.resolve();
6510
+ opts.onViewTargetChange?.(seatId);
5568
6511
  if (panorama) {
5569
6512
  panorama.dispose();
5570
6513
  panorama = null;
6514
+ overviewChip.style.display = "";
6515
+ restorePanoramaLayer();
5571
6516
  }
6517
+ panoramaLoadAbort?.abort();
6518
+ panoramaLoadAbort = null;
5572
6519
  frozen = false;
5573
6520
  const gen = ++flightGen;
5574
6521
  removeArriveChip();
5575
- const seatEye = seatEyeWorld(idx);
5576
- const focal = model.focalWorld;
6522
+ const { eye: seatEye, focal } = resolvedSeatViewPose(seatId, idx);
5577
6523
  const start = [orbit.camera.position.x, orbit.camera.position.y, orbit.camera.position.z];
5578
6524
  const { waypoints, finalPos } = buildWaypoints(start, seatEye, focal, model.bounds.center, model.bounds.radius);
5579
6525
  if (reducedMotion()) {
@@ -5590,11 +6536,26 @@ function mountVenue3D(container, input, opts = {}) {
5590
6536
  return cinematic.start(waypoints, startQuat, endQuat).then(() => {
5591
6537
  if (disposed || gen !== flightGen) return;
5592
6538
  analytics.cinematicPlayed(FLIGHT_DURATION_MS);
5593
- orbit.resumeAfterFlight(model.focalWorld);
6539
+ orbit.resumeAfterFlight(focal);
5594
6540
  loop.requestRender();
5595
6541
  showArriveChip(seatId, gen);
5596
6542
  });
5597
6543
  };
6544
+ const focusSectionCamera = (sectionId) => {
6545
+ const sec = model.sections.find((candidate) => candidate.id === sectionId);
6546
+ if (!sec || sec.seatCount === 0) return false;
6547
+ cinematic.cancel();
6548
+ removeArriveChip();
6549
+ setNavigationMode("pan");
6550
+ showSectionSeatLabels(sectionId);
6551
+ const dx = sec.focalWorld[0] - sec.center[0];
6552
+ const dz = sec.focalWorld[2] - sec.center[2];
6553
+ const azimuth = Math.hypot(dx, dz) > sec.radius * 0.12 ? Math.atan2(dx, dz) : void 0;
6554
+ orbit.frame({ center: sec.center, radius: Math.max(5, sec.radius * 0.5) }, false, azimuth);
6555
+ opts.onSectionFocusChange?.(sectionId);
6556
+ loop.requestRender();
6557
+ return true;
6558
+ };
5598
6559
  let downX = 0, downY = 0, downT = 0, downId = -1, moved = false, suppressTap = false;
5599
6560
  const onDown = (e) => {
5600
6561
  if (downId !== -1) return;
@@ -5613,6 +6574,50 @@ function mountVenue3D(container, input, opts = {}) {
5613
6574
  if (e.pointerId !== downId) return;
5614
6575
  if (Math.hypot(e.clientX - downX, e.clientY - downY) > TAP_SLOP) moved = true;
5615
6576
  };
6577
+ const pickNearestProjectedSeat = (clientX, clientY, sectionId, maxDistance, bookableOnly) => {
6578
+ const rect = glctx.canvas.getBoundingClientRect();
6579
+ const width = glctx.canvas.clientWidth || rect.width || 1;
6580
+ const height = glctx.canvas.clientHeight || rect.height || 1;
6581
+ const tapX = clientX - rect.left;
6582
+ const tapY = clientY - rect.top;
6583
+ const maxDistanceSq = maxDistance * maxDistance;
6584
+ let bestIndex = -1;
6585
+ let bestDistanceSq = maxDistanceSq;
6586
+ let bestDepth = Infinity;
6587
+ for (const seat of input.seats) {
6588
+ if (sectionId && seat.sectionId !== sectionId) continue;
6589
+ const index = model.seats.idToIndex.get(seat.id);
6590
+ if (index === void 0) continue;
6591
+ if (bookableOnly) {
6592
+ const state = Math.round(model.seats.iState[index] ?? 0);
6593
+ if (state !== 0 && state !== 3) continue;
6594
+ }
6595
+ const offset = index * 3;
6596
+ const screen = projectToScreen(
6597
+ orbit.camera.projectionViewMatrix,
6598
+ [
6599
+ model.seats.iPosition[offset],
6600
+ // Match the visible chair/number rather than the deck-level instance
6601
+ // origin. At strong zoom the vertical difference is dozens of pixels
6602
+ // and a perfectly reasonable click on the chair otherwise misses.
6603
+ model.seats.iPosition[offset + 1] + 0.55,
6604
+ model.seats.iPosition[offset + 2]
6605
+ ],
6606
+ width,
6607
+ height
6608
+ );
6609
+ if (!screen.visible) continue;
6610
+ const dx = screen.x - tapX;
6611
+ const dy = screen.y - tapY;
6612
+ const distanceSq = dx * dx + dy * dy;
6613
+ if (distanceSq < bestDistanceSq || Math.abs(distanceSq - bestDistanceSq) < 1 && screen.depth < bestDepth) {
6614
+ bestIndex = index;
6615
+ bestDistanceSq = distanceSq;
6616
+ bestDepth = screen.depth;
6617
+ }
6618
+ }
6619
+ return bestIndex;
6620
+ };
5616
6621
  const onUp = (e) => {
5617
6622
  if (e.pointerId !== downId) return;
5618
6623
  const isTap = !moved && performance.now() - downT < TAP_MS;
@@ -5622,12 +6627,25 @@ function mountVenue3D(container, input, opts = {}) {
5622
6627
  return;
5623
6628
  }
5624
6629
  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);
6630
+ let idx = focusedSectionId ? pickNearestProjectedSeat(e.clientX, e.clientY, focusedSectionId, 44, true) : -1;
6631
+ if (focusedSectionId && idx < 0) {
6632
+ const sectionIndex = pickNearestProjectedSeat(e.clientX, e.clientY, null, 72, false);
6633
+ const sectionSeatId = sectionIndex >= 0 ? seatIdByIndex[sectionIndex] : void 0;
6634
+ const nextSectionId = sectionSeatId ? sectionIdBySeatId.get(sectionSeatId) : void 0;
6635
+ if (nextSectionId && nextSectionId !== focusedSectionId && focusSectionCamera(nextSectionId)) return;
6636
+ }
6637
+ if (!focusedSectionId) {
6638
+ pick.syncFromSeatProgram(gpu.seatProgram);
6639
+ const rect = glctx.canvas.getBoundingClientRect();
6640
+ const dpr = glctx.renderer.dpr;
6641
+ const { x, y } = pickPixelCoords(e.clientX, e.clientY, rect, dpr, glctx.gl.drawingBufferWidth, glctx.gl.drawingBufferHeight);
6642
+ const radius = Math.max(2, Math.round(8 * dpr));
6643
+ idx = pick.pick(orbit.camera, x, y, radius);
6644
+ if (idx < 0) idx = pickNearestProjectedSeat(e.clientX, e.clientY, null, 42, false);
6645
+ const overviewSeatId = idx >= 0 ? seatIdByIndex[idx] : void 0;
6646
+ const sectionId = overviewSeatId ? sectionIdBySeatId.get(overviewSeatId) : void 0;
6647
+ if (sectionId && focusSectionCamera(sectionId)) return;
6648
+ }
5631
6649
  if (idx < 0 || idx >= seatIdByIndex.length) {
5632
6650
  if (selection.size) setSelection([]);
5633
6651
  return;
@@ -5653,9 +6671,12 @@ function mountVenue3D(container, input, opts = {}) {
5653
6671
  },
5654
6672
  setSelection,
5655
6673
  flyToSeat,
6674
+ focusOverview,
5656
6675
  resize() {
5657
6676
  const { width, height } = glctx.resize();
5658
6677
  orbit.setAspect(width / Math.max(1, height));
6678
+ layoutArriveChip();
6679
+ layoutNavigationChip();
5659
6680
  loop.requestRender();
5660
6681
  },
5661
6682
  stats() {
@@ -5680,6 +6701,7 @@ function mountVenue3D(container, input, opts = {}) {
5680
6701
  gpu.chairProgram.uniforms.uFocusFloor.value = value;
5681
6702
  }
5682
6703
  focusedFloor = value;
6704
+ setNavigationMode("orbit");
5683
6705
  if (index !== null) {
5684
6706
  const f = model.floors[index];
5685
6707
  if (f.seatCount > 0) {
@@ -5697,6 +6719,9 @@ function mountVenue3D(container, input, opts = {}) {
5697
6719
  const zone = model.zones.find((z) => z.id === zoneId);
5698
6720
  if (!zone || zone.seatCount === 0) return false;
5699
6721
  cinematic.cancel();
6722
+ setNavigationMode("orbit");
6723
+ showSectionSeatLabels(null);
6724
+ opts.onSectionFocusChange?.(null);
5700
6725
  const dx = zone.focalWorld[0] - zone.center[0];
5701
6726
  const dz = zone.focalWorld[2] - zone.center[2];
5702
6727
  const azimuth = Math.hypot(dx, dz) > zone.radius * 0.12 ? Math.atan2(dx, dz) : void 0;
@@ -5708,13 +6733,24 @@ function mountVenue3D(container, input, opts = {}) {
5708
6733
  return model.sections;
5709
6734
  },
5710
6735
  focusSection(sectionId) {
5711
- const sec = model.sections.find((s) => s.id === sectionId);
5712
- if (!sec || sec.seatCount === 0) return false;
6736
+ return focusSectionCamera(sectionId);
6737
+ },
6738
+ rows(sectionId) {
6739
+ return sectionId ? model.rows.filter((row) => row.sectionId === sectionId) : model.rows;
6740
+ },
6741
+ seatsInRow(rowId) {
6742
+ return input.seats.filter((seat) => seat.rowId === rowId).map((seat) => ({ id: seat.id, label: seat.displayLabel || seat.label }));
6743
+ },
6744
+ focusRow(rowId) {
6745
+ const row = model.rows.find((candidate) => candidate.id === rowId);
6746
+ if (!row || row.seatCount === 0) return false;
5713
6747
  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);
6748
+ setNavigationMode("pan");
6749
+ if (row.sectionId) {
6750
+ showSectionSeatLabels(row.sectionId);
6751
+ opts.onSectionFocusChange?.(row.sectionId);
6752
+ }
6753
+ orbit.frame({ center: row.center, radius: Math.max(2.5, row.radius * 0.65) });
5718
6754
  loop.requestRender();
5719
6755
  return true;
5720
6756
  },
@@ -5725,6 +6761,7 @@ function mountVenue3D(container, input, opts = {}) {
5725
6761
  disposed = true;
5726
6762
  removeArriveChip();
5727
6763
  overviewChip.remove();
6764
+ navigationModeChip.remove();
5728
6765
  cancelFlight();
5729
6766
  loop.stop();
5730
6767
  labelOverlay.dispose();
@@ -5733,6 +6770,8 @@ function mountVenue3D(container, input, opts = {}) {
5733
6770
  panorama.dispose();
5734
6771
  panorama = null;
5735
6772
  }
6773
+ restorePanoramaLayer();
6774
+ restoreContainerPosition();
5736
6775
  glctx.canvas.removeEventListener("pointerdown", onDown);
5737
6776
  glctx.canvas.removeEventListener("pointermove", onMove);
5738
6777
  glctx.canvas.removeEventListener("pointerup", onUp);
@@ -5745,12 +6784,21 @@ function mountVenue3D(container, input, opts = {}) {
5745
6784
  glctx.dispose();
5746
6785
  }
5747
6786
  };
5748
- analytics.opened(model.seatCount, hasHeights);
6787
+ analytics.opened(
6788
+ model.seatCount,
6789
+ hasHeights,
6790
+ input.prepared?.buildMs ?? performance.now() - buildStartedAt,
6791
+ // Known synchronously at mount: either the caller handed us a scene
6792
+ // prepared by `prepareVenue3D` (which reports worker vs. main-thread
6793
+ // fallback) or we compiled it inline here. Never delays the event.
6794
+ input.prepared?.source ?? "inline"
6795
+ );
5749
6796
  return handle;
5750
6797
  }
5751
6798
  // Annotate the CommonJS export names for ESM import in node:
5752
6799
  0 && (module.exports = {
5753
6800
  buildSceneModel,
5754
- mountVenue3D
6801
+ mountVenue3D,
6802
+ prepareVenue3D
5755
6803
  });
5756
6804
  //# sourceMappingURL=index.cjs.map