@seatlayer/core 0.25.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -4,6 +4,7 @@ var ACCESSIBILITY_TYPES = [
4
4
  { key: "companion", label: "Companion seat", short: "Companion", icon: "\u{1F9D1}\u200D\u{1F91D}\u200D\u{1F9D1}" },
5
5
  { key: "semi-ambulatory", label: "Semi-ambulatory (limited mobility)", short: "Limited mobility", icon: "\u{1F9AF}" },
6
6
  { key: "hearing", label: "Assistive listening", short: "Hearing", icon: "\u{1F9BB}" },
7
+ { key: "cart", label: "CART live-caption view", short: "CART captions", icon: "CC" },
7
8
  { key: "sign-language", label: "Sign-language view", short: "Sign language", icon: "\u{1F91F}" },
8
9
  { key: "plus-size", label: "Plus-size seat", short: "Plus-size", icon: "\u{1F4BA}" },
9
10
  { key: "lift-armrest", label: "Lift-up armrest", short: "Lift armrest", icon: "\u2195\uFE0F" }
@@ -17,6 +18,7 @@ var ACCESSIBILITY_RING_COLOR = {
17
18
  companion: "#8b5cf6",
18
19
  "semi-ambulatory": "#0ea5e9",
19
20
  hearing: "#14b8a6",
21
+ cart: "#7c3aed",
20
22
  "sign-language": "#f59e0b",
21
23
  "plus-size": "#ec4899",
22
24
  "lift-armrest": "#22c55e"
@@ -27,6 +29,19 @@ function accessibilityRingColor(types) {
27
29
  }
28
30
  var LABEL_STYLE_MIN_SIZE = 8;
29
31
  var LABEL_STYLE_MAX_SIZE = 24;
32
+ var SURROUNDINGS_SHAPE_ROLES = [
33
+ "reference-focal",
34
+ "bar",
35
+ "entrance",
36
+ "exit",
37
+ "restroom",
38
+ "screen",
39
+ "sound",
40
+ "concession",
41
+ "coat",
42
+ "wall"
43
+ ];
44
+ var SURROUNDINGS_SHAPE_ROLE_SET = new Set(SURROUNDINGS_SHAPE_ROLES);
30
45
  function layerOf(obj) {
31
46
  switch (obj.type) {
32
47
  case "row":
@@ -35,10 +50,17 @@ function layerOf(obj) {
35
50
  case "booth":
36
51
  case "section":
37
52
  return "interactive";
38
- // stage / décor live on 'shape'; free text + decor images are background furniture.
53
+ // Raster décor follows its explicit authored z-layer. Absence retains the
54
+ // legacy Background default; bitmap content is never guessed semantically.
55
+ case "decorImage":
56
+ return obj.layer === "foreground" ? "foreground" : "background";
57
+ // Source-backed focal geometry and curated venue landmarks help an author
58
+ // orient around the sellable plan. A stage and an ordinary authored shape
59
+ // remain Background even when they carry an arbitrary/custom role.
39
60
  case "shape":
61
+ return obj.role && SURROUNDINGS_SHAPE_ROLE_SET.has(obj.role) ? "surroundings" : "background";
62
+ // Free text — including semantic icon text — is background furniture.
40
63
  case "text":
41
- case "decorImage":
42
64
  return "background";
43
65
  default:
44
66
  return "interactive";
@@ -158,10 +180,57 @@ function toRoman(value) {
158
180
  return out;
159
181
  }
160
182
 
183
+ // src/core/units.ts
184
+ var METRES_PER_CHART_UNIT = 0.55 / 24;
185
+ var CHART_UNITS_PER_METRE = 1 / METRES_PER_CHART_UNIT;
186
+ var LIFT_PER_STEP_WORLD = 58;
187
+ var TIER_HEIGHT_M = LIFT_PER_STEP_WORLD * METRES_PER_CHART_UNIT;
188
+ var SECTION_ELEVATION_TIER_MIN = 0;
189
+ var SECTION_ELEVATION_TIER_MAX = 3;
190
+ var SECTION_HEIGHT_MIN_M = 0;
191
+ var SECTION_HEIGHT_MAX_M = 120;
192
+ var SECTION_RAKE_MIN_DEG = 0;
193
+ var SECTION_RAKE_MAX_DEG = 45;
194
+ var LEGACY_SECTION_ELEVATION_TIER_MAX = 7;
195
+ var SEATED_EYE_HEIGHT_M = 1.2;
196
+ function finiteClamped(value, min, max, fallback) {
197
+ return Number.isFinite(value) ? Math.max(min, Math.min(max, value)) : fallback;
198
+ }
199
+ function sectionElevationTier(value) {
200
+ if (!Number.isFinite(value)) return SECTION_ELEVATION_TIER_MIN;
201
+ return Math.max(
202
+ SECTION_ELEVATION_TIER_MIN,
203
+ Math.min(SECTION_ELEVATION_TIER_MAX, Math.round(value))
204
+ );
205
+ }
206
+ function compatibleAutomaticTier(value) {
207
+ if (!Number.isFinite(value) || !Number.isInteger(value) || value < 0) return 0;
208
+ if (value <= LEGACY_SECTION_ELEVATION_TIER_MAX) return value;
209
+ return SECTION_ELEVATION_TIER_MAX;
210
+ }
211
+ function sectionGeometry(section, context = {}) {
212
+ const floorBaseHeightM = finiteClamped(
213
+ context.floorBaseHeightM,
214
+ SECTION_HEIGHT_MIN_M,
215
+ SECTION_HEIGHT_MAX_M,
216
+ 0
217
+ );
218
+ const automaticHeight = Math.min(
219
+ SECTION_HEIGHT_MAX_M,
220
+ floorBaseHeightM + compatibleAutomaticTier(section.elevation) * TIER_HEIGHT_M
221
+ );
222
+ const height = section.height === void 0 ? automaticHeight : finiteClamped(section.height, SECTION_HEIGHT_MIN_M, SECTION_HEIGHT_MAX_M, automaticHeight);
223
+ const rake = finiteClamped(section.rake, SECTION_RAKE_MIN_DEG, SECTION_RAKE_MAX_DEG, 0);
224
+ return { height, rake };
225
+ }
226
+
161
227
  // src/core/layout.ts
162
228
  function overrideAccessibility(o) {
163
229
  if (!o) return [];
164
- if (o.accessibility && o.accessibility.length) return o.accessibility;
230
+ if (o.accessibility && o.accessibility.length) {
231
+ return o.wheelchairSpaceType && !o.accessibility.includes("wheelchair") ? ["wheelchair", ...o.accessibility] : o.accessibility;
232
+ }
233
+ if (o.wheelchairSpaceType) return ["wheelchair"];
165
234
  return o.accessible ? ["wheelchair"] : [];
166
235
  }
167
236
  var DEG = Math.PI / 180;
@@ -203,6 +272,10 @@ function overrideMap(row) {
203
272
  if (row.overrides) for (const o of row.overrides) m.set(o.index, o);
204
273
  return m;
205
274
  }
275
+ function rowInventoryCount(row) {
276
+ const skipped = new Set((row.overrides ?? []).filter((override) => override.skip && Number.isInteger(override.index) && override.index >= 0 && override.index < row.seatCount).map((override) => override.index));
277
+ return Math.max(0, row.seatCount - skipped.size);
278
+ }
206
279
  function centerRank(n) {
207
280
  const rank = new Array(n);
208
281
  Array.from({ length: n }, (_, i) => i).sort((a, b) => Math.abs(2 * a - (n - 1)) - Math.abs(2 * b - (n - 1)) || a - b).forEach((idx, k) => rank[idx] = k);
@@ -216,9 +289,9 @@ function seatLabelPart(row, i) {
216
289
  const prefix = row.seatNumbering?.prefix ?? "";
217
290
  const endAt = row.seatNumbering?.endAt;
218
291
  const n = row.seatCount;
219
- if (scheme === "updown") {
292
+ if (scheme === "updown" || scheme === "updown-descending") {
220
293
  const half = Math.ceil(n / 2);
221
- const core2 = i < half ? rawStart + 2 * i : rawStart - 1 + 2 * (n - i);
294
+ const core2 = scheme === "updown" ? i < half ? rawStart + 2 * i : rawStart - 1 + 2 * (n - i) : i < half ? rawStart + 2 * (half - 1 - i) : rawStart + 1 + 2 * (i - half);
222
295
  return `${prefix}${core2}`;
223
296
  }
224
297
  const effStep = scheme === "odd" || scheme === "even" ? 2 : step;
@@ -271,6 +344,7 @@ function expandRowSlots(row) {
271
344
  skipped: !!o?.skip,
272
345
  accessible: accessibility.length > 0,
273
346
  accessibility,
347
+ wheelchairSpaceType: o?.wheelchairSpaceType,
274
348
  commercial: Object.values(commercial).some((value) => value !== void 0 && value !== false && value !== "") ? commercial : void 0,
275
349
  viewUrl: o?.viewFromSeatUrl ?? row.viewFromSeatUrl,
276
350
  labelStyle: o?.labelStyle
@@ -291,6 +365,7 @@ function expandRow(row) {
291
365
  categoryKey: slot.categoryKey,
292
366
  accessible: slot.accessible || void 0,
293
367
  accessibility: slot.accessibility.length ? slot.accessibility : void 0,
368
+ wheelchairSpaceType: slot.wheelchairSpaceType,
294
369
  commercial: slot.commercial,
295
370
  viewUrl: slot.viewUrl,
296
371
  labelStyle: slot.labelStyle
@@ -298,18 +373,43 @@ function expandRow(row) {
298
373
  }
299
374
  return seats;
300
375
  }
301
- function expandTable(t2) {
376
+ function tableSeatCountsBySide(t2) {
377
+ if (t2.seatCountsBySide) return { ...t2.seatCountsBySide };
378
+ const enabled = t2.sides && t2.sides.length ? t2.sides : ["top", "bottom"];
379
+ const order = ["top", "bottom", "left", "right"].filter((side) => enabled.includes(side));
380
+ const counts = { top: 0, bottom: 0, left: 0, right: 0 };
381
+ if (!order.length) return counts;
382
+ const n = Math.max(0, Math.round(t2.seatCount));
383
+ for (let index = 0; index < n; index++) counts[order[index % order.length]] += 1;
384
+ return counts;
385
+ }
386
+ function expandTableSlots(t2) {
302
387
  const seats = [];
303
388
  const n = Math.max(0, Math.round(t2.seatCount));
304
389
  if (n === 0) return seats;
305
- const mk = (i, x, y) => ({
306
- id: `${t2.id}:${i}`,
307
- label: `${t2.label}-${i + 1}`,
308
- x,
309
- y,
310
- rowId: t2.id,
311
- categoryKey: t2.categoryKey
312
- });
390
+ const overrides2 = new Map((t2.overrides ?? []).map((override) => [override.index, override]));
391
+ const mk = (index, x, y, side) => {
392
+ const override = overrides2.get(index);
393
+ const accessibility = overrideAccessibility(override);
394
+ const label = override?.label ?? `${t2.label}-${index + 1}`;
395
+ const displayPrefix = t2.displayLabel ?? t2.label;
396
+ return {
397
+ index,
398
+ label,
399
+ displayLabel: override?.displayLabel ?? `${displayPrefix}-${index + 1}`,
400
+ x: x + (override?.dx ?? 0),
401
+ y: y + (override?.dy ?? 0),
402
+ categoryKey: override?.categoryKey ?? t2.categoryKey,
403
+ skipped: !!override?.skip,
404
+ accessible: accessibility.length > 0,
405
+ accessibility,
406
+ wheelchairSpaceType: override?.wheelchairSpaceType,
407
+ commercial: override?.commercial,
408
+ viewUrl: override?.viewFromSeatUrl,
409
+ labelStyle: override?.labelStyle,
410
+ ...side ? { side } : {}
411
+ };
412
+ };
313
413
  if (t2.shape === "round") {
314
414
  const R = (t2.radius ?? 40) + TABLE_SEAT_OFFSET;
315
415
  const base = t2.rotation * DEG;
@@ -332,17 +432,11 @@ function expandTable(t2) {
332
432
  }
333
433
  const w = t2.width ?? 80;
334
434
  const h = t2.height ?? 50;
335
- const enabled = t2.sides && t2.sides.length ? t2.sides : ["top", "bottom"];
336
- const order = ["top", "bottom", "left", "right"].filter((s) => enabled.includes(s));
337
- if (!order.length) return seats;
338
- const counts = new Map(order.map((s) => [s, 0]));
339
- for (let i = 0; i < n; i++) {
340
- const s = order[i % order.length];
341
- counts.set(s, counts.get(s) + 1);
342
- }
435
+ const counts = tableSeatCountsBySide(t2);
436
+ const order = ["top", "bottom", "left", "right"];
343
437
  let idx = 0;
344
438
  for (const side of order) {
345
- const count = counts.get(side);
439
+ const count = counts[side];
346
440
  for (let j = 0; j < count; j++) {
347
441
  let localX;
348
442
  let localY;
@@ -359,17 +453,39 @@ function expandTable(t2) {
359
453
  localX = w / 2 + TABLE_SEAT_OFFSET;
360
454
  localY = -h / 2 + (j + 0.5) * h / count;
361
455
  }
362
- const p = place(localX, localY, t2.rotation, t2.center);
363
- seats.push(mk(idx++, p.x, p.y));
456
+ const point = place(localX, localY, t2.rotation, t2.center);
457
+ seats.push(mk(idx++, point.x, point.y, side));
364
458
  }
365
459
  }
366
460
  return seats;
367
461
  }
462
+ function tableInventoryCount(t2) {
463
+ if (t2.bookAsWhole || t2.variableOccupancy) return Math.max(0, Math.round(t2.seatCount));
464
+ return expandTableSlots(t2).filter((slot) => !slot.skipped).length;
465
+ }
466
+ function expandTable(t2) {
467
+ return expandTableSlots(t2).filter((slot) => !slot.skipped).map((slot) => ({
468
+ id: `${t2.id}:${slot.index}`,
469
+ label: slot.label,
470
+ ...slot.displayLabel !== slot.label ? { displayLabel: slot.displayLabel } : {},
471
+ x: slot.x,
472
+ y: slot.y,
473
+ rowId: t2.id,
474
+ categoryKey: slot.categoryKey,
475
+ ...slot.accessible ? { accessible: true } : {},
476
+ ...slot.accessibility.length ? { accessibility: slot.accessibility } : {},
477
+ ...slot.wheelchairSpaceType ? { wheelchairSpaceType: slot.wheelchairSpaceType } : {},
478
+ ...slot.commercial ? { commercial: slot.commercial } : {},
479
+ ...slot.viewUrl ? { viewUrl: slot.viewUrl } : {},
480
+ ...slot.labelStyle ? { labelStyle: slot.labelStyle } : {}
481
+ }));
482
+ }
368
483
  function expandBooth(b) {
369
484
  return [
370
485
  {
371
486
  id: `${b.id}:0`,
372
487
  label: b.label,
488
+ ...b.displayLabel && b.displayLabel !== b.label ? { displayLabel: b.displayLabel } : {},
373
489
  x: b.center.x,
374
490
  y: b.center.y,
375
491
  rowId: b.id,
@@ -407,7 +523,7 @@ function polygonLabelPoint(outer, holes) {
407
523
  if (!outer.length) return { x: 0, y: 0 };
408
524
  const xs = outer.map((point) => point.x);
409
525
  const ys = outer.map((point) => point.y);
410
- const bounds = { minX: Math.min(...xs), maxX: Math.max(...xs), minY: Math.min(...ys), maxY: Math.max(...ys) };
526
+ const bounds2 = { minX: Math.min(...xs), maxX: Math.max(...xs), minY: Math.min(...ys), maxY: Math.max(...ys) };
411
527
  const centroid2 = polygonCentroid(outer);
412
528
  if (pointInPolygonWithHoles(centroid2, outer, holes)) return centroid2;
413
529
  let best = outer[0];
@@ -416,8 +532,8 @@ function polygonLabelPoint(outer, holes) {
416
532
  for (let row = 1; row < 24; row += 1) {
417
533
  for (let column = 1; column < 24; column += 1) {
418
534
  const point = {
419
- x: bounds.minX + (bounds.maxX - bounds.minX) * column / 24,
420
- y: bounds.minY + (bounds.maxY - bounds.minY) * row / 24
535
+ x: bounds2.minX + (bounds2.maxX - bounds2.minX) * column / 24,
536
+ y: bounds2.minY + (bounds2.maxY - bounds2.minY) * row / 24
421
537
  };
422
538
  if (!pointInPolygonWithHoles(point, outer, holes)) continue;
423
539
  const score = Math.min(...rings.flatMap((ring) => ring.map((start, index) => {
@@ -479,9 +595,32 @@ function objectCenter(o) {
479
595
  return { x: o.x + o.width / 2, y: o.y + o.height / 2 };
480
596
  }
481
597
  }
598
+ function samePoints(left, right) {
599
+ return left.length === right.length && left.every((point, index) => point.x === right[index].x && point.y === right[index].y);
600
+ }
601
+ function sameGASurfaceAsSection(object, section) {
602
+ if (object.type !== "gaArea" || !samePoints(object.points, section.outline)) return false;
603
+ const objectHoles = object.holes ?? [];
604
+ const sectionHoles = section.holes ?? [];
605
+ return objectHoles.length === sectionHoles.length && objectHoles.every((hole, index) => samePoints(hole, sectionHoles[index]));
606
+ }
607
+ function owningSectionForObject(objects, object) {
608
+ const sections = objects.filter((candidate) => candidate.type === "section");
609
+ const referencedLogicalId = "referenceInventorySource" in object ? object.referenceInventorySource?.logicalSectionId : void 0;
610
+ const center = objectCenter(object);
611
+ const referencedOwner = referencedLogicalId ? sections.find((section) => (section.logicalSectionId ?? section.id) === referencedLogicalId && (sameGASurfaceAsSection(object, section) || pointInPolygonWithHoles(center, section.outline, section.holes))) : void 0;
612
+ return referencedOwner ?? sections.find((section) => pointInPolygonWithHoles(center, section.outline, section.holes));
613
+ }
482
614
  function floorsOf(doc) {
483
615
  if (doc.floors && doc.floors.length) return doc.floors;
484
- return [{ id: "floor-0", name: "Main", objects: doc.objects, focalPoint: doc.focalPoint, backgroundImage: doc.backgroundImage }];
616
+ return [{
617
+ id: "floor-0",
618
+ name: "Main",
619
+ objects: doc.objects,
620
+ focalPoint: doc.focalPoint,
621
+ referenceImage: doc.referenceImage,
622
+ backgroundImage: doc.backgroundImage
623
+ }];
485
624
  }
486
625
  function floorObjects(doc, floorId) {
487
626
  const floors = floorsOf(doc);
@@ -530,15 +669,138 @@ function stackFloors(doc, spread = 900) {
530
669
  });
531
670
  return { ...doc, objects, floors: void 0 };
532
671
  }
533
- function expandChart(doc) {
672
+ function expandFloorObjects(objects, zones, fallbackFocal) {
534
673
  const out = [];
535
- for (const obj of allObjects(doc)) {
536
- if (obj.type === "row") out.push(...expandRow(obj));
537
- else if (obj.type === "table") out.push(...expandTable(obj));
538
- else if (obj.type === "booth") out.push(...expandBooth(obj));
674
+ const segmented = /* @__PURE__ */ new Map();
675
+ const grouped = /* @__PURE__ */ new Map();
676
+ for (const object of objects) {
677
+ if (object.type !== "row" || !object.segmentedRow) continue;
678
+ const list = grouped.get(object.segmentedRow.groupId) ?? [];
679
+ list.push(object);
680
+ grouped.set(object.segmentedRow.groupId, list);
681
+ }
682
+ for (const [groupId, members] of grouped) {
683
+ const ordered = members.slice().sort((left, right) => left.segmentedRow.componentIndex - right.segmentedRow.componentIndex);
684
+ const expectedCount = ordered[0]?.segmentedRow?.componentCount ?? 0;
685
+ const first = ordered[0]?.segmentedRow;
686
+ if (!first) continue;
687
+ const valid = expectedCount >= 2 && ordered.length === expectedCount && first?.boundaryBefore === "start" && ordered.every((row, index) => row.segmentedRow?.kind === "segmented-row-v1" && row.segmentedRow.groupId === groupId && row.segmentedRow.componentCount === expectedCount && row.segmentedRow.componentIndex === index && (index === 0 ? row.segmentedRow.boundaryBefore === "start" : row.segmentedRow.boundaryBefore !== "start") && row.segmentedRow.displayLabel === first.displayLabel);
688
+ if (!valid) continue;
689
+ const totalSeats = ordered.reduce((sum, row) => sum + row.seatCount, 0);
690
+ let adjacencyOffset = 0;
691
+ let displayOffset = 0;
692
+ for (const row of ordered) {
693
+ if (row.segmentedRow.boundaryBefore === "break") adjacencyOffset += 1;
694
+ segmented.set(row.id, {
695
+ groupId,
696
+ adjacencyOffset,
697
+ displayOffset,
698
+ displayLabel: first.displayLabel,
699
+ totalSeats,
700
+ canonical: ordered[0],
701
+ viewFromSeatUrl: first.viewFromSeatUrl
702
+ });
703
+ adjacencyOffset += row.seatCount;
704
+ displayOffset += row.seatCount;
705
+ }
706
+ }
707
+ for (const obj of objects) {
708
+ let seats = [];
709
+ if (obj.type === "row") seats = expandRow(obj);
710
+ else if (obj.type === "table") seats = expandTable(obj);
711
+ else if (obj.type === "booth") seats = expandBooth(obj);
712
+ if (!seats.length) continue;
713
+ if (obj.type === "row") {
714
+ const logical = segmented.get(obj.id);
715
+ if (logical) {
716
+ const overrides2 = new Map((obj.overrides ?? []).map((override) => [override.index, override]));
717
+ for (const seat of seats) {
718
+ const physicalIndex = Number(seat.id.slice(seat.id.lastIndexOf(":") + 1));
719
+ if (!Number.isInteger(physicalIndex)) continue;
720
+ const displayOrdinal = logical.displayOffset + physicalIndex;
721
+ seat.logicalRowId = logical.groupId;
722
+ seat.logicalSeatIndex = logical.adjacencyOffset + physicalIndex;
723
+ if (!overrides2.get(physicalIndex)?.displayLabel) {
724
+ const numberingRow = {
725
+ ...logical.canonical,
726
+ seatCount: logical.totalSeats,
727
+ label: logical.displayLabel,
728
+ displayLabel: logical.displayLabel
729
+ };
730
+ seat.displayLabel = `${logical.displayLabel}-${seatLabelPart(numberingRow, displayOrdinal)}`;
731
+ }
732
+ seat.viewUrl ??= logical.viewFromSeatUrl;
733
+ }
734
+ }
735
+ }
736
+ const owner = owningSectionForObject(objects, obj);
737
+ const inheritedView = owner?.viewFromSeatUrl;
738
+ const zone = owner?.zone ? zones?.find((candidate) => candidate.id === owner.zone) : void 0;
739
+ const resolvedFocal = zone?.focalPoint ?? fallbackFocal;
740
+ for (const seat of seats) {
741
+ if (inheritedView) seat.viewUrl ??= inheritedView;
742
+ if (owner) seat.sectionId = owner.logicalSectionId ?? owner.id;
743
+ if (owner?.zone) seat.zoneId = owner.zone;
744
+ if (resolvedFocal) seat.focalPoint = { ...resolvedFocal };
745
+ }
746
+ out.push(...seats);
539
747
  }
540
748
  return out;
541
749
  }
750
+ function expandChart(doc, options = {}) {
751
+ if (doc.floors?.length) {
752
+ const out2 = [];
753
+ for (const floor of doc.floors) {
754
+ const floorFocal = floor.focalPoint ?? doc.focalPoint;
755
+ const seats = expandFloorObjects(floor.objects, doc.zones, floorFocal);
756
+ assignEyeHeights(floor.objects, floor.focalPoint ?? doc.focalPoint, floor.baseHeightM ?? 0, seats);
757
+ out2.push(...seats);
758
+ }
759
+ return out2;
760
+ }
761
+ const out = expandFloorObjects(doc.objects, doc.zones, doc.focalPoint);
762
+ assignEyeHeights(doc.objects, doc.focalPoint, options.floorBaseHeightM ?? 0, out);
763
+ return out;
764
+ }
765
+ function assignEyeHeights(objects, focal, floorBaseHeightM, seats) {
766
+ const sections = objects.filter((o) => o.type === "section");
767
+ const hasGeometry = floorBaseHeightM > 0 || sections.some((s) => s.height !== void 0 || s.rake !== void 0 || (s.elevation ?? 0) > 0);
768
+ if (!sections.length || !hasGeometry || !focal) {
769
+ for (const seat of seats) seat.eyeHeightM = floorBaseHeightM + SEATED_EYE_HEIGHT_M;
770
+ return;
771
+ }
772
+ const owner = new Array(seats.length);
773
+ const geo = /* @__PURE__ */ new Map();
774
+ const frontDistU = /* @__PURE__ */ new Map();
775
+ for (let i = 0; i < seats.length; i++) {
776
+ const seat = seats[i];
777
+ const sec = sections.find((s) => pointInPolygonWithHoles({ x: seat.x, y: seat.y }, s.outline, s.holes)) ?? null;
778
+ owner[i] = sec;
779
+ if (!sec) continue;
780
+ if (!geo.has(sec.id)) geo.set(sec.id, sectionGeometry(sec, { floorBaseHeightM }));
781
+ const seatFocal = seat.focalPoint ?? focal;
782
+ const d = Math.hypot(seat.x - seatFocal.x, seat.y - seatFocal.y);
783
+ const cur = frontDistU.get(sec.id);
784
+ if (cur === void 0 || d < cur) frontDistU.set(sec.id, d);
785
+ }
786
+ for (let i = 0; i < seats.length; i++) {
787
+ const seat = seats[i];
788
+ const sec = owner[i];
789
+ if (!sec) {
790
+ seat.eyeHeightM = floorBaseHeightM + SEATED_EYE_HEIGHT_M;
791
+ continue;
792
+ }
793
+ const g = geo.get(sec.id);
794
+ let riseM = 0;
795
+ if (g.rake > 0) {
796
+ const seatFocal = seat.focalPoint ?? focal;
797
+ const d = Math.hypot(seat.x - seatFocal.x, seat.y - seatFocal.y);
798
+ const depthU = Math.max(0, d - (frontDistU.get(sec.id) ?? d));
799
+ riseM = depthU * METRES_PER_CHART_UNIT * Math.tan(g.rake * Math.PI / 180);
800
+ }
801
+ seat.eyeHeightM = g.height + riseM + SEATED_EYE_HEIGHT_M;
802
+ }
803
+ }
542
804
  var PAD = 40;
543
805
  function chartBounds(doc) {
544
806
  let minX = Infinity;
@@ -582,8 +844,9 @@ function chartBounds(doc) {
582
844
  acc(obj.position.x + w, obj.position.y + obj.fontSize);
583
845
  }
584
846
  }
585
- if (doc.backgroundImage) {
586
- const { center, width } = doc.backgroundImage;
847
+ for (const image of [doc.referenceImage, doc.backgroundImage]) {
848
+ if (!image) continue;
849
+ const { center, width } = image;
587
850
  const bh = width * 3 / 4;
588
851
  acc(center.x - width / 2, center.y - bh / 2);
589
852
  acc(center.x + width / 2, center.y + bh / 2);
@@ -607,9 +870,55 @@ var MAX_EVENT_INVENTORY = 1e5;
607
870
  function gaUnitLabel(areaId, index) {
608
871
  return `${PREFIX}${encodeURIComponent(areaId)}__${index + 1}`;
609
872
  }
610
- function gaUnitLabels(area) {
873
+ function validGAInventorySegments(area) {
874
+ const segments = area.inventorySegments;
875
+ if (segments === void 0) return true;
876
+ if (!Array.isArray(segments) || segments.length < 1 || segments.length > 256) return false;
877
+ let total = 0;
878
+ const ranges = /* @__PURE__ */ new Map();
879
+ for (const segment of segments) {
880
+ if (!segment || typeof segment.sourceAreaId !== "string" || !segment.sourceAreaId.trim() || segment.sourceAreaId.length > 160 || !Number.isInteger(segment.startIndex) || segment.startIndex < 0 || !Number.isInteger(segment.count) || segment.count < 1 || segment.startIndex + segment.count > MAX_GA_CAPACITY) return false;
881
+ total += segment.count;
882
+ if (total > MAX_GA_CAPACITY) return false;
883
+ const sourceRanges = ranges.get(segment.sourceAreaId) ?? [];
884
+ const end = segment.startIndex + segment.count;
885
+ if (sourceRanges.some((range) => segment.startIndex < range.end && end > range.start)) return false;
886
+ sourceRanges.push({ start: segment.startIndex, end });
887
+ ranges.set(segment.sourceAreaId, sourceRanges);
888
+ }
889
+ return total === area.capacity;
890
+ }
891
+ function gaInventorySegments(area) {
611
892
  const capacity = Math.min(MAX_GA_CAPACITY, Math.max(0, Math.floor(area.capacity)));
612
- return Array.from({ length: capacity }, (_, i) => gaUnitLabel(area.id, i));
893
+ if (area.inventorySegments === void 0) {
894
+ return capacity ? [{ sourceAreaId: area.id, startIndex: 0, count: capacity }] : [];
895
+ }
896
+ if (!validGAInventorySegments(area)) return [];
897
+ return area.inventorySegments.map((segment) => ({ ...segment }));
898
+ }
899
+ function gaUnitLabels(area) {
900
+ return gaInventorySegments(area).flatMap((segment) => Array.from(
901
+ { length: segment.count },
902
+ (_, offset) => gaUnitLabel(segment.sourceAreaId, segment.startIndex + offset)
903
+ ));
904
+ }
905
+ function growJoinedGAInventory(area, nextCapacity) {
906
+ if (area.inventorySegments === void 0 || nextCapacity === area.capacity) return area.inventorySegments;
907
+ if (nextCapacity < area.capacity) return void 0;
908
+ const segments = gaInventorySegments(area);
909
+ if (!segments.length) return void 0;
910
+ const extra = nextCapacity - area.capacity;
911
+ if (!extra) return segments;
912
+ const ownRanges = segments.filter((segment) => segment.sourceAreaId === area.id);
913
+ const startIndex = ownRanges.reduce((max, segment) => Math.max(max, segment.startIndex + segment.count), 0);
914
+ const last = segments[segments.length - 1];
915
+ if (last?.sourceAreaId === area.id && last.startIndex + last.count === startIndex) {
916
+ last.count += extra;
917
+ } else {
918
+ if (segments.length >= 256) return void 0;
919
+ segments.push({ sourceAreaId: area.id, startIndex, count: extra });
920
+ }
921
+ return segments;
613
922
  }
614
923
  function gaAreasOf(doc) {
615
924
  return allObjects(doc).filter((o) => o.type === "gaArea");
@@ -630,15 +939,6 @@ function objectSeatLabels(o) {
630
939
  function isSeatObject(o) {
631
940
  return o.type === "row" || o.type === "table" || o.type === "booth" || o.type === "gaArea";
632
941
  }
633
- function samePoints(left, right) {
634
- return left.length === right.length && left.every((point, index) => point.x === right[index].x && point.y === right[index].y);
635
- }
636
- function sameGASurfaceAsSection(object, section) {
637
- if (object.type !== "gaArea" || !samePoints(object.points, section.outline)) return false;
638
- const objectHoles = object.holes ?? [];
639
- const sectionHoles = section.holes ?? [];
640
- return objectHoles.length === sectionHoles.length && objectHoles.every((hole, index) => samePoints(hole, sectionHoles[index]));
641
- }
642
942
  function computeSections(doc) {
643
943
  const objs = allObjects(doc);
644
944
  const sectionObjs = objs.filter((o) => o.type === "section");
@@ -664,10 +964,7 @@ function computeSections(doc) {
664
964
  if (!isSeatObject(obj)) continue;
665
965
  const labels = objectSeatLabels(obj);
666
966
  if (labels.length === 0) continue;
667
- const referencedLogicalId = obj.referenceInventorySource?.logicalSectionId;
668
- const c = objectCenter(obj);
669
- const referencedOwner = referencedLogicalId ? sectionObjs.find((section) => (section.logicalSectionId ?? section.id) === referencedLogicalId && (sameGASurfaceAsSection(obj, section) || pointInPolygonWithHoles(c, section.outline, section.holes))) : void 0;
670
- const owner = referencedOwner ?? sectionObjs.find((s) => pointInPolygonWithHoles(c, s.outline, s.holes));
967
+ const owner = owningSectionForObject(objs, obj);
671
968
  const node = owner ? nodes.get(owner.logicalSectionId ?? owner.id) : ungrouped;
672
969
  node.seatCount += labels.length;
673
970
  node.objectIds.push(obj.id);
@@ -1238,11 +1535,310 @@ import { Circle } from "konva/lib/shapes/Circle";
1238
1535
  import { Rect } from "konva/lib/shapes/Rect";
1239
1536
  import { Ellipse } from "konva/lib/shapes/Ellipse";
1240
1537
  import { Line } from "konva/lib/shapes/Line";
1538
+ import { Arrow } from "konva/lib/shapes/Arrow";
1241
1539
  import { Text } from "konva/lib/shapes/Text";
1242
1540
  import { Path } from "konva/lib/shapes/Path";
1243
1541
  import { Image as KImage } from "konva/lib/shapes/Image";
1244
1542
  import { Shape } from "konva/lib/Shape";
1245
1543
 
1544
+ // src/core/chartBackgrounds.ts
1545
+ function buyerBackgroundImage(owner) {
1546
+ const background = owner.backgroundImage;
1547
+ return background?.url && !background.assetId ? background : void 0;
1548
+ }
1549
+
1550
+ // src/core/spatialIndex.ts
1551
+ var DEFAULT_SEAT_RADIUS = 9;
1552
+ function halfExtents(shape) {
1553
+ if (shape.kind === "circle") return { hx: shape.r, hy: shape.r };
1554
+ const hw = shape.width / 2;
1555
+ const hh = shape.height / 2;
1556
+ const rot = shape.rotation ?? 0;
1557
+ if (!rot) return { hx: hw, hy: hh };
1558
+ const rad = rot * Math.PI / 180;
1559
+ const c = Math.abs(Math.cos(rad));
1560
+ const s = Math.abs(Math.sin(rad));
1561
+ return { hx: hw * c + hh * s, hy: hw * s + hh * c };
1562
+ }
1563
+ function shapeIntersectsRect(e, x0, y0, x1, y1) {
1564
+ if (e.shape.kind === "circle") {
1565
+ const cx = Math.min(Math.max(e.seat.x, x0), x1);
1566
+ const cy = Math.min(Math.max(e.seat.y, y0), y1);
1567
+ const dx = e.seat.x - cx;
1568
+ const dy = e.seat.y - cy;
1569
+ return dx * dx + dy * dy <= e.shape.r * e.shape.r;
1570
+ }
1571
+ const rot = e.shape.rotation ?? 0;
1572
+ if (!rot) {
1573
+ return e.seat.x - e.hx <= x1 && e.seat.x + e.hx >= x0 && e.seat.y - e.hy <= y1 && e.seat.y + e.hy >= y0;
1574
+ }
1575
+ const rad = rot * Math.PI / 180;
1576
+ const c = Math.cos(rad);
1577
+ const s = Math.sin(rad);
1578
+ const hw = e.shape.width / 2;
1579
+ const hh = e.shape.height / 2;
1580
+ const corners = [
1581
+ [-hw, -hh],
1582
+ [hw, -hh],
1583
+ [hw, hh],
1584
+ [-hw, hh]
1585
+ ].map(([lx, ly]) => [e.seat.x + lx * c - ly * s, e.seat.y + lx * s + ly * c]);
1586
+ const query = [
1587
+ [x0, y0],
1588
+ [x1, y0],
1589
+ [x1, y1],
1590
+ [x0, y1]
1591
+ ];
1592
+ const axes = [
1593
+ [1, 0],
1594
+ [0, 1],
1595
+ [c, s],
1596
+ [-s, c]
1597
+ ];
1598
+ for (const [ax, ay] of axes) {
1599
+ let minA = Infinity;
1600
+ let maxA = -Infinity;
1601
+ for (const [px, py] of corners) {
1602
+ const d = px * ax + py * ay;
1603
+ if (d < minA) minA = d;
1604
+ if (d > maxA) maxA = d;
1605
+ }
1606
+ let minB = Infinity;
1607
+ let maxB = -Infinity;
1608
+ for (const [px, py] of query) {
1609
+ const d = px * ax + py * ay;
1610
+ if (d < minB) minB = d;
1611
+ if (d > maxB) maxB = d;
1612
+ }
1613
+ if (maxA < minB || maxB < minA) return false;
1614
+ }
1615
+ return true;
1616
+ }
1617
+ function buildSeatIndex(seats, opts = {}) {
1618
+ const seatRadius = opts.seatRadius ?? DEFAULT_SEAT_RADIUS;
1619
+ const n = seats.length;
1620
+ const entries = new Array(n);
1621
+ let minX = Infinity;
1622
+ let minY = Infinity;
1623
+ let maxX = -Infinity;
1624
+ let maxY = -Infinity;
1625
+ let maxHalf = 0;
1626
+ for (let i = 0; i < n; i++) {
1627
+ const seat = seats[i];
1628
+ const shape = opts.shapeOf?.(seat) ?? { kind: "circle", r: seatRadius };
1629
+ const { hx, hy } = halfExtents(shape);
1630
+ entries[i] = { seat, shape, hx, hy };
1631
+ if (seat.x - hx < minX) minX = seat.x - hx;
1632
+ if (seat.y - hy < minY) minY = seat.y - hy;
1633
+ if (seat.x + hx > maxX) maxX = seat.x + hx;
1634
+ if (seat.y + hy > maxY) maxY = seat.y + hy;
1635
+ if (hx > maxHalf) maxHalf = hx;
1636
+ if (hy > maxHalf) maxHalf = hy;
1637
+ }
1638
+ if (n === 0) {
1639
+ minX = 0;
1640
+ minY = 0;
1641
+ maxX = 0;
1642
+ maxY = 0;
1643
+ }
1644
+ const nominal = maxHalf > 0 ? maxHalf * 4 : 1;
1645
+ const spanX = Math.max(maxX - minX, 0);
1646
+ const spanY = Math.max(maxY - minY, 0);
1647
+ const maxCells = Math.max(64, opts.maxCells ?? n * 4);
1648
+ let cell = nominal;
1649
+ let cols = Math.max(1, Math.ceil(spanX / cell) || 1);
1650
+ let rows = Math.max(1, Math.ceil(spanY / cell) || 1);
1651
+ while (cols * rows > maxCells && (cols > 1 || rows > 1)) {
1652
+ cell *= 2;
1653
+ cols = Math.max(1, Math.ceil(spanX / cell) || 1);
1654
+ rows = Math.max(1, Math.ceil(spanY / cell) || 1);
1655
+ }
1656
+ const cellCount = cols * rows;
1657
+ const cellStart = new Int32Array(cellCount + 1);
1658
+ let total = 0;
1659
+ for (let i = 0; i < n; i++) {
1660
+ const e = entries[i];
1661
+ const cx0 = clampInt(Math.floor((e.seat.x - e.hx - minX) / cell), 0, cols - 1);
1662
+ const cx1 = clampInt(Math.floor((e.seat.x + e.hx - minX) / cell), 0, cols - 1);
1663
+ const cy0 = clampInt(Math.floor((e.seat.y - e.hy - minY) / cell), 0, rows - 1);
1664
+ const cy1 = clampInt(Math.floor((e.seat.y + e.hy - minY) / cell), 0, rows - 1);
1665
+ for (let cy = cy0; cy <= cy1; cy++) {
1666
+ for (let cx = cx0; cx <= cx1; cx++) {
1667
+ cellStart[cy * cols + cx + 1]++;
1668
+ total++;
1669
+ }
1670
+ }
1671
+ }
1672
+ for (let c = 0; c < cellCount; c++) cellStart[c + 1] += cellStart[c];
1673
+ const items = new Int32Array(total);
1674
+ const cursor = cellStart.slice(0, cellCount);
1675
+ for (let i = 0; i < n; i++) {
1676
+ const e = entries[i];
1677
+ const cx0 = clampInt(Math.floor((e.seat.x - e.hx - minX) / cell), 0, cols - 1);
1678
+ const cx1 = clampInt(Math.floor((e.seat.x + e.hx - minX) / cell), 0, cols - 1);
1679
+ const cy0 = clampInt(Math.floor((e.seat.y - e.hy - minY) / cell), 0, rows - 1);
1680
+ const cy1 = clampInt(Math.floor((e.seat.y + e.hy - minY) / cell), 0, rows - 1);
1681
+ for (let cy = cy0; cy <= cy1; cy++) {
1682
+ for (let cx = cx0; cx <= cx1; cx++) {
1683
+ const c = cy * cols + cx;
1684
+ items[cursor[c]++] = i;
1685
+ }
1686
+ }
1687
+ }
1688
+ return {
1689
+ seats,
1690
+ entries,
1691
+ minX,
1692
+ minY,
1693
+ cell,
1694
+ cols,
1695
+ rows,
1696
+ cellStart,
1697
+ items,
1698
+ stamp: new Int32Array(n),
1699
+ epoch: 0
1700
+ };
1701
+ }
1702
+ function clampInt(v, lo, hi) {
1703
+ return v < lo ? lo : v > hi ? hi : v;
1704
+ }
1705
+ function queryRect(index, rect, opts = {}) {
1706
+ const x0 = Math.min(rect.x, rect.x + rect.width);
1707
+ const x1 = Math.max(rect.x, rect.x + rect.width);
1708
+ const y0 = Math.min(rect.y, rect.y + rect.height);
1709
+ const y1 = Math.max(rect.y, rect.y + rect.height);
1710
+ const mode = opts.mode ?? "center";
1711
+ const { cols, rows, cell, minX, minY, cellStart, items, entries } = index;
1712
+ const cx0 = Math.max(0, Math.floor((x0 - minX) / cell));
1713
+ const cx1 = Math.min(cols - 1, Math.floor((x1 - minX) / cell));
1714
+ const cy0 = Math.max(0, Math.floor((y0 - minY) / cell));
1715
+ const cy1 = Math.min(rows - 1, Math.floor((y1 - minY) / cell));
1716
+ if (cx0 > cx1 || cy0 > cy1) return [];
1717
+ const found = [];
1718
+ const stamp = index.stamp;
1719
+ const ep = ++index.epoch;
1720
+ for (let cy = cy0; cy <= cy1; cy++) {
1721
+ const rowBase = cy * cols;
1722
+ for (let cx = cx0; cx <= cx1; cx++) {
1723
+ const c = rowBase + cx;
1724
+ const end = cellStart[c + 1];
1725
+ for (let k = cellStart[c]; k < end; k++) {
1726
+ const i = items[k];
1727
+ if (stamp[i] === ep) continue;
1728
+ stamp[i] = ep;
1729
+ const e = entries[i];
1730
+ if (opts.filter && !opts.filter(e.seat.id, e.seat)) continue;
1731
+ const ok = mode === "center" ? e.seat.x >= x0 && e.seat.x <= x1 && e.seat.y >= y0 && e.seat.y <= y1 : shapeIntersectsRect(e, x0, y0, x1, y1);
1732
+ if (ok) found.push(i);
1733
+ }
1734
+ }
1735
+ }
1736
+ found.sort((a, b) => a - b);
1737
+ return found.map((i) => entries[i].seat.id);
1738
+ }
1739
+
1740
+ // src/core/perspectiveProjection.ts
1741
+ function createPerspectiveCamera(bounds2, maxSurfaceHeightWorld = 0) {
1742
+ const span = Math.max(1, bounds2.width, bounds2.height);
1743
+ const target = {
1744
+ x: bounds2.x + bounds2.width / 2,
1745
+ y: bounds2.y + bounds2.height / 2
1746
+ };
1747
+ const distance = span * 1.55;
1748
+ const height = Math.max(span * 0.82, Math.max(0, maxSurfaceHeightWorld) + span * 0.45);
1749
+ return {
1750
+ target,
1751
+ distance,
1752
+ height,
1753
+ focalLength: Math.hypot(distance, height),
1754
+ yawRad: -8 * Math.PI / 180
1755
+ };
1756
+ }
1757
+ function projectPerspectivePoint(camera, point, heightWorld = 0) {
1758
+ const dx = point.x - camera.target.x;
1759
+ const dy = point.y - camera.target.y;
1760
+ const cos = Math.cos(camera.yawRad);
1761
+ const sin = Math.sin(camera.yawRad);
1762
+ const x = dx * cos - dy * sin;
1763
+ const y = dx * sin + dy * cos;
1764
+ const translatedY = y - camera.distance;
1765
+ const translatedZ = heightWorld - camera.height;
1766
+ const cameraLength = Math.hypot(camera.distance, camera.height);
1767
+ const depth = (-camera.distance * translatedY - camera.height * translatedZ) / cameraLength;
1768
+ const up = (-camera.height * translatedY + camera.distance * translatedZ) / cameraLength;
1769
+ const safeDepth = Math.max(cameraLength * 0.08, depth);
1770
+ const scale = camera.focalLength / safeDepth;
1771
+ return {
1772
+ x: camera.target.x + x * scale,
1773
+ y: camera.target.y - up * scale,
1774
+ depth: safeDepth,
1775
+ scale
1776
+ };
1777
+ }
1778
+ function applyAffine(affine, point) {
1779
+ return {
1780
+ x: affine.a * point.x + affine.c * point.y + affine.e,
1781
+ y: affine.b * point.x + affine.d * point.y + affine.f
1782
+ };
1783
+ }
1784
+ function invertAffine(affine) {
1785
+ const determinant = affine.a * affine.d - affine.b * affine.c;
1786
+ if (!Number.isFinite(determinant) || Math.abs(determinant) < 1e-9) {
1787
+ throw new Error("perspective projection produced a singular affine");
1788
+ }
1789
+ const a = affine.d / determinant;
1790
+ const b = -affine.b / determinant;
1791
+ const c = -affine.c / determinant;
1792
+ const d = affine.a / determinant;
1793
+ return {
1794
+ a,
1795
+ b,
1796
+ c,
1797
+ d,
1798
+ e: -(a * affine.e + c * affine.f),
1799
+ f: -(b * affine.e + d * affine.f)
1800
+ };
1801
+ }
1802
+ function composeAffine(left, right) {
1803
+ return {
1804
+ a: left.a * right.a + left.c * right.b,
1805
+ b: left.b * right.a + left.d * right.b,
1806
+ c: left.a * right.c + left.c * right.d,
1807
+ d: left.b * right.c + left.d * right.d,
1808
+ e: left.a * right.e + left.c * right.f + left.e,
1809
+ f: left.b * right.e + left.d * right.f + left.f
1810
+ };
1811
+ }
1812
+ function perspectiveTangentAffine(camera, anchor, heightAt = () => 0) {
1813
+ const origin = projectPerspectivePoint(camera, anchor, heightAt(anchor));
1814
+ const alongXPoint = { x: anchor.x + 1, y: anchor.y };
1815
+ const alongYPoint = { x: anchor.x, y: anchor.y + 1 };
1816
+ const alongX = projectPerspectivePoint(camera, alongXPoint, heightAt(alongXPoint));
1817
+ const alongY = projectPerspectivePoint(camera, alongYPoint, heightAt(alongYPoint));
1818
+ const a = alongX.x - origin.x;
1819
+ const b = alongX.y - origin.y;
1820
+ const c = alongY.x - origin.x;
1821
+ const d = alongY.y - origin.y;
1822
+ return {
1823
+ a,
1824
+ b,
1825
+ c,
1826
+ d,
1827
+ e: origin.x - a * anchor.x - c * anchor.y,
1828
+ f: origin.y - b * anchor.x - d * anchor.y
1829
+ };
1830
+ }
1831
+ function decomposeAffineLinear(affine) {
1832
+ const scaleX = Math.hypot(affine.a, affine.b);
1833
+ const determinant = affine.a * affine.d - affine.b * affine.c;
1834
+ return {
1835
+ rotationDeg: Math.atan2(affine.b, affine.a) * 180 / Math.PI,
1836
+ scaleX,
1837
+ scaleY: determinant / Math.max(scaleX, 1e-9),
1838
+ skewX: (affine.a * affine.c + affine.b * affine.d) / Math.max(determinant, 1e-9)
1839
+ };
1840
+ }
1841
+
1246
1842
  // src/lib/money.ts
1247
1843
  var DEFAULT_CURRENCY = "USD";
1248
1844
  var displayLocale;
@@ -1363,6 +1959,25 @@ function formatDate(value, opts) {
1363
1959
  return new Intl.DateTimeFormat(active, opts ?? { dateStyle: "medium", timeStyle: "short" }).format(value);
1364
1960
  }
1365
1961
 
1962
+ // src/core/shapeLineStyle.ts
1963
+ var DEFAULT_SHAPE_LINE_CAP = "round";
1964
+ var DEFAULT_SHAPE_LINE_JOIN = "round";
1965
+ var DEFAULT_SHAPE_LINE_ENDING = "none";
1966
+ function resolvedShapeLineStyle(shape) {
1967
+ return {
1968
+ lineCap: shape.lineCap ?? DEFAULT_SHAPE_LINE_CAP,
1969
+ lineJoin: shape.lineJoin ?? DEFAULT_SHAPE_LINE_JOIN,
1970
+ startEnding: shape.startEnding ?? DEFAULT_SHAPE_LINE_ENDING,
1971
+ endEnding: shape.endEnding ?? DEFAULT_SHAPE_LINE_ENDING
1972
+ };
1973
+ }
1974
+ function shapeArrowMetrics(strokeWidth) {
1975
+ return {
1976
+ pointerLength: Math.min(48, Math.max(8, strokeWidth * 3)),
1977
+ pointerWidth: Math.min(40, Math.max(8, strokeWidth * 2.25))
1978
+ };
1979
+ }
1980
+
1366
1981
  // src/engine/SeatmapRenderer.ts
1367
1982
  var SEAT_RADIUS = 9;
1368
1983
  var SEAT_LEGIBLE_SCALE = 0.9;
@@ -1370,17 +1985,18 @@ var CACHE_THRESHOLD = 0.55 * SEAT_LEGIBLE_SCALE;
1370
1985
  var LABEL_SCALE = MIN_VISIBLE_BOOKABLE_LABEL_PX / SEAT_LABEL_FONT_SIZE;
1371
1986
  var MIN_FITTED_SEAT_LABEL_FONT_SIZE = 4;
1372
1987
  var SEAT_TAP_SLOP_PX = 14;
1373
- var SEAT_GLYPH_MIN_PX = 6.5;
1988
+ var WHEELCHAIR_GLYPH_MIN_PX = 10;
1989
+ var FILTERED_WHEELCHAIR_GLYPH_MIN_PX = 14;
1374
1990
  var SECTION_PROMINENT_SCALE = 0.45 * SEAT_LEGIBLE_SCALE;
1375
1991
  var BLOCK_MELT_TOP = 0.9 * SEAT_LEGIBLE_SCALE;
1376
1992
  var SEAT_FOCUS_SCALE = Math.max(SEAT_LEGIBLE_SCALE * 1.1, BLOCK_MELT_TOP);
1377
1993
  var PAN_START_SLOP_PX = 8;
1994
+ var GHOST_CLICK_MS = 700;
1378
1995
  var ZONE_PROMINENT_SCALE = 0.55 * SECTION_PROMINENT_SCALE;
1379
1996
  var MAX_LABELS = 700;
1380
1997
  var MARQUEE_RING_CAP = 2500;
1381
1998
  var ISO_ANGLE_DEG = -11.5;
1382
1999
  var ISO_SQUASH = 0.58;
1383
- var LIFT_PER_STEP = 58;
1384
2000
  var ISO_TWEEN_MS = 320;
1385
2001
  var CAMERA_GLIDE_MS = 650;
1386
2002
  var BLOCK_FILL_ALPHA = 1;
@@ -1417,6 +2033,10 @@ var DEF_SELECTION = "#ffffff";
1417
2033
  var DEF_SELECTION_ON_LIGHT = "#0b1220";
1418
2034
  var DEF_DECOR_FILL = "#232c40";
1419
2035
  var DEF_TEXT = "#8b93a7";
2036
+ function konvaFontStyle(bold, italic, defaultWeight = "normal") {
2037
+ const weight = bold ? "bold" : defaultWeight;
2038
+ return italic ? `italic ${weight}`.trim() : weight;
2039
+ }
1420
2040
  var DEF_CANVAS_BACKGROUND = "#0e1117";
1421
2041
  function colorLuminance(color) {
1422
2042
  const s = color.trim();
@@ -1522,8 +2142,8 @@ function rotatedRectPoints(center, width, height, rotation) {
1522
2142
  }));
1523
2143
  }
1524
2144
  function pointsBounds(points) {
1525
- const bounds = polyBounds(points);
1526
- return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height };
2145
+ const bounds2 = polyBounds(points);
2146
+ return { x: bounds2.x, y: bounds2.y, width: bounds2.width, height: bounds2.height };
1527
2147
  }
1528
2148
  function rotatedRectFitsPolygon(center, width, height, rotation, outer, holes) {
1529
2149
  const radians = rotation * Math.PI / 180;
@@ -1543,14 +2163,14 @@ function rotatedRectFitsPolygon(center, width, height, rotation, outer, holes) {
1543
2163
  return true;
1544
2164
  }
1545
2165
  function polygonLabelCandidates(outer, holes, preferred) {
1546
- const bounds = polyBounds(outer);
1547
- const centre = { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 };
2166
+ const bounds2 = polyBounds(outer);
2167
+ const centre = { x: bounds2.x + bounds2.width / 2, y: bounds2.y + bounds2.height / 2 };
1548
2168
  const points = [preferred];
1549
2169
  for (let row = 1; row < 12; row += 1) {
1550
2170
  for (let column = 1; column < 12; column += 1) {
1551
2171
  const point = {
1552
- x: bounds.x + bounds.width * column / 12,
1553
- y: bounds.y + bounds.height * row / 12
2172
+ x: bounds2.x + bounds2.width * column / 12,
2173
+ y: bounds2.y + bounds2.height * row / 12
1554
2174
  };
1555
2175
  if (pointInPolygonWithHoles(point, outer, holes)) points.push(point);
1556
2176
  }
@@ -1615,6 +2235,33 @@ function rgba(hex, a) {
1615
2235
  const n = parseInt(m[1], 16);
1616
2236
  return `rgba(${n >> 16 & 255},${n >> 8 & 255},${n & 255},${a})`;
1617
2237
  }
2238
+ var ViewportGroup = class extends Group {
2239
+ constructor() {
2240
+ super(...arguments);
2241
+ this.viewportCulled = false;
2242
+ }
2243
+ setViewportCulled(culled) {
2244
+ if (culled === this.viewportCulled) return;
2245
+ this.viewportCulled = culled;
2246
+ if (!culled) super._clearSelfAndDescendantCache();
2247
+ }
2248
+ isViewportCulled() {
2249
+ return this.viewportCulled;
2250
+ }
2251
+ drawScene(...args) {
2252
+ return this.viewportCulled ? this : super.drawScene(...args);
2253
+ }
2254
+ drawHit(...args) {
2255
+ return this.viewportCulled ? this : super.drawHit(...args);
2256
+ }
2257
+ _clearSelfAndDescendantCache(attr) {
2258
+ if (!this.viewportCulled) {
2259
+ super._clearSelfAndDescendantCache(attr);
2260
+ return;
2261
+ }
2262
+ this._clearCache(attr);
2263
+ }
2264
+ };
1618
2265
  function hexToRgb(hex) {
1619
2266
  const m = /^#?([\da-f]{6})$/i.exec(hex.trim());
1620
2267
  if (!m) return null;
@@ -1645,8 +2292,15 @@ function lerpColor(a, b, t2) {
1645
2292
  }
1646
2293
  var _SeatmapRenderer = class _SeatmapRenderer {
1647
2294
  constructor(container, options = {}) {
2295
+ /** Per-elevated-section label containers. Keeping the labels grouped lets the
2296
+ * Phase-B lift move thousands of seat labels with one transform per section
2297
+ * instead of rewriting every label on every tween frame. */
2298
+ this.labelLiftGroups = /* @__PURE__ */ new Map();
1648
2299
  this.seats = [];
1649
2300
  this.seatById = /* @__PURE__ */ new Map();
2301
+ /** One build per chart/floor; section membership queries only inspect seats in
2302
+ * the section bounds instead of rescanning the full 13k venue per section. */
2303
+ this.seatIndex = null;
1650
2304
  /** Multi-floor (Batch 5): the last-set chart + which floor we're rendering. */
1651
2305
  this.chartDoc = null;
1652
2306
  this.activeFloorId = "";
@@ -1662,13 +2316,11 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1662
2316
  this.seatLabelById = /* @__PURE__ */ new Map();
1663
2317
  /** Coloured accommodation ring per accessible seat (few per chart). */
1664
2318
  this.accessRingById = /* @__PURE__ */ new Map();
1665
- /** Centred accessibility glyph per accessible seat shown once the seat is
1666
- * big enough on-screen (see {@link SEAT_GLYPH_MIN_PX}); the ring is the
1667
- * smaller-zoom fallback. Kept in a map so zoom toggles touch only the handful
1668
- * of accessible seats, never all 13k nodes. */
2319
+ /** Centred glyph per physical wheelchair provision. It keeps a small
2320
+ * screen-space floor at every LOD and grows when the Wheelchair filter is
2321
+ * active. Kept in a map so zoom updates touch only the handful of matching
2322
+ * seats, never all 13k nodes. */
1669
2323
  this.accessGlyphById = /* @__PURE__ */ new Map();
1670
- /** Whether the accessibility glyph is legible at the current camera scale. */
1671
- this.accessGlyphVisible = false;
1672
2324
  /** Authored free-text nodes obey the same rendered-size visibility floor. */
1673
2325
  this.freeTextById = /* @__PURE__ */ new Map();
1674
2326
  /** Stage/rink landmarks retain a readable screen-space caption at overview. */
@@ -1693,6 +2345,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1693
2345
  this.ownedHold = /* @__PURE__ */ new Set();
1694
2346
  /** One selected seat being inspected before it is committed to the cart. */
1695
2347
  this.selectionFocusId = null;
2348
+ /** Seat currently owning the shared hover ring (null while not hovering). */
2349
+ this.hoveredId = null;
1696
2350
  this.focusedId = null;
1697
2351
  /**
1698
2352
  * Accessibility filter: `null` = off; `[]` = dim all non-accessible free seats;
@@ -1711,6 +2365,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1711
2365
  this.sections = [];
1712
2366
  this.zones = [];
1713
2367
  this.seatSection = /* @__PURE__ */ new Map();
2368
+ /** Seats outside every authored section retain the legacy path in one group. */
2369
+ this.unsectionedSeatGroup = null;
1714
2370
  this.catPrice = /* @__PURE__ */ new Map();
1715
2371
  /** Section/zone ids to render dimmed (organizer manager: held-back inventory). */
1716
2372
  this.dimmedSections = /* @__PURE__ */ new Set();
@@ -1723,6 +2379,9 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1723
2379
  this.focusedSectionId = null;
1724
2380
  /** Light backdrop panel drawn behind the focused section (removed on clear). */
1725
2381
  this.focusBackdrop = null;
2382
+ /** Non-listening visual dim outside the focused section. One overlay replaces
2383
+ * descendant opacity mutations across every seat in the venue. */
2384
+ this.focusDimOverlay = null;
1726
2385
  /** Object id → floor id (multi-floor only) — resolves a deck tap in the 3D stack. */
1727
2386
  this.objectFloor = /* @__PURE__ */ new Map();
1728
2387
  /** Zone id → colour (drives extruded side faces in iso view). */
@@ -1735,10 +2394,33 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1735
2394
  this.isoT = 0;
1736
2395
  this.isoTarget = 0;
1737
2396
  this.isoRaf = 0;
2397
+ /** Public view target; perspective is a separate non-affine Phase-C lane. */
2398
+ this.viewMode = "flat";
2399
+ this.perspectiveCamera = null;
2400
+ /** Ground-plane tangent applied once to every renderer layer. */
2401
+ this.perspectiveBaseAffine = null;
2402
+ this.perspectiveBaseInverse = null;
2403
+ /** Exact pinhole anchor expressed in the overlay tangent-layer coordinates. */
2404
+ this.perspectiveSeatLocal = /* @__PURE__ */ new Map();
2405
+ /** Exact projected point (before Stage pan/zoom), keyed by seat. */
2406
+ this.perspectiveSeatProjected = /* @__PURE__ */ new Map();
2407
+ /** Positive camera depth, used for deterministic far→near paint order. */
2408
+ this.perspectiveSeatDepth = /* @__PURE__ */ new Map();
2409
+ /** Exact pinhole size ratio at each seat anchor. */
2410
+ this.perspectiveSeatScale = /* @__PURE__ */ new Map();
2411
+ /** Seats whose native Konva nodes currently carry the exact projected pose.
2412
+ * Large sectioned venues apply these lazily as a section enters the live-seat
2413
+ * viewport; the overview rung hides the seat layer completely. */
2414
+ this.perspectiveAppliedSeats = /* @__PURE__ */ new Set();
2415
+ this.perspectiveBounds = null;
1738
2416
  /** Chart centre the iso projection pivots about (bounds centre). */
1739
2417
  this.isoCentre = { x: 0, y: 0 };
1740
2418
  /** rAF for an in-flight camera glide (focusRegion / setRung); 0 = none. */
1741
2419
  this.glideRaf = 0;
2420
+ /** While the camera tween is active, defer polygon label fitting/collision to
2421
+ * the settled frame. Geometry remains smoothly stage-scaled, while expensive
2422
+ * point-in-polygon text work no longer repeats at 120 Hz. */
2423
+ this.glideInProgress = false;
1742
2424
  /** Set in destroy() so an in-flight iso tween bails. */
1743
2425
  this.destroyed = false;
1744
2426
  /** Cached scale the section/zone labels were last sized for (scale-compensation). */
@@ -1768,6 +2450,19 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1768
2450
  this.panStarted = false;
1769
2451
  /** Maximum displacement from gesture start — suppress taps only after a real pan/pinch. */
1770
2452
  this.moved = 0;
2453
+ /**
2454
+ * Ghost-click guard. Konva binds `_pointerup` to mouseup, touchend AND
2455
+ * pointerup, so `on('click tap')` is two handlers for one finger tap unless
2456
+ * the browser's synthesized compatibility click is suppressed. Konva only
2457
+ * suppresses it when the touch lands on a LISTENING SHAPE (Stage.js returns
2458
+ * early before its preventDefault() when `!shape || !shape.isListening()`),
2459
+ * so a near-miss rescued by nearestSeatToScreen used to fire `tap` (select)
2460
+ * then `click` (deselect) 1-3ms apart and net to nothing. With
2461
+ * SEAT_TAP_SLOP_PX=14 against a ~12px seat radius, that broken annulus is
2462
+ * ~3.7x the area of the seat itself — i.e. most successful mobile taps.
2463
+ * `touch-action: none` does NOT suppress compatibility mouse events.
2464
+ */
2465
+ this.lastTapAt = 0;
1771
2466
  /**
1772
2467
  * Manage-mode rubber-band marquee (option-gated). `start`/`cur` are WORLD-space
1773
2468
  * points (overlayLayer rides the stage transform); `rect` is the on-canvas
@@ -1864,6 +2559,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1864
2559
  x: mid.x - this.pinch.worldMid.x * scale,
1865
2560
  y: mid.y - this.pinch.worldMid.y * scale
1866
2561
  });
2562
+ this.updateSeatGroupVisibility();
1867
2563
  this.stage.batchDraw();
1868
2564
  this.scheduleViewChange();
1869
2565
  } else if (this.panLast && this.pointers.size === 1) {
@@ -1876,6 +2572,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1876
2572
  y: this.stage.y() + (p.y - this.panLast.y)
1877
2573
  });
1878
2574
  this.panLast = p;
2575
+ this.updateSeatGroupVisibility();
1879
2576
  this.stage.batchDraw();
1880
2577
  this.scheduleViewChange();
1881
2578
  }
@@ -1928,6 +2625,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1928
2625
  this.overlayLayer = new Layer({ listening: false });
1929
2626
  this.labelGroup = new Group({ listening: false });
1930
2627
  this.overlayLayer.add(this.labelGroup);
2628
+ this.fgDecorGroup = new Group({ listening: false });
2629
+ this.overlayLayer.add(this.fgDecorGroup);
1931
2630
  this.hoverRing = new Circle({
1932
2631
  radius: SEAT_RADIUS + 2,
1933
2632
  stroke: "#ffffff",
@@ -1962,6 +2661,21 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1962
2661
  this.resizeObs.observe(container);
1963
2662
  }
1964
2663
  }
2664
+ /**
2665
+ * True when this is the browser's synthesized compatibility click following a
2666
+ * finger tap we already handled. Discriminates on `e.type` deliberately —
2667
+ * `PointerEvent extends MouseEvent`, so an `instanceof MouseEvent` test
2668
+ * matches genuine pointer taps too. One shared timestamp across the layer and
2669
+ * stage handlers: a tap consumed at the layer must also suppress the ghost at
2670
+ * the stage, and only `click` is ever rejected, so bubbling stays intact.
2671
+ */
2672
+ isGhostClick(e) {
2673
+ if (e.type === "tap") {
2674
+ this.lastTapAt = performance.now();
2675
+ return false;
2676
+ }
2677
+ return this.lastTapAt > 0 && performance.now() - this.lastTapAt < GHOST_CLICK_MS;
2678
+ }
1965
2679
  // ---- ISeatmapRenderer -----------------------------------------------------
1966
2680
  setChart(doc, opts) {
1967
2681
  if (doc !== this.chartDoc) this.stacked = false;
@@ -1975,8 +2689,15 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1975
2689
  this.focusedId = null;
1976
2690
  this.focusRing.visible(false);
1977
2691
  this.bgLayer.destroyChildren();
2692
+ this.focusDimOverlay?.destroy();
2693
+ this.focusDimOverlay = null;
2694
+ this.seatLayer.clearCache();
2695
+ this.seatLayer.listening(true);
1978
2696
  this.seatLayer.destroyChildren();
2697
+ this.unsectionedSeatGroup = null;
1979
2698
  this.labelGroup.destroyChildren();
2699
+ this.labelLiftGroups.clear();
2700
+ this.fgDecorGroup.destroyChildren();
1980
2701
  this.circleById.clear();
1981
2702
  this.boothDims.clear();
1982
2703
  this.boothLabelById.clear();
@@ -1993,6 +2714,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1993
2714
  this.statusById.clear();
1994
2715
  this.selection.clear();
1995
2716
  this.seatById.clear();
2717
+ this.seatIndex = null;
1996
2718
  this.cached = false;
1997
2719
  this.accessFilter = null;
1998
2720
  this.sections = [];
@@ -2010,11 +2732,22 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2010
2732
  }
2011
2733
  this.isoT = 0;
2012
2734
  this.isoTarget = 0;
2735
+ this.viewMode = "flat";
2736
+ this.perspectiveCamera = null;
2737
+ this.perspectiveBaseAffine = null;
2738
+ this.perspectiveBaseInverse = null;
2739
+ this.perspectiveSeatLocal.clear();
2740
+ this.perspectiveSeatProjected.clear();
2741
+ this.perspectiveSeatDepth.clear();
2742
+ this.perspectiveSeatScale.clear();
2743
+ this.perspectiveAppliedSeats.clear();
2744
+ this.perspectiveBounds = null;
2013
2745
  this.resetLayerTransforms();
2014
2746
  this.hasSections = view.objects.some((o) => o.type === "section");
2015
2747
  for (const z of doc.zones ?? []) if (z.color) this.zoneColor.set(z.id, z.color);
2016
2748
  this.seatLayer.opacity(1);
2017
2749
  this.hoverRing.visible(false);
2750
+ this.hoveredId = null;
2018
2751
  this.theme = doc.theme ?? {};
2019
2752
  this.seatR = clamp(this.theme.seatScale ?? 1, 0.7, 1.6) * SEAT_RADIUS;
2020
2753
  this.container.style.background = "";
@@ -2035,26 +2768,61 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2035
2768
  this.boothDims.set(obj.id, { width: obj.width, height: obj.height, rotation: obj.rotation });
2036
2769
  }
2037
2770
  }
2038
- this.seats = expandChart(view);
2771
+ this.seats = expandChart(view, {
2772
+ // A stacked overview is presentation-only and never opens a seat POV. The
2773
+ // per-section renderer below still resolves each object's real floor base.
2774
+ floorBaseHeightM: this.stacked ? 0 : this.floorBaseHeightMFor()
2775
+ });
2039
2776
  for (const s of this.seats) {
2040
2777
  this.seatById.set(s.id, s);
2041
2778
  this.statusById.set(s.id, "free");
2042
2779
  }
2780
+ this.seatIndex = buildSeatIndex(this.seats, { seatRadius: this.seatR });
2781
+ this.bounds = chartBounds(view);
2782
+ this.isoCentre = { x: this.bounds.x + this.bounds.width / 2, y: this.bounds.y + this.bounds.height / 2 };
2783
+ this.buildPerspectiveProjection(view);
2043
2784
  this.renderBackground(view);
2785
+ this.unsectionedSeatGroup = new Group({ listening: true });
2786
+ this.seatLayer.add(this.unsectionedSeatGroup);
2044
2787
  this.renderSeats();
2045
2788
  this.overlayLayer.add(this.labelGroup);
2789
+ this.overlayLayer.add(this.fgDecorGroup);
2046
2790
  this.overlayLayer.add(this.hoverRing);
2047
- this.bounds = chartBounds(view);
2048
- this.isoCentre = { x: this.bounds.x + this.bounds.width / 2, y: this.bounds.y + this.bounds.height / 2 };
2791
+ this.overlayLayer.add(this.focusRing);
2049
2792
  this.zoomToFit();
2050
2793
  }
2051
2794
  /** The chart to render: all floors stacked (3D overview), the active floor, or
2052
2795
  * the whole chart for single-floor charts. */
2053
2796
  floorView(doc) {
2054
- if (!doc.floors || !doc.floors.length) return doc;
2055
- if (this.stacked && doc.floors.length >= 2) return stackFloors(doc);
2797
+ if (!doc.floors || !doc.floors.length) return {
2798
+ ...doc,
2799
+ referenceImage: void 0,
2800
+ backgroundImage: buyerBackgroundImage(doc)
2801
+ };
2802
+ if (this.stacked && doc.floors.length >= 2) {
2803
+ return {
2804
+ ...stackFloors(doc),
2805
+ referenceImage: void 0,
2806
+ backgroundImage: buyerBackgroundImage(doc)
2807
+ };
2808
+ }
2056
2809
  const floor = doc.floors.find((f) => f.id === this.activeFloorId) ?? doc.floors[0];
2057
- return { ...doc, objects: floor.objects, focalPoint: floor.focalPoint, backgroundImage: floor.backgroundImage, floors: void 0 };
2810
+ return {
2811
+ ...doc,
2812
+ objects: floor.objects,
2813
+ focalPoint: floor.focalPoint,
2814
+ referenceImage: void 0,
2815
+ backgroundImage: buyerBackgroundImage(floor),
2816
+ floors: void 0
2817
+ };
2818
+ }
2819
+ /** Physical floor height is authored data; the 900-unit stacked overview
2820
+ * spread is presentation-only and must never leak into real 3D geometry. */
2821
+ floorBaseHeightMFor(objectId) {
2822
+ const floors = this.chartDoc?.floors;
2823
+ if (!floors?.length) return 0;
2824
+ const floorId = this.stacked && objectId ? this.objectFloor.get(objectId) ?? this.activeFloorId : this.activeFloorId;
2825
+ return floors.find((floor) => floor.id === floorId)?.baseHeightM ?? 0;
2058
2826
  }
2059
2827
  /** Toggle the 3D all-floors stacked overview (Batch 5). Re-renders; no-op on
2060
2828
  * single-floor charts. The caller re-applies statuses + animates the iso view. */
@@ -2367,7 +3135,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2367
3135
  }
2368
3136
  const ids = [];
2369
3137
  for (const seat of this.seats) {
2370
- if (seat.x < x0 || seat.x > x1 || seat.y < y0 || seat.y > y1) continue;
3138
+ const rendered = this.renderedSeatPoint(seat);
3139
+ if (rendered.x < x0 || rendered.x > x1 || rendered.y < y0 || rendered.y > y1) continue;
2371
3140
  if (!this.isSelectable(seat.id)) continue;
2372
3141
  ids.push(seat.id);
2373
3142
  }
@@ -2377,9 +3146,10 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2377
3146
  flashSeat(seatId, color = "#f43f5e") {
2378
3147
  const seat = this.seatById.get(seatId);
2379
3148
  if (!seat) return;
3149
+ const rendered = this.renderedSeatPoint(seat);
2380
3150
  const ring = new Circle({
2381
- x: seat.x,
2382
- y: seat.y,
3151
+ x: rendered.x,
3152
+ y: rendered.y,
2383
3153
  radius: this.seatR,
2384
3154
  stroke: color,
2385
3155
  strokeWidth: 3,
@@ -2388,6 +3158,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2388
3158
  perfectDrawEnabled: false,
2389
3159
  shadowForStrokeEnabled: false
2390
3160
  });
3161
+ const projectionScale = this.viewMode === "perspective" ? this.perspectiveSeatScale.get(seatId) ?? 1 : 1;
3162
+ ring.scale({ x: projectionScale, y: projectionScale });
2391
3163
  this.overlayLayer.add(ring);
2392
3164
  const start = performance.now();
2393
3165
  const dur = 620;
@@ -2414,17 +3186,20 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2414
3186
  const matches = this.sections.filter((section) => section.id === sectionId || section.zone === sectionId);
2415
3187
  if (!matches.length) return;
2416
3188
  for (const section of matches) {
2417
- const centre = section.outline.reduce(
3189
+ const outline = this.viewMode === "perspective" && section.perspectiveAffine ? section.outline.map((point) => this.perspectiveLocalPoint(this.projectedSectionPoint(section, point))) : section.outline.map((point) => {
3190
+ const lift = section.liftWorld > 0 ? this.isoLiftLocal(section.liftWorld) : { x: 0, y: 0 };
3191
+ return { x: point.x + lift.x, y: point.y + lift.y };
3192
+ });
3193
+ const centre = outline.reduce(
2418
3194
  (sum, point) => ({ x: sum.x + point.x, y: sum.y + point.y }),
2419
3195
  { x: 0, y: 0 }
2420
3196
  );
2421
- centre.x /= section.outline.length;
2422
- centre.y /= section.outline.length;
2423
- const lift = section.elevation > 0 ? this.isoLiftLocal(section.elevation) : { x: 0, y: 0 };
3197
+ centre.x /= outline.length;
3198
+ centre.y /= outline.length;
2424
3199
  const halo = new Line({
2425
- x: centre.x + lift.x,
2426
- y: centre.y + lift.y,
2427
- points: section.outline.flatMap((point) => [point.x - centre.x, point.y - centre.y]),
3200
+ x: centre.x,
3201
+ y: centre.y,
3202
+ points: outline.flatMap((point) => [point.x - centre.x, point.y - centre.y]),
2428
3203
  closed: true,
2429
3204
  stroke: color,
2430
3205
  strokeWidth: 3,
@@ -2468,12 +3243,14 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2468
3243
  nearestSeat(fromId, dir) {
2469
3244
  const from = this.seatById.get(fromId);
2470
3245
  if (!from) return null;
3246
+ const fromPoint = this.viewMode === "perspective" ? this.perspectiveSeatProjected.get(from.id) ?? from : from;
2471
3247
  let best = null;
2472
3248
  let bestScore = Infinity;
2473
3249
  for (const s of this.seats) {
2474
3250
  if (s.id === fromId) continue;
2475
- const dx = s.x - from.x;
2476
- const dy = s.y - from.y;
3251
+ const candidatePoint = this.viewMode === "perspective" ? this.perspectiveSeatProjected.get(s.id) ?? s : s;
3252
+ const dx = candidatePoint.x - fromPoint.x;
3253
+ const dy = candidatePoint.y - fromPoint.y;
2477
3254
  const proj = dx * dir.x + dy * dir.y;
2478
3255
  if (proj <= 0.5) continue;
2479
3256
  const perp = Math.abs(dx * dir.y - dy * dir.x);
@@ -2490,7 +3267,9 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2490
3267
  if (!seat) return;
2491
3268
  this.focusedId = id;
2492
3269
  this.focusRing.radius(this.seatR + 3);
2493
- this.focusRing.position({ x: seat.x, y: seat.y });
3270
+ this.focusRing.position(this.renderedSeatPoint(seat));
3271
+ const projectionScale = this.viewMode === "perspective" ? this.perspectiveSeatScale.get(id) ?? 1 : 1;
3272
+ this.focusRing.scale({ x: projectionScale, y: projectionScale });
2494
3273
  this.focusRing.visible(true);
2495
3274
  this.ensureVisible(seat);
2496
3275
  this.overlayLayer.batchDraw();
@@ -2505,7 +3284,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2505
3284
  const margin = 70;
2506
3285
  const offscreen = p.x < margin || p.x > w - margin || p.y < margin || p.y > h - margin;
2507
3286
  if (this.stage.scaleX() < target || offscreen) {
2508
- const ip = this.isoT === 0 ? seat : this.isoForward(seat);
3287
+ const rendered = this.renderedSeatPoint(seat);
3288
+ const ip = this.viewMode === "perspective" && this.perspectiveBaseAffine ? applyAffine(this.perspectiveBaseAffine, rendered) : this.isoT === 0 ? rendered : this.isoForward(rendered);
2509
3289
  this.stage.scale({ x: target, y: target });
2510
3290
  this.stage.position({ x: w / 2 - ip.x * target, y: h / 2 - ip.y * target });
2511
3291
  this.afterViewChange();
@@ -2515,7 +3295,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2515
3295
  zoomToFit() {
2516
3296
  const w = this.stage.width();
2517
3297
  const h = this.stage.height();
2518
- const b = this.bounds;
3298
+ const b = this.viewMode === "perspective" && this.perspectiveBounds ? this.perspectiveBounds : this.bounds;
2519
3299
  this.fitScale = Math.min(w / b.width, h / b.height) || 1;
2520
3300
  const s = this.fitScale;
2521
3301
  this.stage.scale({ x: s, y: s });
@@ -2544,7 +3324,14 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2544
3324
  }
2545
3325
  worldToScreen(point) {
2546
3326
  const s = this.stage.scaleX();
2547
- const p = this.isoT === 0 ? point : this.isoForward(point);
3327
+ const candidateId = point.id;
3328
+ const seat = typeof candidateId === "string" ? this.seatById.get(candidateId) : void 0;
3329
+ if (this.viewMode === "perspective" && this.perspectiveBaseAffine) {
3330
+ const projected = seat ? this.perspectiveSeatProjected.get(seat.id) ?? applyAffine(this.perspectiveBaseAffine, point) : applyAffine(this.perspectiveBaseAffine, point);
3331
+ return { x: projected.x * s + this.stage.x(), y: projected.y * s + this.stage.y() };
3332
+ }
3333
+ const localPoint = seat ? this.renderedSeatPoint(seat) : point;
3334
+ const p = this.isoT === 0 ? localPoint : this.isoForward(localPoint);
2548
3335
  return { x: p.x * s + this.stage.x(), y: p.y * s + this.stage.y() };
2549
3336
  }
2550
3337
  setAccessibleFilter(on) {
@@ -2554,6 +3341,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2554
3341
  const next = types === null ? null : [...types];
2555
3342
  if (this.sameAccessFilter(next)) return;
2556
3343
  this.accessFilter = next;
3344
+ this.updateAccessGlyphs(this.effScale());
2557
3345
  for (const seat of this.seats) {
2558
3346
  const c = this.circleById.get(seat.id);
2559
3347
  if (c) this.paintSeat(c, seat.id);
@@ -2652,10 +3440,11 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2652
3440
  let minY = Infinity;
2653
3441
  let maxY = -Infinity;
2654
3442
  for (const seat of matching) {
2655
- minX = Math.min(minX, seat.x);
2656
- maxX = Math.max(maxX, seat.x);
2657
- minY = Math.min(minY, seat.y);
2658
- maxY = Math.max(maxY, seat.y);
3443
+ const point = this.renderedSeatPoint(seat);
3444
+ minX = Math.min(minX, point.x);
3445
+ maxX = Math.max(maxX, point.x);
3446
+ minY = Math.min(minY, point.y);
3447
+ maxY = Math.max(maxY, point.y);
2659
3448
  }
2660
3449
  const pad = Math.max(24, this.seatR * 3);
2661
3450
  minX -= pad;
@@ -2676,12 +3465,37 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2676
3465
  * inverse) keeps landing on the projected seats/sections.
2677
3466
  */
2678
3467
  setViewMode(mode) {
3468
+ if (mode === "perspective") {
3469
+ if (this.viewMode === mode) {
3470
+ this.afterViewChange();
3471
+ return;
3472
+ }
3473
+ const resetElevatedSeatGroups = this.viewMode === "isometric" && this.isoT > 0;
3474
+ this.viewMode = mode;
3475
+ this.isoTarget = 1;
3476
+ this.isoT = 1;
3477
+ if (this.isoRaf) {
3478
+ cancelAnimationFrame(this.isoRaf);
3479
+ this.isoRaf = 0;
3480
+ }
3481
+ this.applyPerspective(resetElevatedSeatGroups);
3482
+ this.zoomToFit();
3483
+ return;
3484
+ }
2679
3485
  const target = mode === "isometric" ? 1 : 0;
3486
+ const leavingPerspective = this.viewMode === "perspective";
3487
+ this.viewMode = mode;
2680
3488
  this.isoTarget = target;
2681
3489
  if (this.isoRaf) {
2682
3490
  cancelAnimationFrame(this.isoRaf);
2683
3491
  this.isoRaf = 0;
2684
3492
  }
3493
+ if (leavingPerspective) {
3494
+ this.isoT = target;
3495
+ this.applyIso();
3496
+ this.afterViewChange();
3497
+ return;
3498
+ }
2685
3499
  if (this.reducedMotion) {
2686
3500
  this.isoT = target;
2687
3501
  this.applyIso();
@@ -2715,7 +3529,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2715
3529
  }
2716
3530
  /** Current projection — reflects the tween target, not the mid-tween isoT. */
2717
3531
  getViewMode() {
2718
- return this.isoTarget === 1 ? "isometric" : "flat";
3532
+ return this.viewMode;
2719
3533
  }
2720
3534
  /** Iso angle (rad) + y-squash for the current isoT. */
2721
3535
  isoParams() {
@@ -2723,6 +3537,11 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2723
3537
  }
2724
3538
  /** Effective vertical scale = stage scale × iso squash — legibility math uses this. */
2725
3539
  effScale() {
3540
+ if (this.viewMode === "perspective" && this.perspectiveBaseAffine) {
3541
+ const xScale = Math.hypot(this.perspectiveBaseAffine.a, this.perspectiveBaseAffine.b);
3542
+ const yScale = Math.hypot(this.perspectiveBaseAffine.c, this.perspectiveBaseAffine.d);
3543
+ return this.stage.scaleX() * Math.min(xScale, yScale);
3544
+ }
2726
3545
  return this.stage.scaleX() * (1 - (1 - ISO_SQUASH) * this.isoT);
2727
3546
  }
2728
3547
  /** Project a world point through the iso affine about the chart centre (→ iso-world). */
@@ -2746,19 +3565,113 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2746
3565
  const wy = -dx * Math.sin(th) + uy * Math.cos(th);
2747
3566
  return { x: c.x + wx, y: c.y + wy };
2748
3567
  }
3568
+ /** Precompute the Phase-C pinhole camera and every exact seat anchor. */
3569
+ buildPerspectiveProjection(view) {
3570
+ let maxSurfaceHeightWorld = 0;
3571
+ for (const seat of this.seats) {
3572
+ const surfaceM = Math.max(0, (seat.eyeHeightM ?? SEATED_EYE_HEIGHT_M) - SEATED_EYE_HEIGHT_M);
3573
+ maxSurfaceHeightWorld = Math.max(maxSurfaceHeightWorld, surfaceM * CHART_UNITS_PER_METRE);
3574
+ }
3575
+ for (const object of view.objects) {
3576
+ if (object.type !== "section") continue;
3577
+ maxSurfaceHeightWorld = Math.max(
3578
+ maxSurfaceHeightWorld,
3579
+ sectionGeometry(object, { floorBaseHeightM: this.floorBaseHeightMFor(object.id) }).height * CHART_UNITS_PER_METRE
3580
+ );
3581
+ }
3582
+ const camera = createPerspectiveCamera(this.bounds, maxSurfaceHeightWorld);
3583
+ const base = perspectiveTangentAffine(camera, camera.target);
3584
+ const inverse = invertAffine(base);
3585
+ this.perspectiveCamera = camera;
3586
+ this.perspectiveBaseAffine = base;
3587
+ this.perspectiveBaseInverse = inverse;
3588
+ this.perspectiveSeatLocal.clear();
3589
+ this.perspectiveSeatProjected.clear();
3590
+ this.perspectiveSeatDepth.clear();
3591
+ this.perspectiveSeatScale.clear();
3592
+ this.perspectiveBounds = null;
3593
+ let minX = Infinity;
3594
+ let minY = Infinity;
3595
+ let maxX = -Infinity;
3596
+ let maxY = -Infinity;
3597
+ for (const seat of this.seats) {
3598
+ const surfaceM = Math.max(0, (seat.eyeHeightM ?? SEATED_EYE_HEIGHT_M) - SEATED_EYE_HEIGHT_M);
3599
+ const projected = projectPerspectivePoint(camera, seat, surfaceM * CHART_UNITS_PER_METRE);
3600
+ const point = { x: projected.x, y: projected.y };
3601
+ this.perspectiveSeatProjected.set(seat.id, point);
3602
+ this.perspectiveSeatLocal.set(seat.id, applyAffine(inverse, point));
3603
+ this.perspectiveSeatDepth.set(seat.id, projected.depth);
3604
+ this.perspectiveSeatScale.set(seat.id, projected.scale);
3605
+ minX = Math.min(minX, projected.x);
3606
+ minY = Math.min(minY, projected.y);
3607
+ maxX = Math.max(maxX, projected.x);
3608
+ maxY = Math.max(maxY, projected.y);
3609
+ }
3610
+ for (const point of [
3611
+ { x: this.bounds.x, y: this.bounds.y },
3612
+ { x: this.bounds.x + this.bounds.width, y: this.bounds.y },
3613
+ { x: this.bounds.x + this.bounds.width, y: this.bounds.y + this.bounds.height },
3614
+ { x: this.bounds.x, y: this.bounds.y + this.bounds.height }
3615
+ ]) {
3616
+ const projected = projectPerspectivePoint(camera, point, 0);
3617
+ minX = Math.min(minX, projected.x);
3618
+ minY = Math.min(minY, projected.y);
3619
+ maxX = Math.max(maxX, projected.x);
3620
+ maxY = Math.max(maxY, projected.y);
3621
+ }
3622
+ const pad = Math.max(24, this.seatR * 3);
3623
+ this.perspectiveBounds = Number.isFinite(minX) ? { x: minX - pad, y: minY - pad, width: Math.max(1, maxX - minX + pad * 2), height: Math.max(1, maxY - minY + pad * 2) } : null;
3624
+ }
3625
+ /** Apply an affine to a Konva container while keeping `pivot` fixed logically. */
3626
+ setContainerAffine(node, affine, pivot) {
3627
+ const decomposed = decomposeAffineLinear(affine);
3628
+ node.offset(pivot);
3629
+ node.position(applyAffine(affine, pivot));
3630
+ node.rotation(decomposed.rotationDeg);
3631
+ node.scale({ x: decomposed.scaleX, y: decomposed.scaleY });
3632
+ node.skewX(decomposed.skewX);
3633
+ node.skewY(0);
3634
+ }
3635
+ resetContainerTransform(node) {
3636
+ node.position({ x: 0, y: 0 });
3637
+ node.offset({ x: 0, y: 0 });
3638
+ node.scale({ x: 1, y: 1 });
3639
+ node.rotation(0);
3640
+ node.skewX(0);
3641
+ node.skewY(0);
3642
+ }
3643
+ /** Exact raked surface height used for the bounded section tangent plane. */
3644
+ sectionSurfaceHeight(section, point) {
3645
+ const radial = Math.hypot(point.x - section.surfaceFocal.x, point.y - section.surfaceFocal.y);
3646
+ const depth = Math.max(0, radial - section.frontDistanceWorld);
3647
+ return section.liftWorld + depth * Math.tan(section.rakeDeg * Math.PI / 180);
3648
+ }
3649
+ /** Desired perspective-space point for a section surface, before Stage pan/zoom. */
3650
+ projectedSectionPoint(section, point) {
3651
+ return section.perspectiveAffine ? applyAffine(section.perspectiveAffine, point) : applyAffine(this.perspectiveBaseAffine, point);
3652
+ }
3653
+ /** Shared-layer local point which the ground affine paints at `projected`. */
3654
+ perspectiveLocalPoint(projected) {
3655
+ return this.perspectiveBaseInverse ? applyAffine(this.perspectiveBaseInverse, projected) : projected;
3656
+ }
2749
3657
  /**
2750
3658
  * Local-space offset that, once the layer applies the iso affine, lifts an
2751
- * object straight UP in iso-world by `elevation × LIFT_PER_STEP × isoT`
2752
- * (= inverse-linear of the pure vertical lift). Zero at isoT=0.
3659
+ * object straight UP in iso-world by `liftWorld × isoT` world units
3660
+ * (= inverse-linear of the pure vertical lift). Zero at isoT=0. `liftWorld` is
3661
+ * the section's resolved real height in world units (Phase B1).
2753
3662
  */
2754
- isoLiftLocal(elevation) {
3663
+ isoLiftLocal(liftWorld) {
2755
3664
  const { th, sg } = this.isoParams();
2756
- const delta = elevation * LIFT_PER_STEP * this.isoT;
3665
+ const delta = liftWorld * this.isoT;
2757
3666
  return { x: -(delta / sg) * Math.sin(th), y: -(delta / sg) * Math.cos(th) };
2758
3667
  }
2759
- /** Restore the three layers to identity (flat) — byte-for-byte the original. */
2760
- resetLayerTransforms() {
2761
- for (const layer of [this.bgLayer, this.seatLayer, this.overlayLayer]) {
3668
+ /** Restore projection layers to identity (flat) — byte-for-byte the original.
3669
+ * `seatLayer` can be skipped when entering perspective from flat: assigning a
3670
+ * top-level transform invalidates all 14k descendant absolute transforms even
3671
+ * while the semantic overview keeps that layer fully transparent. */
3672
+ resetLayerTransforms(includeSeatLayer = true) {
3673
+ const layers = includeSeatLayer ? [this.bgLayer, this.seatLayer, this.overlayLayer] : [this.bgLayer, this.overlayLayer];
3674
+ for (const layer of layers) {
2762
3675
  layer.position({ x: 0, y: 0 });
2763
3676
  layer.offset({ x: 0, y: 0 });
2764
3677
  layer.scale({ x: 1, y: 1 });
@@ -2767,6 +3680,117 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2767
3680
  layer.skewY(0);
2768
3681
  }
2769
3682
  }
3683
+ /** Restore section-owned containers before applying another projection. */
3684
+ resetSectionProjection(resetSeatGroups = true) {
3685
+ for (const section of this.sections) {
3686
+ this.resetContainerTransform(section.rootGroupBg);
3687
+ this.resetContainerTransform(section.liftGroupBg);
3688
+ if (resetSeatGroups) this.resetContainerTransform(section.liftGroupSeat);
3689
+ section.rootGroupBg.zIndex(section.bgZIndex);
3690
+ if (resetSeatGroups) section.liftGroupSeat.zIndex(section.seatZIndex);
3691
+ const labelGroup = this.labelLiftGroups.get(section.id);
3692
+ if (labelGroup && resetSeatGroups) this.resetContainerTransform(labelGroup);
3693
+ section.perspectiveAffine = void 0;
3694
+ section.perspectiveCorrection = void 0;
3695
+ section.perspectiveDepth = void 0;
3696
+ }
3697
+ }
3698
+ /** Move native seat/booth hit shapes to or from their exact pinhole anchors. */
3699
+ applyExactSeatAnchors(perspective, seatIds) {
3700
+ const seats = seatIds ? [...seatIds].map((id) => this.seatById.get(id)).filter((seat) => !!seat) : perspective ? this.seats : [...this.perspectiveAppliedSeats].map((id) => this.seatById.get(id)).filter((seat) => !!seat);
3701
+ for (const seat of seats) {
3702
+ if (perspective && this.perspectiveAppliedSeats.has(seat.id)) continue;
3703
+ const point = perspective ? this.perspectiveSeatProjected.get(seat.id) ?? seat : seat;
3704
+ const scale = perspective ? this.perspectiveSeatScale.get(seat.id) ?? 1 : 1;
3705
+ const shape = this.circleById.get(seat.id);
3706
+ shape?.position(point);
3707
+ shape?.scale({ x: scale, y: scale });
3708
+ const ring = this.accessRingById.get(seat.id);
3709
+ ring?.position(point);
3710
+ ring?.scale({ x: scale, y: scale });
3711
+ const glyph = this.accessGlyphById.get(seat.id);
3712
+ if (glyph) {
3713
+ const baseGlyphScale = this.seatR * 1.5 / ACCESS_GLYPH_VIEWBOX;
3714
+ glyph.position(point);
3715
+ glyph.scale({ x: baseGlyphScale * scale, y: baseGlyphScale * scale });
3716
+ }
3717
+ const boothLabel = this.boothLabelById.get(seat.id);
3718
+ boothLabel?.position(point);
3719
+ boothLabel?.scale({ x: scale, y: scale });
3720
+ if (perspective) this.perspectiveAppliedSeats.add(seat.id);
3721
+ }
3722
+ if (!perspective) this.perspectiveAppliedSeats.clear();
3723
+ }
3724
+ /**
3725
+ * Phase C projected 2.5D. Seat/booth anchors are exact pinhole projections;
3726
+ * section top surfaces use one explicitly bounded tangent plane per authored
3727
+ * section. Native Konva shapes move with the anchors, so direct hit testing is
3728
+ * the same graph that paints the buyer-visible unit.
3729
+ */
3730
+ applyPerspective(resetElevatedSeatGroups = true) {
3731
+ const camera = this.perspectiveCamera;
3732
+ const base = this.perspectiveBaseAffine;
3733
+ const inverse = this.perspectiveBaseInverse;
3734
+ if (!camera || !base || !inverse) return;
3735
+ const lazySectionSeats = this.seats.length > 2500 && this.sections.length > 0;
3736
+ if (this.cached && !lazySectionSeats) {
3737
+ this.seatLayer.clearCache();
3738
+ this.seatLayer.listening(true);
3739
+ this.cached = false;
3740
+ }
3741
+ this.resetLayerTransforms(resetElevatedSeatGroups);
3742
+ this.resetSectionProjection(resetElevatedSeatGroups);
3743
+ for (const layer of [this.bgLayer, this.overlayLayer]) {
3744
+ this.setContainerAffine(layer, base, camera.target);
3745
+ }
3746
+ this.perspectiveAppliedSeats.clear();
3747
+ if (!lazySectionSeats) this.applyExactSeatAnchors(true);
3748
+ for (const section of this.sections) {
3749
+ const heightAt = (point) => this.sectionSurfaceHeight(section, point);
3750
+ const desired = perspectiveTangentAffine(camera, section.centroid, heightAt);
3751
+ const correction = composeAffine(inverse, desired);
3752
+ section.perspectiveAffine = desired;
3753
+ section.perspectiveCorrection = correction;
3754
+ section.perspectiveDepth = projectPerspectivePoint(
3755
+ camera,
3756
+ section.centroid,
3757
+ heightAt(section.centroid)
3758
+ ).depth;
3759
+ this.setContainerAffine(section.liftGroupBg, correction, section.centroid);
3760
+ for (let index = 0; index < section.sideFaces.length; index++) {
3761
+ const p0 = section.outline[index];
3762
+ const p1 = section.outline[(index + 1) % section.outline.length];
3763
+ const base0 = this.perspectiveLocalPoint(projectPerspectivePoint(camera, p0, 0));
3764
+ const base1 = this.perspectiveLocalPoint(projectPerspectivePoint(camera, p1, 0));
3765
+ const top1 = this.perspectiveLocalPoint(projectPerspectivePoint(camera, p1, heightAt(p1)));
3766
+ const top0 = this.perspectiveLocalPoint(projectPerspectivePoint(camera, p0, heightAt(p0)));
3767
+ section.sideFaces[index].points([
3768
+ base0.x,
3769
+ base0.y,
3770
+ base1.x,
3771
+ base1.y,
3772
+ top1.x,
3773
+ top1.y,
3774
+ top0.x,
3775
+ top0.y
3776
+ ]);
3777
+ section.sideFaces[index].opacity(0.9);
3778
+ }
3779
+ }
3780
+ const farToNear = [...this.sections].sort(
3781
+ (left, right) => (right.perspectiveDepth ?? 0) - (left.perspectiveDepth ?? 0)
3782
+ );
3783
+ for (const section of farToNear) {
3784
+ section.rootGroupBg.moveToTop();
3785
+ section.liftGroupSeat.moveToTop();
3786
+ }
3787
+ this.unsectionedSeatGroup?.moveToTop();
3788
+ this.syncSeatOverlayPositions();
3789
+ if (!lazySectionSeats) this.updateSeatGroupVisibility();
3790
+ this.bgLayer.batchDraw();
3791
+ this.seatLayer.batchDraw();
3792
+ this.overlayLayer.batchDraw();
3793
+ }
2770
3794
  /**
2771
3795
  * Apply the current isoT to the scene: the base rotate+squash as a decomposed
2772
3796
  * layer transform (so seats/décor/rings project together and Konva's own
@@ -2774,6 +3798,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2774
3798
  * lift + extruded side faces on elevated sections.
2775
3799
  */
2776
3800
  applyIso() {
3801
+ this.resetSectionProjection();
3802
+ this.applyExactSeatAnchors(false);
2777
3803
  const t2 = this.isoT;
2778
3804
  if (t2 === 0) {
2779
3805
  this.resetLayerTransforms();
@@ -2802,6 +3828,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2802
3828
  }
2803
3829
  this.applyUprightLabels();
2804
3830
  this.applyElevation();
3831
+ this.updateSeatGroupVisibility();
2805
3832
  this.bgLayer.batchDraw();
2806
3833
  this.seatLayer.batchDraw();
2807
3834
  this.overlayLayer.batchDraw();
@@ -2830,10 +3857,11 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2830
3857
  applyElevation() {
2831
3858
  const t2 = this.isoT;
2832
3859
  for (const sec of this.sections) {
2833
- if (sec.elevation <= 0) continue;
2834
- const off = this.isoLiftLocal(sec.elevation);
3860
+ if (sec.liftWorld <= 0) continue;
3861
+ const off = this.isoLiftLocal(sec.liftWorld);
2835
3862
  sec.liftGroupBg?.position(off);
2836
- sec.liftGroupSeat?.position(off);
3863
+ sec.liftGroupSeat.position(off);
3864
+ this.labelLiftGroups.get(sec.id)?.position(off);
2837
3865
  const alpha = 0.9 * t2;
2838
3866
  for (let i = 0; i < sec.sideFaces.length; i++) {
2839
3867
  const face = sec.sideFaces[i];
@@ -2843,6 +3871,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2843
3871
  face.opacity(alpha);
2844
3872
  }
2845
3873
  }
3874
+ this.syncSeatOverlayPositions();
2846
3875
  }
2847
3876
  destroy() {
2848
3877
  this.destroyed = true;
@@ -2869,16 +3898,166 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2869
3898
  * tier shifts with one offset in iso view) or the seat layer directly.
2870
3899
  */
2871
3900
  seatContainer(id) {
2872
- return this.seatSection.get(id)?.liftGroupSeat ?? this.seatLayer;
3901
+ return this.seatSection.get(id)?.liftGroupSeat ?? this.unsectionedSeatGroup ?? this.seatLayer;
2873
3902
  }
2874
- renderSeats() {
3903
+ /** True when a section belongs to the active logical section/zone focus. */
3904
+ sectionMatchesFocus(section) {
3905
+ const focus = this.focusedSectionId;
3906
+ return focus == null || section.id === focus || section.logicalId === focus || section.zone === focus;
3907
+ }
3908
+ /** Visual opacity after the section-focus overlay is composed. */
3909
+ focusedSeatOpacity(id, localOpacity) {
3910
+ if (!this.focusedSectionId) return localOpacity;
3911
+ const section = this.seatSection.get(id);
3912
+ return localOpacity * (section && this.sectionMatchesFocus(section) ? 1 : FOCUS_DIM_OPACITY);
3913
+ }
3914
+ /** Project a section outline through elevation + the active affine view and
3915
+ * then into viewport pixels. The result is conservative (AABB), so a visible
3916
+ * section is never clipped even for rotated or isometric outlines. */
3917
+ sectionScreenBounds(section) {
3918
+ if (this.viewMode === "perspective" && section.perspectiveAffine) {
3919
+ const scale2 = this.stage.scaleX();
3920
+ const stageX2 = this.stage.x();
3921
+ const stageY2 = this.stage.y();
3922
+ return polyBounds(section.outline.map((point) => {
3923
+ const projected = this.projectedSectionPoint(section, point);
3924
+ return { x: projected.x * scale2 + stageX2, y: projected.y * scale2 + stageY2 };
3925
+ }));
3926
+ }
3927
+ const offset = section.liftWorld > 0 ? this.isoLiftLocal(section.liftWorld) : { x: 0, y: 0 };
3928
+ const scale = this.stage.scaleX();
3929
+ const stageX = this.stage.x();
3930
+ const stageY = this.stage.y();
3931
+ return polyBounds(section.outline.map((point) => {
3932
+ const local = { x: point.x + offset.x, y: point.y + offset.y };
3933
+ const projected = this.isoT === 0 ? local : this.isoForward(local);
3934
+ return { x: projected.x * scale + stageX, y: projected.y * scale + stageY };
3935
+ }));
3936
+ }
3937
+ /** Hide only section groups wholly outside a padded viewport at the live-seat
3938
+ * rung. Overview caching always restores all groups first, so panning a
3939
+ * cached whole-venue bitmap can never reveal missing inventory. */
3940
+ updateSeatGroupVisibility() {
3941
+ const liveSeats = this.effScale() >= CACHE_THRESHOLD;
3942
+ const deferPerspectiveSeatReveal = this.viewMode === "perspective" && this.glideInProgress && this.seats.length > 2500;
3943
+ const padding = 96;
3944
+ const width = this.stage.width();
3945
+ const height = this.stage.height();
3946
+ for (const section of this.sections) {
3947
+ let visible = true;
3948
+ if (liveSeats) {
3949
+ const bounds2 = this.sectionScreenBounds(section);
3950
+ visible = bounds2.x + bounds2.width >= -padding && bounds2.y + bounds2.height >= -padding && bounds2.x <= width + padding && bounds2.y <= height + padding;
3951
+ if (visible && this.viewMode === "perspective" && !deferPerspectiveSeatReveal) {
3952
+ this.applyExactSeatAnchors(true, section.memberIds);
3953
+ }
3954
+ }
3955
+ section.liftGroupSeat.setViewportCulled(!visible);
3956
+ const labelGroup = this.labelLiftGroups.get(section.id);
3957
+ labelGroup?.setViewportCulled(!visible);
3958
+ }
3959
+ if (this.unsectionedSeatGroup && !this.unsectionedSeatGroup.visible()) {
3960
+ this.unsectionedSeatGroup.visible(true);
3961
+ }
3962
+ if (liveSeats && this.viewMode === "perspective" && !deferPerspectiveSeatReveal) {
3963
+ this.applyExactSeatAnchors(true, this.seats.filter((seat) => !this.seatSection.has(seat.id)).map((seat) => seat.id));
3964
+ }
3965
+ }
3966
+ /** Seats worth considering for viewport labels. Section culling is a safe
3967
+ * coarse index; the later screen test remains the exact filter. */
3968
+ visibleSeatCandidates() {
3969
+ if (!this.sections.length) return this.seats;
3970
+ const candidates = [];
3971
+ for (const section of this.sections) {
3972
+ if (section.liftGroupSeat.isViewportCulled()) continue;
3973
+ for (const id of section.memberIds) {
3974
+ const seat = this.seatById.get(id);
3975
+ if (seat) candidates.push(seat);
3976
+ }
3977
+ }
2875
3978
  for (const seat of this.seats) {
3979
+ if (!this.seatSection.has(seat.id)) candidates.push(seat);
3980
+ }
3981
+ return candidates;
3982
+ }
3983
+ seatViewportCulled(id) {
3984
+ return this.seatSection.get(id)?.liftGroupSeat.isViewportCulled() ?? false;
3985
+ }
3986
+ /** Local world coordinate at which an elevated seat is actually painted for
3987
+ * the current iso tween. Flat seats and the 2D endpoint are unchanged. */
3988
+ renderedSeatPoint(seat) {
3989
+ if (this.viewMode === "perspective") return this.perspectiveSeatLocal.get(seat.id) ?? seat;
3990
+ const section = this.seatSection.get(seat.id);
3991
+ if (!section || section.liftWorld <= 0 || this.isoT === 0) return seat;
3992
+ const offset = this.isoLiftLocal(section.liftWorld);
3993
+ return { x: seat.x + offset.x, y: seat.y + offset.y };
3994
+ }
3995
+ /** Overlay labels mirror the seat-layer lift without an O(seats) per-frame
3996
+ * rewrite. The groups are rebuilt with the zoom-dependent label set. */
3997
+ seatLabelContainer(id) {
3998
+ const section = this.seatSection.get(id);
3999
+ if (!section) return this.labelGroup;
4000
+ let group = this.labelLiftGroups.get(section.id);
4001
+ if (!group) {
4002
+ group = new ViewportGroup({
4003
+ listening: false
4004
+ });
4005
+ group.setViewportCulled(section.liftGroupSeat.isViewportCulled());
4006
+ group.position(this.viewMode === "perspective" ? { x: 0, y: 0 } : this.isoLiftLocal(section.liftWorld));
4007
+ this.labelGroup.add(group);
4008
+ this.labelLiftGroups.set(section.id, group);
4009
+ }
4010
+ return group;
4011
+ }
4012
+ /** Shared overlay furniture is not parented to section lift groups, so keep
4013
+ * the small transient set aligned explicitly. Selection is capped in buyer
4014
+ * mode, making this constant-sized work during the iso tween. */
4015
+ syncSeatOverlayPositions() {
4016
+ if (this.hoveredId) {
4017
+ const seat = this.seatById.get(this.hoveredId);
4018
+ if (seat) {
4019
+ this.hoverRing.position(this.renderedSeatPoint(seat));
4020
+ const scale = this.viewMode === "perspective" ? this.perspectiveSeatScale.get(seat.id) ?? 1 : 1;
4021
+ this.hoverRing.scale({ x: scale, y: scale });
4022
+ }
4023
+ }
4024
+ if (this.focusedId) {
4025
+ const seat = this.seatById.get(this.focusedId);
4026
+ if (seat) {
4027
+ this.focusRing.position(this.renderedSeatPoint(seat));
4028
+ const scale = this.viewMode === "perspective" ? this.perspectiveSeatScale.get(seat.id) ?? 1 : 1;
4029
+ this.focusRing.scale({ x: scale, y: scale });
4030
+ }
4031
+ }
4032
+ for (const [id, marker] of this.selectionMarkers) {
4033
+ const seat = this.seatById.get(id);
4034
+ if (seat) {
4035
+ marker.position(this.renderedSeatPoint(seat));
4036
+ const scale = this.viewMode === "perspective" ? this.perspectiveSeatScale.get(seat.id) ?? 1 : 1;
4037
+ marker.scale({ x: scale, y: scale });
4038
+ }
4039
+ }
4040
+ }
4041
+ renderSeats() {
4042
+ const paintOrder = [...this.seats].sort((left, right) => (this.perspectiveSeatDepth.get(right.id) ?? 0) - (this.perspectiveSeatDepth.get(left.id) ?? 0));
4043
+ for (const seat of paintOrder) {
2876
4044
  if (seat.kind === "booth") {
2877
4045
  this.renderBoothUnit(seat);
2878
4046
  continue;
2879
4047
  }
2880
4048
  const target = this.seatContainer(seat.id);
2881
- const c = new Circle({
4049
+ const c = seat.wheelchairSpaceType === "no-seat" ? new Rect({
4050
+ x: seat.x,
4051
+ y: seat.y,
4052
+ width: this.seatR * 2,
4053
+ height: this.seatR * 2,
4054
+ offsetX: this.seatR,
4055
+ offsetY: this.seatR,
4056
+ cornerRadius: 2,
4057
+ perfectDrawEnabled: false,
4058
+ shadowForStrokeEnabled: false,
4059
+ hitStrokeWidth: 0
4060
+ }) : new Circle({
2882
4061
  x: seat.x,
2883
4062
  y: seat.y,
2884
4063
  radius: this.seatR,
@@ -2891,21 +4070,25 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2891
4070
  this.paintSeat(c, seat.id);
2892
4071
  target.add(c);
2893
4072
  if (seat.accessible) {
2894
- const ring = new Circle({
2895
- x: seat.x,
2896
- y: seat.y,
2897
- radius: this.seatR + 1.5,
2898
- stroke: accessibilityRingColor(seat.accessibility),
2899
- strokeWidth: 2.5,
2900
- listening: false,
2901
- perfectDrawEnabled: false,
2902
- shadowForStrokeEnabled: false
2903
- });
2904
- this.accessRingById.set(seat.id, ring);
2905
- target.add(ring);
2906
- const glyph = this.buildAccessGlyph(seat, typeof c.fill() === "string" ? c.fill() : "");
2907
- this.accessGlyphById.set(seat.id, glyph);
2908
- target.add(glyph);
4073
+ if (seat.wheelchairSpaceType !== "no-seat") {
4074
+ const ring = new Circle({
4075
+ x: seat.x,
4076
+ y: seat.y,
4077
+ radius: this.seatR + 1.5,
4078
+ stroke: accessibilityRingColor(seat.accessibility),
4079
+ strokeWidth: 2.5,
4080
+ listening: false,
4081
+ perfectDrawEnabled: false,
4082
+ shadowForStrokeEnabled: false
4083
+ });
4084
+ this.accessRingById.set(seat.id, ring);
4085
+ target.add(ring);
4086
+ }
4087
+ if (seat.accessibility?.includes("wheelchair")) {
4088
+ const glyph = this.buildAccessGlyph(seat, typeof c.fill() === "string" ? c.fill() : "");
4089
+ this.accessGlyphById.set(seat.id, glyph);
4090
+ target.add(glyph);
4091
+ }
2909
4092
  }
2910
4093
  }
2911
4094
  }
@@ -2943,7 +4126,6 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2943
4126
  t2.offsetX(t2.width() / 2);
2944
4127
  t2.offsetY(t2.height() / 2);
2945
4128
  t2.visible(false);
2946
- this.boothLabelById.set(seat.id, t2);
2947
4129
  this.hasBoothText = true;
2948
4130
  this.boothLabelById.set(seat.id, t2);
2949
4131
  target.add(t2);
@@ -2966,24 +4148,40 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2966
4148
  scaleY: k,
2967
4149
  fill: stateAwareBookableLabelInk(seatFill, "#ffffff"),
2968
4150
  listening: false,
2969
- visible: this.accessGlyphVisible,
4151
+ visible: false,
2970
4152
  perfectDrawEnabled: false,
2971
4153
  shadowForStrokeEnabled: false
2972
4154
  });
2973
4155
  }
2974
4156
  /**
2975
- * Toggle the accessibility glyphs for the current camera scale: shown once the
2976
- * effective on-screen seat radius clears {@link SEAT_GLYPH_MIN_PX}, otherwise
2977
- * hidden so only the ring remains. Iterates the (small) accessible-seat set,
2978
- * never the full node graph, so it is cheap to call on every view change.
4157
+ * Keep physical wheelchair provision readable in screen space at every map
4158
+ * LOD. The active Wheelchair filter promotes it further. Iterates the small
4159
+ * wheelchair-seat set, never the full node graph.
2979
4160
  */
2980
4161
  updateAccessGlyphs(scale) {
2981
4162
  if (!this.accessGlyphById.size) return;
2982
- this.accessGlyphVisible = this.seatR * scale >= SEAT_GLYPH_MIN_PX;
2983
4163
  for (const [id, glyph] of this.accessGlyphById) {
2984
- glyph.visible(this.accessGlyphVisible && this.accessGlyphEligible(id));
4164
+ const perspectiveScale = this.viewMode === "perspective" ? this.perspectiveSeatScale.get(id) ?? 1 : 1;
4165
+ const baseScale = this.seatR * 1.5 / ACCESS_GLYPH_VIEWBOX * perspectiveScale;
4166
+ const emphasized = this.accessGlyphFilterEmphasized(id);
4167
+ const minimumScreenPx = emphasized ? FILTERED_WHEELCHAIR_GLYPH_MIN_PX : WHEELCHAIR_GLYPH_MIN_PX;
4168
+ const glyphScale = Math.max(
4169
+ baseScale,
4170
+ minimumScreenPx / (ACCESS_GLYPH_VIEWBOX * Math.max(scale, 1e-3))
4171
+ );
4172
+ glyph.scale({ x: glyphScale, y: glyphScale });
4173
+ glyph.visible(this.accessGlyphShouldShow(id));
2985
4174
  }
2986
4175
  }
4176
+ /** True only for a physical wheelchair provision matching an active buyer filter. */
4177
+ accessGlyphFilterEmphasized(id) {
4178
+ const seat = this.seatById.get(id);
4179
+ const filter = this.accessFilter;
4180
+ return !!seat && filter !== null && !!seat.accessibility?.includes("wheelchair") && (filter.length === 0 || filter.includes("wheelchair")) && seatMatchesAccess(seat, filter);
4181
+ }
4182
+ accessGlyphShouldShow(id) {
4183
+ return this.accessGlyphById.has(id) && this.accessGlyphEligible(id);
4184
+ }
2987
4185
  /**
2988
4186
  * Whether an accessible seat's glyph should show for its current status. It is
2989
4187
  * hidden on seats a buyer cannot take (sold, or another buyer's hold) where the
@@ -3063,6 +4261,11 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3063
4261
  c.dash([2, 2]);
3064
4262
  break;
3065
4263
  }
4264
+ if (seat.wheelchairSpaceType === "no-seat" && status === "free") {
4265
+ c.stroke(accessibilityRingColor(seat.accessibility));
4266
+ c.strokeWidth(selected ? 3 : 2);
4267
+ c.dash([3, 2]);
4268
+ }
3066
4269
  if (boothLabel) {
3067
4270
  boothLabel.text(status === "booked" ? "SOLD" : status === "held" ? "HELD" : seat.label);
3068
4271
  boothLabel.fontSize(status === "free" ? 10 : Math.min(10, Math.max(6, this.boothDims.get(seat.rowId)?.width ?? 40) / 6));
@@ -3096,11 +4299,6 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3096
4299
  c.dash([]);
3097
4300
  c.opacity(CLOSED_SEAT_OPACITY);
3098
4301
  }
3099
- if (this.focusedSectionId) {
3100
- const sec = this.seatSection.get(id);
3101
- const inFocus = !!sec && (sec.id === this.focusedSectionId || sec.logicalId === this.focusedSectionId || sec.zone === this.focusedSectionId);
3102
- if (!inFocus) c.opacity(FOCUS_DIM_OPACITY);
3103
- }
3104
4302
  if (this.selectionFocusId && id !== this.selectionFocusId) c.opacity(Math.min(c.opacity(), 0.16));
3105
4303
  const bookableLabel = this.boothLabelById.get(id) ?? this.seatLabelById.get(id);
3106
4304
  if (bookableLabel) {
@@ -3114,7 +4312,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3114
4312
  const fill = c.fill();
3115
4313
  accessGlyph.fill(stateAwareBookableLabelInk(typeof fill === "string" ? fill : "", "#ffffff"));
3116
4314
  accessGlyph.opacity(c.opacity());
3117
- accessGlyph.visible(this.accessGlyphVisible && this.accessGlyphEligible(id));
4315
+ accessGlyph.visible(this.accessGlyphShouldShow(id));
3118
4316
  }
3119
4317
  this.accessRingById.get(id)?.opacity(c.opacity());
3120
4318
  }
@@ -3183,7 +4381,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3183
4381
  if (!this.sections.some((section) => section.id === id || section.logicalId === id)) return;
3184
4382
  this.focusedSectionId = id;
3185
4383
  this.drawFocusBackdrop(id);
3186
- this.repaintSectionsAndSeats();
4384
+ this.bgLayer.batchDraw();
4385
+ this.overlayLayer.batchDraw();
3187
4386
  this.updateLOD();
3188
4387
  this.focusRegion(id, { minScale: SEAT_FOCUS_SCALE });
3189
4388
  }
@@ -3195,7 +4394,10 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3195
4394
  this.focusBackdrop.destroy();
3196
4395
  this.focusBackdrop = null;
3197
4396
  }
3198
- this.repaintSectionsAndSeats();
4397
+ this.focusDimOverlay?.destroy();
4398
+ this.focusDimOverlay = null;
4399
+ this.bgLayer.batchDraw();
4400
+ this.overlayLayer.batchDraw();
3199
4401
  this.updateLOD();
3200
4402
  }
3201
4403
  /** The currently AXS-focused section id, or null. */
@@ -3212,16 +4414,69 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3212
4414
  if (!sections.length) return;
3213
4415
  const backdrop = new Group({ listening: false });
3214
4416
  for (const section of sections) {
3215
- backdrop.add(polygonWithHolesShape(section.outline, section.holes, {
4417
+ const perspective = this.viewMode === "perspective" && section.perspectiveAffine;
4418
+ const outline = perspective ? section.outline.map((point) => this.perspectiveLocalPoint(this.projectedSectionPoint(section, point))) : section.outline;
4419
+ const holes = perspective ? section.holes.map((hole) => hole.map((point) => this.perspectiveLocalPoint(this.projectedSectionPoint(section, point)))) : section.holes;
4420
+ backdrop.add(polygonWithHolesShape(outline, holes, {
3216
4421
  fill: FOCUS_BACKDROP_FILL,
3217
4422
  stroke: rgba("#ffffff", 0.1),
3218
4423
  strokeWidth: 1,
3219
4424
  listening: false
3220
- }, section.outlinePath));
4425
+ }, perspective ? void 0 : section.outlinePath));
3221
4426
  }
3222
4427
  this.bgLayer.add(backdrop);
3223
4428
  backdrop.moveToTop();
3224
4429
  this.focusBackdrop = backdrop;
4430
+ this.drawFocusDimOverlay(sections);
4431
+ }
4432
+ /** Dim everything outside the active section with one paint-only shape. Group
4433
+ * opacity would make Konva recursively invalidate absolute-opacity caches on
4434
+ * all 14k descendants. The cut-out follows the live elevation offset and is
4435
+ * non-listening, so direct seat hit-testing is unchanged. */
4436
+ drawFocusDimOverlay(sections) {
4437
+ this.focusDimOverlay?.destroy();
4438
+ const span = Math.max(this.bounds.width, this.bounds.height, 1);
4439
+ const padding = span * 4 + 1e3;
4440
+ const outer = [
4441
+ { x: this.bounds.x - padding, y: this.bounds.y - padding },
4442
+ { x: this.bounds.x + this.bounds.width + padding, y: this.bounds.y - padding },
4443
+ { x: this.bounds.x + this.bounds.width + padding, y: this.bounds.y + this.bounds.height + padding },
4444
+ { x: this.bounds.x - padding, y: this.bounds.y + this.bounds.height + padding }
4445
+ ];
4446
+ const signedArea = (points) => points.reduce((sum, point, index) => {
4447
+ const next = points[(index + 1) % points.length];
4448
+ return sum + point.x * next.y - next.x * point.y;
4449
+ }, 0);
4450
+ const overlay = new Shape({
4451
+ fill: this.canvasBackground,
4452
+ opacity: 1 - FOCUS_DIM_OPACITY,
4453
+ listening: false,
4454
+ perfectDrawEnabled: false,
4455
+ sceneFunc: (context, shape) => {
4456
+ const polygonPath = (points) => {
4457
+ if (!points.length) return;
4458
+ context.moveTo(points[0].x, points[0].y);
4459
+ for (let index = 1; index < points.length; index++) context.lineTo(points[index].x, points[index].y);
4460
+ context.closePath();
4461
+ };
4462
+ context.beginPath();
4463
+ polygonPath(outer);
4464
+ for (const section of sections) {
4465
+ const hole = this.viewMode === "perspective" && section.perspectiveAffine ? section.outline.map((point) => this.perspectiveLocalPoint(this.projectedSectionPoint(section, point))) : section.outline.map((point) => {
4466
+ const offset = section.liftWorld > 0 ? this.isoLiftLocal(section.liftWorld) : { x: 0, y: 0 };
4467
+ return { x: point.x + offset.x, y: point.y + offset.y };
4468
+ });
4469
+ polygonPath(signedArea(hole) > 0 ? [...hole].reverse() : hole);
4470
+ }
4471
+ context.fillStrokeShape(shape);
4472
+ }
4473
+ });
4474
+ this.overlayLayer.add(overlay);
4475
+ overlay.moveToTop();
4476
+ for (const marker of this.selectionMarkers.values()) marker.moveToTop();
4477
+ this.hoverRing.moveToTop();
4478
+ this.focusRing.moveToTop();
4479
+ this.focusDimOverlay = overlay;
3225
4480
  }
3226
4481
  /** Repaint every seat + section block to reflect closed/focus state, then redraw. */
3227
4482
  repaintSectionsAndSeats() {
@@ -3285,7 +4540,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3285
4540
  this.applyGAFilterState();
3286
4541
  }
3287
4542
  renderBackground(doc) {
3288
- if (doc.backgroundImage) this.renderBackgroundImage(doc.backgroundImage);
4543
+ const buyerBackground = buyerBackgroundImage(doc);
4544
+ if (buyerBackground) this.renderBackgroundImage(buyerBackground);
3289
4545
  for (const obj of doc.objects) if (obj.type === "decorImage") this.renderDecorImage(obj);
3290
4546
  for (const obj of doc.objects) if (obj.type === "section") this.renderSection(obj);
3291
4547
  this.renderZones(doc);
@@ -3365,10 +4621,12 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3365
4621
  listening: false,
3366
4622
  perfectDrawEnabled: false
3367
4623
  });
3368
- this.bgLayer.add(node);
4624
+ if (obj.layer === "foreground") this.fgDecorGroup.add(node);
4625
+ else this.bgLayer.add(node);
3369
4626
  img.onload = () => {
3370
- if (!node.getLayer()) return;
3371
- this.bgLayer.batchDraw();
4627
+ const layer = node.getLayer();
4628
+ if (!layer) return;
4629
+ layer.batchDraw();
3372
4630
  };
3373
4631
  img.src = obj.href;
3374
4632
  }
@@ -3405,7 +4663,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3405
4663
  })
3406
4664
  );
3407
4665
  }
3408
- const label = this.addCentredLabel(this.bgLayer, obj.label, obj.center.x, obj.center.y, "#cbd5e1", 12, true);
4666
+ const label = this.addCentredLabel(this.bgLayer, obj.displayLabel ?? obj.label, obj.center.x, obj.center.y, "#cbd5e1", 12, true);
3409
4667
  this.freeTextById.set(obj.id, { node: label, background: "#232c40", kind: "table" });
3410
4668
  }
3411
4669
  renderText(obj) {
@@ -3417,11 +4675,12 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3417
4675
  text: obj.text,
3418
4676
  fontSize: obj.fontSize,
3419
4677
  rotation: obj.rotation,
4678
+ fontStyle: konvaFontStyle(obj.bold, obj.italic, "normal"),
3420
4679
  // Authored ink remains preferred, but an embed/theme surface can change
3421
4680
  // the actual canvas. Fail over to readable black/white instead of
3422
4681
  // painting an otherwise valid caption invisibly on that active surface.
3423
4682
  fill: stateAwareBookableLabelInk(background, preferredInk),
3424
- fontFamily: this.labelFont(),
4683
+ fontFamily: obj.fontFamily ?? this.labelFont(),
3425
4684
  listening: false,
3426
4685
  perfectDrawEnabled: false
3427
4686
  });
@@ -3439,11 +4698,40 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3439
4698
  const isDecor = !!obj.role && !isStage;
3440
4699
  const palette = overviewPalette(this.canvasBackground);
3441
4700
  const fill = referenceFocal ? palette.focalFill : authoredFill;
3442
- const stroke = isStage ? lighten(fill, 0.28) : referenceFocal ? palette.focalStroke : void 0;
3443
- const strokeWidth = isStage ? 1 : referenceFocal ? 2 : 0;
4701
+ const isOpenPath = obj.kind === "line" || obj.kind === "polyline";
4702
+ const stroke = obj.stroke?.color ?? (isStage ? lighten(fill, 0.28) : referenceFocal ? palette.focalStroke : isOpenPath ? "#9aa3b5" : void 0);
4703
+ const strokeWidth = obj.stroke?.width ?? (isStage ? 1 : referenceFocal ? 2 : isOpenPath ? 2 : 0);
4704
+ const opacity = obj.opacity != null ? clamp(obj.opacity, 0.1, 1) : 1;
3444
4705
  let cx = 0;
3445
4706
  let cy = 0;
3446
- if (obj.kind === "rect" && obj.x != null && obj.y != null && obj.width != null && obj.height != null) {
4707
+ if (isOpenPath && obj.points && obj.points.length >= 2) {
4708
+ cx = obj.points.reduce((a, p) => a + p.x, 0) / obj.points.length;
4709
+ cy = obj.points.reduce((a, p) => a + p.y, 0) / obj.points.length;
4710
+ const lineStyle = resolvedShapeLineStyle(obj);
4711
+ const arrow = shapeArrowMetrics(strokeWidth);
4712
+ const path = new Arrow({
4713
+ points: obj.points.flatMap((p) => [p.x, p.y]),
4714
+ closed: false,
4715
+ x: cx,
4716
+ y: cy,
4717
+ offsetX: cx,
4718
+ offsetY: cy,
4719
+ rotation: obj.rotation ?? 0,
4720
+ stroke,
4721
+ fill: stroke,
4722
+ strokeWidth,
4723
+ opacity,
4724
+ lineCap: lineStyle.lineCap,
4725
+ lineJoin: lineStyle.lineJoin,
4726
+ pointerAtBeginning: lineStyle.startEnding === "arrow",
4727
+ pointerAtEnding: lineStyle.endEnding === "arrow",
4728
+ ...arrow,
4729
+ listening: false,
4730
+ name: "buyer-shape-open-path"
4731
+ });
4732
+ path.setAttr("shapeObjectId", obj.id);
4733
+ this.bgLayer.add(path);
4734
+ } else if (obj.kind === "rect" && obj.x != null && obj.y != null && obj.width != null && obj.height != null) {
3447
4735
  const grad = isStage ? {
3448
4736
  fillLinearGradientStartPoint: { x: 0, y: 0 },
3449
4737
  fillLinearGradientEndPoint: { x: 0, y: obj.height },
@@ -3463,7 +4751,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3463
4751
  ...grad,
3464
4752
  stroke,
3465
4753
  strokeWidth,
3466
- cornerRadius: 4,
4754
+ cornerRadius: clamp(obj.cornerRadius ?? 4, 0, Math.min(obj.width, obj.height) / 2),
4755
+ opacity,
3467
4756
  listening: false
3468
4757
  })
3469
4758
  );
@@ -3476,7 +4765,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3476
4765
  fillLinearGradientColorStops: [0, darken(fill, 0.3), 1, lighten(fill, 0.12)]
3477
4766
  } : { fill };
3478
4767
  this.bgLayer.add(
3479
- new Ellipse({ x: cx, y: cy, rotation: obj.rotation ?? 0, radiusX: obj.width / 2, radiusY: obj.height / 2, ...grad, stroke, strokeWidth, listening: false })
4768
+ new Ellipse({ x: cx, y: cy, rotation: obj.rotation ?? 0, radiusX: obj.width / 2, radiusY: obj.height / 2, ...grad, stroke, strokeWidth, opacity, listening: false })
3480
4769
  );
3481
4770
  } else if (obj.kind === "polygon" && obj.points && obj.points.length) {
3482
4771
  const pts = obj.points.flatMap((p) => [p.x, p.y]);
@@ -3491,7 +4780,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3491
4780
  fillLinearGradientColorStops: [0, darken(fill, 0.3), 1, lighten(fill, 0.12)]
3492
4781
  } : { fill };
3493
4782
  this.bgLayer.add(
3494
- new Line({ points: pts, closed: true, x: cx, y: cy, offsetX: cx, offsetY: cy, rotation: obj.rotation ?? 0, ...grad, stroke, strokeWidth, listening: false })
4783
+ new Line({ points: pts, closed: true, x: cx, y: cy, offsetX: cx, offsetY: cy, rotation: obj.rotation ?? 0, ...grad, stroke, strokeWidth, opacity, listening: false })
3495
4784
  );
3496
4785
  }
3497
4786
  if (obj.label) {
@@ -3550,7 +4839,10 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3550
4839
  strokeWidth: 1.5
3551
4840
  });
3552
4841
  poly.setAttr("gaId", obj.id);
3553
- poly.on("click tap", () => this.opts.onGAClick?.(obj.id));
4842
+ poly.on("click tap", (e) => {
4843
+ if (this.isGhostClick(e)) return;
4844
+ this.opts.onGAClick?.(obj.id);
4845
+ });
3554
4846
  poly.on("mouseenter", () => {
3555
4847
  this.container.style.cursor = "pointer";
3556
4848
  });
@@ -3560,7 +4852,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3560
4852
  this.bgLayer.add(poly);
3561
4853
  const labelPoint = polygonLabelPoint(obj.points, obj.holes);
3562
4854
  const containingSection = this.sections.find((section) => pointInPolygonWithHoles(labelPoint, section.outline, section.holes));
3563
- const label = this.addCentredLabel(this.bgLayer, obj.label, labelPoint.x, labelPoint.y - 8, ink, GA_LABEL_FONT_SIZE, false);
4855
+ const label = this.addCentredLabel(this.bgLayer, obj.displayLabel ?? obj.label, labelPoint.x, labelPoint.y - 8, ink, GA_LABEL_FONT_SIZE, false);
3564
4856
  const capacity = this.addCentredLabel(this.bgLayer, `cap ${obj.capacity}`, labelPoint.x, labelPoint.y + 10, ink, GA_CAPACITY_LABEL_FONT_SIZE, false);
3565
4857
  this.freeTextById.set(`${obj.id}:label`, {
3566
4858
  objectId: obj.id,
@@ -3577,7 +4869,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3577
4869
  categoryKey: obj.categoryKey
3578
4870
  });
3579
4871
  this.gaById.set(obj.id, {
3580
- label: obj.label,
4872
+ label: obj.displayLabel ?? obj.label,
3581
4873
  capacity: obj.capacity,
3582
4874
  categoryKey: obj.categoryKey,
3583
4875
  points: obj.points,
@@ -3600,7 +4892,9 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3600
4892
  const memberIds = [];
3601
4893
  const catCounts = /* @__PURE__ */ new Map();
3602
4894
  let free = 0;
3603
- for (const seat of this.seats) {
4895
+ const bounds2 = polyBounds(obj.outline);
4896
+ const candidates = this.seatIndex ? queryRect(this.seatIndex, bounds2).map((id) => this.seatById.get(id)).filter((seat) => !!seat) : this.seats;
4897
+ for (const seat of candidates) {
3604
4898
  if (this.seatSection.has(seat.id)) continue;
3605
4899
  if (!pointInPolygonWithHoles(seat, obj.outline, obj.holes)) continue;
3606
4900
  memberIds.push(seat.id);
@@ -3611,11 +4905,19 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3611
4905
  [...catCounts].map(([key, w]) => ({ hex: this.catColor.get(key) ?? "#6e7bff", w })),
3612
4906
  "#3a4358"
3613
4907
  );
3614
- const elevation = Math.max(0, Math.round(obj.elevation ?? 0));
3615
- let liftGroupBg = null;
3616
- let liftGroupSeat = null;
4908
+ const elevation = sectionElevationTier(obj.elevation);
4909
+ const geometry = sectionGeometry(obj, {
4910
+ floorBaseHeightM: this.floorBaseHeightMFor(obj.id)
4911
+ });
4912
+ const liftWorld = geometry.height * CHART_UNITS_PER_METRE;
4913
+ const rootGroupBg = new Group({ listening: false });
4914
+ this.bgLayer.add(rootGroupBg);
4915
+ const liftGroupBg = new Group({ listening: false });
4916
+ rootGroupBg.add(liftGroupBg);
4917
+ const liftGroupSeat = new ViewportGroup({ listening: true });
4918
+ this.seatLayer.add(liftGroupSeat);
3617
4919
  const sideFaces = [];
3618
- if (elevation > 0) {
4920
+ if (liftWorld > 0) {
3619
4921
  const faceFill = darken(this.zoneColor.get(obj.zone ?? "") ?? baseFill, 0.42);
3620
4922
  for (let i = 0; i < obj.outline.length; i++) {
3621
4923
  const face = new Line({
@@ -3629,14 +4931,11 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3629
4931
  perfectDrawEnabled: false
3630
4932
  });
3631
4933
  sideFaces.push(face);
3632
- this.bgLayer.add(face);
4934
+ rootGroupBg.add(face);
3633
4935
  }
3634
- liftGroupBg = new Group({ listening: false });
3635
- this.bgLayer.add(liftGroupBg);
3636
- liftGroupSeat = new Group({ listening: false });
3637
- this.seatLayer.add(liftGroupSeat);
3638
4936
  }
3639
- const bgTarget = liftGroupBg ?? this.bgLayer;
4937
+ liftGroupBg.moveToTop();
4938
+ const bgTarget = liftGroupBg;
3640
4939
  const outlineTint = obj.color ?? this.zoneColor.get(obj.zone ?? "") ?? "#3a4358";
3641
4940
  const outlinePoly = polygonWithHolesShape(obj.outline, obj.holes, {
3642
4941
  stroke: rgba(outlineTint, 0.5),
@@ -3710,10 +5009,23 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3710
5009
  nameLabelFits: true,
3711
5010
  subLabelFits: true,
3712
5011
  elevation,
5012
+ liftWorld,
5013
+ rakeDeg: geometry.rake,
5014
+ surfaceFocal: memberIds.length ? { ...this.seatById.get(memberIds[0])?.focalPoint ?? this.isoCentre } : { ...this.chartDoc?.focalPoint ?? this.isoCentre },
5015
+ frontDistanceWorld: 0,
5016
+ rootGroupBg,
5017
+ bgZIndex: rootGroupBg.zIndex(),
5018
+ seatZIndex: liftGroupSeat.zIndex(),
3713
5019
  liftGroupBg,
3714
5020
  liftGroupSeat,
3715
5021
  sideFaces
3716
5022
  };
5023
+ const surfaceCandidates = memberIds.map((id) => this.seatById.get(id)).filter((seat) => !!seat);
5024
+ const distanceCandidates = surfaceCandidates.length ? surfaceCandidates : obj.outline;
5025
+ sec.frontDistanceWorld = Math.min(...distanceCandidates.map((point) => Math.hypot(
5026
+ point.x - sec.surfaceFocal.x,
5027
+ point.y - sec.surfaceFocal.y
5028
+ )));
3717
5029
  for (const id of memberIds) this.seatSection.set(id, sec);
3718
5030
  this.refreshSectionFill(sec);
3719
5031
  this.refreshSectionHeat(sec);
@@ -3859,15 +5171,16 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3859
5171
  const sectionOverview = scale < CACHE_THRESHOLD;
3860
5172
  if (sectionOverview) blockT = 1;
3861
5173
  if (!this.zones.length) zoneT = 0;
3862
- this.seatLayer.opacity(sectionOverview ? 0 : 1 - blockT);
5174
+ const deferPerspectiveSeatReveal = this.viewMode === "perspective" && this.glideInProgress && this.seats.length > 2500;
5175
+ this.seatLayer.opacity(sectionOverview || deferPerspectiveSeatReveal ? 0 : 1 - blockT);
3863
5176
  const sx = this.stage.scaleX();
3864
- const rescale = this.lodScale === 0 || Math.abs(scale - this.lodScale) / (this.lodScale || 1) > 0.02;
5177
+ const rescale = !this.glideInProgress && (this.lodScale === 0 || Math.abs(scale - this.lodScale) / (this.lodScale || 1) > 0.08);
3865
5178
  if (rescale) this.lodScale = scale;
3866
5179
  const focus = this.focusedSectionId;
3867
5180
  const palette = overviewPalette(this.canvasBackground);
3868
5181
  const sectionLabelT = clamp((blockT - 0.2) / 0.8, 0, 1);
3869
5182
  for (const sec of this.sections) {
3870
- const dim = focus && sec.id !== focus && sec.logicalId !== focus && sec.zone !== focus ? FOCUS_DIM_OPACITY : 1;
5183
+ const dim = this.focusDimOverlay ? 1 : focus && sec.id !== focus && sec.logicalId !== focus && sec.zone !== focus ? FOCUS_DIM_OPACITY : 1;
3871
5184
  sec.outlinePoly.opacity(sectionOverview ? 0 : (1 - blockT) * dim);
3872
5185
  sec.blockPoly.opacity(BLOCK_FILL_ALPHA * blockT * dim);
3873
5186
  sec.blockPoly.stroke(palette.sectionStroke);
@@ -3879,7 +5192,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3879
5192
  );
3880
5193
  sec.nameLabel.fill(sectionInk);
3881
5194
  sec.subLabel.fill(sectionInk);
3882
- if (rescale) this.fitSectionRungLabels(sec, sx);
5195
+ if (rescale && sectionLabelT > 0.01) this.fitSectionRungLabels(sec, sx);
3883
5196
  const labelOpacity = sectionLabelT * (1 - zoneT) * dim;
3884
5197
  sec.nameLabel.opacity(sec.nameLabelFits ? labelOpacity : 0);
3885
5198
  sec.subLabel.opacity(0);
@@ -3891,12 +5204,14 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3891
5204
  if (zone.sub) zone.sub.opacity(zoneOpacity);
3892
5205
  if (rescale) this.sizeZonePill(zone, sx);
3893
5206
  }
3894
- this.decollideRungLabels(sx);
3895
- this.dedupeLogicalSectionLabels();
3896
- for (const zone of this.zones) {
3897
- const opacity = zone.label.opacity();
3898
- zone.back.opacity(opacity);
3899
- if (zone.sub) zone.sub.opacity(opacity);
5207
+ if (!this.glideInProgress && (sectionLabelT > 0.01 || zoneOpacity > 0.01)) {
5208
+ this.decollideRungLabels(sx);
5209
+ this.dedupeLogicalSectionLabels();
5210
+ for (const zone of this.zones) {
5211
+ const opacity = zone.label.opacity();
5212
+ zone.back.opacity(opacity);
5213
+ if (zone.sub) zone.sub.opacity(opacity);
5214
+ }
3900
5215
  }
3901
5216
  this.bgLayer.batchDraw();
3902
5217
  }
@@ -4023,13 +5338,30 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4023
5338
  screenToWorld(clientPoint) {
4024
5339
  const s = this.stage.scaleX();
4025
5340
  const iso = { x: (clientPoint.x - this.stage.x()) / s, y: (clientPoint.y - this.stage.y()) / s };
5341
+ if (this.viewMode === "perspective" && this.perspectiveBaseInverse) {
5342
+ return applyAffine(this.perspectiveBaseInverse, iso);
5343
+ }
4026
5344
  return this.isoT === 0 ? iso : this.isoInverse(iso);
4027
5345
  }
4028
5346
  /** Section id under a container-relative screen point, or null (Slice 5 tap-to-zoom). */
4029
5347
  sectionAt(clientPoint) {
4030
5348
  if (!this.sections.length) return null;
4031
5349
  const world = this.screenToWorld(clientPoint);
4032
- const hit = this.sections.find((sec) => pointInPolygonWithHoles(world, sec.outline, sec.holes));
5350
+ const hit = this.sections.find((sec) => {
5351
+ if (this.viewMode === "perspective" && sec.perspectiveAffine) {
5352
+ return pointInPolygonWithHoles(
5353
+ world,
5354
+ sec.outline.map((point) => this.perspectiveLocalPoint(this.projectedSectionPoint(sec, point))),
5355
+ sec.holes.map((hole) => hole.map((point) => this.perspectiveLocalPoint(this.projectedSectionPoint(sec, point))))
5356
+ );
5357
+ }
5358
+ const offset = sec.liftWorld > 0 ? this.isoLiftLocal(sec.liftWorld) : { x: 0, y: 0 };
5359
+ return pointInPolygonWithHoles(
5360
+ { x: world.x - offset.x, y: world.y - offset.y },
5361
+ sec.outline,
5362
+ sec.holes
5363
+ );
5364
+ });
4033
5365
  return hit ? hit.logicalId : null;
4034
5366
  }
4035
5367
  /** Seat ids belonging to a section (Slice 5 section-summary card). */
@@ -4125,15 +5457,20 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4125
5457
  if (!seat) return;
4126
5458
  const candidate = this.selectionFocusId === id;
4127
5459
  const dims = this.boothDims.get(seat.rowId);
5460
+ const rendered = this.renderedSeatPoint(seat);
4128
5461
  const marker = new Group({
4129
5462
  name: "selection-ring",
4130
- x: seat.x,
4131
- y: seat.y,
5463
+ x: rendered.x,
5464
+ y: rendered.y,
4132
5465
  rotation: dims?.rotation ?? 0,
4133
5466
  listening: false,
4134
5467
  perfectDrawEnabled: false,
4135
5468
  opacity: this.selectionFocusId && !candidate ? 0.2 : 1
4136
5469
  });
5470
+ if (this.viewMode === "perspective") {
5471
+ const projectionScale = this.perspectiveSeatScale.get(id) ?? 1;
5472
+ marker.scale({ x: projectionScale, y: projectionScale });
5473
+ }
4137
5474
  marker.setAttr("seatId", id);
4138
5475
  const common = {
4139
5476
  stroke: this.effSelection,
@@ -4212,12 +5549,22 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4212
5549
  * must fall through and pick.
4213
5550
  */
4214
5551
  sectionBounds(id) {
4215
- const bounds = this.sections.filter((section) => section.id === id || section.logicalId === id).map((section) => polyBounds(section.outline));
4216
- if (!bounds.length) return null;
4217
- const left = Math.min(...bounds.map((box) => box.x));
4218
- const top = Math.min(...bounds.map((box) => box.y));
4219
- const right = Math.max(...bounds.map((box) => box.x + box.width));
4220
- const bottom = Math.max(...bounds.map((box) => box.y + box.height));
5552
+ const bounds2 = this.sections.filter((section) => section.id === id || section.logicalId === id).map((section) => {
5553
+ if (this.viewMode === "perspective" && section.perspectiveAffine) {
5554
+ return polyBounds(section.outline.map((point) => this.projectedSectionPoint(section, point)));
5555
+ }
5556
+ const offset = section.liftWorld > 0 ? this.isoLiftLocal(section.liftWorld) : { x: 0, y: 0 };
5557
+ const points = section.outline.map((point) => {
5558
+ const local = { x: point.x + offset.x, y: point.y + offset.y };
5559
+ return this.isoT === 0 ? local : this.isoForward(local);
5560
+ });
5561
+ return polyBounds(points);
5562
+ });
5563
+ if (!bounds2.length) return null;
5564
+ const left = Math.min(...bounds2.map((box) => box.x));
5565
+ const top = Math.min(...bounds2.map((box) => box.y));
5566
+ const right = Math.max(...bounds2.map((box) => box.x + box.width));
5567
+ const bottom = Math.max(...bounds2.map((box) => box.y + box.height));
4221
5568
  return { x: left, y: top, width: right - left, height: bottom - top };
4222
5569
  }
4223
5570
  sectionFrameScale(id) {
@@ -4270,15 +5617,16 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4270
5617
  * hijacking a clean tap on empty space beyond the slop.
4271
5618
  */
4272
5619
  nearestSeatToScreen(screen, slopPx) {
4273
- const s = this.stage.scaleX() || 1;
4274
- const reachWorld = this.seatR + slopPx / s;
4275
- const world = this.screenToWorld(screen);
4276
5620
  let best = null;
4277
- let bestD = reachWorld;
5621
+ let bestD = Infinity;
5622
+ const stageScale = this.stage.scaleX() || 1;
4278
5623
  for (const seat of this.seats) {
4279
5624
  if (!this.selection.has(seat.id) && !this.isSelectable(seat.id)) continue;
4280
- const d = Math.hypot(seat.x - world.x, seat.y - world.y);
4281
- if (d < bestD) {
5625
+ const centre = this.worldToScreen(seat);
5626
+ const perspectiveScale = this.viewMode === "perspective" ? this.perspectiveSeatScale.get(seat.id) ?? 1 : 1;
5627
+ const reachPx = this.seatR * stageScale * perspectiveScale + slopPx;
5628
+ const d = Math.hypot(centre.x - screen.x, centre.y - screen.y);
5629
+ if (d <= reachPx && d < bestD) {
4282
5630
  bestD = d;
4283
5631
  best = seat.id;
4284
5632
  }
@@ -4288,6 +5636,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4288
5636
  wireInteraction() {
4289
5637
  this.seatLayer.on("click tap", (e) => {
4290
5638
  if (this.moved > PAN_START_SLOP_PX) return;
5639
+ if (this.isGhostClick(e)) return;
4291
5640
  const id = seatIdOf(e.target);
4292
5641
  if (!id) return;
4293
5642
  this.handleSeatTap(id);
@@ -4297,13 +5646,17 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4297
5646
  if (!id) return;
4298
5647
  const seat = this.seatById.get(id);
4299
5648
  if (!seat) return;
4300
- this.hoverRing.position({ x: seat.x, y: seat.y });
5649
+ this.hoveredId = id;
5650
+ this.hoverRing.position(this.renderedSeatPoint(seat));
5651
+ const projectionScale = this.viewMode === "perspective" ? this.perspectiveSeatScale.get(id) ?? 1 : 1;
5652
+ this.hoverRing.scale({ x: projectionScale, y: projectionScale });
4301
5653
  this.hoverRing.visible(true);
4302
5654
  this.overlayLayer.batchDraw();
4303
5655
  this.container.style.cursor = "pointer";
4304
5656
  this.opts.onHover?.(seat);
4305
5657
  });
4306
5658
  this.seatLayer.on("mouseout", () => {
5659
+ this.hoveredId = null;
4307
5660
  this.hoverRing.visible(false);
4308
5661
  this.overlayLayer.batchDraw();
4309
5662
  this.container.style.cursor = "default";
@@ -4318,6 +5671,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4318
5671
  });
4319
5672
  this.stage.on("click tap", (e) => {
4320
5673
  if (this.moved > PAN_START_SLOP_PX) return;
5674
+ if (this.isGhostClick(e)) return;
4321
5675
  const pointer = this.stage.getPointerPosition();
4322
5676
  if (!pointer) return;
4323
5677
  if (!this.cached) {
@@ -4334,11 +5688,10 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4334
5688
  }
4335
5689
  }
4336
5690
  if (this.sections.length) {
4337
- const world = this.screenToWorld(pointer);
4338
- const hit = this.sections.find((sn) => pointInPolygonWithHoles(world, sn.outline, sn.holes));
4339
- if (hit) {
4340
- if (this.opts.onSectionTap) this.opts.onSectionTap(hit.logicalId);
4341
- else this.focusRegion(hit.logicalId);
5691
+ const sectionId = this.sectionAt(pointer);
5692
+ if (sectionId) {
5693
+ if (this.opts.onSectionTap) this.opts.onSectionTap(sectionId);
5694
+ else this.focusRegion(sectionId);
4342
5695
  return;
4343
5696
  }
4344
5697
  }
@@ -4355,13 +5708,15 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4355
5708
  */
4356
5709
  deckFloorAt() {
4357
5710
  if (!this.objectFloor.size) return null;
4358
- const p = this.seatLayer.getRelativePointerPosition();
4359
- if (!p) return null;
5711
+ const pointer = this.stage.getPointerPosition();
5712
+ if (!pointer) return null;
5713
+ const p = this.screenToWorld(pointer);
4360
5714
  let best = null;
4361
5715
  let bestD = Infinity;
4362
5716
  for (const s of this.seats) {
4363
- const dx = s.x - p.x;
4364
- const dy = s.y - p.y;
5717
+ const rendered = this.renderedSeatPoint(s);
5718
+ const dx = rendered.x - p.x;
5719
+ const dy = rendered.y - p.y;
4365
5720
  const d = dx * dx + dy * dy;
4366
5721
  if (d < bestD) {
4367
5722
  bestD = d;
@@ -4448,9 +5803,11 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4448
5803
  }
4449
5804
  const start = performance.now();
4450
5805
  const duration = Math.max(180, Math.min(1200, opts?.durationMs ?? CAMERA_GLIDE_MS));
5806
+ this.glideInProgress = true;
4451
5807
  const step = (now) => {
4452
5808
  if (this.destroyed) {
4453
5809
  this.glideRaf = 0;
5810
+ this.glideInProgress = false;
4454
5811
  return;
4455
5812
  }
4456
5813
  const raw = Math.min(1, (now - start) / duration);
@@ -4458,6 +5815,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4458
5815
  const sc = fromScale + (toScale - fromScale) * e;
4459
5816
  this.stage.scale({ x: sc, y: sc });
4460
5817
  this.stage.position({ x: fromX + (toX - fromX) * e, y: fromY + (toY - fromY) * e });
5818
+ this.updateSeatGroupVisibility();
4461
5819
  this.updateLOD();
4462
5820
  this.scheduleViewChange();
4463
5821
  this.stage.batchDraw();
@@ -4465,6 +5823,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4465
5823
  this.glideRaf = requestAnimationFrame(step);
4466
5824
  } else {
4467
5825
  this.glideRaf = 0;
5826
+ this.glideInProgress = false;
4468
5827
  this.afterViewChange();
4469
5828
  }
4470
5829
  };
@@ -4476,6 +5835,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4476
5835
  cancelAnimationFrame(this.glideRaf);
4477
5836
  this.glideRaf = 0;
4478
5837
  }
5838
+ this.glideInProgress = false;
4479
5839
  }
4480
5840
  /** Current LOD rung derived from effective zoom — drives the ZONES/SECTIONS/SEATS pill. */
4481
5841
  getRung() {
@@ -4492,11 +5852,14 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4492
5852
  const labels = this.seats.map((seat) => {
4493
5853
  const shape = this.circleById.get(seat.id);
4494
5854
  const label = this.boothLabelById.get(seat.id) ?? this.seatLabelById.get(seat.id);
5855
+ const localPerspectiveScale = this.viewMode === "perspective" ? this.perspectiveSeatScale.get(seat.id) ?? 1 : 1;
5856
+ const seatEffectiveScale = (this.viewMode === "perspective" ? stageScale : effectiveScale) * localPerspectiveScale;
4495
5857
  const authoredFontSize = seat.kind === "booth" ? BOOTH_LABEL_FONT_SIZE : SEAT_LABEL_FONT_SIZE * this.seatLabelScale(seat);
4496
- const renderedFontPx = rounded((label?.fontSize() ?? authoredFontSize) * effectiveScale);
5858
+ const labelEffectiveScale = this.viewMode === "perspective" && seat.kind !== "booth" ? effectiveScale * localPerspectiveScale : seatEffectiveScale;
5859
+ const renderedFontPx = rounded((label?.fontSize() ?? authoredFontSize) * labelEffectiveScale);
4497
5860
  const screen = this.worldToScreen(seat);
4498
5861
  const outside = screen.x < 0 || screen.x > viewport.width || screen.y < 0 || screen.y > viewport.height;
4499
- const opacity = shape?.opacity() ?? 0;
5862
+ const opacity = this.focusedSeatOpacity(seat.id, shape?.getAbsoluteOpacity() ?? 0);
4500
5863
  const section = this.seatSection.get(seat.id);
4501
5864
  const visible = Boolean(label?.isVisible()) && opacity >= 0.5 && !outside;
4502
5865
  let hiddenReason;
@@ -4507,17 +5870,21 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4507
5870
  else if (!label) hiddenReason = "clutter-or-fit";
4508
5871
  else hiddenReason = "renderer-hidden";
4509
5872
  }
4510
- const labelWidth = label ? label.width() * stageScale : 0;
4511
- const labelHeight = label ? label.height() * effectiveScale : 0;
4512
- const directWidthPx = shape instanceof Rect ? shape.width() * stageScale : this.seatR * 2 * effectiveScale;
4513
- const directHeightPx = shape instanceof Rect ? shape.height() * stageScale : this.seatR * 2 * effectiveScale;
4514
- const assistedDiameterPx = 2 * (this.seatR * effectiveScale + SEAT_TAP_SLOP_PX);
5873
+ const labelWidth = label ? label.width() * stageScale * localPerspectiveScale : 0;
5874
+ const labelHeight = label ? label.height() * (this.viewMode === "perspective" && seat.kind === "booth" ? stageScale : effectiveScale) * localPerspectiveScale : 0;
5875
+ const directWidthPx = shape instanceof Rect ? shape.width() * stageScale * localPerspectiveScale : this.seatR * 2 * seatEffectiveScale;
5876
+ const directHeightPx = shape instanceof Rect ? shape.height() * (this.viewMode === "perspective" ? stageScale : effectiveScale) * localPerspectiveScale : this.seatR * 2 * seatEffectiveScale;
5877
+ const assistedDiameterPx = 2 * (this.seatR * seatEffectiveScale + SEAT_TAP_SLOP_PX);
4515
5878
  const fill = shape?.fill();
4516
5879
  const ink = label?.fill();
5880
+ const accessGlyph = this.accessGlyphById.get(seat.id);
5881
+ const accessGlyphScale = accessGlyph?.getAbsoluteScale();
4517
5882
  return {
4518
5883
  seatId: seat.id,
4519
5884
  label: seat.label,
4520
5885
  kind: seat.kind === "booth" ? "booth" : "seat",
5886
+ markerShape: seat.kind === "booth" ? "booth" : seat.wheelchairSpaceType === "no-seat" ? "square" : "circle",
5887
+ ...seat.wheelchairSpaceType ? { wheelchairSpaceType: seat.wheelchairSpaceType } : {},
4521
5888
  categoryKey: seat.categoryKey,
4522
5889
  ...section ? { sectionId: section.id } : {},
4523
5890
  ...section?.zone ? { zoneId: section.zone } : {},
@@ -4528,8 +5895,15 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4528
5895
  fill: typeof fill === "string" ? fill : "",
4529
5896
  ink: typeof ink === "string" ? ink : this.theme.seatLabelColor ?? DEF_SEAT_LABEL,
4530
5897
  opacity: rounded(opacity),
5898
+ ...accessGlyph ? {
5899
+ accessibilityMarker: {
5900
+ glyphVisible: accessGlyph.isVisible(),
5901
+ glyphWidthPx: rounded(ACCESS_GLYPH_VIEWBOX * Math.abs(accessGlyphScale?.x ?? 0)),
5902
+ emphasizedByFilter: this.accessGlyphFilterEmphasized(seat.id)
5903
+ }
5904
+ } : {},
4531
5905
  pointerTarget: {
4532
- active: !this.cached && this.isSelectable(seat.id),
5906
+ active: !this.cached && !this.seatViewportCulled(seat.id) && Boolean(shape?.isVisible()) && Boolean(shape?.isListening()) && this.isSelectable(seat.id),
4533
5907
  directWidthPx: rounded(directWidthPx),
4534
5908
  directHeightPx: rounded(directHeightPx),
4535
5909
  effectiveMinimumPx: rounded(Math.max(
@@ -4557,7 +5931,11 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4557
5931
  node.height(),
4558
5932
  node.rotation()
4559
5933
  );
4560
- const screenBounds = pointsBounds(worldCorners.map((corner) => this.worldToScreen(corner)));
5934
+ const lift = section && section.liftWorld > 0 ? this.isoLiftLocal(section.liftWorld) : { x: 0, y: 0 };
5935
+ const screenBounds = pointsBounds(worldCorners.map((corner) => this.worldToScreen({
5936
+ x: corner.x + lift.x,
5937
+ y: corner.y + lift.y
5938
+ })));
4561
5939
  const opacity = rounded(node.opacity());
4562
5940
  const ink = node.fill();
4563
5941
  const outside = screenBounds.x + screenBounds.width < 0 || screenBounds.x > viewport.width || screenBounds.y + screenBounds.height < 0 || screenBounds.y > viewport.height;
@@ -4696,6 +6074,15 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4696
6074
  const visibleSectionShells = this.sections.filter((section) => section.blockPoly.opacity() > 0.05);
4697
6075
  return {
4698
6076
  viewport,
6077
+ projection: this.viewMode,
6078
+ ...this.viewMode === "perspective" ? {
6079
+ perspective: {
6080
+ model: "pinhole-exact-seat-anchors",
6081
+ sectionSurfaceModel: "tangent-plane",
6082
+ exactSeatAnchorCount: this.perspectiveSeatProjected.size,
6083
+ depthSorted: true
6084
+ }
6085
+ } : {},
4699
6086
  canvasBackground: this.canvasBackground,
4700
6087
  effectiveScale: rounded(effectiveScale),
4701
6088
  rung: this.getRung(),
@@ -4750,10 +6137,10 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4750
6137
  let cy = viewCentre.y;
4751
6138
  if (rung === "sections" && this.sections.length > 0) {
4752
6139
  const sectionCentres = this.sections.map((section) => {
4753
- const bounds = polyBounds(section.outline);
6140
+ const bounds2 = polyBounds(section.outline);
4754
6141
  return {
4755
- x: bounds.x + bounds.width / 2,
4756
- y: bounds.y + bounds.height / 2
6142
+ x: bounds2.x + bounds2.width / 2,
6143
+ y: bounds2.y + bounds2.height / 2
4757
6144
  };
4758
6145
  });
4759
6146
  const halfWidth = w / (target * 2);
@@ -4820,6 +6207,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4820
6207
  }
4821
6208
  /** Recompute LOD (cache/labels) after any pan/zoom settles. */
4822
6209
  afterViewChange() {
6210
+ this.updateSeatGroupVisibility();
4823
6211
  this.updateLOD();
4824
6212
  this.updateFreeTextVisibility();
4825
6213
  this.updateLabels();
@@ -4844,10 +6232,15 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4844
6232
  this.paintGAStateForView();
4845
6233
  this.updateAccessGlyphs(scale);
4846
6234
  const shouldCache = scale < CACHE_THRESHOLD;
6235
+ const suppressPerspectiveSeatCache = this.viewMode === "perspective" && this.hasSections && this.seats.length > 2500 && shouldCache;
6236
+ if (suppressPerspectiveSeatCache) {
6237
+ this.seatLayer.listening(false);
6238
+ return;
6239
+ }
4847
6240
  if (shouldCache && !this.cached) {
4848
6241
  this.cacheSeatLayer();
4849
- } else if (!shouldCache && this.cached) {
4850
- this.seatLayer.clearCache();
6242
+ } else if (!shouldCache && (this.cached || !this.seatLayer.listening())) {
6243
+ if (this.cached) this.seatLayer.clearCache();
4851
6244
  this.seatLayer.listening(true);
4852
6245
  this.cached = false;
4853
6246
  this.seatLayer.batchDraw();
@@ -4856,6 +6249,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4856
6249
  /** Rebuild the seat-layer bitmap synchronously (no paint). Shared by the
4857
6250
  * debounced cacheSeatLayer() and the synchronous forceDraw() catch-up. */
4858
6251
  rebuildSeatCache() {
6252
+ this.updateSeatGroupVisibility();
4859
6253
  const pr = clamp(this.stage.scaleX() * this.dpr, 0.15, 2);
4860
6254
  this.seatLayer.clearCache();
4861
6255
  this.seatLayer.cache({ pixelRatio: pr });
@@ -4907,31 +6301,31 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4907
6301
  for (const [id, label] of this.boothLabelById) {
4908
6302
  const shape = this.circleById.get(id);
4909
6303
  label.visible(
4910
- isBookableLabelLegibleAtScale(label.fontSize(), effectiveScale) && (shape?.opacity() ?? 1) >= 0.5
6304
+ isBookableLabelLegibleAtScale(label.fontSize(), effectiveScale) && Boolean(shape?.isVisible()) && this.focusedSeatOpacity(id, shape?.getAbsoluteOpacity() ?? 1) >= 0.5
4911
6305
  );
4912
6306
  }
4913
6307
  this.labelGroup.destroyChildren();
6308
+ this.labelLiftGroups.clear();
4914
6309
  this.seatLabelById.clear();
4915
6310
  if (!show) {
4916
6311
  this.overlayLayer.batchDraw();
4917
6312
  return;
4918
6313
  }
4919
- const s = this.stage.scaleX();
4920
- const x0 = -this.stage.x() / s;
4921
- const y0 = -this.stage.y() / s;
4922
- const x1 = (this.stage.width() - this.stage.x()) / s;
4923
- const y1 = (this.stage.height() - this.stage.y()) / s;
4924
6314
  let count = 0;
4925
- for (const seat of this.seats) {
6315
+ for (const seat of this.visibleSeatCandidates()) {
4926
6316
  if (seat.kind === "booth") continue;
4927
- if (seat.x < x0 || seat.x > x1 || seat.y < y0 || seat.y > y1) continue;
4928
- if (seat.accessible && this.accessGlyphVisible) continue;
6317
+ const screen = this.worldToScreen(seat);
6318
+ if (screen.x < -20 || screen.x > this.stage.width() + 20 || screen.y < -20 || screen.y > this.stage.height() + 20) continue;
6319
+ if (this.accessGlyphById.has(seat.id) && this.accessGlyphShouldShow(seat.id)) continue;
4929
6320
  const shape = this.circleById.get(seat.id);
4930
- if ((shape?.opacity() ?? 1) < 0.5) continue;
6321
+ if (!shape?.isVisible() || this.focusedSeatOpacity(seat.id, shape.getAbsoluteOpacity()) < 0.5) continue;
4931
6322
  const status = this.statusById.get(seat.id) ?? "free";
6323
+ const labelPoint = this.renderedSeatPoint(seat);
6324
+ const perspectiveScale = this.viewMode === "perspective" ? this.perspectiveSeatScale.get(seat.id) ?? 1 : 1;
4932
6325
  const unavailable = status === "booked" || status === "held" && !this.ownedHold.has(seat.id) && !this.opts.manageMode;
4933
6326
  if (unavailable) {
4934
- const cue = new Group({ x: seat.x, y: seat.y, listening: false });
6327
+ const cue = new Group({ x: labelPoint.x, y: labelPoint.y, listening: false });
6328
+ cue.scale({ x: perspectiveScale, y: perspectiveScale });
4935
6329
  if (status === "held") {
4936
6330
  cue.add(new Rect({
4937
6331
  x: -4.2,
@@ -4960,14 +6354,14 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4960
6354
  listening: false
4961
6355
  }));
4962
6356
  }
4963
- this.labelGroup.add(cue);
6357
+ this.seatLabelContainer(seat.id).add(cue);
4964
6358
  if (++count >= MAX_LABELS) break;
4965
6359
  continue;
4966
6360
  }
4967
6361
  const authoredFontSize = SEAT_LABEL_FONT_SIZE * this.seatLabelScale(seat);
4968
6362
  const t2 = new Text({
4969
- x: seat.x,
4970
- y: seat.y,
6363
+ x: labelPoint.x,
6364
+ y: labelPoint.y,
4971
6365
  text: bookableMarkerLabel(seat.displayLabel ?? seat.label),
4972
6366
  fontSize: authoredFontSize,
4973
6367
  fontStyle: "600",
@@ -4988,11 +6382,12 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4988
6382
  }
4989
6383
  t2.offsetX(t2.width() / 2);
4990
6384
  t2.offsetY(t2.height() / 2);
4991
- this.labelGroup.add(t2);
6385
+ t2.scale({ x: perspectiveScale, y: perspectiveScale });
6386
+ this.seatLabelContainer(seat.id).add(t2);
4992
6387
  this.seatLabelById.set(seat.id, t2);
4993
6388
  if (++count >= MAX_LABELS) break;
4994
6389
  }
4995
- if (this.isoT > 0) this.applyUprightLabels();
6390
+ if (this.isoT > 0 && this.viewMode !== "perspective") this.applyUprightLabels();
4996
6391
  this.overlayLayer.batchDraw();
4997
6392
  }
4998
6393
  handleResize() {
@@ -5058,6 +6453,7 @@ function strandedSingles(seats, statusOf, selectedIds) {
5058
6453
 
5059
6454
  // src/core/bestAvailable.ts
5060
6455
  function seatIndex2(seat, fallback) {
6456
+ if (Number.isInteger(seat.logicalSeatIndex)) return seat.logicalSeatIndex;
5061
6457
  const i = seat.id.lastIndexOf(":");
5062
6458
  if (i < 0) return fallback;
5063
6459
  const n = Number(seat.id.slice(i + 1));
@@ -5077,21 +6473,25 @@ function centroid(seats) {
5077
6473
  }
5078
6474
  return { x: x / seats.length, y: y / seats.length };
5079
6475
  }
6476
+ function candidateFocal(seats, fallback) {
6477
+ return seats.find((seat) => seat.focalPoint)?.focalPoint ?? fallback;
6478
+ }
5080
6479
  function isPremium(seat) {
5081
6480
  return seat.commercial?.premium === true;
5082
6481
  }
5083
6482
  function pickBestAvailable(seats, available, opts) {
5084
6483
  const qty = Math.floor(opts.qty);
5085
6484
  if (!Number.isFinite(qty) || qty <= 0) return { labels: [], reason: "sold_out" };
5086
- const { categoryKey, focal, preferPremium } = opts;
6485
+ const { categoryKey, zoneId, focal, preferPremium } = opts;
5087
6486
  const rows = /* @__PURE__ */ new Map();
5088
6487
  const eligibleAll = [];
5089
6488
  seats.forEach((seat, i) => {
5090
- const elig = available.has(seat.label) && (!categoryKey || seat.categoryKey === categoryKey);
5091
- let arr = rows.get(seat.rowId);
6489
+ const elig = available.has(seat.label) && (!categoryKey || seat.categoryKey === categoryKey) && (!zoneId || seat.zoneId === zoneId);
6490
+ const rowId = seat.logicalRowId ?? seat.rowId;
6491
+ let arr = rows.get(rowId);
5092
6492
  if (!arr) {
5093
6493
  arr = [];
5094
- rows.set(seat.rowId, arr);
6494
+ rows.set(rowId, arr);
5095
6495
  }
5096
6496
  arr.push({ seat, index: seatIndex2(seat, i), elig });
5097
6497
  if (elig) eligibleAll.push(seat);
@@ -5116,7 +6516,7 @@ function pickBestAvailable(seats, available, opts) {
5116
6516
  continue;
5117
6517
  }
5118
6518
  let j = i;
5119
- while (j < slots.length && slots[j].elig) j++;
6519
+ while (j < slots.length && slots[j].elig && (j === i || slots[j].index === slots[j - 1].index + 1)) j++;
5120
6520
  const segLen = j - i;
5121
6521
  for (let p = 0; p + qty <= segLen; p++) {
5122
6522
  const leftRem = p;
@@ -5130,7 +6530,7 @@ function pickBestAvailable(seats, available, opts) {
5130
6530
  startIndex: slots[i + p].index,
5131
6531
  orphan,
5132
6532
  nonPremium: preferPremium ? runSeats.reduce((n, s) => n + (isPremium(s) ? 0 : 1), 0) : 0,
5133
- d2: dist2(centroid(runSeats), focal)
6533
+ d2: dist2(centroid(runSeats), candidateFocal(runSeats, focal))
5134
6534
  };
5135
6535
  if (better(c)) best = c;
5136
6536
  }
@@ -5139,20 +6539,71 @@ function pickBestAvailable(seats, available, opts) {
5139
6539
  }
5140
6540
  if (best) return { labels: best.labels };
5141
6541
  if (eligibleAll.length < qty) return { labels: [], reason: "not_enough_together" };
5142
- const ranked = eligibleAll.map((seat, i) => ({ seat, i, d2: dist2(seat, focal) })).sort((a, b) => {
6542
+ const ranked = eligibleAll.map((seat, i) => ({ seat, i, d2: dist2(seat, seat.focalPoint ?? focal) })).sort((a, b) => {
5143
6543
  if (preferPremium) {
5144
6544
  const pa = isPremium(a.seat) ? 0 : 1;
5145
6545
  const pb = isPremium(b.seat) ? 0 : 1;
5146
6546
  if (pa !== pb) return pa - pb;
5147
6547
  }
5148
6548
  if (a.d2 !== b.d2) return a.d2 - b.d2;
5149
- const r = a.seat.rowId.localeCompare(b.seat.rowId);
6549
+ const r = (a.seat.logicalRowId ?? a.seat.rowId).localeCompare(b.seat.logicalRowId ?? b.seat.rowId);
5150
6550
  if (r !== 0) return r;
5151
6551
  return seatIndex2(a.seat, a.i) - seatIndex2(b.seat, b.i);
5152
6552
  });
5153
6553
  return { labels: ranked.slice(0, qty).map((r) => r.seat.label) };
5154
6554
  }
5155
6555
 
6556
+ // src/core/tableInventory.ts
6557
+ var GroupedTableProjectionError = class extends Error {
6558
+ constructor(objectId, message) {
6559
+ super(message);
6560
+ this.objectId = objectId;
6561
+ this.name = "GroupedTableProjectionError";
6562
+ }
6563
+ };
6564
+ function bounds(table) {
6565
+ const min = table.minOccupancy;
6566
+ const max = table.maxOccupancy;
6567
+ if (!Number.isInteger(min) || !Number.isInteger(max) || min < 1 || max < min || max > table.seatCount) {
6568
+ throw new GroupedTableProjectionError(
6569
+ table.id,
6570
+ `Table "${table.label}" has invalid variable-occupancy bounds`
6571
+ );
6572
+ }
6573
+ return { min, max };
6574
+ }
6575
+ function groupedTableInventory(doc, modelVersion) {
6576
+ if (modelVersion !== 2) return [];
6577
+ const units = [];
6578
+ for (const object of allObjects(doc)) {
6579
+ if (object.type !== "table" || !object.bookAsWhole && !object.variableOccupancy) continue;
6580
+ if (object.bookAsWhole && object.variableOccupancy) {
6581
+ throw new GroupedTableProjectionError(
6582
+ object.id,
6583
+ `Table "${object.label}" cannot be whole-table and variable-occupancy inventory`
6584
+ );
6585
+ }
6586
+ if (!Number.isInteger(object.seatCount) || object.seatCount < 1 || !object.label.trim()) {
6587
+ throw new GroupedTableProjectionError(object.id, "Grouped tables require a label and at least one chair");
6588
+ }
6589
+ const mode = object.variableOccupancy ? "variable" : "whole";
6590
+ const occupancy = mode === "variable" ? bounds(object) : { min: object.seatCount, max: object.seatCount };
6591
+ units.push({
6592
+ objectId: object.id,
6593
+ label: object.label,
6594
+ ...object.displayLabel ? { displayLabel: object.displayLabel } : {},
6595
+ ...object.displayType ? { displayType: object.displayType } : {},
6596
+ categoryKey: object.categoryKey,
6597
+ mode,
6598
+ capacity: object.seatCount,
6599
+ minOccupancy: occupancy.min,
6600
+ maxOccupancy: occupancy.max,
6601
+ chairs: expandTable(object)
6602
+ });
6603
+ }
6604
+ return units;
6605
+ }
6606
+
5156
6607
  // src/picker/PickerController.ts
5157
6608
  var DEFAULT_MAX_SELECTION = 10;
5158
6609
  var MAX_BACKOFF_MS = 15e3;
@@ -5184,6 +6635,14 @@ var PickerController = class {
5184
6635
  /** id → buyer-facing spatial metadata used by every tooltip/confirm surface. */
5185
6636
  this.seatContext = /* @__PURE__ */ new Map();
5186
6637
  this.allIds = [];
6638
+ /** Model-2 tables are one booking unit whose chairs remain renderer geometry. */
6639
+ this.groupedTablesByObject = /* @__PURE__ */ new Map();
6640
+ this.groupedTablesByLabel = /* @__PURE__ */ new Map();
6641
+ this.groupedTableBySeatId = /* @__PURE__ */ new Map();
6642
+ this.tableQuantities = /* @__PURE__ */ new Map();
6643
+ /** Variable quantity is a buyer contract, never an inferred default. */
6644
+ this.confirmedVariableTables = /* @__PURE__ */ new Set();
6645
+ this.groupedSelectionOverhead = 0;
5187
6646
  // realtime socket
5188
6647
  this.ws = null;
5189
6648
  this.reconnectTimer = null;
@@ -5245,6 +6704,20 @@ var PickerController = class {
5245
6704
  idForLabel(label) {
5246
6705
  return this.labelToId.get(label);
5247
6706
  }
6707
+ idsForLabel(label) {
6708
+ const table = this.groupedTablesByLabel.get(label);
6709
+ if (table) return table.chairs.map((chair) => chair.id);
6710
+ const id = this.labelToId.get(label);
6711
+ return id ? [id] : [];
6712
+ }
6713
+ idsForLabels(labels) {
6714
+ return [...new Set([...labels].flatMap((label) => this.idsForLabel(label)))];
6715
+ }
6716
+ /** Resolve a physical chair id (or table inventory label) to its table unit. */
6717
+ tableSelection(seatIdOrLabel) {
6718
+ const group = this.groupedTableBySeatId.get(seatIdOrLabel) ?? this.groupedTablesByLabel.get(seatIdOrLabel);
6719
+ return group ? this.toTableSeat(group) : null;
6720
+ }
5248
6721
  /** Fetch chart, build label maps, mount the renderer, seed statuses, go live. */
5249
6722
  async render(host) {
5250
6723
  if (this.renderer) return null;
@@ -5258,6 +6731,29 @@ var PickerController = class {
5258
6731
  }
5259
6732
  if (this.closed) return null;
5260
6733
  this._doc = res.doc;
6734
+ try {
6735
+ const groups = groupedTableInventory(
6736
+ res.doc,
6737
+ res.event.inventoryModelVersion === 2 ? 2 : 1
6738
+ );
6739
+ this.groupedTablesByObject = new Map(groups.map((group) => [group.objectId, group]));
6740
+ this.groupedTablesByLabel = new Map(groups.map((group) => [group.label, group]));
6741
+ this.groupedTableBySeatId = new Map(
6742
+ groups.flatMap((group) => group.chairs.map((chair) => [chair.id, group]))
6743
+ );
6744
+ this.tableQuantities = new Map(groups.map((group) => [
6745
+ group.label,
6746
+ group.mode === "whole" ? group.capacity : group.minOccupancy
6747
+ ]));
6748
+ this.confirmedVariableTables = /* @__PURE__ */ new Set();
6749
+ this.groupedSelectionOverhead = groups.reduce(
6750
+ (sum, group) => sum + Math.max(0, group.chairs.length - 1),
6751
+ 0
6752
+ );
6753
+ } catch (err) {
6754
+ this.emitError(err);
6755
+ return null;
6756
+ }
5261
6757
  this.labelToId = /* @__PURE__ */ new Map();
5262
6758
  this.labelToSeat = /* @__PURE__ */ new Map();
5263
6759
  this.seatById = /* @__PURE__ */ new Map();
@@ -5275,32 +6771,43 @@ var PickerController = class {
5275
6771
  this.allIds.push(s.id);
5276
6772
  const source = chartObjects.get(s.rowId);
5277
6773
  const sourceLabel = source && "label" in source && typeof source.label === "string" ? source.label : void 0;
5278
- const sourceDisplayLabel = source && "displayLabel" in source && typeof source.displayLabel === "string" && source.displayLabel ? source.displayLabel : sourceLabel;
5279
- const rowLabel = s.kind === "booth" ? void 0 : sourceDisplayLabel;
5280
- const rowType = source && "displayType" in source && typeof source.displayType === "string" && source.displayType.trim() ? source.displayType.trim() : void 0;
6774
+ const logicalRow = source?.type === "row" ? source.segmentedRow : void 0;
6775
+ const sourceDisplayLabel = logicalRow?.displayLabel ?? (source && "displayLabel" in source && typeof source.displayLabel === "string" && source.displayLabel ? source.displayLabel : sourceLabel);
6776
+ const groupedTable = this.groupedTablesByObject.get(s.rowId);
6777
+ const objectType = source?.type === "table" ? "table" : source?.type === "booth" ? "booth" : "seat";
6778
+ const rowLabel = sourceDisplayLabel;
6779
+ const rowType = logicalRow?.displayType?.trim() || (source && "displayType" in source && typeof source.displayType === "string" && source.displayType.trim() ? source.displayType.trim() : void 0);
5281
6780
  const visibleSeatLabel = s.displayLabel ?? s.label;
5282
6781
  const labelParts = visibleSeatLabel.split("-");
5283
- const seatNumber = sourceDisplayLabel && visibleSeatLabel.startsWith(`${sourceDisplayLabel}-`) ? visibleSeatLabel.slice(sourceDisplayLabel.length + 1) : s.kind === "booth" ? visibleSeatLabel : labelParts[labelParts.length - 1] ?? visibleSeatLabel;
6782
+ const seatNumber = groupedTable ? void 0 : s.kind === "booth" ? void 0 : sourceDisplayLabel && visibleSeatLabel.startsWith(`${sourceDisplayLabel}-`) ? visibleSeatLabel.slice(sourceDisplayLabel.length + 1) : labelParts[labelParts.length - 1] ?? visibleSeatLabel;
5284
6783
  this.seatContext.set(s.id, {
6784
+ objectId: s.rowId,
6785
+ objectType,
5285
6786
  sectionLabel: sectionLabels.get(membership.objectToSection.get(s.rowId) ?? ""),
5286
6787
  rowLabel,
5287
6788
  seatNumber,
5288
- rowType
6789
+ ...rowType ? { displayType: rowType, rowType } : {}
5289
6790
  });
5290
6791
  }
6792
+ for (const group of this.groupedTablesByLabel.values()) {
6793
+ const representative = group.chairs[0];
6794
+ if (!representative) continue;
6795
+ this.labelToId.set(group.label, representative.id);
6796
+ this.labelToSeat.set(group.label, representative);
6797
+ }
5291
6798
  const currency = res.event.currency ?? this.opts.currency;
5292
6799
  this.currency = currency ?? "USD";
5293
6800
  const renderer = createRenderer(host, {
5294
- maxSelection: this.maxSelection,
6801
+ // Group chairs are selected together for paint, but count as one logical
6802
+ // inventory line. The controller enforces the guest-weighted cap.
6803
+ maxSelection: this.rendererSelectionCap(),
5295
6804
  confirmSelection: this.opts.confirmSelection,
5296
6805
  currency,
5297
6806
  onSelect: (seat) => {
5298
- this.opts.onSelect?.(seat);
5299
- this.emitSelectionChange();
6807
+ this.handleRendererSelect(seat);
5300
6808
  },
5301
6809
  onDeselect: (seat) => {
5302
- this.opts.onDeselect?.(seat);
5303
- this.emitSelectionChange();
6810
+ this.handleRendererDeselect(seat);
5304
6811
  },
5305
6812
  onSelectionLimit: this.opts.onSelectionLimit,
5306
6813
  onHover: (seat) => {
@@ -5355,7 +6862,20 @@ var PickerController = class {
5355
6862
  // ---- selection ------------------------------------------------------------
5356
6863
  getSelection() {
5357
6864
  if (!this.renderer) return [];
5358
- return this.renderer.getSelection().map((s) => this.toSeat(s));
6865
+ const out = [];
6866
+ const grouped = /* @__PURE__ */ new Set();
6867
+ for (const seat of this.renderer.getSelection()) {
6868
+ const table = this.groupedTableBySeatId.get(seat.id);
6869
+ if (table) {
6870
+ if (!grouped.has(table.label)) {
6871
+ grouped.add(table.label);
6872
+ out.push(this.toTableSeat(table));
6873
+ }
6874
+ } else {
6875
+ out.push(this.toSeat(seat));
6876
+ }
6877
+ }
6878
+ return out;
5359
6879
  }
5360
6880
  /** Enriched metadata for a seat confirmation card or tooltip. */
5361
6881
  seatDetails(seatId) {
@@ -5364,20 +6884,168 @@ var PickerController = class {
5364
6884
  }
5365
6885
  clearSelection() {
5366
6886
  this.renderer?.clearSelection();
6887
+ this.resetUnheldTableQuantities();
5367
6888
  this.emitSelectionChange();
5368
6889
  }
5369
6890
  deselect(ids) {
5370
- this.renderer?.deselect(ids);
6891
+ const expanded = /* @__PURE__ */ new Set();
6892
+ for (const id of ids) {
6893
+ const table = this.groupedTableBySeatId.get(id) ?? this.groupedTablesByLabel.get(id);
6894
+ if (table) {
6895
+ table.chairs.forEach((chair) => expanded.add(chair.id));
6896
+ if (!this.hold_?.labels.includes(table.label)) {
6897
+ this.tableQuantities.set(
6898
+ table.label,
6899
+ table.mode === "whole" ? table.capacity : table.minOccupancy
6900
+ );
6901
+ if (table.mode === "variable") this.confirmedVariableTables.delete(table.label);
6902
+ }
6903
+ } else expanded.add(id);
6904
+ }
6905
+ this.renderer?.deselect([...expanded]);
5371
6906
  this.emitSelectionChange();
5372
6907
  }
5373
6908
  setMaxSelection(maxSelection) {
5374
6909
  this.maxSelection = Math.max(0, Math.floor(maxSelection));
5375
- this.renderer?.setMaxSelection?.(this.maxSelection);
6910
+ this.renderer?.setMaxSelection?.(this.rendererSelectionCap());
5376
6911
  }
5377
6912
  select(ids) {
5378
- const added = this.renderer?.select?.(ids) ?? [];
6913
+ const before = new Set(this.getSelection().map((seat) => seat.label));
6914
+ const expanded = /* @__PURE__ */ new Set();
6915
+ for (const id of ids) {
6916
+ const table = this.groupedTableBySeatId.get(id) ?? this.groupedTablesByLabel.get(id);
6917
+ if (table) table.chairs.forEach((chair) => expanded.add(chair.id));
6918
+ else expanded.add(id);
6919
+ }
6920
+ this.renderer?.select?.([...expanded]);
6921
+ if (this.selectionGuestCount() > this.maxSelection) {
6922
+ this.renderer?.deselect([...expanded]);
6923
+ this.opts.onSelectionLimit?.(this.maxSelection);
6924
+ return [];
6925
+ }
6926
+ const added = this.getSelection().filter((seat) => !before.has(seat.label));
5379
6927
  if (added.length) this.emitSelectionChange();
5380
- return added.map((seat) => this.toSeat(seat));
6928
+ return added;
6929
+ }
6930
+ /** Confirm/update the guest quantity for a selected variable table. */
6931
+ setTableQuantity(label, quantity) {
6932
+ const table = this.groupedTablesByLabel.get(label);
6933
+ if (!table) return false;
6934
+ const q = table.mode === "whole" ? table.capacity : Math.floor(quantity);
6935
+ if (!Number.isInteger(q) || q < table.minOccupancy || q > table.maxOccupancy) return false;
6936
+ const previous = this.tableQuantities.get(label) ?? table.minOccupancy;
6937
+ this.tableQuantities.set(label, q);
6938
+ if (this.selectionGuestCount() > this.maxSelection) {
6939
+ this.tableQuantities.set(label, previous);
6940
+ this.opts.onSelectionLimit?.(this.maxSelection);
6941
+ return false;
6942
+ }
6943
+ if (table.mode === "variable") this.confirmedVariableTables.add(label);
6944
+ this.emitSelectionChange();
6945
+ return true;
6946
+ }
6947
+ /** Atomically replace an active variable-table hold with a new guest count. */
6948
+ async replaceTableQuantity(label, quantity) {
6949
+ const table = this.groupedTablesByLabel.get(label);
6950
+ const hold = this.hold_;
6951
+ if (!table || table.mode !== "variable" || !hold?.labels.includes(label)) return null;
6952
+ const q = Math.floor(quantity);
6953
+ if (!Number.isInteger(q) || q < table.minOccupancy || q > table.maxOccupancy) return null;
6954
+ const otherGuests = (hold.items ?? []).filter((item) => item.label !== label).reduce((sum, item) => sum + (item.quantity ?? 1), 0);
6955
+ if (otherGuests + q > this.maxSelection) {
6956
+ this.opts.onSelectionLimit?.(this.maxSelection);
6957
+ return null;
6958
+ }
6959
+ const selections = (hold.items ?? []).map((item) => ({
6960
+ label: item.label,
6961
+ tierId: item.tierId,
6962
+ quantity: item.label === label ? q : item.quantity
6963
+ }));
6964
+ if (!selections.some((item) => item.label === label)) return null;
6965
+ const result = await this.api.hold(this.key, selections, void 0, hold.holdId);
6966
+ this.tableQuantities.set(label, q);
6967
+ this.setHold({
6968
+ holdId: result.holdId,
6969
+ labels: selections.map((item) => item.label),
6970
+ expiresAt: result.expiresAt,
6971
+ items: result.items
6972
+ });
6973
+ return this.hold_;
6974
+ }
6975
+ rendererSelectionCap() {
6976
+ return this.maxSelection + this.groupedSelectionOverhead;
6977
+ }
6978
+ selectionGuestCount() {
6979
+ return this.getSelection().reduce((sum, seat) => sum + (seat.quantity ?? 1), 0);
6980
+ }
6981
+ handleRendererSelect(seat) {
6982
+ const table = this.groupedTableBySeatId.get(seat.id);
6983
+ if (table) {
6984
+ if (this.selectionGuestCount() > this.maxSelection) {
6985
+ this.renderer?.deselect(table.chairs.map((chair) => chair.id));
6986
+ this.opts.onSelectionLimit?.(this.maxSelection);
6987
+ this.emitSelectionChange();
6988
+ return;
6989
+ }
6990
+ this.renderer?.select?.(table.chairs.map((chair) => chair.id));
6991
+ this.opts.onSelect?.(table.chairs[0] ?? seat);
6992
+ this.opts.onTableSelectionRequest?.(this.toTableSeat(table));
6993
+ this.emitSelectionChange();
6994
+ return;
6995
+ }
6996
+ if (this.selectionGuestCount() > this.maxSelection) {
6997
+ this.renderer?.deselect([seat.id]);
6998
+ this.opts.onSelectionLimit?.(this.maxSelection);
6999
+ this.emitSelectionChange();
7000
+ return;
7001
+ }
7002
+ this.opts.onSelect?.(seat);
7003
+ this.emitSelectionChange();
7004
+ }
7005
+ handleRendererDeselect(seat) {
7006
+ const table = this.groupedTableBySeatId.get(seat.id);
7007
+ if (table) {
7008
+ this.renderer?.deselect(table.chairs.map((chair) => chair.id));
7009
+ if (!this.hold_?.labels.includes(table.label)) {
7010
+ this.tableQuantities.set(
7011
+ table.label,
7012
+ table.mode === "whole" ? table.capacity : table.minOccupancy
7013
+ );
7014
+ if (table.mode === "variable") this.confirmedVariableTables.delete(table.label);
7015
+ }
7016
+ this.opts.onDeselect?.(table.chairs[0] ?? seat);
7017
+ } else {
7018
+ this.opts.onDeselect?.(seat);
7019
+ }
7020
+ this.emitSelectionChange();
7021
+ }
7022
+ resetUnheldTableQuantities() {
7023
+ for (const table of this.groupedTablesByLabel.values()) {
7024
+ if (this.hold_?.labels.includes(table.label)) continue;
7025
+ this.tableQuantities.set(
7026
+ table.label,
7027
+ table.mode === "whole" ? table.capacity : table.minOccupancy
7028
+ );
7029
+ if (table.mode === "variable") this.confirmedVariableTables.delete(table.label);
7030
+ }
7031
+ }
7032
+ resetTableQuantitiesForLabels(labels) {
7033
+ for (const label of labels) {
7034
+ const table = this.groupedTablesByLabel.get(label);
7035
+ if (!table) continue;
7036
+ this.tableQuantities.set(
7037
+ label,
7038
+ table.mode === "whole" ? table.capacity : table.minOccupancy
7039
+ );
7040
+ if (table.mode === "variable") this.confirmedVariableTables.delete(label);
7041
+ }
7042
+ }
7043
+ tableQuantityRequest(seat) {
7044
+ if (seat?.objectType !== "table") return {};
7045
+ if (seat.bookingMode === "variable" && !this.confirmedVariableTables.has(seat.label)) {
7046
+ throw new Error("picker: confirm a variable table guest quantity before holding");
7047
+ }
7048
+ return { quantity: seat.quantity };
5381
7049
  }
5382
7050
  // ---- booking machine ------------------------------------------------------
5383
7051
  /**
@@ -5389,7 +7057,7 @@ var PickerController = class {
5389
7057
  async hold(labelsArg, ttlMs) {
5390
7058
  const r = this.renderer;
5391
7059
  if (!r) return null;
5392
- const labels = labelsArg ?? r.getSelection().map((s) => s.label);
7060
+ const labels = labelsArg ?? this.getSelection().map((seat) => seat.label);
5393
7061
  const existingGA = (this.hold_?.items ?? []).filter((item) => item.objectType === "ga");
5394
7062
  const combinedLabels = [.../* @__PURE__ */ new Set([...existingGA.map((item) => item.label), ...labels])];
5395
7063
  if (!combinedLabels.length) return null;
@@ -5397,10 +7065,14 @@ var PickerController = class {
5397
7065
  try {
5398
7066
  const selections = combinedLabels.map((label) => {
5399
7067
  const ga = existingGA.find((item) => item.label === label);
5400
- if (ga) return { label, tierId: ga.tierId };
7068
+ if (ga) return { label, tierId: ga.tierId, quantity: ga.quantity };
5401
7069
  const seat = this.labelToSeat.get(label);
5402
7070
  const resolved = seat ? this.toSeat(seat) : null;
5403
- return { label, ...resolved?.tierId ? { tierId: resolved.tierId } : {} };
7071
+ return {
7072
+ label,
7073
+ ...resolved?.tierId ? { tierId: resolved.tierId } : {},
7074
+ ...this.tableQuantityRequest(resolved)
7075
+ };
5404
7076
  });
5405
7077
  const result = await this.api.hold(this.key, selections, ttlMs, this.hold_?.holdId);
5406
7078
  this.setHold({ holdId: result.holdId, labels: combinedLabels, expiresAt: result.expiresAt, items: result.items });
@@ -5452,11 +7124,19 @@ var PickerController = class {
5452
7124
  const out = {};
5453
7125
  const closedMembers = this.closedMemberIds();
5454
7126
  for (const [id, s] of this.seatById) {
7127
+ if (this.groupedTableBySeatId.has(id)) continue;
5455
7128
  if (closedMembers.has(id)) continue;
5456
7129
  if ((this.getStatus(id) ?? "free") === "free") {
5457
7130
  out[s.categoryKey] = (out[s.categoryKey] ?? 0) + 1;
5458
7131
  }
5459
7132
  }
7133
+ for (const table of this.groupedTablesByLabel.values()) {
7134
+ if (table.chairs.some((chair) => closedMembers.has(chair.id))) continue;
7135
+ const representative = table.chairs[0];
7136
+ if (representative && (this.getStatus(representative.id) ?? "free") === "free") {
7137
+ out[table.categoryKey] = (out[table.categoryKey] ?? 0) + table.maxOccupancy;
7138
+ }
7139
+ }
5460
7140
  return out;
5461
7141
  }
5462
7142
  /** Seat ids belonging to a currently-closed section (excluded from counts). */
@@ -5475,6 +7155,8 @@ var PickerController = class {
5475
7155
  return {
5476
7156
  id: area.id,
5477
7157
  label: area.label,
7158
+ ...area.displayLabel ? { displayLabel: area.displayLabel } : {},
7159
+ ...area.displayType ? { displayType: area.displayType } : {},
5478
7160
  capacity: Math.max(0, Math.floor(area.capacity)),
5479
7161
  available: gaUnitLabels(area).filter((label) => (this.liveStatuses.get(label) ?? "free") === "free").length,
5480
7162
  categoryKey: area.categoryKey,
@@ -5493,14 +7175,20 @@ var PickerController = class {
5493
7175
  const labels = gaUnitLabels(area).filter((label) => !this.hold_?.labels.includes(label) && (this.liveStatuses.get(label) ?? "free") === "free").slice(0, Math.floor(qty));
5494
7176
  if (labels.length !== Math.floor(qty)) return null;
5495
7177
  const selections = /* @__PURE__ */ new Map();
5496
- for (const item of this.hold_?.items ?? []) selections.set(item.label, { label: item.label, tierId: item.tierId });
7178
+ for (const item of this.hold_?.items ?? []) selections.set(item.label, {
7179
+ label: item.label,
7180
+ tierId: item.tierId,
7181
+ quantity: item.quantity
7182
+ });
5497
7183
  if (this.hold_ && !this.hold_?.items?.length) {
5498
7184
  for (const seat of this.hold_.seats) selections.set(seat.label, { label: seat.label, ...seat.tierId ? { tierId: seat.tierId } : {} });
5499
7185
  }
5500
- for (const seat of this.renderer?.getSelection() ?? []) {
5501
- const resolved = this.labelToSeat.get(seat.label);
5502
- const chosen = resolved ? this.toSeat(resolved) : null;
5503
- selections.set(seat.label, { label: seat.label, ...chosen?.tierId ? { tierId: chosen.tierId } : {} });
7186
+ for (const seat of this.getSelection()) {
7187
+ selections.set(seat.label, {
7188
+ label: seat.label,
7189
+ ...seat.tierId ? { tierId: seat.tierId } : {},
7190
+ ...this.tableQuantityRequest(seat)
7191
+ });
5504
7192
  }
5505
7193
  for (const label of labels) selections.set(label, { label, ...options.tierId ? { tierId: options.tierId } : {} });
5506
7194
  const combined = [...selections.values()];
@@ -5517,6 +7205,15 @@ var PickerController = class {
5517
7205
  }
5518
7206
  return false;
5519
7207
  }
7208
+ /** Zones which still own visible buyer inventory, in authored order. */
7209
+ getBestAvailableZones() {
7210
+ const doc = this.visibleDoc();
7211
+ if (!doc?.zones?.length) return [];
7212
+ const used = new Set(
7213
+ computeSections(doc).sections.filter((section) => section.seatCount > 0 && !!section.zone).map((section) => section.zone)
7214
+ );
7215
+ return doc.zones.filter((zone) => used.has(zone.id)).map((zone) => ({ id: zone.id, label: zone.label }));
7216
+ }
5520
7217
  /**
5521
7218
  * Client-side premium pre-pass: find the best contiguous block of `qty`
5522
7219
  * PREMIUM-flagged free seats (orphan-avoiding, closest to the focal point)
@@ -5525,11 +7222,12 @@ var PickerController = class {
5525
7222
  * seat status. Returns a FULL premium block of `qty`, or null when none
5526
7223
  * exists (the caller then falls back to the normal server pick).
5527
7224
  */
5528
- pickPremiumBlock(qty, categoryKey) {
7225
+ pickPremiumBlock(qty, categoryKey, zoneId) {
5529
7226
  const doc = this.visibleDoc();
5530
7227
  if (!doc?.objects?.length) return null;
5531
7228
  const focal = doc.focalPoint ?? { x: 0, y: 0 };
5532
- const seats = expandChart(doc);
7229
+ const groupedObjectIds = new Set(this.groupedTablesByObject.keys());
7230
+ const seats = expandChart(doc).filter((seat) => !groupedObjectIds.has(seat.rowId));
5533
7231
  const held = new Set(this.hold_?.labels ?? []);
5534
7232
  const available = /* @__PURE__ */ new Set();
5535
7233
  for (const seat of seats) {
@@ -5538,7 +7236,7 @@ var PickerController = class {
5538
7236
  const status = id ? this.getStatus(id) : void 0;
5539
7237
  if ((status ?? "free") === "free") available.add(seat.label);
5540
7238
  }
5541
- const pick = pickBestAvailable(seats, available, { qty, categoryKey, focal, preferPremium: true });
7239
+ const pick = pickBestAvailable(seats, available, { qty, categoryKey, zoneId, focal, preferPremium: true });
5542
7240
  if (pick.labels.length !== qty) return null;
5543
7241
  const allPremium = pick.labels.every((l) => this.labelToSeat.get(l)?.commercial?.premium);
5544
7242
  return allPremium ? [...pick.labels] : null;
@@ -5553,7 +7251,7 @@ var PickerController = class {
5553
7251
  const r = this.renderer;
5554
7252
  if (!r) return null;
5555
7253
  if (opts.preferPremium) {
5556
- const block = this.pickPremiumBlock(qty, categoryKey);
7254
+ const block = this.pickPremiumBlock(qty, categoryKey, opts.zoneId);
5557
7255
  if (block) {
5558
7256
  if (this.hold_ && !await this.release()) return null;
5559
7257
  try {
@@ -5564,7 +7262,7 @@ var PickerController = class {
5564
7262
  });
5565
7263
  const result = await this.api.hold(this.key, selection);
5566
7264
  r.clearSelection();
5567
- const ids = block.map((l) => this.labelToId.get(l)).filter((v) => !!v);
7265
+ const ids = this.idsForLabels(block);
5568
7266
  if (ids.length) r.setStatus(ids, "held");
5569
7267
  this.setHold({ holdId: result.holdId, labels: [...block], expiresAt: result.expiresAt, items: result.items });
5570
7268
  const seats = block.map((l) => this.labelToSeat.get(l)).filter((s) => !!s).map((s) => this.toSeat(s));
@@ -5581,9 +7279,9 @@ var PickerController = class {
5581
7279
  }
5582
7280
  if (this.hold_ && !await this.release()) return null;
5583
7281
  try {
5584
- const result = await this.api.bestAvailable(this.key, qty, categoryKey);
7282
+ const result = await this.api.bestAvailable(this.key, qty, categoryKey, opts.zoneId);
5585
7283
  r.clearSelection();
5586
- const ids = result.labels.map((l) => this.labelToId.get(l)).filter((v) => !!v);
7284
+ const ids = this.idsForLabels(result.labels);
5587
7285
  if (ids.length) r.setStatus(ids, "held");
5588
7286
  this.setHold({ holdId: result.holdId, labels: [...result.labels], expiresAt: result.expiresAt, items: result.items });
5589
7287
  const seats = result.labels.map((l) => this.labelToSeat.get(l)).filter((s) => !!s).map((s) => this.toSeat(s));
@@ -5607,7 +7305,7 @@ var PickerController = class {
5607
7305
  const r = this.renderer;
5608
7306
  if (!r) return null;
5609
7307
  if (!this.api.book) throw new Error("picker: transport has no book() \u2014 hold-only mode");
5610
- const labels = labelsArg ?? r.getSelection().map((s) => s.label);
7308
+ const labels = labelsArg ?? this.getSelection().map((seat) => seat.label);
5611
7309
  if (!labels.length) return null;
5612
7310
  let holdId;
5613
7311
  try {
@@ -5620,7 +7318,11 @@ var PickerController = class {
5620
7318
  labels.map((label) => {
5621
7319
  const seat = this.labelToSeat.get(label);
5622
7320
  const resolved = seat ? this.toSeat(seat) : null;
5623
- return { label, ...resolved?.tierId ? { tierId: resolved.tierId } : {} };
7321
+ return {
7322
+ label,
7323
+ ...resolved?.tierId ? { tierId: resolved.tierId } : {},
7324
+ ...this.tableQuantityRequest(resolved)
7325
+ };
5624
7326
  }),
5625
7327
  void 0,
5626
7328
  replaceHoldId
@@ -5636,10 +7338,11 @@ var PickerController = class {
5636
7338
  this.opts.onSalesClosed?.();
5637
7339
  } else if (status === 409 && conflicts?.length) {
5638
7340
  const takenLabels = new Set(conflicts.map((c) => c.label));
5639
- const takenIds = [...takenLabels].map((l) => this.labelToId.get(l)).filter((v) => !!v);
7341
+ const takenIds = this.idsForLabels(takenLabels);
5640
7342
  if (takenIds.length) {
5641
7343
  r.setStatus(takenIds, "booked");
5642
7344
  r.deselect(takenIds);
7345
+ this.resetTableQuantitiesForLabels(takenLabels);
5643
7346
  }
5644
7347
  const stillFree = labels.filter((l) => !takenLabels.has(l));
5645
7348
  if (stillFree.length && holdId) void this.api.release(this.key, stillFree, holdId).catch(() => {
@@ -5651,12 +7354,13 @@ var PickerController = class {
5651
7354
  }
5652
7355
  throw err;
5653
7356
  }
5654
- const ids = labels.map((l) => this.labelToId.get(l)).filter((v) => !!v);
7357
+ const ids = this.idsForLabels(labels);
5655
7358
  if (ids.length) {
5656
7359
  r.setStatus(ids, "booked");
5657
7360
  r.deselect(ids);
5658
7361
  }
5659
7362
  this.clearHold();
7363
+ this.resetTableQuantitiesForLabels(labels);
5660
7364
  this.emitSelectionChange();
5661
7365
  this.opts.onBook?.(bookingRef);
5662
7366
  return labels;
@@ -5677,7 +7381,8 @@ var PickerController = class {
5677
7381
  }
5678
7382
  if (this.hold_?.holdId !== hold.holdId) return true;
5679
7383
  this.clearHold();
5680
- const ids = hold.labels.map((l) => this.labelToId.get(l)).filter((v) => !!v);
7384
+ this.resetTableQuantitiesForLabels(hold.labels);
7385
+ const ids = this.idsForLabels(hold.labels);
5681
7386
  if (ids.length) {
5682
7387
  this.renderer?.deselect(ids);
5683
7388
  this.renderer?.setStatus(ids, "free");
@@ -5708,7 +7413,8 @@ var PickerController = class {
5708
7413
  const remainingItems = hold.items?.filter((item) => !drop.includes(item.label));
5709
7414
  if (remaining.length) this.setHold({ ...hold, labels: remaining, items: remainingItems });
5710
7415
  else this.clearHold();
5711
- const ids = drop.map((l) => this.labelToId.get(l)).filter((v) => !!v);
7416
+ this.resetTableQuantitiesForLabels(drop);
7417
+ const ids = this.idsForLabels(drop);
5712
7418
  if (ids.length) {
5713
7419
  this.renderer?.deselect(ids);
5714
7420
  this.renderer?.setStatus(ids, "free");
@@ -5797,7 +7503,7 @@ var PickerController = class {
5797
7503
  this.opts.onDeckTap?.(floorId);
5798
7504
  }
5799
7505
  // ---- big-venue: sections / rungs / projection (Slice 5) -------------------
5800
- /** Switch the map projection (2D flat ⇄ 3D isometric). No-op on a flat renderer. */
7506
+ /** Switch the map projection. No-op on a flat-only renderer. */
5801
7507
  setViewMode(mode) {
5802
7508
  this.renderer?.setViewMode?.(mode);
5803
7509
  }
@@ -5896,12 +7602,21 @@ var PickerController = class {
5896
7602
  if (!sec) return null;
5897
7603
  const memberIds = r.sectionMembers?.(id) ?? [];
5898
7604
  const byCat = /* @__PURE__ */ new Map();
7605
+ const seenTables = /* @__PURE__ */ new Set();
5899
7606
  let seatsLeft = 0;
5900
7607
  for (const sid of memberIds) {
5901
7608
  const seat = this.seatById.get(sid);
5902
7609
  if (!seat) continue;
5903
- byCat.set(seat.categoryKey, (byCat.get(seat.categoryKey) ?? 0) + 1);
5904
- if (r.getStatus(sid) === "free") seatsLeft++;
7610
+ const table = this.groupedTableBySeatId.get(sid);
7611
+ if (table) {
7612
+ if (seenTables.has(table.label)) continue;
7613
+ seenTables.add(table.label);
7614
+ byCat.set(table.categoryKey, (byCat.get(table.categoryKey) ?? 0) + table.maxOccupancy);
7615
+ if (r.getStatus(table.chairs[0]?.id ?? sid) === "free") seatsLeft += table.maxOccupancy;
7616
+ } else {
7617
+ byCat.set(seat.categoryKey, (byCat.get(seat.categoryKey) ?? 0) + 1);
7618
+ if (r.getStatus(sid) === "free") seatsLeft++;
7619
+ }
5905
7620
  }
5906
7621
  const categories = [...byCat.entries()].map(([key, count]) => {
5907
7622
  const cat = doc.categories.find((c) => c.key === key);
@@ -5951,22 +7666,54 @@ var PickerController = class {
5951
7666
  }
5952
7667
  // ---- internals ------------------------------------------------------------
5953
7668
  toSeat(s) {
7669
+ const table = this.groupedTableBySeatId.get(s.id);
7670
+ if (table) return this.toTableSeat(table);
5954
7671
  const commercial = s.commercial ? { commercial: s.commercial } : void 0;
5955
7672
  const display = s.displayLabel ? { displayLabel: s.displayLabel } : void 0;
7673
+ const accessibility = s.accessibility?.length ? { accessibility: s.accessibility } : void 0;
7674
+ const wheelchair = s.wheelchairSpaceType ? { wheelchairSpaceType: s.wheelchairSpaceType } : void 0;
7675
+ const context = this.seatContext.get(s.id);
5956
7676
  const tiers = this.tiersFor(s.categoryKey);
5957
7677
  if (!tiers) {
5958
- return { id: s.id, label: s.label, ...display, categoryKey: s.categoryKey, price: this.priceFor(s.categoryKey), ...commercial };
7678
+ return { id: s.id, label: s.label, ...display, ...context, categoryKey: s.categoryKey, price: this.priceFor(s.categoryKey), ...commercial, ...accessibility, ...wheelchair };
5959
7679
  }
5960
7680
  const chosen = tiers.find((t2) => t2.id === this.seatTiers.get(s.id)) ?? tiers[0];
5961
7681
  return {
5962
7682
  id: s.id,
5963
7683
  label: s.label,
5964
7684
  ...display,
7685
+ ...context,
5965
7686
  categoryKey: s.categoryKey,
5966
7687
  price: chosen.price,
5967
7688
  tiers,
5968
7689
  tierId: chosen.id,
5969
- ...commercial
7690
+ ...commercial,
7691
+ ...accessibility,
7692
+ ...wheelchair
7693
+ };
7694
+ }
7695
+ toTableSeat(table) {
7696
+ const representative = table.chairs[0];
7697
+ const tiers = this.tiersFor(table.categoryKey);
7698
+ const chosen = tiers?.find((tier) => tier.id === this.seatTiers.get(representative?.id ?? "")) ?? tiers?.[0];
7699
+ return {
7700
+ id: representative?.id ?? table.objectId,
7701
+ label: table.label,
7702
+ displayLabel: table.displayLabel ?? table.label,
7703
+ ...table.displayType ? { displayType: table.displayType, rowType: table.displayType } : {},
7704
+ objectId: table.objectId,
7705
+ sectionLabel: representative ? this.seatContext.get(representative.id)?.sectionLabel : void 0,
7706
+ rowLabel: table.displayLabel ?? table.label,
7707
+ categoryKey: table.categoryKey,
7708
+ price: chosen?.price ?? this.priceFor(table.categoryKey),
7709
+ ...tiers ? { tiers, tierId: chosen?.id } : {},
7710
+ objectType: "table",
7711
+ bookingMode: table.mode,
7712
+ quantity: this.tableQuantities.get(table.label) ?? (table.mode === "whole" ? table.capacity : table.minOccupancy),
7713
+ capacity: table.capacity,
7714
+ minOccupancy: table.minOccupancy,
7715
+ maxOccupancy: table.maxOccupancy,
7716
+ physicalSeatIds: table.chairs.map((chair) => chair.id)
5970
7717
  };
5971
7718
  }
5972
7719
  /** Tooltip payload for a hovered seat — see PickerCallbacks.onSeatHover. */
@@ -6050,10 +7797,13 @@ var PickerController = class {
6050
7797
  const h = this.hold_;
6051
7798
  if (!h || h.labels.length !== labels.length || !labels.every((l) => h.labels.includes(l))) return false;
6052
7799
  const tiers = new Map((h.items ?? []).map((item) => [item.label, item.tierId]));
7800
+ const quantities = new Map((h.items ?? []).map((item) => [item.label, item.quantity ?? 1]));
6053
7801
  return labels.every((label) => {
6054
7802
  const seat = this.labelToSeat.get(label);
6055
- const currentTier = seat ? this.toSeat(seat).tierId ?? null : null;
6056
- return !tiers.has(label) || tiers.get(label) === currentTier;
7803
+ const resolved = seat ? this.toSeat(seat) : null;
7804
+ const currentTier = resolved?.tierId ?? null;
7805
+ const quantityMatches = resolved?.objectType !== "table" || (resolved.bookingMode !== "variable" || this.confirmedVariableTables.has(label)) && (!quantities.has(label) || quantities.get(label) === resolved.quantity);
7806
+ return quantityMatches && (!tiers.has(label) || tiers.get(label) === currentTier);
6057
7807
  });
6058
7808
  }
6059
7809
  handle409Conflicts(err) {
@@ -6061,19 +7811,28 @@ var PickerController = class {
6061
7811
  if (status !== 409 || !conflicts?.length) return;
6062
7812
  const r = this.renderer;
6063
7813
  if (!r) return;
6064
- const takenIds = conflicts.map((c) => this.labelToId.get(c.label)).filter((v) => !!v);
7814
+ const takenIds = this.idsForLabels(conflicts.map((conflict) => conflict.label));
6065
7815
  if (takenIds.length) {
6066
7816
  r.deselect(takenIds);
6067
7817
  r.setStatus(takenIds, "held");
7818
+ this.resetTableQuantitiesForLabels(conflicts.map((conflict) => conflict.label));
6068
7819
  }
6069
7820
  this.emitSelectionChange();
6070
7821
  }
6071
7822
  /** Set the open hold + (re)arm the server-authoritative expiry timer. */
6072
7823
  setHold(hold, source = "created") {
7824
+ for (const item of hold.items ?? []) {
7825
+ if (this.groupedTablesByLabel.has(item.label) && item.quantity != null) {
7826
+ this.tableQuantities.set(item.label, item.quantity);
7827
+ if (this.groupedTablesByLabel.get(item.label)?.mode === "variable") {
7828
+ this.confirmedVariableTables.add(item.label);
7829
+ }
7830
+ }
7831
+ }
6073
7832
  const full = { ...hold, seats: this.seatsForLabels(hold.labels) };
6074
7833
  this.hold_ = full;
6075
7834
  this.renderer?.setOwnedHold?.(
6076
- full.labels.map((label) => this.labelToId.get(label)).filter((id) => !!id)
7835
+ this.idsForLabels(full.labels)
6077
7836
  );
6078
7837
  if (this.expiryTimer) clearTimeout(this.expiryTimer);
6079
7838
  const ms = Math.max(0, full.expiresAt - Date.now());
@@ -6116,12 +7875,11 @@ var PickerController = class {
6116
7875
  this.liveStatuses = new Map(Object.entries(seats));
6117
7876
  if (this.allIds.length) r.setStatus(this.allIds, "free");
6118
7877
  r.setOwnedHold?.(
6119
- (this.hold_?.labels ?? []).map((label) => this.labelToId.get(label)).filter((id) => !!id)
7878
+ this.idsForLabels(this.hold_?.labels ?? [])
6120
7879
  );
6121
7880
  const byStatus = { free: [], held: [], booked: [], not_for_sale: [] };
6122
7881
  for (const [label, st] of Object.entries(seats)) {
6123
- const id = this.labelToId.get(label);
6124
- if (id) byStatus[mapStatus(st)].push(id);
7882
+ byStatus[mapStatus(st)].push(...this.idsForLabel(label));
6125
7883
  }
6126
7884
  ["held", "booked", "not_for_sale"].forEach((st) => {
6127
7885
  if (byStatus[st].length) r.setStatus(byStatus[st], st);
@@ -6130,7 +7888,11 @@ var PickerController = class {
6130
7888
  this.clearBookedHoldIfSettled();
6131
7889
  }
6132
7890
  clearBookedHoldIfSettled() {
6133
- if (this.hold_?.labels.every((label) => this.liveStatuses.get(label) === "booked")) this.clearHold();
7891
+ const hold = this.hold_;
7892
+ if (hold?.labels.every((label) => this.liveStatuses.get(label) === "booked")) {
7893
+ this.clearHold();
7894
+ this.resetTableQuantitiesForLabels(hold.labels);
7895
+ }
6134
7896
  }
6135
7897
  async resnapshot() {
6136
7898
  try {
@@ -6219,13 +7981,13 @@ var PickerController = class {
6219
7981
  } else if (Array.isArray(m.changes)) {
6220
7982
  for (const ch of m.changes) {
6221
7983
  this.liveStatuses.set(ch.label, ch.status);
6222
- const id = this.labelToId.get(ch.label);
6223
- if (!id) continue;
7984
+ const ids = this.idsForLabel(ch.label);
7985
+ if (!ids.length) continue;
6224
7986
  const next = mapStatus(ch.status);
6225
- if (this.opts.flashOnLiveChange && next !== "free" && r.getStatus(id) === "free" && !this.hold_?.labels.includes(ch.label)) {
6226
- r.flashSeat(id, next === "held" ? "#f4b740" : "#f43f5e");
7987
+ if (this.opts.flashOnLiveChange && next !== "free" && ids.some((id) => r.getStatus(id) === "free") && !this.hold_?.labels.includes(ch.label)) {
7988
+ ids.forEach((id) => r.flashSeat(id, next === "held" ? "#f4b740" : "#f43f5e"));
6227
7989
  }
6228
- r.setStatus([id], next);
7990
+ r.setStatus(ids, next);
6229
7991
  }
6230
7992
  if (this.opts.keepLiveWhileHidden && typeof document !== "undefined" && document.visibilityState === "hidden") {
6231
7993
  r.forceDraw();
@@ -6256,10 +8018,25 @@ var PickerController = class {
6256
8018
  }
6257
8019
  };
6258
8020
 
8021
+ // src/view/sightline.ts
8022
+ var STAGE_TOP_M = 5;
8023
+ var STAGE_BASE_M = -1.1;
8024
+ var MAX_LOOKDOWN_DEG = 35;
8025
+ function stageSightlinePitch(eyeHeightM, distM) {
8026
+ const extraEyeM = eyeHeightM - SEATED_EYE_HEIGHT_M;
8027
+ let topPitch = Math.atan2(STAGE_TOP_M - extraEyeM, distM) * 180 / Math.PI;
8028
+ let basePitch = Math.atan2(STAGE_BASE_M - extraEyeM, distM) * 180 / Math.PI;
8029
+ if (basePitch < -MAX_LOOKDOWN_DEG) {
8030
+ topPitch += -MAX_LOOKDOWN_DEG - basePitch;
8031
+ basePitch = -MAX_LOOKDOWN_DEG;
8032
+ }
8033
+ return { topPitch, basePitch };
8034
+ }
8035
+
6259
8036
  // src/view/generatePanorama.ts
6260
8037
  var W = 2048;
6261
8038
  var H = 1024;
6262
- var UNIT = 0.55 / 24;
8039
+ var UNIT = METRES_PER_CHART_UNIT;
6263
8040
  var yawToX = (yawDeg) => (yawDeg + 180) / 360 * W;
6264
8041
  var pitchToY = (pitchDeg) => (90 - pitchDeg) / 180 * H;
6265
8042
  function generateSeatPanorama(seat, focalPoint, neighborSeats) {
@@ -6279,9 +8056,11 @@ function generateSeatPanorama(seat, focalPoint, neighborSeats) {
6279
8056
  sky.addColorStop(1, "#07090f");
6280
8057
  ctx.fillStyle = sky;
6281
8058
  ctx.fillRect(0, 0, W, H);
8059
+ const eyeM = seat.eyeHeightM ?? SEATED_EYE_HEIGHT_M;
6282
8060
  const stageHalfYaw = Math.min(80, Math.atan2(4, distM) * 180 / Math.PI);
6283
- const stageTopPitch = Math.min(45, Math.atan2(5, distM) * 180 / Math.PI);
6284
- const stageBasePitch = Math.max(-30, -Math.atan2(1.1, distM) * 180 / Math.PI);
8061
+ const sightline = stageSightlinePitch(eyeM, distM);
8062
+ const stageTopPitch = Math.min(45, sightline.topPitch);
8063
+ const stageBasePitch = sightline.basePitch;
6285
8064
  const sx0 = yawToX(-stageHalfYaw);
6286
8065
  const sx1 = yawToX(stageHalfYaw);
6287
8066
  const sy0 = pitchToY(stageTopPitch);
@@ -6388,9 +8167,11 @@ function generateSeatThumb(seat, focalPoint, _neighborSeats) {
6388
8167
  sky.addColorStop(1, "#070910");
6389
8168
  ctx.fillStyle = sky;
6390
8169
  ctx.fillRect(0, 0, TW, TH);
8170
+ const eyeM = seat.eyeHeightM ?? SEATED_EYE_HEIGHT_M;
8171
+ const sightline = stageSightlinePitch(eyeM, distM);
6391
8172
  const stageHalfYaw = Math.min(HALF_HFOV - 2, Math.atan2(4, distM) * 180 / Math.PI);
6392
- const stageTopPitch = Math.min(HALF_VFOV - 2, Math.atan2(5, distM) * 180 / Math.PI);
6393
- const stageBasePitch = Math.max(-HALF_VFOV + 2, -Math.atan2(1.1, distM) * 180 / Math.PI);
8173
+ const stageTopPitch = Math.min(HALF_VFOV - 2, sightline.topPitch);
8174
+ const stageBasePitch = Math.max(-HALF_VFOV + 2, sightline.basePitch);
6394
8175
  const sx0 = yawToTX(-stageHalfYaw);
6395
8176
  const sx1 = yawToTX(stageHalfYaw);
6396
8177
  const sy0 = pitchToTY(stageTopPitch);
@@ -6487,7 +8268,9 @@ export {
6487
8268
  PickerController,
6488
8269
  RENDERED_QUALITY_REPORT_VERSION,
6489
8270
  SUPPORTED_LOCALES,
8271
+ SURROUNDINGS_SHAPE_ROLES,
6490
8272
  SeatmapRenderer,
8273
+ TIER_HEIGHT_M,
6491
8274
  UNGROUPED_ID,
6492
8275
  accessibilityMeta,
6493
8276
  accessibilityRingColor,
@@ -6502,16 +8285,19 @@ export {
6502
8285
  expandRow,
6503
8286
  expandRowSlots,
6504
8287
  expandTable,
8288
+ expandTableSlots,
6505
8289
  floorObjects,
6506
8290
  floorsOf,
6507
8291
  formatDate,
6508
8292
  formatMoney,
6509
8293
  gaAreasOf,
8294
+ gaInventorySegments,
6510
8295
  gaUnitLabel,
6511
8296
  gaUnitLabels,
6512
8297
  generateSeatPanorama,
6513
8298
  generateSeatThumb,
6514
8299
  getLocale,
8300
+ growJoinedGAInventory,
6515
8301
  hiddenObjectIds,
6516
8302
  inspectRenderedQualityEvidence,
6517
8303
  isGaUnitLabel,
@@ -6519,17 +8305,23 @@ export {
6519
8305
  layerOf,
6520
8306
  loadLocale,
6521
8307
  objectCenter,
8308
+ owningSectionForObject,
6522
8309
  pointInPolygon,
6523
8310
  pointInPolygonWithHoles,
6524
8311
  polygonLabelPoint,
6525
8312
  resolveLocale,
8313
+ rowInventoryCount,
6526
8314
  rowSeatPositions,
6527
8315
  seatLabelPart,
8316
+ sectionGeometry,
6528
8317
  setLocale,
6529
8318
  setMoneyLocale,
6530
8319
  setStringOverrides,
6531
8320
  stackFloors,
6532
8321
  t,
6533
- tCount
8322
+ tCount,
8323
+ tableInventoryCount,
8324
+ tableSeatCountsBySide,
8325
+ validGAInventorySegments
6534
8326
  };
6535
8327
  //# sourceMappingURL=index.js.map