@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.cjs CHANGED
@@ -216,7 +216,9 @@ __export(index_exports, {
216
216
  PickerController: () => PickerController,
217
217
  RENDERED_QUALITY_REPORT_VERSION: () => RENDERED_QUALITY_REPORT_VERSION,
218
218
  SUPPORTED_LOCALES: () => SUPPORTED_LOCALES,
219
+ SURROUNDINGS_SHAPE_ROLES: () => SURROUNDINGS_SHAPE_ROLES,
219
220
  SeatmapRenderer: () => SeatmapRenderer,
221
+ TIER_HEIGHT_M: () => TIER_HEIGHT_M,
220
222
  UNGROUPED_ID: () => UNGROUPED_ID,
221
223
  accessibilityMeta: () => accessibilityMeta,
222
224
  accessibilityRingColor: () => accessibilityRingColor,
@@ -231,16 +233,19 @@ __export(index_exports, {
231
233
  expandRow: () => expandRow,
232
234
  expandRowSlots: () => expandRowSlots,
233
235
  expandTable: () => expandTable,
236
+ expandTableSlots: () => expandTableSlots,
234
237
  floorObjects: () => floorObjects,
235
238
  floorsOf: () => floorsOf,
236
239
  formatDate: () => formatDate,
237
240
  formatMoney: () => formatMoney,
238
241
  gaAreasOf: () => gaAreasOf,
242
+ gaInventorySegments: () => gaInventorySegments,
239
243
  gaUnitLabel: () => gaUnitLabel,
240
244
  gaUnitLabels: () => gaUnitLabels,
241
245
  generateSeatPanorama: () => generateSeatPanorama,
242
246
  generateSeatThumb: () => generateSeatThumb,
243
247
  getLocale: () => getLocale,
248
+ growJoinedGAInventory: () => growJoinedGAInventory,
244
249
  hiddenObjectIds: () => hiddenObjectIds,
245
250
  inspectRenderedQualityEvidence: () => inspectRenderedQualityEvidence,
246
251
  isGaUnitLabel: () => isGaUnitLabel,
@@ -248,18 +253,24 @@ __export(index_exports, {
248
253
  layerOf: () => layerOf,
249
254
  loadLocale: () => loadLocale,
250
255
  objectCenter: () => objectCenter,
256
+ owningSectionForObject: () => owningSectionForObject,
251
257
  pointInPolygon: () => pointInPolygon,
252
258
  pointInPolygonWithHoles: () => pointInPolygonWithHoles,
253
259
  polygonLabelPoint: () => polygonLabelPoint,
254
260
  resolveLocale: () => resolveLocale,
261
+ rowInventoryCount: () => rowInventoryCount,
255
262
  rowSeatPositions: () => rowSeatPositions,
256
263
  seatLabelPart: () => seatLabelPart,
264
+ sectionGeometry: () => sectionGeometry,
257
265
  setLocale: () => setLocale,
258
266
  setMoneyLocale: () => setMoneyLocale,
259
267
  setStringOverrides: () => setStringOverrides,
260
268
  stackFloors: () => stackFloors,
261
269
  t: () => t,
262
- tCount: () => tCount
270
+ tCount: () => tCount,
271
+ tableInventoryCount: () => tableInventoryCount,
272
+ tableSeatCountsBySide: () => tableSeatCountsBySide,
273
+ validGAInventorySegments: () => validGAInventorySegments
263
274
  });
264
275
  module.exports = __toCommonJS(index_exports);
265
276
 
@@ -269,6 +280,7 @@ var ACCESSIBILITY_TYPES = [
269
280
  { key: "companion", label: "Companion seat", short: "Companion", icon: "\u{1F9D1}\u200D\u{1F91D}\u200D\u{1F9D1}" },
270
281
  { key: "semi-ambulatory", label: "Semi-ambulatory (limited mobility)", short: "Limited mobility", icon: "\u{1F9AF}" },
271
282
  { key: "hearing", label: "Assistive listening", short: "Hearing", icon: "\u{1F9BB}" },
283
+ { key: "cart", label: "CART live-caption view", short: "CART captions", icon: "CC" },
272
284
  { key: "sign-language", label: "Sign-language view", short: "Sign language", icon: "\u{1F91F}" },
273
285
  { key: "plus-size", label: "Plus-size seat", short: "Plus-size", icon: "\u{1F4BA}" },
274
286
  { key: "lift-armrest", label: "Lift-up armrest", short: "Lift armrest", icon: "\u2195\uFE0F" }
@@ -282,6 +294,7 @@ var ACCESSIBILITY_RING_COLOR = {
282
294
  companion: "#8b5cf6",
283
295
  "semi-ambulatory": "#0ea5e9",
284
296
  hearing: "#14b8a6",
297
+ cart: "#7c3aed",
285
298
  "sign-language": "#f59e0b",
286
299
  "plus-size": "#ec4899",
287
300
  "lift-armrest": "#22c55e"
@@ -292,6 +305,19 @@ function accessibilityRingColor(types) {
292
305
  }
293
306
  var LABEL_STYLE_MIN_SIZE = 8;
294
307
  var LABEL_STYLE_MAX_SIZE = 24;
308
+ var SURROUNDINGS_SHAPE_ROLES = [
309
+ "reference-focal",
310
+ "bar",
311
+ "entrance",
312
+ "exit",
313
+ "restroom",
314
+ "screen",
315
+ "sound",
316
+ "concession",
317
+ "coat",
318
+ "wall"
319
+ ];
320
+ var SURROUNDINGS_SHAPE_ROLE_SET = new Set(SURROUNDINGS_SHAPE_ROLES);
295
321
  function layerOf(obj) {
296
322
  switch (obj.type) {
297
323
  case "row":
@@ -300,10 +326,17 @@ function layerOf(obj) {
300
326
  case "booth":
301
327
  case "section":
302
328
  return "interactive";
303
- // stage / décor live on 'shape'; free text + decor images are background furniture.
329
+ // Raster décor follows its explicit authored z-layer. Absence retains the
330
+ // legacy Background default; bitmap content is never guessed semantically.
331
+ case "decorImage":
332
+ return obj.layer === "foreground" ? "foreground" : "background";
333
+ // Source-backed focal geometry and curated venue landmarks help an author
334
+ // orient around the sellable plan. A stage and an ordinary authored shape
335
+ // remain Background even when they carry an arbitrary/custom role.
304
336
  case "shape":
337
+ return obj.role && SURROUNDINGS_SHAPE_ROLE_SET.has(obj.role) ? "surroundings" : "background";
338
+ // Free text — including semantic icon text — is background furniture.
305
339
  case "text":
306
- case "decorImage":
307
340
  return "background";
308
341
  default:
309
342
  return "interactive";
@@ -423,10 +456,57 @@ function toRoman(value) {
423
456
  return out;
424
457
  }
425
458
 
459
+ // src/core/units.ts
460
+ var METRES_PER_CHART_UNIT = 0.55 / 24;
461
+ var CHART_UNITS_PER_METRE = 1 / METRES_PER_CHART_UNIT;
462
+ var LIFT_PER_STEP_WORLD = 58;
463
+ var TIER_HEIGHT_M = LIFT_PER_STEP_WORLD * METRES_PER_CHART_UNIT;
464
+ var SECTION_ELEVATION_TIER_MIN = 0;
465
+ var SECTION_ELEVATION_TIER_MAX = 3;
466
+ var SECTION_HEIGHT_MIN_M = 0;
467
+ var SECTION_HEIGHT_MAX_M = 120;
468
+ var SECTION_RAKE_MIN_DEG = 0;
469
+ var SECTION_RAKE_MAX_DEG = 45;
470
+ var LEGACY_SECTION_ELEVATION_TIER_MAX = 7;
471
+ var SEATED_EYE_HEIGHT_M = 1.2;
472
+ function finiteClamped(value, min, max, fallback) {
473
+ return Number.isFinite(value) ? Math.max(min, Math.min(max, value)) : fallback;
474
+ }
475
+ function sectionElevationTier(value) {
476
+ if (!Number.isFinite(value)) return SECTION_ELEVATION_TIER_MIN;
477
+ return Math.max(
478
+ SECTION_ELEVATION_TIER_MIN,
479
+ Math.min(SECTION_ELEVATION_TIER_MAX, Math.round(value))
480
+ );
481
+ }
482
+ function compatibleAutomaticTier(value) {
483
+ if (!Number.isFinite(value) || !Number.isInteger(value) || value < 0) return 0;
484
+ if (value <= LEGACY_SECTION_ELEVATION_TIER_MAX) return value;
485
+ return SECTION_ELEVATION_TIER_MAX;
486
+ }
487
+ function sectionGeometry(section, context = {}) {
488
+ const floorBaseHeightM = finiteClamped(
489
+ context.floorBaseHeightM,
490
+ SECTION_HEIGHT_MIN_M,
491
+ SECTION_HEIGHT_MAX_M,
492
+ 0
493
+ );
494
+ const automaticHeight = Math.min(
495
+ SECTION_HEIGHT_MAX_M,
496
+ floorBaseHeightM + compatibleAutomaticTier(section.elevation) * TIER_HEIGHT_M
497
+ );
498
+ const height = section.height === void 0 ? automaticHeight : finiteClamped(section.height, SECTION_HEIGHT_MIN_M, SECTION_HEIGHT_MAX_M, automaticHeight);
499
+ const rake = finiteClamped(section.rake, SECTION_RAKE_MIN_DEG, SECTION_RAKE_MAX_DEG, 0);
500
+ return { height, rake };
501
+ }
502
+
426
503
  // src/core/layout.ts
427
504
  function overrideAccessibility(o) {
428
505
  if (!o) return [];
429
- if (o.accessibility && o.accessibility.length) return o.accessibility;
506
+ if (o.accessibility && o.accessibility.length) {
507
+ return o.wheelchairSpaceType && !o.accessibility.includes("wheelchair") ? ["wheelchair", ...o.accessibility] : o.accessibility;
508
+ }
509
+ if (o.wheelchairSpaceType) return ["wheelchair"];
430
510
  return o.accessible ? ["wheelchair"] : [];
431
511
  }
432
512
  var DEG = Math.PI / 180;
@@ -468,6 +548,10 @@ function overrideMap(row) {
468
548
  if (row.overrides) for (const o of row.overrides) m.set(o.index, o);
469
549
  return m;
470
550
  }
551
+ function rowInventoryCount(row) {
552
+ 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));
553
+ return Math.max(0, row.seatCount - skipped.size);
554
+ }
471
555
  function centerRank(n) {
472
556
  const rank = new Array(n);
473
557
  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);
@@ -481,9 +565,9 @@ function seatLabelPart(row, i) {
481
565
  const prefix = row.seatNumbering?.prefix ?? "";
482
566
  const endAt = row.seatNumbering?.endAt;
483
567
  const n = row.seatCount;
484
- if (scheme === "updown") {
568
+ if (scheme === "updown" || scheme === "updown-descending") {
485
569
  const half = Math.ceil(n / 2);
486
- const core2 = i < half ? rawStart + 2 * i : rawStart - 1 + 2 * (n - i);
570
+ 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);
487
571
  return `${prefix}${core2}`;
488
572
  }
489
573
  const effStep = scheme === "odd" || scheme === "even" ? 2 : step;
@@ -536,6 +620,7 @@ function expandRowSlots(row) {
536
620
  skipped: !!o?.skip,
537
621
  accessible: accessibility.length > 0,
538
622
  accessibility,
623
+ wheelchairSpaceType: o?.wheelchairSpaceType,
539
624
  commercial: Object.values(commercial).some((value) => value !== void 0 && value !== false && value !== "") ? commercial : void 0,
540
625
  viewUrl: o?.viewFromSeatUrl ?? row.viewFromSeatUrl,
541
626
  labelStyle: o?.labelStyle
@@ -556,6 +641,7 @@ function expandRow(row) {
556
641
  categoryKey: slot.categoryKey,
557
642
  accessible: slot.accessible || void 0,
558
643
  accessibility: slot.accessibility.length ? slot.accessibility : void 0,
644
+ wheelchairSpaceType: slot.wheelchairSpaceType,
559
645
  commercial: slot.commercial,
560
646
  viewUrl: slot.viewUrl,
561
647
  labelStyle: slot.labelStyle
@@ -563,18 +649,43 @@ function expandRow(row) {
563
649
  }
564
650
  return seats;
565
651
  }
566
- function expandTable(t2) {
652
+ function tableSeatCountsBySide(t2) {
653
+ if (t2.seatCountsBySide) return { ...t2.seatCountsBySide };
654
+ const enabled = t2.sides && t2.sides.length ? t2.sides : ["top", "bottom"];
655
+ const order = ["top", "bottom", "left", "right"].filter((side) => enabled.includes(side));
656
+ const counts = { top: 0, bottom: 0, left: 0, right: 0 };
657
+ if (!order.length) return counts;
658
+ const n = Math.max(0, Math.round(t2.seatCount));
659
+ for (let index = 0; index < n; index++) counts[order[index % order.length]] += 1;
660
+ return counts;
661
+ }
662
+ function expandTableSlots(t2) {
567
663
  const seats = [];
568
664
  const n = Math.max(0, Math.round(t2.seatCount));
569
665
  if (n === 0) return seats;
570
- const mk = (i, x, y) => ({
571
- id: `${t2.id}:${i}`,
572
- label: `${t2.label}-${i + 1}`,
573
- x,
574
- y,
575
- rowId: t2.id,
576
- categoryKey: t2.categoryKey
577
- });
666
+ const overrides2 = new Map((t2.overrides ?? []).map((override) => [override.index, override]));
667
+ const mk = (index, x, y, side) => {
668
+ const override = overrides2.get(index);
669
+ const accessibility = overrideAccessibility(override);
670
+ const label = override?.label ?? `${t2.label}-${index + 1}`;
671
+ const displayPrefix = t2.displayLabel ?? t2.label;
672
+ return {
673
+ index,
674
+ label,
675
+ displayLabel: override?.displayLabel ?? `${displayPrefix}-${index + 1}`,
676
+ x: x + (override?.dx ?? 0),
677
+ y: y + (override?.dy ?? 0),
678
+ categoryKey: override?.categoryKey ?? t2.categoryKey,
679
+ skipped: !!override?.skip,
680
+ accessible: accessibility.length > 0,
681
+ accessibility,
682
+ wheelchairSpaceType: override?.wheelchairSpaceType,
683
+ commercial: override?.commercial,
684
+ viewUrl: override?.viewFromSeatUrl,
685
+ labelStyle: override?.labelStyle,
686
+ ...side ? { side } : {}
687
+ };
688
+ };
578
689
  if (t2.shape === "round") {
579
690
  const R = (t2.radius ?? 40) + TABLE_SEAT_OFFSET;
580
691
  const base = t2.rotation * DEG;
@@ -597,17 +708,11 @@ function expandTable(t2) {
597
708
  }
598
709
  const w = t2.width ?? 80;
599
710
  const h = t2.height ?? 50;
600
- const enabled = t2.sides && t2.sides.length ? t2.sides : ["top", "bottom"];
601
- const order = ["top", "bottom", "left", "right"].filter((s) => enabled.includes(s));
602
- if (!order.length) return seats;
603
- const counts = new Map(order.map((s) => [s, 0]));
604
- for (let i = 0; i < n; i++) {
605
- const s = order[i % order.length];
606
- counts.set(s, counts.get(s) + 1);
607
- }
711
+ const counts = tableSeatCountsBySide(t2);
712
+ const order = ["top", "bottom", "left", "right"];
608
713
  let idx = 0;
609
714
  for (const side of order) {
610
- const count = counts.get(side);
715
+ const count = counts[side];
611
716
  for (let j = 0; j < count; j++) {
612
717
  let localX;
613
718
  let localY;
@@ -624,17 +729,39 @@ function expandTable(t2) {
624
729
  localX = w / 2 + TABLE_SEAT_OFFSET;
625
730
  localY = -h / 2 + (j + 0.5) * h / count;
626
731
  }
627
- const p = place(localX, localY, t2.rotation, t2.center);
628
- seats.push(mk(idx++, p.x, p.y));
732
+ const point = place(localX, localY, t2.rotation, t2.center);
733
+ seats.push(mk(idx++, point.x, point.y, side));
629
734
  }
630
735
  }
631
736
  return seats;
632
737
  }
738
+ function tableInventoryCount(t2) {
739
+ if (t2.bookAsWhole || t2.variableOccupancy) return Math.max(0, Math.round(t2.seatCount));
740
+ return expandTableSlots(t2).filter((slot) => !slot.skipped).length;
741
+ }
742
+ function expandTable(t2) {
743
+ return expandTableSlots(t2).filter((slot) => !slot.skipped).map((slot) => ({
744
+ id: `${t2.id}:${slot.index}`,
745
+ label: slot.label,
746
+ ...slot.displayLabel !== slot.label ? { displayLabel: slot.displayLabel } : {},
747
+ x: slot.x,
748
+ y: slot.y,
749
+ rowId: t2.id,
750
+ categoryKey: slot.categoryKey,
751
+ ...slot.accessible ? { accessible: true } : {},
752
+ ...slot.accessibility.length ? { accessibility: slot.accessibility } : {},
753
+ ...slot.wheelchairSpaceType ? { wheelchairSpaceType: slot.wheelchairSpaceType } : {},
754
+ ...slot.commercial ? { commercial: slot.commercial } : {},
755
+ ...slot.viewUrl ? { viewUrl: slot.viewUrl } : {},
756
+ ...slot.labelStyle ? { labelStyle: slot.labelStyle } : {}
757
+ }));
758
+ }
633
759
  function expandBooth(b) {
634
760
  return [
635
761
  {
636
762
  id: `${b.id}:0`,
637
763
  label: b.label,
764
+ ...b.displayLabel && b.displayLabel !== b.label ? { displayLabel: b.displayLabel } : {},
638
765
  x: b.center.x,
639
766
  y: b.center.y,
640
767
  rowId: b.id,
@@ -672,7 +799,7 @@ function polygonLabelPoint(outer, holes) {
672
799
  if (!outer.length) return { x: 0, y: 0 };
673
800
  const xs = outer.map((point) => point.x);
674
801
  const ys = outer.map((point) => point.y);
675
- const bounds = { minX: Math.min(...xs), maxX: Math.max(...xs), minY: Math.min(...ys), maxY: Math.max(...ys) };
802
+ const bounds2 = { minX: Math.min(...xs), maxX: Math.max(...xs), minY: Math.min(...ys), maxY: Math.max(...ys) };
676
803
  const centroid2 = polygonCentroid(outer);
677
804
  if (pointInPolygonWithHoles(centroid2, outer, holes)) return centroid2;
678
805
  let best = outer[0];
@@ -681,8 +808,8 @@ function polygonLabelPoint(outer, holes) {
681
808
  for (let row = 1; row < 24; row += 1) {
682
809
  for (let column = 1; column < 24; column += 1) {
683
810
  const point = {
684
- x: bounds.minX + (bounds.maxX - bounds.minX) * column / 24,
685
- y: bounds.minY + (bounds.maxY - bounds.minY) * row / 24
811
+ x: bounds2.minX + (bounds2.maxX - bounds2.minX) * column / 24,
812
+ y: bounds2.minY + (bounds2.maxY - bounds2.minY) * row / 24
686
813
  };
687
814
  if (!pointInPolygonWithHoles(point, outer, holes)) continue;
688
815
  const score = Math.min(...rings.flatMap((ring) => ring.map((start, index) => {
@@ -744,9 +871,32 @@ function objectCenter(o) {
744
871
  return { x: o.x + o.width / 2, y: o.y + o.height / 2 };
745
872
  }
746
873
  }
874
+ function samePoints(left, right) {
875
+ return left.length === right.length && left.every((point, index) => point.x === right[index].x && point.y === right[index].y);
876
+ }
877
+ function sameGASurfaceAsSection(object, section) {
878
+ if (object.type !== "gaArea" || !samePoints(object.points, section.outline)) return false;
879
+ const objectHoles = object.holes ?? [];
880
+ const sectionHoles = section.holes ?? [];
881
+ return objectHoles.length === sectionHoles.length && objectHoles.every((hole, index) => samePoints(hole, sectionHoles[index]));
882
+ }
883
+ function owningSectionForObject(objects, object) {
884
+ const sections = objects.filter((candidate) => candidate.type === "section");
885
+ const referencedLogicalId = "referenceInventorySource" in object ? object.referenceInventorySource?.logicalSectionId : void 0;
886
+ const center = objectCenter(object);
887
+ const referencedOwner = referencedLogicalId ? sections.find((section) => (section.logicalSectionId ?? section.id) === referencedLogicalId && (sameGASurfaceAsSection(object, section) || pointInPolygonWithHoles(center, section.outline, section.holes))) : void 0;
888
+ return referencedOwner ?? sections.find((section) => pointInPolygonWithHoles(center, section.outline, section.holes));
889
+ }
747
890
  function floorsOf(doc) {
748
891
  if (doc.floors && doc.floors.length) return doc.floors;
749
- return [{ id: "floor-0", name: "Main", objects: doc.objects, focalPoint: doc.focalPoint, backgroundImage: doc.backgroundImage }];
892
+ return [{
893
+ id: "floor-0",
894
+ name: "Main",
895
+ objects: doc.objects,
896
+ focalPoint: doc.focalPoint,
897
+ referenceImage: doc.referenceImage,
898
+ backgroundImage: doc.backgroundImage
899
+ }];
750
900
  }
751
901
  function floorObjects(doc, floorId) {
752
902
  const floors = floorsOf(doc);
@@ -795,15 +945,138 @@ function stackFloors(doc, spread = 900) {
795
945
  });
796
946
  return { ...doc, objects, floors: void 0 };
797
947
  }
798
- function expandChart(doc) {
948
+ function expandFloorObjects(objects, zones, fallbackFocal) {
799
949
  const out = [];
800
- for (const obj of allObjects(doc)) {
801
- if (obj.type === "row") out.push(...expandRow(obj));
802
- else if (obj.type === "table") out.push(...expandTable(obj));
803
- else if (obj.type === "booth") out.push(...expandBooth(obj));
950
+ const segmented = /* @__PURE__ */ new Map();
951
+ const grouped = /* @__PURE__ */ new Map();
952
+ for (const object of objects) {
953
+ if (object.type !== "row" || !object.segmentedRow) continue;
954
+ const list = grouped.get(object.segmentedRow.groupId) ?? [];
955
+ list.push(object);
956
+ grouped.set(object.segmentedRow.groupId, list);
957
+ }
958
+ for (const [groupId, members] of grouped) {
959
+ const ordered = members.slice().sort((left, right) => left.segmentedRow.componentIndex - right.segmentedRow.componentIndex);
960
+ const expectedCount = ordered[0]?.segmentedRow?.componentCount ?? 0;
961
+ const first = ordered[0]?.segmentedRow;
962
+ if (!first) continue;
963
+ 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);
964
+ if (!valid) continue;
965
+ const totalSeats = ordered.reduce((sum, row) => sum + row.seatCount, 0);
966
+ let adjacencyOffset = 0;
967
+ let displayOffset = 0;
968
+ for (const row of ordered) {
969
+ if (row.segmentedRow.boundaryBefore === "break") adjacencyOffset += 1;
970
+ segmented.set(row.id, {
971
+ groupId,
972
+ adjacencyOffset,
973
+ displayOffset,
974
+ displayLabel: first.displayLabel,
975
+ totalSeats,
976
+ canonical: ordered[0],
977
+ viewFromSeatUrl: first.viewFromSeatUrl
978
+ });
979
+ adjacencyOffset += row.seatCount;
980
+ displayOffset += row.seatCount;
981
+ }
982
+ }
983
+ for (const obj of objects) {
984
+ let seats = [];
985
+ if (obj.type === "row") seats = expandRow(obj);
986
+ else if (obj.type === "table") seats = expandTable(obj);
987
+ else if (obj.type === "booth") seats = expandBooth(obj);
988
+ if (!seats.length) continue;
989
+ if (obj.type === "row") {
990
+ const logical = segmented.get(obj.id);
991
+ if (logical) {
992
+ const overrides2 = new Map((obj.overrides ?? []).map((override) => [override.index, override]));
993
+ for (const seat of seats) {
994
+ const physicalIndex = Number(seat.id.slice(seat.id.lastIndexOf(":") + 1));
995
+ if (!Number.isInteger(physicalIndex)) continue;
996
+ const displayOrdinal = logical.displayOffset + physicalIndex;
997
+ seat.logicalRowId = logical.groupId;
998
+ seat.logicalSeatIndex = logical.adjacencyOffset + physicalIndex;
999
+ if (!overrides2.get(physicalIndex)?.displayLabel) {
1000
+ const numberingRow = {
1001
+ ...logical.canonical,
1002
+ seatCount: logical.totalSeats,
1003
+ label: logical.displayLabel,
1004
+ displayLabel: logical.displayLabel
1005
+ };
1006
+ seat.displayLabel = `${logical.displayLabel}-${seatLabelPart(numberingRow, displayOrdinal)}`;
1007
+ }
1008
+ seat.viewUrl ??= logical.viewFromSeatUrl;
1009
+ }
1010
+ }
1011
+ }
1012
+ const owner = owningSectionForObject(objects, obj);
1013
+ const inheritedView = owner?.viewFromSeatUrl;
1014
+ const zone = owner?.zone ? zones?.find((candidate) => candidate.id === owner.zone) : void 0;
1015
+ const resolvedFocal = zone?.focalPoint ?? fallbackFocal;
1016
+ for (const seat of seats) {
1017
+ if (inheritedView) seat.viewUrl ??= inheritedView;
1018
+ if (owner) seat.sectionId = owner.logicalSectionId ?? owner.id;
1019
+ if (owner?.zone) seat.zoneId = owner.zone;
1020
+ if (resolvedFocal) seat.focalPoint = { ...resolvedFocal };
1021
+ }
1022
+ out.push(...seats);
804
1023
  }
805
1024
  return out;
806
1025
  }
1026
+ function expandChart(doc, options = {}) {
1027
+ if (doc.floors?.length) {
1028
+ const out2 = [];
1029
+ for (const floor of doc.floors) {
1030
+ const floorFocal = floor.focalPoint ?? doc.focalPoint;
1031
+ const seats = expandFloorObjects(floor.objects, doc.zones, floorFocal);
1032
+ assignEyeHeights(floor.objects, floor.focalPoint ?? doc.focalPoint, floor.baseHeightM ?? 0, seats);
1033
+ out2.push(...seats);
1034
+ }
1035
+ return out2;
1036
+ }
1037
+ const out = expandFloorObjects(doc.objects, doc.zones, doc.focalPoint);
1038
+ assignEyeHeights(doc.objects, doc.focalPoint, options.floorBaseHeightM ?? 0, out);
1039
+ return out;
1040
+ }
1041
+ function assignEyeHeights(objects, focal, floorBaseHeightM, seats) {
1042
+ const sections = objects.filter((o) => o.type === "section");
1043
+ const hasGeometry = floorBaseHeightM > 0 || sections.some((s) => s.height !== void 0 || s.rake !== void 0 || (s.elevation ?? 0) > 0);
1044
+ if (!sections.length || !hasGeometry || !focal) {
1045
+ for (const seat of seats) seat.eyeHeightM = floorBaseHeightM + SEATED_EYE_HEIGHT_M;
1046
+ return;
1047
+ }
1048
+ const owner = new Array(seats.length);
1049
+ const geo = /* @__PURE__ */ new Map();
1050
+ const frontDistU = /* @__PURE__ */ new Map();
1051
+ for (let i = 0; i < seats.length; i++) {
1052
+ const seat = seats[i];
1053
+ const sec = sections.find((s) => pointInPolygonWithHoles({ x: seat.x, y: seat.y }, s.outline, s.holes)) ?? null;
1054
+ owner[i] = sec;
1055
+ if (!sec) continue;
1056
+ if (!geo.has(sec.id)) geo.set(sec.id, sectionGeometry(sec, { floorBaseHeightM }));
1057
+ const seatFocal = seat.focalPoint ?? focal;
1058
+ const d = Math.hypot(seat.x - seatFocal.x, seat.y - seatFocal.y);
1059
+ const cur = frontDistU.get(sec.id);
1060
+ if (cur === void 0 || d < cur) frontDistU.set(sec.id, d);
1061
+ }
1062
+ for (let i = 0; i < seats.length; i++) {
1063
+ const seat = seats[i];
1064
+ const sec = owner[i];
1065
+ if (!sec) {
1066
+ seat.eyeHeightM = floorBaseHeightM + SEATED_EYE_HEIGHT_M;
1067
+ continue;
1068
+ }
1069
+ const g = geo.get(sec.id);
1070
+ let riseM = 0;
1071
+ if (g.rake > 0) {
1072
+ const seatFocal = seat.focalPoint ?? focal;
1073
+ const d = Math.hypot(seat.x - seatFocal.x, seat.y - seatFocal.y);
1074
+ const depthU = Math.max(0, d - (frontDistU.get(sec.id) ?? d));
1075
+ riseM = depthU * METRES_PER_CHART_UNIT * Math.tan(g.rake * Math.PI / 180);
1076
+ }
1077
+ seat.eyeHeightM = g.height + riseM + SEATED_EYE_HEIGHT_M;
1078
+ }
1079
+ }
807
1080
  var PAD = 40;
808
1081
  function chartBounds(doc) {
809
1082
  let minX = Infinity;
@@ -847,8 +1120,9 @@ function chartBounds(doc) {
847
1120
  acc(obj.position.x + w, obj.position.y + obj.fontSize);
848
1121
  }
849
1122
  }
850
- if (doc.backgroundImage) {
851
- const { center, width } = doc.backgroundImage;
1123
+ for (const image of [doc.referenceImage, doc.backgroundImage]) {
1124
+ if (!image) continue;
1125
+ const { center, width } = image;
852
1126
  const bh = width * 3 / 4;
853
1127
  acc(center.x - width / 2, center.y - bh / 2);
854
1128
  acc(center.x + width / 2, center.y + bh / 2);
@@ -872,9 +1146,55 @@ var MAX_EVENT_INVENTORY = 1e5;
872
1146
  function gaUnitLabel(areaId, index) {
873
1147
  return `${PREFIX}${encodeURIComponent(areaId)}__${index + 1}`;
874
1148
  }
875
- function gaUnitLabels(area) {
1149
+ function validGAInventorySegments(area) {
1150
+ const segments = area.inventorySegments;
1151
+ if (segments === void 0) return true;
1152
+ if (!Array.isArray(segments) || segments.length < 1 || segments.length > 256) return false;
1153
+ let total = 0;
1154
+ const ranges = /* @__PURE__ */ new Map();
1155
+ for (const segment of segments) {
1156
+ 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;
1157
+ total += segment.count;
1158
+ if (total > MAX_GA_CAPACITY) return false;
1159
+ const sourceRanges = ranges.get(segment.sourceAreaId) ?? [];
1160
+ const end = segment.startIndex + segment.count;
1161
+ if (sourceRanges.some((range) => segment.startIndex < range.end && end > range.start)) return false;
1162
+ sourceRanges.push({ start: segment.startIndex, end });
1163
+ ranges.set(segment.sourceAreaId, sourceRanges);
1164
+ }
1165
+ return total === area.capacity;
1166
+ }
1167
+ function gaInventorySegments(area) {
876
1168
  const capacity = Math.min(MAX_GA_CAPACITY, Math.max(0, Math.floor(area.capacity)));
877
- return Array.from({ length: capacity }, (_, i) => gaUnitLabel(area.id, i));
1169
+ if (area.inventorySegments === void 0) {
1170
+ return capacity ? [{ sourceAreaId: area.id, startIndex: 0, count: capacity }] : [];
1171
+ }
1172
+ if (!validGAInventorySegments(area)) return [];
1173
+ return area.inventorySegments.map((segment) => ({ ...segment }));
1174
+ }
1175
+ function gaUnitLabels(area) {
1176
+ return gaInventorySegments(area).flatMap((segment) => Array.from(
1177
+ { length: segment.count },
1178
+ (_, offset) => gaUnitLabel(segment.sourceAreaId, segment.startIndex + offset)
1179
+ ));
1180
+ }
1181
+ function growJoinedGAInventory(area, nextCapacity) {
1182
+ if (area.inventorySegments === void 0 || nextCapacity === area.capacity) return area.inventorySegments;
1183
+ if (nextCapacity < area.capacity) return void 0;
1184
+ const segments = gaInventorySegments(area);
1185
+ if (!segments.length) return void 0;
1186
+ const extra = nextCapacity - area.capacity;
1187
+ if (!extra) return segments;
1188
+ const ownRanges = segments.filter((segment) => segment.sourceAreaId === area.id);
1189
+ const startIndex = ownRanges.reduce((max, segment) => Math.max(max, segment.startIndex + segment.count), 0);
1190
+ const last = segments[segments.length - 1];
1191
+ if (last?.sourceAreaId === area.id && last.startIndex + last.count === startIndex) {
1192
+ last.count += extra;
1193
+ } else {
1194
+ if (segments.length >= 256) return void 0;
1195
+ segments.push({ sourceAreaId: area.id, startIndex, count: extra });
1196
+ }
1197
+ return segments;
878
1198
  }
879
1199
  function gaAreasOf(doc) {
880
1200
  return allObjects(doc).filter((o) => o.type === "gaArea");
@@ -895,15 +1215,6 @@ function objectSeatLabels(o) {
895
1215
  function isSeatObject(o) {
896
1216
  return o.type === "row" || o.type === "table" || o.type === "booth" || o.type === "gaArea";
897
1217
  }
898
- function samePoints(left, right) {
899
- return left.length === right.length && left.every((point, index) => point.x === right[index].x && point.y === right[index].y);
900
- }
901
- function sameGASurfaceAsSection(object, section) {
902
- if (object.type !== "gaArea" || !samePoints(object.points, section.outline)) return false;
903
- const objectHoles = object.holes ?? [];
904
- const sectionHoles = section.holes ?? [];
905
- return objectHoles.length === sectionHoles.length && objectHoles.every((hole, index) => samePoints(hole, sectionHoles[index]));
906
- }
907
1218
  function computeSections(doc) {
908
1219
  const objs = allObjects(doc);
909
1220
  const sectionObjs = objs.filter((o) => o.type === "section");
@@ -929,10 +1240,7 @@ function computeSections(doc) {
929
1240
  if (!isSeatObject(obj)) continue;
930
1241
  const labels = objectSeatLabels(obj);
931
1242
  if (labels.length === 0) continue;
932
- const referencedLogicalId = obj.referenceInventorySource?.logicalSectionId;
933
- const c = objectCenter(obj);
934
- const referencedOwner = referencedLogicalId ? sectionObjs.find((section) => (section.logicalSectionId ?? section.id) === referencedLogicalId && (sameGASurfaceAsSection(obj, section) || pointInPolygonWithHoles(c, section.outline, section.holes))) : void 0;
935
- const owner = referencedOwner ?? sectionObjs.find((s) => pointInPolygonWithHoles(c, s.outline, s.holes));
1243
+ const owner = owningSectionForObject(objs, obj);
936
1244
  const node = owner ? nodes.get(owner.logicalSectionId ?? owner.id) : ungrouped;
937
1245
  node.seatCount += labels.length;
938
1246
  node.objectIds.push(obj.id);
@@ -1503,11 +1811,310 @@ var import_Circle = require("konva/lib/shapes/Circle");
1503
1811
  var import_Rect = require("konva/lib/shapes/Rect");
1504
1812
  var import_Ellipse = require("konva/lib/shapes/Ellipse");
1505
1813
  var import_Line = require("konva/lib/shapes/Line");
1814
+ var import_Arrow = require("konva/lib/shapes/Arrow");
1506
1815
  var import_Text = require("konva/lib/shapes/Text");
1507
1816
  var import_Path = require("konva/lib/shapes/Path");
1508
1817
  var import_Image = require("konva/lib/shapes/Image");
1509
1818
  var import_Shape = require("konva/lib/Shape");
1510
1819
 
1820
+ // src/core/chartBackgrounds.ts
1821
+ function buyerBackgroundImage(owner) {
1822
+ const background = owner.backgroundImage;
1823
+ return background?.url && !background.assetId ? background : void 0;
1824
+ }
1825
+
1826
+ // src/core/spatialIndex.ts
1827
+ var DEFAULT_SEAT_RADIUS = 9;
1828
+ function halfExtents(shape) {
1829
+ if (shape.kind === "circle") return { hx: shape.r, hy: shape.r };
1830
+ const hw = shape.width / 2;
1831
+ const hh = shape.height / 2;
1832
+ const rot = shape.rotation ?? 0;
1833
+ if (!rot) return { hx: hw, hy: hh };
1834
+ const rad = rot * Math.PI / 180;
1835
+ const c = Math.abs(Math.cos(rad));
1836
+ const s = Math.abs(Math.sin(rad));
1837
+ return { hx: hw * c + hh * s, hy: hw * s + hh * c };
1838
+ }
1839
+ function shapeIntersectsRect(e, x0, y0, x1, y1) {
1840
+ if (e.shape.kind === "circle") {
1841
+ const cx = Math.min(Math.max(e.seat.x, x0), x1);
1842
+ const cy = Math.min(Math.max(e.seat.y, y0), y1);
1843
+ const dx = e.seat.x - cx;
1844
+ const dy = e.seat.y - cy;
1845
+ return dx * dx + dy * dy <= e.shape.r * e.shape.r;
1846
+ }
1847
+ const rot = e.shape.rotation ?? 0;
1848
+ if (!rot) {
1849
+ 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;
1850
+ }
1851
+ const rad = rot * Math.PI / 180;
1852
+ const c = Math.cos(rad);
1853
+ const s = Math.sin(rad);
1854
+ const hw = e.shape.width / 2;
1855
+ const hh = e.shape.height / 2;
1856
+ const corners = [
1857
+ [-hw, -hh],
1858
+ [hw, -hh],
1859
+ [hw, hh],
1860
+ [-hw, hh]
1861
+ ].map(([lx, ly]) => [e.seat.x + lx * c - ly * s, e.seat.y + lx * s + ly * c]);
1862
+ const query = [
1863
+ [x0, y0],
1864
+ [x1, y0],
1865
+ [x1, y1],
1866
+ [x0, y1]
1867
+ ];
1868
+ const axes = [
1869
+ [1, 0],
1870
+ [0, 1],
1871
+ [c, s],
1872
+ [-s, c]
1873
+ ];
1874
+ for (const [ax, ay] of axes) {
1875
+ let minA = Infinity;
1876
+ let maxA = -Infinity;
1877
+ for (const [px, py] of corners) {
1878
+ const d = px * ax + py * ay;
1879
+ if (d < minA) minA = d;
1880
+ if (d > maxA) maxA = d;
1881
+ }
1882
+ let minB = Infinity;
1883
+ let maxB = -Infinity;
1884
+ for (const [px, py] of query) {
1885
+ const d = px * ax + py * ay;
1886
+ if (d < minB) minB = d;
1887
+ if (d > maxB) maxB = d;
1888
+ }
1889
+ if (maxA < minB || maxB < minA) return false;
1890
+ }
1891
+ return true;
1892
+ }
1893
+ function buildSeatIndex(seats, opts = {}) {
1894
+ const seatRadius = opts.seatRadius ?? DEFAULT_SEAT_RADIUS;
1895
+ const n = seats.length;
1896
+ const entries = new Array(n);
1897
+ let minX = Infinity;
1898
+ let minY = Infinity;
1899
+ let maxX = -Infinity;
1900
+ let maxY = -Infinity;
1901
+ let maxHalf = 0;
1902
+ for (let i = 0; i < n; i++) {
1903
+ const seat = seats[i];
1904
+ const shape = opts.shapeOf?.(seat) ?? { kind: "circle", r: seatRadius };
1905
+ const { hx, hy } = halfExtents(shape);
1906
+ entries[i] = { seat, shape, hx, hy };
1907
+ if (seat.x - hx < minX) minX = seat.x - hx;
1908
+ if (seat.y - hy < minY) minY = seat.y - hy;
1909
+ if (seat.x + hx > maxX) maxX = seat.x + hx;
1910
+ if (seat.y + hy > maxY) maxY = seat.y + hy;
1911
+ if (hx > maxHalf) maxHalf = hx;
1912
+ if (hy > maxHalf) maxHalf = hy;
1913
+ }
1914
+ if (n === 0) {
1915
+ minX = 0;
1916
+ minY = 0;
1917
+ maxX = 0;
1918
+ maxY = 0;
1919
+ }
1920
+ const nominal = maxHalf > 0 ? maxHalf * 4 : 1;
1921
+ const spanX = Math.max(maxX - minX, 0);
1922
+ const spanY = Math.max(maxY - minY, 0);
1923
+ const maxCells = Math.max(64, opts.maxCells ?? n * 4);
1924
+ let cell = nominal;
1925
+ let cols = Math.max(1, Math.ceil(spanX / cell) || 1);
1926
+ let rows = Math.max(1, Math.ceil(spanY / cell) || 1);
1927
+ while (cols * rows > maxCells && (cols > 1 || rows > 1)) {
1928
+ cell *= 2;
1929
+ cols = Math.max(1, Math.ceil(spanX / cell) || 1);
1930
+ rows = Math.max(1, Math.ceil(spanY / cell) || 1);
1931
+ }
1932
+ const cellCount = cols * rows;
1933
+ const cellStart = new Int32Array(cellCount + 1);
1934
+ let total = 0;
1935
+ for (let i = 0; i < n; i++) {
1936
+ const e = entries[i];
1937
+ const cx0 = clampInt(Math.floor((e.seat.x - e.hx - minX) / cell), 0, cols - 1);
1938
+ const cx1 = clampInt(Math.floor((e.seat.x + e.hx - minX) / cell), 0, cols - 1);
1939
+ const cy0 = clampInt(Math.floor((e.seat.y - e.hy - minY) / cell), 0, rows - 1);
1940
+ const cy1 = clampInt(Math.floor((e.seat.y + e.hy - minY) / cell), 0, rows - 1);
1941
+ for (let cy = cy0; cy <= cy1; cy++) {
1942
+ for (let cx = cx0; cx <= cx1; cx++) {
1943
+ cellStart[cy * cols + cx + 1]++;
1944
+ total++;
1945
+ }
1946
+ }
1947
+ }
1948
+ for (let c = 0; c < cellCount; c++) cellStart[c + 1] += cellStart[c];
1949
+ const items = new Int32Array(total);
1950
+ const cursor = cellStart.slice(0, cellCount);
1951
+ for (let i = 0; i < n; i++) {
1952
+ const e = entries[i];
1953
+ const cx0 = clampInt(Math.floor((e.seat.x - e.hx - minX) / cell), 0, cols - 1);
1954
+ const cx1 = clampInt(Math.floor((e.seat.x + e.hx - minX) / cell), 0, cols - 1);
1955
+ const cy0 = clampInt(Math.floor((e.seat.y - e.hy - minY) / cell), 0, rows - 1);
1956
+ const cy1 = clampInt(Math.floor((e.seat.y + e.hy - minY) / cell), 0, rows - 1);
1957
+ for (let cy = cy0; cy <= cy1; cy++) {
1958
+ for (let cx = cx0; cx <= cx1; cx++) {
1959
+ const c = cy * cols + cx;
1960
+ items[cursor[c]++] = i;
1961
+ }
1962
+ }
1963
+ }
1964
+ return {
1965
+ seats,
1966
+ entries,
1967
+ minX,
1968
+ minY,
1969
+ cell,
1970
+ cols,
1971
+ rows,
1972
+ cellStart,
1973
+ items,
1974
+ stamp: new Int32Array(n),
1975
+ epoch: 0
1976
+ };
1977
+ }
1978
+ function clampInt(v, lo, hi) {
1979
+ return v < lo ? lo : v > hi ? hi : v;
1980
+ }
1981
+ function queryRect(index, rect, opts = {}) {
1982
+ const x0 = Math.min(rect.x, rect.x + rect.width);
1983
+ const x1 = Math.max(rect.x, rect.x + rect.width);
1984
+ const y0 = Math.min(rect.y, rect.y + rect.height);
1985
+ const y1 = Math.max(rect.y, rect.y + rect.height);
1986
+ const mode = opts.mode ?? "center";
1987
+ const { cols, rows, cell, minX, minY, cellStart, items, entries } = index;
1988
+ const cx0 = Math.max(0, Math.floor((x0 - minX) / cell));
1989
+ const cx1 = Math.min(cols - 1, Math.floor((x1 - minX) / cell));
1990
+ const cy0 = Math.max(0, Math.floor((y0 - minY) / cell));
1991
+ const cy1 = Math.min(rows - 1, Math.floor((y1 - minY) / cell));
1992
+ if (cx0 > cx1 || cy0 > cy1) return [];
1993
+ const found = [];
1994
+ const stamp = index.stamp;
1995
+ const ep = ++index.epoch;
1996
+ for (let cy = cy0; cy <= cy1; cy++) {
1997
+ const rowBase = cy * cols;
1998
+ for (let cx = cx0; cx <= cx1; cx++) {
1999
+ const c = rowBase + cx;
2000
+ const end = cellStart[c + 1];
2001
+ for (let k = cellStart[c]; k < end; k++) {
2002
+ const i = items[k];
2003
+ if (stamp[i] === ep) continue;
2004
+ stamp[i] = ep;
2005
+ const e = entries[i];
2006
+ if (opts.filter && !opts.filter(e.seat.id, e.seat)) continue;
2007
+ 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);
2008
+ if (ok) found.push(i);
2009
+ }
2010
+ }
2011
+ }
2012
+ found.sort((a, b) => a - b);
2013
+ return found.map((i) => entries[i].seat.id);
2014
+ }
2015
+
2016
+ // src/core/perspectiveProjection.ts
2017
+ function createPerspectiveCamera(bounds2, maxSurfaceHeightWorld = 0) {
2018
+ const span = Math.max(1, bounds2.width, bounds2.height);
2019
+ const target = {
2020
+ x: bounds2.x + bounds2.width / 2,
2021
+ y: bounds2.y + bounds2.height / 2
2022
+ };
2023
+ const distance = span * 1.55;
2024
+ const height = Math.max(span * 0.82, Math.max(0, maxSurfaceHeightWorld) + span * 0.45);
2025
+ return {
2026
+ target,
2027
+ distance,
2028
+ height,
2029
+ focalLength: Math.hypot(distance, height),
2030
+ yawRad: -8 * Math.PI / 180
2031
+ };
2032
+ }
2033
+ function projectPerspectivePoint(camera, point, heightWorld = 0) {
2034
+ const dx = point.x - camera.target.x;
2035
+ const dy = point.y - camera.target.y;
2036
+ const cos = Math.cos(camera.yawRad);
2037
+ const sin = Math.sin(camera.yawRad);
2038
+ const x = dx * cos - dy * sin;
2039
+ const y = dx * sin + dy * cos;
2040
+ const translatedY = y - camera.distance;
2041
+ const translatedZ = heightWorld - camera.height;
2042
+ const cameraLength = Math.hypot(camera.distance, camera.height);
2043
+ const depth = (-camera.distance * translatedY - camera.height * translatedZ) / cameraLength;
2044
+ const up = (-camera.height * translatedY + camera.distance * translatedZ) / cameraLength;
2045
+ const safeDepth = Math.max(cameraLength * 0.08, depth);
2046
+ const scale = camera.focalLength / safeDepth;
2047
+ return {
2048
+ x: camera.target.x + x * scale,
2049
+ y: camera.target.y - up * scale,
2050
+ depth: safeDepth,
2051
+ scale
2052
+ };
2053
+ }
2054
+ function applyAffine(affine, point) {
2055
+ return {
2056
+ x: affine.a * point.x + affine.c * point.y + affine.e,
2057
+ y: affine.b * point.x + affine.d * point.y + affine.f
2058
+ };
2059
+ }
2060
+ function invertAffine(affine) {
2061
+ const determinant = affine.a * affine.d - affine.b * affine.c;
2062
+ if (!Number.isFinite(determinant) || Math.abs(determinant) < 1e-9) {
2063
+ throw new Error("perspective projection produced a singular affine");
2064
+ }
2065
+ const a = affine.d / determinant;
2066
+ const b = -affine.b / determinant;
2067
+ const c = -affine.c / determinant;
2068
+ const d = affine.a / determinant;
2069
+ return {
2070
+ a,
2071
+ b,
2072
+ c,
2073
+ d,
2074
+ e: -(a * affine.e + c * affine.f),
2075
+ f: -(b * affine.e + d * affine.f)
2076
+ };
2077
+ }
2078
+ function composeAffine(left, right) {
2079
+ return {
2080
+ a: left.a * right.a + left.c * right.b,
2081
+ b: left.b * right.a + left.d * right.b,
2082
+ c: left.a * right.c + left.c * right.d,
2083
+ d: left.b * right.c + left.d * right.d,
2084
+ e: left.a * right.e + left.c * right.f + left.e,
2085
+ f: left.b * right.e + left.d * right.f + left.f
2086
+ };
2087
+ }
2088
+ function perspectiveTangentAffine(camera, anchor, heightAt = () => 0) {
2089
+ const origin = projectPerspectivePoint(camera, anchor, heightAt(anchor));
2090
+ const alongXPoint = { x: anchor.x + 1, y: anchor.y };
2091
+ const alongYPoint = { x: anchor.x, y: anchor.y + 1 };
2092
+ const alongX = projectPerspectivePoint(camera, alongXPoint, heightAt(alongXPoint));
2093
+ const alongY = projectPerspectivePoint(camera, alongYPoint, heightAt(alongYPoint));
2094
+ const a = alongX.x - origin.x;
2095
+ const b = alongX.y - origin.y;
2096
+ const c = alongY.x - origin.x;
2097
+ const d = alongY.y - origin.y;
2098
+ return {
2099
+ a,
2100
+ b,
2101
+ c,
2102
+ d,
2103
+ e: origin.x - a * anchor.x - c * anchor.y,
2104
+ f: origin.y - b * anchor.x - d * anchor.y
2105
+ };
2106
+ }
2107
+ function decomposeAffineLinear(affine) {
2108
+ const scaleX = Math.hypot(affine.a, affine.b);
2109
+ const determinant = affine.a * affine.d - affine.b * affine.c;
2110
+ return {
2111
+ rotationDeg: Math.atan2(affine.b, affine.a) * 180 / Math.PI,
2112
+ scaleX,
2113
+ scaleY: determinant / Math.max(scaleX, 1e-9),
2114
+ skewX: (affine.a * affine.c + affine.b * affine.d) / Math.max(determinant, 1e-9)
2115
+ };
2116
+ }
2117
+
1511
2118
  // src/lib/money.ts
1512
2119
  var DEFAULT_CURRENCY = "USD";
1513
2120
  var displayLocale;
@@ -1628,6 +2235,25 @@ function formatDate(value, opts) {
1628
2235
  return new Intl.DateTimeFormat(active, opts ?? { dateStyle: "medium", timeStyle: "short" }).format(value);
1629
2236
  }
1630
2237
 
2238
+ // src/core/shapeLineStyle.ts
2239
+ var DEFAULT_SHAPE_LINE_CAP = "round";
2240
+ var DEFAULT_SHAPE_LINE_JOIN = "round";
2241
+ var DEFAULT_SHAPE_LINE_ENDING = "none";
2242
+ function resolvedShapeLineStyle(shape) {
2243
+ return {
2244
+ lineCap: shape.lineCap ?? DEFAULT_SHAPE_LINE_CAP,
2245
+ lineJoin: shape.lineJoin ?? DEFAULT_SHAPE_LINE_JOIN,
2246
+ startEnding: shape.startEnding ?? DEFAULT_SHAPE_LINE_ENDING,
2247
+ endEnding: shape.endEnding ?? DEFAULT_SHAPE_LINE_ENDING
2248
+ };
2249
+ }
2250
+ function shapeArrowMetrics(strokeWidth) {
2251
+ return {
2252
+ pointerLength: Math.min(48, Math.max(8, strokeWidth * 3)),
2253
+ pointerWidth: Math.min(40, Math.max(8, strokeWidth * 2.25))
2254
+ };
2255
+ }
2256
+
1631
2257
  // src/engine/SeatmapRenderer.ts
1632
2258
  var SEAT_RADIUS = 9;
1633
2259
  var SEAT_LEGIBLE_SCALE = 0.9;
@@ -1635,17 +2261,18 @@ var CACHE_THRESHOLD = 0.55 * SEAT_LEGIBLE_SCALE;
1635
2261
  var LABEL_SCALE = MIN_VISIBLE_BOOKABLE_LABEL_PX / SEAT_LABEL_FONT_SIZE;
1636
2262
  var MIN_FITTED_SEAT_LABEL_FONT_SIZE = 4;
1637
2263
  var SEAT_TAP_SLOP_PX = 14;
1638
- var SEAT_GLYPH_MIN_PX = 6.5;
2264
+ var WHEELCHAIR_GLYPH_MIN_PX = 10;
2265
+ var FILTERED_WHEELCHAIR_GLYPH_MIN_PX = 14;
1639
2266
  var SECTION_PROMINENT_SCALE = 0.45 * SEAT_LEGIBLE_SCALE;
1640
2267
  var BLOCK_MELT_TOP = 0.9 * SEAT_LEGIBLE_SCALE;
1641
2268
  var SEAT_FOCUS_SCALE = Math.max(SEAT_LEGIBLE_SCALE * 1.1, BLOCK_MELT_TOP);
1642
2269
  var PAN_START_SLOP_PX = 8;
2270
+ var GHOST_CLICK_MS = 700;
1643
2271
  var ZONE_PROMINENT_SCALE = 0.55 * SECTION_PROMINENT_SCALE;
1644
2272
  var MAX_LABELS = 700;
1645
2273
  var MARQUEE_RING_CAP = 2500;
1646
2274
  var ISO_ANGLE_DEG = -11.5;
1647
2275
  var ISO_SQUASH = 0.58;
1648
- var LIFT_PER_STEP = 58;
1649
2276
  var ISO_TWEEN_MS = 320;
1650
2277
  var CAMERA_GLIDE_MS = 650;
1651
2278
  var BLOCK_FILL_ALPHA = 1;
@@ -1682,6 +2309,10 @@ var DEF_SELECTION = "#ffffff";
1682
2309
  var DEF_SELECTION_ON_LIGHT = "#0b1220";
1683
2310
  var DEF_DECOR_FILL = "#232c40";
1684
2311
  var DEF_TEXT = "#8b93a7";
2312
+ function konvaFontStyle(bold, italic, defaultWeight = "normal") {
2313
+ const weight = bold ? "bold" : defaultWeight;
2314
+ return italic ? `italic ${weight}`.trim() : weight;
2315
+ }
1685
2316
  var DEF_CANVAS_BACKGROUND = "#0e1117";
1686
2317
  function colorLuminance(color) {
1687
2318
  const s = color.trim();
@@ -1787,8 +2418,8 @@ function rotatedRectPoints(center, width, height, rotation) {
1787
2418
  }));
1788
2419
  }
1789
2420
  function pointsBounds(points) {
1790
- const bounds = polyBounds(points);
1791
- return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height };
2421
+ const bounds2 = polyBounds(points);
2422
+ return { x: bounds2.x, y: bounds2.y, width: bounds2.width, height: bounds2.height };
1792
2423
  }
1793
2424
  function rotatedRectFitsPolygon(center, width, height, rotation, outer, holes) {
1794
2425
  const radians = rotation * Math.PI / 180;
@@ -1808,14 +2439,14 @@ function rotatedRectFitsPolygon(center, width, height, rotation, outer, holes) {
1808
2439
  return true;
1809
2440
  }
1810
2441
  function polygonLabelCandidates(outer, holes, preferred) {
1811
- const bounds = polyBounds(outer);
1812
- const centre = { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 };
2442
+ const bounds2 = polyBounds(outer);
2443
+ const centre = { x: bounds2.x + bounds2.width / 2, y: bounds2.y + bounds2.height / 2 };
1813
2444
  const points = [preferred];
1814
2445
  for (let row = 1; row < 12; row += 1) {
1815
2446
  for (let column = 1; column < 12; column += 1) {
1816
2447
  const point = {
1817
- x: bounds.x + bounds.width * column / 12,
1818
- y: bounds.y + bounds.height * row / 12
2448
+ x: bounds2.x + bounds2.width * column / 12,
2449
+ y: bounds2.y + bounds2.height * row / 12
1819
2450
  };
1820
2451
  if (pointInPolygonWithHoles(point, outer, holes)) points.push(point);
1821
2452
  }
@@ -1880,6 +2511,33 @@ function rgba(hex, a) {
1880
2511
  const n = parseInt(m[1], 16);
1881
2512
  return `rgba(${n >> 16 & 255},${n >> 8 & 255},${n & 255},${a})`;
1882
2513
  }
2514
+ var ViewportGroup = class extends import_Group.Group {
2515
+ constructor() {
2516
+ super(...arguments);
2517
+ this.viewportCulled = false;
2518
+ }
2519
+ setViewportCulled(culled) {
2520
+ if (culled === this.viewportCulled) return;
2521
+ this.viewportCulled = culled;
2522
+ if (!culled) super._clearSelfAndDescendantCache();
2523
+ }
2524
+ isViewportCulled() {
2525
+ return this.viewportCulled;
2526
+ }
2527
+ drawScene(...args) {
2528
+ return this.viewportCulled ? this : super.drawScene(...args);
2529
+ }
2530
+ drawHit(...args) {
2531
+ return this.viewportCulled ? this : super.drawHit(...args);
2532
+ }
2533
+ _clearSelfAndDescendantCache(attr) {
2534
+ if (!this.viewportCulled) {
2535
+ super._clearSelfAndDescendantCache(attr);
2536
+ return;
2537
+ }
2538
+ this._clearCache(attr);
2539
+ }
2540
+ };
1883
2541
  function hexToRgb(hex) {
1884
2542
  const m = /^#?([\da-f]{6})$/i.exec(hex.trim());
1885
2543
  if (!m) return null;
@@ -1910,8 +2568,15 @@ function lerpColor(a, b, t2) {
1910
2568
  }
1911
2569
  var _SeatmapRenderer = class _SeatmapRenderer {
1912
2570
  constructor(container, options = {}) {
2571
+ /** Per-elevated-section label containers. Keeping the labels grouped lets the
2572
+ * Phase-B lift move thousands of seat labels with one transform per section
2573
+ * instead of rewriting every label on every tween frame. */
2574
+ this.labelLiftGroups = /* @__PURE__ */ new Map();
1913
2575
  this.seats = [];
1914
2576
  this.seatById = /* @__PURE__ */ new Map();
2577
+ /** One build per chart/floor; section membership queries only inspect seats in
2578
+ * the section bounds instead of rescanning the full 13k venue per section. */
2579
+ this.seatIndex = null;
1915
2580
  /** Multi-floor (Batch 5): the last-set chart + which floor we're rendering. */
1916
2581
  this.chartDoc = null;
1917
2582
  this.activeFloorId = "";
@@ -1927,13 +2592,11 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1927
2592
  this.seatLabelById = /* @__PURE__ */ new Map();
1928
2593
  /** Coloured accommodation ring per accessible seat (few per chart). */
1929
2594
  this.accessRingById = /* @__PURE__ */ new Map();
1930
- /** Centred accessibility glyph per accessible seat shown once the seat is
1931
- * big enough on-screen (see {@link SEAT_GLYPH_MIN_PX}); the ring is the
1932
- * smaller-zoom fallback. Kept in a map so zoom toggles touch only the handful
1933
- * of accessible seats, never all 13k nodes. */
2595
+ /** Centred glyph per physical wheelchair provision. It keeps a small
2596
+ * screen-space floor at every LOD and grows when the Wheelchair filter is
2597
+ * active. Kept in a map so zoom updates touch only the handful of matching
2598
+ * seats, never all 13k nodes. */
1934
2599
  this.accessGlyphById = /* @__PURE__ */ new Map();
1935
- /** Whether the accessibility glyph is legible at the current camera scale. */
1936
- this.accessGlyphVisible = false;
1937
2600
  /** Authored free-text nodes obey the same rendered-size visibility floor. */
1938
2601
  this.freeTextById = /* @__PURE__ */ new Map();
1939
2602
  /** Stage/rink landmarks retain a readable screen-space caption at overview. */
@@ -1958,6 +2621,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1958
2621
  this.ownedHold = /* @__PURE__ */ new Set();
1959
2622
  /** One selected seat being inspected before it is committed to the cart. */
1960
2623
  this.selectionFocusId = null;
2624
+ /** Seat currently owning the shared hover ring (null while not hovering). */
2625
+ this.hoveredId = null;
1961
2626
  this.focusedId = null;
1962
2627
  /**
1963
2628
  * Accessibility filter: `null` = off; `[]` = dim all non-accessible free seats;
@@ -1976,6 +2641,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1976
2641
  this.sections = [];
1977
2642
  this.zones = [];
1978
2643
  this.seatSection = /* @__PURE__ */ new Map();
2644
+ /** Seats outside every authored section retain the legacy path in one group. */
2645
+ this.unsectionedSeatGroup = null;
1979
2646
  this.catPrice = /* @__PURE__ */ new Map();
1980
2647
  /** Section/zone ids to render dimmed (organizer manager: held-back inventory). */
1981
2648
  this.dimmedSections = /* @__PURE__ */ new Set();
@@ -1988,6 +2655,9 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1988
2655
  this.focusedSectionId = null;
1989
2656
  /** Light backdrop panel drawn behind the focused section (removed on clear). */
1990
2657
  this.focusBackdrop = null;
2658
+ /** Non-listening visual dim outside the focused section. One overlay replaces
2659
+ * descendant opacity mutations across every seat in the venue. */
2660
+ this.focusDimOverlay = null;
1991
2661
  /** Object id → floor id (multi-floor only) — resolves a deck tap in the 3D stack. */
1992
2662
  this.objectFloor = /* @__PURE__ */ new Map();
1993
2663
  /** Zone id → colour (drives extruded side faces in iso view). */
@@ -2000,10 +2670,33 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2000
2670
  this.isoT = 0;
2001
2671
  this.isoTarget = 0;
2002
2672
  this.isoRaf = 0;
2673
+ /** Public view target; perspective is a separate non-affine Phase-C lane. */
2674
+ this.viewMode = "flat";
2675
+ this.perspectiveCamera = null;
2676
+ /** Ground-plane tangent applied once to every renderer layer. */
2677
+ this.perspectiveBaseAffine = null;
2678
+ this.perspectiveBaseInverse = null;
2679
+ /** Exact pinhole anchor expressed in the overlay tangent-layer coordinates. */
2680
+ this.perspectiveSeatLocal = /* @__PURE__ */ new Map();
2681
+ /** Exact projected point (before Stage pan/zoom), keyed by seat. */
2682
+ this.perspectiveSeatProjected = /* @__PURE__ */ new Map();
2683
+ /** Positive camera depth, used for deterministic far→near paint order. */
2684
+ this.perspectiveSeatDepth = /* @__PURE__ */ new Map();
2685
+ /** Exact pinhole size ratio at each seat anchor. */
2686
+ this.perspectiveSeatScale = /* @__PURE__ */ new Map();
2687
+ /** Seats whose native Konva nodes currently carry the exact projected pose.
2688
+ * Large sectioned venues apply these lazily as a section enters the live-seat
2689
+ * viewport; the overview rung hides the seat layer completely. */
2690
+ this.perspectiveAppliedSeats = /* @__PURE__ */ new Set();
2691
+ this.perspectiveBounds = null;
2003
2692
  /** Chart centre the iso projection pivots about (bounds centre). */
2004
2693
  this.isoCentre = { x: 0, y: 0 };
2005
2694
  /** rAF for an in-flight camera glide (focusRegion / setRung); 0 = none. */
2006
2695
  this.glideRaf = 0;
2696
+ /** While the camera tween is active, defer polygon label fitting/collision to
2697
+ * the settled frame. Geometry remains smoothly stage-scaled, while expensive
2698
+ * point-in-polygon text work no longer repeats at 120 Hz. */
2699
+ this.glideInProgress = false;
2007
2700
  /** Set in destroy() so an in-flight iso tween bails. */
2008
2701
  this.destroyed = false;
2009
2702
  /** Cached scale the section/zone labels were last sized for (scale-compensation). */
@@ -2033,6 +2726,19 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2033
2726
  this.panStarted = false;
2034
2727
  /** Maximum displacement from gesture start — suppress taps only after a real pan/pinch. */
2035
2728
  this.moved = 0;
2729
+ /**
2730
+ * Ghost-click guard. Konva binds `_pointerup` to mouseup, touchend AND
2731
+ * pointerup, so `on('click tap')` is two handlers for one finger tap unless
2732
+ * the browser's synthesized compatibility click is suppressed. Konva only
2733
+ * suppresses it when the touch lands on a LISTENING SHAPE (Stage.js returns
2734
+ * early before its preventDefault() when `!shape || !shape.isListening()`),
2735
+ * so a near-miss rescued by nearestSeatToScreen used to fire `tap` (select)
2736
+ * then `click` (deselect) 1-3ms apart and net to nothing. With
2737
+ * SEAT_TAP_SLOP_PX=14 against a ~12px seat radius, that broken annulus is
2738
+ * ~3.7x the area of the seat itself — i.e. most successful mobile taps.
2739
+ * `touch-action: none` does NOT suppress compatibility mouse events.
2740
+ */
2741
+ this.lastTapAt = 0;
2036
2742
  /**
2037
2743
  * Manage-mode rubber-band marquee (option-gated). `start`/`cur` are WORLD-space
2038
2744
  * points (overlayLayer rides the stage transform); `rect` is the on-canvas
@@ -2129,6 +2835,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2129
2835
  x: mid.x - this.pinch.worldMid.x * scale,
2130
2836
  y: mid.y - this.pinch.worldMid.y * scale
2131
2837
  });
2838
+ this.updateSeatGroupVisibility();
2132
2839
  this.stage.batchDraw();
2133
2840
  this.scheduleViewChange();
2134
2841
  } else if (this.panLast && this.pointers.size === 1) {
@@ -2141,6 +2848,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2141
2848
  y: this.stage.y() + (p.y - this.panLast.y)
2142
2849
  });
2143
2850
  this.panLast = p;
2851
+ this.updateSeatGroupVisibility();
2144
2852
  this.stage.batchDraw();
2145
2853
  this.scheduleViewChange();
2146
2854
  }
@@ -2193,6 +2901,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2193
2901
  this.overlayLayer = new import_Layer.Layer({ listening: false });
2194
2902
  this.labelGroup = new import_Group.Group({ listening: false });
2195
2903
  this.overlayLayer.add(this.labelGroup);
2904
+ this.fgDecorGroup = new import_Group.Group({ listening: false });
2905
+ this.overlayLayer.add(this.fgDecorGroup);
2196
2906
  this.hoverRing = new import_Circle.Circle({
2197
2907
  radius: SEAT_RADIUS + 2,
2198
2908
  stroke: "#ffffff",
@@ -2227,6 +2937,21 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2227
2937
  this.resizeObs.observe(container);
2228
2938
  }
2229
2939
  }
2940
+ /**
2941
+ * True when this is the browser's synthesized compatibility click following a
2942
+ * finger tap we already handled. Discriminates on `e.type` deliberately —
2943
+ * `PointerEvent extends MouseEvent`, so an `instanceof MouseEvent` test
2944
+ * matches genuine pointer taps too. One shared timestamp across the layer and
2945
+ * stage handlers: a tap consumed at the layer must also suppress the ghost at
2946
+ * the stage, and only `click` is ever rejected, so bubbling stays intact.
2947
+ */
2948
+ isGhostClick(e) {
2949
+ if (e.type === "tap") {
2950
+ this.lastTapAt = performance.now();
2951
+ return false;
2952
+ }
2953
+ return this.lastTapAt > 0 && performance.now() - this.lastTapAt < GHOST_CLICK_MS;
2954
+ }
2230
2955
  // ---- ISeatmapRenderer -----------------------------------------------------
2231
2956
  setChart(doc, opts) {
2232
2957
  if (doc !== this.chartDoc) this.stacked = false;
@@ -2240,8 +2965,15 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2240
2965
  this.focusedId = null;
2241
2966
  this.focusRing.visible(false);
2242
2967
  this.bgLayer.destroyChildren();
2968
+ this.focusDimOverlay?.destroy();
2969
+ this.focusDimOverlay = null;
2970
+ this.seatLayer.clearCache();
2971
+ this.seatLayer.listening(true);
2243
2972
  this.seatLayer.destroyChildren();
2973
+ this.unsectionedSeatGroup = null;
2244
2974
  this.labelGroup.destroyChildren();
2975
+ this.labelLiftGroups.clear();
2976
+ this.fgDecorGroup.destroyChildren();
2245
2977
  this.circleById.clear();
2246
2978
  this.boothDims.clear();
2247
2979
  this.boothLabelById.clear();
@@ -2258,6 +2990,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2258
2990
  this.statusById.clear();
2259
2991
  this.selection.clear();
2260
2992
  this.seatById.clear();
2993
+ this.seatIndex = null;
2261
2994
  this.cached = false;
2262
2995
  this.accessFilter = null;
2263
2996
  this.sections = [];
@@ -2275,11 +3008,22 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2275
3008
  }
2276
3009
  this.isoT = 0;
2277
3010
  this.isoTarget = 0;
3011
+ this.viewMode = "flat";
3012
+ this.perspectiveCamera = null;
3013
+ this.perspectiveBaseAffine = null;
3014
+ this.perspectiveBaseInverse = null;
3015
+ this.perspectiveSeatLocal.clear();
3016
+ this.perspectiveSeatProjected.clear();
3017
+ this.perspectiveSeatDepth.clear();
3018
+ this.perspectiveSeatScale.clear();
3019
+ this.perspectiveAppliedSeats.clear();
3020
+ this.perspectiveBounds = null;
2278
3021
  this.resetLayerTransforms();
2279
3022
  this.hasSections = view.objects.some((o) => o.type === "section");
2280
3023
  for (const z of doc.zones ?? []) if (z.color) this.zoneColor.set(z.id, z.color);
2281
3024
  this.seatLayer.opacity(1);
2282
3025
  this.hoverRing.visible(false);
3026
+ this.hoveredId = null;
2283
3027
  this.theme = doc.theme ?? {};
2284
3028
  this.seatR = clamp(this.theme.seatScale ?? 1, 0.7, 1.6) * SEAT_RADIUS;
2285
3029
  this.container.style.background = "";
@@ -2300,26 +3044,61 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2300
3044
  this.boothDims.set(obj.id, { width: obj.width, height: obj.height, rotation: obj.rotation });
2301
3045
  }
2302
3046
  }
2303
- this.seats = expandChart(view);
3047
+ this.seats = expandChart(view, {
3048
+ // A stacked overview is presentation-only and never opens a seat POV. The
3049
+ // per-section renderer below still resolves each object's real floor base.
3050
+ floorBaseHeightM: this.stacked ? 0 : this.floorBaseHeightMFor()
3051
+ });
2304
3052
  for (const s of this.seats) {
2305
3053
  this.seatById.set(s.id, s);
2306
3054
  this.statusById.set(s.id, "free");
2307
3055
  }
3056
+ this.seatIndex = buildSeatIndex(this.seats, { seatRadius: this.seatR });
3057
+ this.bounds = chartBounds(view);
3058
+ this.isoCentre = { x: this.bounds.x + this.bounds.width / 2, y: this.bounds.y + this.bounds.height / 2 };
3059
+ this.buildPerspectiveProjection(view);
2308
3060
  this.renderBackground(view);
3061
+ this.unsectionedSeatGroup = new import_Group.Group({ listening: true });
3062
+ this.seatLayer.add(this.unsectionedSeatGroup);
2309
3063
  this.renderSeats();
2310
3064
  this.overlayLayer.add(this.labelGroup);
3065
+ this.overlayLayer.add(this.fgDecorGroup);
2311
3066
  this.overlayLayer.add(this.hoverRing);
2312
- this.bounds = chartBounds(view);
2313
- this.isoCentre = { x: this.bounds.x + this.bounds.width / 2, y: this.bounds.y + this.bounds.height / 2 };
3067
+ this.overlayLayer.add(this.focusRing);
2314
3068
  this.zoomToFit();
2315
3069
  }
2316
3070
  /** The chart to render: all floors stacked (3D overview), the active floor, or
2317
3071
  * the whole chart for single-floor charts. */
2318
3072
  floorView(doc) {
2319
- if (!doc.floors || !doc.floors.length) return doc;
2320
- if (this.stacked && doc.floors.length >= 2) return stackFloors(doc);
3073
+ if (!doc.floors || !doc.floors.length) return {
3074
+ ...doc,
3075
+ referenceImage: void 0,
3076
+ backgroundImage: buyerBackgroundImage(doc)
3077
+ };
3078
+ if (this.stacked && doc.floors.length >= 2) {
3079
+ return {
3080
+ ...stackFloors(doc),
3081
+ referenceImage: void 0,
3082
+ backgroundImage: buyerBackgroundImage(doc)
3083
+ };
3084
+ }
2321
3085
  const floor = doc.floors.find((f) => f.id === this.activeFloorId) ?? doc.floors[0];
2322
- return { ...doc, objects: floor.objects, focalPoint: floor.focalPoint, backgroundImage: floor.backgroundImage, floors: void 0 };
3086
+ return {
3087
+ ...doc,
3088
+ objects: floor.objects,
3089
+ focalPoint: floor.focalPoint,
3090
+ referenceImage: void 0,
3091
+ backgroundImage: buyerBackgroundImage(floor),
3092
+ floors: void 0
3093
+ };
3094
+ }
3095
+ /** Physical floor height is authored data; the 900-unit stacked overview
3096
+ * spread is presentation-only and must never leak into real 3D geometry. */
3097
+ floorBaseHeightMFor(objectId) {
3098
+ const floors = this.chartDoc?.floors;
3099
+ if (!floors?.length) return 0;
3100
+ const floorId = this.stacked && objectId ? this.objectFloor.get(objectId) ?? this.activeFloorId : this.activeFloorId;
3101
+ return floors.find((floor) => floor.id === floorId)?.baseHeightM ?? 0;
2323
3102
  }
2324
3103
  /** Toggle the 3D all-floors stacked overview (Batch 5). Re-renders; no-op on
2325
3104
  * single-floor charts. The caller re-applies statuses + animates the iso view. */
@@ -2632,7 +3411,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2632
3411
  }
2633
3412
  const ids = [];
2634
3413
  for (const seat of this.seats) {
2635
- if (seat.x < x0 || seat.x > x1 || seat.y < y0 || seat.y > y1) continue;
3414
+ const rendered = this.renderedSeatPoint(seat);
3415
+ if (rendered.x < x0 || rendered.x > x1 || rendered.y < y0 || rendered.y > y1) continue;
2636
3416
  if (!this.isSelectable(seat.id)) continue;
2637
3417
  ids.push(seat.id);
2638
3418
  }
@@ -2642,9 +3422,10 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2642
3422
  flashSeat(seatId, color = "#f43f5e") {
2643
3423
  const seat = this.seatById.get(seatId);
2644
3424
  if (!seat) return;
3425
+ const rendered = this.renderedSeatPoint(seat);
2645
3426
  const ring = new import_Circle.Circle({
2646
- x: seat.x,
2647
- y: seat.y,
3427
+ x: rendered.x,
3428
+ y: rendered.y,
2648
3429
  radius: this.seatR,
2649
3430
  stroke: color,
2650
3431
  strokeWidth: 3,
@@ -2653,6 +3434,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2653
3434
  perfectDrawEnabled: false,
2654
3435
  shadowForStrokeEnabled: false
2655
3436
  });
3437
+ const projectionScale = this.viewMode === "perspective" ? this.perspectiveSeatScale.get(seatId) ?? 1 : 1;
3438
+ ring.scale({ x: projectionScale, y: projectionScale });
2656
3439
  this.overlayLayer.add(ring);
2657
3440
  const start = performance.now();
2658
3441
  const dur = 620;
@@ -2679,17 +3462,20 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2679
3462
  const matches = this.sections.filter((section) => section.id === sectionId || section.zone === sectionId);
2680
3463
  if (!matches.length) return;
2681
3464
  for (const section of matches) {
2682
- const centre = section.outline.reduce(
3465
+ const outline = this.viewMode === "perspective" && section.perspectiveAffine ? section.outline.map((point) => this.perspectiveLocalPoint(this.projectedSectionPoint(section, point))) : section.outline.map((point) => {
3466
+ const lift = section.liftWorld > 0 ? this.isoLiftLocal(section.liftWorld) : { x: 0, y: 0 };
3467
+ return { x: point.x + lift.x, y: point.y + lift.y };
3468
+ });
3469
+ const centre = outline.reduce(
2683
3470
  (sum, point) => ({ x: sum.x + point.x, y: sum.y + point.y }),
2684
3471
  { x: 0, y: 0 }
2685
3472
  );
2686
- centre.x /= section.outline.length;
2687
- centre.y /= section.outline.length;
2688
- const lift = section.elevation > 0 ? this.isoLiftLocal(section.elevation) : { x: 0, y: 0 };
3473
+ centre.x /= outline.length;
3474
+ centre.y /= outline.length;
2689
3475
  const halo = new import_Line.Line({
2690
- x: centre.x + lift.x,
2691
- y: centre.y + lift.y,
2692
- points: section.outline.flatMap((point) => [point.x - centre.x, point.y - centre.y]),
3476
+ x: centre.x,
3477
+ y: centre.y,
3478
+ points: outline.flatMap((point) => [point.x - centre.x, point.y - centre.y]),
2693
3479
  closed: true,
2694
3480
  stroke: color,
2695
3481
  strokeWidth: 3,
@@ -2733,12 +3519,14 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2733
3519
  nearestSeat(fromId, dir) {
2734
3520
  const from = this.seatById.get(fromId);
2735
3521
  if (!from) return null;
3522
+ const fromPoint = this.viewMode === "perspective" ? this.perspectiveSeatProjected.get(from.id) ?? from : from;
2736
3523
  let best = null;
2737
3524
  let bestScore = Infinity;
2738
3525
  for (const s of this.seats) {
2739
3526
  if (s.id === fromId) continue;
2740
- const dx = s.x - from.x;
2741
- const dy = s.y - from.y;
3527
+ const candidatePoint = this.viewMode === "perspective" ? this.perspectiveSeatProjected.get(s.id) ?? s : s;
3528
+ const dx = candidatePoint.x - fromPoint.x;
3529
+ const dy = candidatePoint.y - fromPoint.y;
2742
3530
  const proj = dx * dir.x + dy * dir.y;
2743
3531
  if (proj <= 0.5) continue;
2744
3532
  const perp = Math.abs(dx * dir.y - dy * dir.x);
@@ -2755,7 +3543,9 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2755
3543
  if (!seat) return;
2756
3544
  this.focusedId = id;
2757
3545
  this.focusRing.radius(this.seatR + 3);
2758
- this.focusRing.position({ x: seat.x, y: seat.y });
3546
+ this.focusRing.position(this.renderedSeatPoint(seat));
3547
+ const projectionScale = this.viewMode === "perspective" ? this.perspectiveSeatScale.get(id) ?? 1 : 1;
3548
+ this.focusRing.scale({ x: projectionScale, y: projectionScale });
2759
3549
  this.focusRing.visible(true);
2760
3550
  this.ensureVisible(seat);
2761
3551
  this.overlayLayer.batchDraw();
@@ -2770,7 +3560,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2770
3560
  const margin = 70;
2771
3561
  const offscreen = p.x < margin || p.x > w - margin || p.y < margin || p.y > h - margin;
2772
3562
  if (this.stage.scaleX() < target || offscreen) {
2773
- const ip = this.isoT === 0 ? seat : this.isoForward(seat);
3563
+ const rendered = this.renderedSeatPoint(seat);
3564
+ const ip = this.viewMode === "perspective" && this.perspectiveBaseAffine ? applyAffine(this.perspectiveBaseAffine, rendered) : this.isoT === 0 ? rendered : this.isoForward(rendered);
2774
3565
  this.stage.scale({ x: target, y: target });
2775
3566
  this.stage.position({ x: w / 2 - ip.x * target, y: h / 2 - ip.y * target });
2776
3567
  this.afterViewChange();
@@ -2780,7 +3571,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2780
3571
  zoomToFit() {
2781
3572
  const w = this.stage.width();
2782
3573
  const h = this.stage.height();
2783
- const b = this.bounds;
3574
+ const b = this.viewMode === "perspective" && this.perspectiveBounds ? this.perspectiveBounds : this.bounds;
2784
3575
  this.fitScale = Math.min(w / b.width, h / b.height) || 1;
2785
3576
  const s = this.fitScale;
2786
3577
  this.stage.scale({ x: s, y: s });
@@ -2809,7 +3600,14 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2809
3600
  }
2810
3601
  worldToScreen(point) {
2811
3602
  const s = this.stage.scaleX();
2812
- const p = this.isoT === 0 ? point : this.isoForward(point);
3603
+ const candidateId = point.id;
3604
+ const seat = typeof candidateId === "string" ? this.seatById.get(candidateId) : void 0;
3605
+ if (this.viewMode === "perspective" && this.perspectiveBaseAffine) {
3606
+ const projected = seat ? this.perspectiveSeatProjected.get(seat.id) ?? applyAffine(this.perspectiveBaseAffine, point) : applyAffine(this.perspectiveBaseAffine, point);
3607
+ return { x: projected.x * s + this.stage.x(), y: projected.y * s + this.stage.y() };
3608
+ }
3609
+ const localPoint = seat ? this.renderedSeatPoint(seat) : point;
3610
+ const p = this.isoT === 0 ? localPoint : this.isoForward(localPoint);
2813
3611
  return { x: p.x * s + this.stage.x(), y: p.y * s + this.stage.y() };
2814
3612
  }
2815
3613
  setAccessibleFilter(on) {
@@ -2819,6 +3617,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2819
3617
  const next = types === null ? null : [...types];
2820
3618
  if (this.sameAccessFilter(next)) return;
2821
3619
  this.accessFilter = next;
3620
+ this.updateAccessGlyphs(this.effScale());
2822
3621
  for (const seat of this.seats) {
2823
3622
  const c = this.circleById.get(seat.id);
2824
3623
  if (c) this.paintSeat(c, seat.id);
@@ -2917,10 +3716,11 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2917
3716
  let minY = Infinity;
2918
3717
  let maxY = -Infinity;
2919
3718
  for (const seat of matching) {
2920
- minX = Math.min(minX, seat.x);
2921
- maxX = Math.max(maxX, seat.x);
2922
- minY = Math.min(minY, seat.y);
2923
- maxY = Math.max(maxY, seat.y);
3719
+ const point = this.renderedSeatPoint(seat);
3720
+ minX = Math.min(minX, point.x);
3721
+ maxX = Math.max(maxX, point.x);
3722
+ minY = Math.min(minY, point.y);
3723
+ maxY = Math.max(maxY, point.y);
2924
3724
  }
2925
3725
  const pad = Math.max(24, this.seatR * 3);
2926
3726
  minX -= pad;
@@ -2941,12 +3741,37 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2941
3741
  * inverse) keeps landing on the projected seats/sections.
2942
3742
  */
2943
3743
  setViewMode(mode) {
3744
+ if (mode === "perspective") {
3745
+ if (this.viewMode === mode) {
3746
+ this.afterViewChange();
3747
+ return;
3748
+ }
3749
+ const resetElevatedSeatGroups = this.viewMode === "isometric" && this.isoT > 0;
3750
+ this.viewMode = mode;
3751
+ this.isoTarget = 1;
3752
+ this.isoT = 1;
3753
+ if (this.isoRaf) {
3754
+ cancelAnimationFrame(this.isoRaf);
3755
+ this.isoRaf = 0;
3756
+ }
3757
+ this.applyPerspective(resetElevatedSeatGroups);
3758
+ this.zoomToFit();
3759
+ return;
3760
+ }
2944
3761
  const target = mode === "isometric" ? 1 : 0;
3762
+ const leavingPerspective = this.viewMode === "perspective";
3763
+ this.viewMode = mode;
2945
3764
  this.isoTarget = target;
2946
3765
  if (this.isoRaf) {
2947
3766
  cancelAnimationFrame(this.isoRaf);
2948
3767
  this.isoRaf = 0;
2949
3768
  }
3769
+ if (leavingPerspective) {
3770
+ this.isoT = target;
3771
+ this.applyIso();
3772
+ this.afterViewChange();
3773
+ return;
3774
+ }
2950
3775
  if (this.reducedMotion) {
2951
3776
  this.isoT = target;
2952
3777
  this.applyIso();
@@ -2980,7 +3805,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2980
3805
  }
2981
3806
  /** Current projection — reflects the tween target, not the mid-tween isoT. */
2982
3807
  getViewMode() {
2983
- return this.isoTarget === 1 ? "isometric" : "flat";
3808
+ return this.viewMode;
2984
3809
  }
2985
3810
  /** Iso angle (rad) + y-squash for the current isoT. */
2986
3811
  isoParams() {
@@ -2988,6 +3813,11 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2988
3813
  }
2989
3814
  /** Effective vertical scale = stage scale × iso squash — legibility math uses this. */
2990
3815
  effScale() {
3816
+ if (this.viewMode === "perspective" && this.perspectiveBaseAffine) {
3817
+ const xScale = Math.hypot(this.perspectiveBaseAffine.a, this.perspectiveBaseAffine.b);
3818
+ const yScale = Math.hypot(this.perspectiveBaseAffine.c, this.perspectiveBaseAffine.d);
3819
+ return this.stage.scaleX() * Math.min(xScale, yScale);
3820
+ }
2991
3821
  return this.stage.scaleX() * (1 - (1 - ISO_SQUASH) * this.isoT);
2992
3822
  }
2993
3823
  /** Project a world point through the iso affine about the chart centre (→ iso-world). */
@@ -3011,19 +3841,113 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3011
3841
  const wy = -dx * Math.sin(th) + uy * Math.cos(th);
3012
3842
  return { x: c.x + wx, y: c.y + wy };
3013
3843
  }
3844
+ /** Precompute the Phase-C pinhole camera and every exact seat anchor. */
3845
+ buildPerspectiveProjection(view) {
3846
+ let maxSurfaceHeightWorld = 0;
3847
+ for (const seat of this.seats) {
3848
+ const surfaceM = Math.max(0, (seat.eyeHeightM ?? SEATED_EYE_HEIGHT_M) - SEATED_EYE_HEIGHT_M);
3849
+ maxSurfaceHeightWorld = Math.max(maxSurfaceHeightWorld, surfaceM * CHART_UNITS_PER_METRE);
3850
+ }
3851
+ for (const object of view.objects) {
3852
+ if (object.type !== "section") continue;
3853
+ maxSurfaceHeightWorld = Math.max(
3854
+ maxSurfaceHeightWorld,
3855
+ sectionGeometry(object, { floorBaseHeightM: this.floorBaseHeightMFor(object.id) }).height * CHART_UNITS_PER_METRE
3856
+ );
3857
+ }
3858
+ const camera = createPerspectiveCamera(this.bounds, maxSurfaceHeightWorld);
3859
+ const base = perspectiveTangentAffine(camera, camera.target);
3860
+ const inverse = invertAffine(base);
3861
+ this.perspectiveCamera = camera;
3862
+ this.perspectiveBaseAffine = base;
3863
+ this.perspectiveBaseInverse = inverse;
3864
+ this.perspectiveSeatLocal.clear();
3865
+ this.perspectiveSeatProjected.clear();
3866
+ this.perspectiveSeatDepth.clear();
3867
+ this.perspectiveSeatScale.clear();
3868
+ this.perspectiveBounds = null;
3869
+ let minX = Infinity;
3870
+ let minY = Infinity;
3871
+ let maxX = -Infinity;
3872
+ let maxY = -Infinity;
3873
+ for (const seat of this.seats) {
3874
+ const surfaceM = Math.max(0, (seat.eyeHeightM ?? SEATED_EYE_HEIGHT_M) - SEATED_EYE_HEIGHT_M);
3875
+ const projected = projectPerspectivePoint(camera, seat, surfaceM * CHART_UNITS_PER_METRE);
3876
+ const point = { x: projected.x, y: projected.y };
3877
+ this.perspectiveSeatProjected.set(seat.id, point);
3878
+ this.perspectiveSeatLocal.set(seat.id, applyAffine(inverse, point));
3879
+ this.perspectiveSeatDepth.set(seat.id, projected.depth);
3880
+ this.perspectiveSeatScale.set(seat.id, projected.scale);
3881
+ minX = Math.min(minX, projected.x);
3882
+ minY = Math.min(minY, projected.y);
3883
+ maxX = Math.max(maxX, projected.x);
3884
+ maxY = Math.max(maxY, projected.y);
3885
+ }
3886
+ for (const point of [
3887
+ { x: this.bounds.x, y: this.bounds.y },
3888
+ { x: this.bounds.x + this.bounds.width, y: this.bounds.y },
3889
+ { x: this.bounds.x + this.bounds.width, y: this.bounds.y + this.bounds.height },
3890
+ { x: this.bounds.x, y: this.bounds.y + this.bounds.height }
3891
+ ]) {
3892
+ const projected = projectPerspectivePoint(camera, point, 0);
3893
+ minX = Math.min(minX, projected.x);
3894
+ minY = Math.min(minY, projected.y);
3895
+ maxX = Math.max(maxX, projected.x);
3896
+ maxY = Math.max(maxY, projected.y);
3897
+ }
3898
+ const pad = Math.max(24, this.seatR * 3);
3899
+ 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;
3900
+ }
3901
+ /** Apply an affine to a Konva container while keeping `pivot` fixed logically. */
3902
+ setContainerAffine(node, affine, pivot) {
3903
+ const decomposed = decomposeAffineLinear(affine);
3904
+ node.offset(pivot);
3905
+ node.position(applyAffine(affine, pivot));
3906
+ node.rotation(decomposed.rotationDeg);
3907
+ node.scale({ x: decomposed.scaleX, y: decomposed.scaleY });
3908
+ node.skewX(decomposed.skewX);
3909
+ node.skewY(0);
3910
+ }
3911
+ resetContainerTransform(node) {
3912
+ node.position({ x: 0, y: 0 });
3913
+ node.offset({ x: 0, y: 0 });
3914
+ node.scale({ x: 1, y: 1 });
3915
+ node.rotation(0);
3916
+ node.skewX(0);
3917
+ node.skewY(0);
3918
+ }
3919
+ /** Exact raked surface height used for the bounded section tangent plane. */
3920
+ sectionSurfaceHeight(section, point) {
3921
+ const radial = Math.hypot(point.x - section.surfaceFocal.x, point.y - section.surfaceFocal.y);
3922
+ const depth = Math.max(0, radial - section.frontDistanceWorld);
3923
+ return section.liftWorld + depth * Math.tan(section.rakeDeg * Math.PI / 180);
3924
+ }
3925
+ /** Desired perspective-space point for a section surface, before Stage pan/zoom. */
3926
+ projectedSectionPoint(section, point) {
3927
+ return section.perspectiveAffine ? applyAffine(section.perspectiveAffine, point) : applyAffine(this.perspectiveBaseAffine, point);
3928
+ }
3929
+ /** Shared-layer local point which the ground affine paints at `projected`. */
3930
+ perspectiveLocalPoint(projected) {
3931
+ return this.perspectiveBaseInverse ? applyAffine(this.perspectiveBaseInverse, projected) : projected;
3932
+ }
3014
3933
  /**
3015
3934
  * Local-space offset that, once the layer applies the iso affine, lifts an
3016
- * object straight UP in iso-world by `elevation × LIFT_PER_STEP × isoT`
3017
- * (= inverse-linear of the pure vertical lift). Zero at isoT=0.
3935
+ * object straight UP in iso-world by `liftWorld × isoT` world units
3936
+ * (= inverse-linear of the pure vertical lift). Zero at isoT=0. `liftWorld` is
3937
+ * the section's resolved real height in world units (Phase B1).
3018
3938
  */
3019
- isoLiftLocal(elevation) {
3939
+ isoLiftLocal(liftWorld) {
3020
3940
  const { th, sg } = this.isoParams();
3021
- const delta = elevation * LIFT_PER_STEP * this.isoT;
3941
+ const delta = liftWorld * this.isoT;
3022
3942
  return { x: -(delta / sg) * Math.sin(th), y: -(delta / sg) * Math.cos(th) };
3023
3943
  }
3024
- /** Restore the three layers to identity (flat) — byte-for-byte the original. */
3025
- resetLayerTransforms() {
3026
- for (const layer of [this.bgLayer, this.seatLayer, this.overlayLayer]) {
3944
+ /** Restore projection layers to identity (flat) — byte-for-byte the original.
3945
+ * `seatLayer` can be skipped when entering perspective from flat: assigning a
3946
+ * top-level transform invalidates all 14k descendant absolute transforms even
3947
+ * while the semantic overview keeps that layer fully transparent. */
3948
+ resetLayerTransforms(includeSeatLayer = true) {
3949
+ const layers = includeSeatLayer ? [this.bgLayer, this.seatLayer, this.overlayLayer] : [this.bgLayer, this.overlayLayer];
3950
+ for (const layer of layers) {
3027
3951
  layer.position({ x: 0, y: 0 });
3028
3952
  layer.offset({ x: 0, y: 0 });
3029
3953
  layer.scale({ x: 1, y: 1 });
@@ -3032,6 +3956,117 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3032
3956
  layer.skewY(0);
3033
3957
  }
3034
3958
  }
3959
+ /** Restore section-owned containers before applying another projection. */
3960
+ resetSectionProjection(resetSeatGroups = true) {
3961
+ for (const section of this.sections) {
3962
+ this.resetContainerTransform(section.rootGroupBg);
3963
+ this.resetContainerTransform(section.liftGroupBg);
3964
+ if (resetSeatGroups) this.resetContainerTransform(section.liftGroupSeat);
3965
+ section.rootGroupBg.zIndex(section.bgZIndex);
3966
+ if (resetSeatGroups) section.liftGroupSeat.zIndex(section.seatZIndex);
3967
+ const labelGroup = this.labelLiftGroups.get(section.id);
3968
+ if (labelGroup && resetSeatGroups) this.resetContainerTransform(labelGroup);
3969
+ section.perspectiveAffine = void 0;
3970
+ section.perspectiveCorrection = void 0;
3971
+ section.perspectiveDepth = void 0;
3972
+ }
3973
+ }
3974
+ /** Move native seat/booth hit shapes to or from their exact pinhole anchors. */
3975
+ applyExactSeatAnchors(perspective, seatIds) {
3976
+ 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);
3977
+ for (const seat of seats) {
3978
+ if (perspective && this.perspectiveAppliedSeats.has(seat.id)) continue;
3979
+ const point = perspective ? this.perspectiveSeatProjected.get(seat.id) ?? seat : seat;
3980
+ const scale = perspective ? this.perspectiveSeatScale.get(seat.id) ?? 1 : 1;
3981
+ const shape = this.circleById.get(seat.id);
3982
+ shape?.position(point);
3983
+ shape?.scale({ x: scale, y: scale });
3984
+ const ring = this.accessRingById.get(seat.id);
3985
+ ring?.position(point);
3986
+ ring?.scale({ x: scale, y: scale });
3987
+ const glyph = this.accessGlyphById.get(seat.id);
3988
+ if (glyph) {
3989
+ const baseGlyphScale = this.seatR * 1.5 / ACCESS_GLYPH_VIEWBOX;
3990
+ glyph.position(point);
3991
+ glyph.scale({ x: baseGlyphScale * scale, y: baseGlyphScale * scale });
3992
+ }
3993
+ const boothLabel = this.boothLabelById.get(seat.id);
3994
+ boothLabel?.position(point);
3995
+ boothLabel?.scale({ x: scale, y: scale });
3996
+ if (perspective) this.perspectiveAppliedSeats.add(seat.id);
3997
+ }
3998
+ if (!perspective) this.perspectiveAppliedSeats.clear();
3999
+ }
4000
+ /**
4001
+ * Phase C projected 2.5D. Seat/booth anchors are exact pinhole projections;
4002
+ * section top surfaces use one explicitly bounded tangent plane per authored
4003
+ * section. Native Konva shapes move with the anchors, so direct hit testing is
4004
+ * the same graph that paints the buyer-visible unit.
4005
+ */
4006
+ applyPerspective(resetElevatedSeatGroups = true) {
4007
+ const camera = this.perspectiveCamera;
4008
+ const base = this.perspectiveBaseAffine;
4009
+ const inverse = this.perspectiveBaseInverse;
4010
+ if (!camera || !base || !inverse) return;
4011
+ const lazySectionSeats = this.seats.length > 2500 && this.sections.length > 0;
4012
+ if (this.cached && !lazySectionSeats) {
4013
+ this.seatLayer.clearCache();
4014
+ this.seatLayer.listening(true);
4015
+ this.cached = false;
4016
+ }
4017
+ this.resetLayerTransforms(resetElevatedSeatGroups);
4018
+ this.resetSectionProjection(resetElevatedSeatGroups);
4019
+ for (const layer of [this.bgLayer, this.overlayLayer]) {
4020
+ this.setContainerAffine(layer, base, camera.target);
4021
+ }
4022
+ this.perspectiveAppliedSeats.clear();
4023
+ if (!lazySectionSeats) this.applyExactSeatAnchors(true);
4024
+ for (const section of this.sections) {
4025
+ const heightAt = (point) => this.sectionSurfaceHeight(section, point);
4026
+ const desired = perspectiveTangentAffine(camera, section.centroid, heightAt);
4027
+ const correction = composeAffine(inverse, desired);
4028
+ section.perspectiveAffine = desired;
4029
+ section.perspectiveCorrection = correction;
4030
+ section.perspectiveDepth = projectPerspectivePoint(
4031
+ camera,
4032
+ section.centroid,
4033
+ heightAt(section.centroid)
4034
+ ).depth;
4035
+ this.setContainerAffine(section.liftGroupBg, correction, section.centroid);
4036
+ for (let index = 0; index < section.sideFaces.length; index++) {
4037
+ const p0 = section.outline[index];
4038
+ const p1 = section.outline[(index + 1) % section.outline.length];
4039
+ const base0 = this.perspectiveLocalPoint(projectPerspectivePoint(camera, p0, 0));
4040
+ const base1 = this.perspectiveLocalPoint(projectPerspectivePoint(camera, p1, 0));
4041
+ const top1 = this.perspectiveLocalPoint(projectPerspectivePoint(camera, p1, heightAt(p1)));
4042
+ const top0 = this.perspectiveLocalPoint(projectPerspectivePoint(camera, p0, heightAt(p0)));
4043
+ section.sideFaces[index].points([
4044
+ base0.x,
4045
+ base0.y,
4046
+ base1.x,
4047
+ base1.y,
4048
+ top1.x,
4049
+ top1.y,
4050
+ top0.x,
4051
+ top0.y
4052
+ ]);
4053
+ section.sideFaces[index].opacity(0.9);
4054
+ }
4055
+ }
4056
+ const farToNear = [...this.sections].sort(
4057
+ (left, right) => (right.perspectiveDepth ?? 0) - (left.perspectiveDepth ?? 0)
4058
+ );
4059
+ for (const section of farToNear) {
4060
+ section.rootGroupBg.moveToTop();
4061
+ section.liftGroupSeat.moveToTop();
4062
+ }
4063
+ this.unsectionedSeatGroup?.moveToTop();
4064
+ this.syncSeatOverlayPositions();
4065
+ if (!lazySectionSeats) this.updateSeatGroupVisibility();
4066
+ this.bgLayer.batchDraw();
4067
+ this.seatLayer.batchDraw();
4068
+ this.overlayLayer.batchDraw();
4069
+ }
3035
4070
  /**
3036
4071
  * Apply the current isoT to the scene: the base rotate+squash as a decomposed
3037
4072
  * layer transform (so seats/décor/rings project together and Konva's own
@@ -3039,6 +4074,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3039
4074
  * lift + extruded side faces on elevated sections.
3040
4075
  */
3041
4076
  applyIso() {
4077
+ this.resetSectionProjection();
4078
+ this.applyExactSeatAnchors(false);
3042
4079
  const t2 = this.isoT;
3043
4080
  if (t2 === 0) {
3044
4081
  this.resetLayerTransforms();
@@ -3067,6 +4104,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3067
4104
  }
3068
4105
  this.applyUprightLabels();
3069
4106
  this.applyElevation();
4107
+ this.updateSeatGroupVisibility();
3070
4108
  this.bgLayer.batchDraw();
3071
4109
  this.seatLayer.batchDraw();
3072
4110
  this.overlayLayer.batchDraw();
@@ -3095,10 +4133,11 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3095
4133
  applyElevation() {
3096
4134
  const t2 = this.isoT;
3097
4135
  for (const sec of this.sections) {
3098
- if (sec.elevation <= 0) continue;
3099
- const off = this.isoLiftLocal(sec.elevation);
4136
+ if (sec.liftWorld <= 0) continue;
4137
+ const off = this.isoLiftLocal(sec.liftWorld);
3100
4138
  sec.liftGroupBg?.position(off);
3101
- sec.liftGroupSeat?.position(off);
4139
+ sec.liftGroupSeat.position(off);
4140
+ this.labelLiftGroups.get(sec.id)?.position(off);
3102
4141
  const alpha = 0.9 * t2;
3103
4142
  for (let i = 0; i < sec.sideFaces.length; i++) {
3104
4143
  const face = sec.sideFaces[i];
@@ -3108,6 +4147,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3108
4147
  face.opacity(alpha);
3109
4148
  }
3110
4149
  }
4150
+ this.syncSeatOverlayPositions();
3111
4151
  }
3112
4152
  destroy() {
3113
4153
  this.destroyed = true;
@@ -3134,16 +4174,166 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3134
4174
  * tier shifts with one offset in iso view) or the seat layer directly.
3135
4175
  */
3136
4176
  seatContainer(id) {
3137
- return this.seatSection.get(id)?.liftGroupSeat ?? this.seatLayer;
4177
+ return this.seatSection.get(id)?.liftGroupSeat ?? this.unsectionedSeatGroup ?? this.seatLayer;
3138
4178
  }
3139
- renderSeats() {
4179
+ /** True when a section belongs to the active logical section/zone focus. */
4180
+ sectionMatchesFocus(section) {
4181
+ const focus = this.focusedSectionId;
4182
+ return focus == null || section.id === focus || section.logicalId === focus || section.zone === focus;
4183
+ }
4184
+ /** Visual opacity after the section-focus overlay is composed. */
4185
+ focusedSeatOpacity(id, localOpacity) {
4186
+ if (!this.focusedSectionId) return localOpacity;
4187
+ const section = this.seatSection.get(id);
4188
+ return localOpacity * (section && this.sectionMatchesFocus(section) ? 1 : FOCUS_DIM_OPACITY);
4189
+ }
4190
+ /** Project a section outline through elevation + the active affine view and
4191
+ * then into viewport pixels. The result is conservative (AABB), so a visible
4192
+ * section is never clipped even for rotated or isometric outlines. */
4193
+ sectionScreenBounds(section) {
4194
+ if (this.viewMode === "perspective" && section.perspectiveAffine) {
4195
+ const scale2 = this.stage.scaleX();
4196
+ const stageX2 = this.stage.x();
4197
+ const stageY2 = this.stage.y();
4198
+ return polyBounds(section.outline.map((point) => {
4199
+ const projected = this.projectedSectionPoint(section, point);
4200
+ return { x: projected.x * scale2 + stageX2, y: projected.y * scale2 + stageY2 };
4201
+ }));
4202
+ }
4203
+ const offset = section.liftWorld > 0 ? this.isoLiftLocal(section.liftWorld) : { x: 0, y: 0 };
4204
+ const scale = this.stage.scaleX();
4205
+ const stageX = this.stage.x();
4206
+ const stageY = this.stage.y();
4207
+ return polyBounds(section.outline.map((point) => {
4208
+ const local = { x: point.x + offset.x, y: point.y + offset.y };
4209
+ const projected = this.isoT === 0 ? local : this.isoForward(local);
4210
+ return { x: projected.x * scale + stageX, y: projected.y * scale + stageY };
4211
+ }));
4212
+ }
4213
+ /** Hide only section groups wholly outside a padded viewport at the live-seat
4214
+ * rung. Overview caching always restores all groups first, so panning a
4215
+ * cached whole-venue bitmap can never reveal missing inventory. */
4216
+ updateSeatGroupVisibility() {
4217
+ const liveSeats = this.effScale() >= CACHE_THRESHOLD;
4218
+ const deferPerspectiveSeatReveal = this.viewMode === "perspective" && this.glideInProgress && this.seats.length > 2500;
4219
+ const padding = 96;
4220
+ const width = this.stage.width();
4221
+ const height = this.stage.height();
4222
+ for (const section of this.sections) {
4223
+ let visible = true;
4224
+ if (liveSeats) {
4225
+ const bounds2 = this.sectionScreenBounds(section);
4226
+ visible = bounds2.x + bounds2.width >= -padding && bounds2.y + bounds2.height >= -padding && bounds2.x <= width + padding && bounds2.y <= height + padding;
4227
+ if (visible && this.viewMode === "perspective" && !deferPerspectiveSeatReveal) {
4228
+ this.applyExactSeatAnchors(true, section.memberIds);
4229
+ }
4230
+ }
4231
+ section.liftGroupSeat.setViewportCulled(!visible);
4232
+ const labelGroup = this.labelLiftGroups.get(section.id);
4233
+ labelGroup?.setViewportCulled(!visible);
4234
+ }
4235
+ if (this.unsectionedSeatGroup && !this.unsectionedSeatGroup.visible()) {
4236
+ this.unsectionedSeatGroup.visible(true);
4237
+ }
4238
+ if (liveSeats && this.viewMode === "perspective" && !deferPerspectiveSeatReveal) {
4239
+ this.applyExactSeatAnchors(true, this.seats.filter((seat) => !this.seatSection.has(seat.id)).map((seat) => seat.id));
4240
+ }
4241
+ }
4242
+ /** Seats worth considering for viewport labels. Section culling is a safe
4243
+ * coarse index; the later screen test remains the exact filter. */
4244
+ visibleSeatCandidates() {
4245
+ if (!this.sections.length) return this.seats;
4246
+ const candidates = [];
4247
+ for (const section of this.sections) {
4248
+ if (section.liftGroupSeat.isViewportCulled()) continue;
4249
+ for (const id of section.memberIds) {
4250
+ const seat = this.seatById.get(id);
4251
+ if (seat) candidates.push(seat);
4252
+ }
4253
+ }
3140
4254
  for (const seat of this.seats) {
4255
+ if (!this.seatSection.has(seat.id)) candidates.push(seat);
4256
+ }
4257
+ return candidates;
4258
+ }
4259
+ seatViewportCulled(id) {
4260
+ return this.seatSection.get(id)?.liftGroupSeat.isViewportCulled() ?? false;
4261
+ }
4262
+ /** Local world coordinate at which an elevated seat is actually painted for
4263
+ * the current iso tween. Flat seats and the 2D endpoint are unchanged. */
4264
+ renderedSeatPoint(seat) {
4265
+ if (this.viewMode === "perspective") return this.perspectiveSeatLocal.get(seat.id) ?? seat;
4266
+ const section = this.seatSection.get(seat.id);
4267
+ if (!section || section.liftWorld <= 0 || this.isoT === 0) return seat;
4268
+ const offset = this.isoLiftLocal(section.liftWorld);
4269
+ return { x: seat.x + offset.x, y: seat.y + offset.y };
4270
+ }
4271
+ /** Overlay labels mirror the seat-layer lift without an O(seats) per-frame
4272
+ * rewrite. The groups are rebuilt with the zoom-dependent label set. */
4273
+ seatLabelContainer(id) {
4274
+ const section = this.seatSection.get(id);
4275
+ if (!section) return this.labelGroup;
4276
+ let group = this.labelLiftGroups.get(section.id);
4277
+ if (!group) {
4278
+ group = new ViewportGroup({
4279
+ listening: false
4280
+ });
4281
+ group.setViewportCulled(section.liftGroupSeat.isViewportCulled());
4282
+ group.position(this.viewMode === "perspective" ? { x: 0, y: 0 } : this.isoLiftLocal(section.liftWorld));
4283
+ this.labelGroup.add(group);
4284
+ this.labelLiftGroups.set(section.id, group);
4285
+ }
4286
+ return group;
4287
+ }
4288
+ /** Shared overlay furniture is not parented to section lift groups, so keep
4289
+ * the small transient set aligned explicitly. Selection is capped in buyer
4290
+ * mode, making this constant-sized work during the iso tween. */
4291
+ syncSeatOverlayPositions() {
4292
+ if (this.hoveredId) {
4293
+ const seat = this.seatById.get(this.hoveredId);
4294
+ if (seat) {
4295
+ this.hoverRing.position(this.renderedSeatPoint(seat));
4296
+ const scale = this.viewMode === "perspective" ? this.perspectiveSeatScale.get(seat.id) ?? 1 : 1;
4297
+ this.hoverRing.scale({ x: scale, y: scale });
4298
+ }
4299
+ }
4300
+ if (this.focusedId) {
4301
+ const seat = this.seatById.get(this.focusedId);
4302
+ if (seat) {
4303
+ this.focusRing.position(this.renderedSeatPoint(seat));
4304
+ const scale = this.viewMode === "perspective" ? this.perspectiveSeatScale.get(seat.id) ?? 1 : 1;
4305
+ this.focusRing.scale({ x: scale, y: scale });
4306
+ }
4307
+ }
4308
+ for (const [id, marker] of this.selectionMarkers) {
4309
+ const seat = this.seatById.get(id);
4310
+ if (seat) {
4311
+ marker.position(this.renderedSeatPoint(seat));
4312
+ const scale = this.viewMode === "perspective" ? this.perspectiveSeatScale.get(seat.id) ?? 1 : 1;
4313
+ marker.scale({ x: scale, y: scale });
4314
+ }
4315
+ }
4316
+ }
4317
+ renderSeats() {
4318
+ const paintOrder = [...this.seats].sort((left, right) => (this.perspectiveSeatDepth.get(right.id) ?? 0) - (this.perspectiveSeatDepth.get(left.id) ?? 0));
4319
+ for (const seat of paintOrder) {
3141
4320
  if (seat.kind === "booth") {
3142
4321
  this.renderBoothUnit(seat);
3143
4322
  continue;
3144
4323
  }
3145
4324
  const target = this.seatContainer(seat.id);
3146
- const c = new import_Circle.Circle({
4325
+ const c = seat.wheelchairSpaceType === "no-seat" ? new import_Rect.Rect({
4326
+ x: seat.x,
4327
+ y: seat.y,
4328
+ width: this.seatR * 2,
4329
+ height: this.seatR * 2,
4330
+ offsetX: this.seatR,
4331
+ offsetY: this.seatR,
4332
+ cornerRadius: 2,
4333
+ perfectDrawEnabled: false,
4334
+ shadowForStrokeEnabled: false,
4335
+ hitStrokeWidth: 0
4336
+ }) : new import_Circle.Circle({
3147
4337
  x: seat.x,
3148
4338
  y: seat.y,
3149
4339
  radius: this.seatR,
@@ -3156,21 +4346,25 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3156
4346
  this.paintSeat(c, seat.id);
3157
4347
  target.add(c);
3158
4348
  if (seat.accessible) {
3159
- const ring = new import_Circle.Circle({
3160
- x: seat.x,
3161
- y: seat.y,
3162
- radius: this.seatR + 1.5,
3163
- stroke: accessibilityRingColor(seat.accessibility),
3164
- strokeWidth: 2.5,
3165
- listening: false,
3166
- perfectDrawEnabled: false,
3167
- shadowForStrokeEnabled: false
3168
- });
3169
- this.accessRingById.set(seat.id, ring);
3170
- target.add(ring);
3171
- const glyph = this.buildAccessGlyph(seat, typeof c.fill() === "string" ? c.fill() : "");
3172
- this.accessGlyphById.set(seat.id, glyph);
3173
- target.add(glyph);
4349
+ if (seat.wheelchairSpaceType !== "no-seat") {
4350
+ const ring = new import_Circle.Circle({
4351
+ x: seat.x,
4352
+ y: seat.y,
4353
+ radius: this.seatR + 1.5,
4354
+ stroke: accessibilityRingColor(seat.accessibility),
4355
+ strokeWidth: 2.5,
4356
+ listening: false,
4357
+ perfectDrawEnabled: false,
4358
+ shadowForStrokeEnabled: false
4359
+ });
4360
+ this.accessRingById.set(seat.id, ring);
4361
+ target.add(ring);
4362
+ }
4363
+ if (seat.accessibility?.includes("wheelchair")) {
4364
+ const glyph = this.buildAccessGlyph(seat, typeof c.fill() === "string" ? c.fill() : "");
4365
+ this.accessGlyphById.set(seat.id, glyph);
4366
+ target.add(glyph);
4367
+ }
3174
4368
  }
3175
4369
  }
3176
4370
  }
@@ -3208,7 +4402,6 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3208
4402
  t2.offsetX(t2.width() / 2);
3209
4403
  t2.offsetY(t2.height() / 2);
3210
4404
  t2.visible(false);
3211
- this.boothLabelById.set(seat.id, t2);
3212
4405
  this.hasBoothText = true;
3213
4406
  this.boothLabelById.set(seat.id, t2);
3214
4407
  target.add(t2);
@@ -3231,24 +4424,40 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3231
4424
  scaleY: k,
3232
4425
  fill: stateAwareBookableLabelInk(seatFill, "#ffffff"),
3233
4426
  listening: false,
3234
- visible: this.accessGlyphVisible,
4427
+ visible: false,
3235
4428
  perfectDrawEnabled: false,
3236
4429
  shadowForStrokeEnabled: false
3237
4430
  });
3238
4431
  }
3239
4432
  /**
3240
- * Toggle the accessibility glyphs for the current camera scale: shown once the
3241
- * effective on-screen seat radius clears {@link SEAT_GLYPH_MIN_PX}, otherwise
3242
- * hidden so only the ring remains. Iterates the (small) accessible-seat set,
3243
- * never the full node graph, so it is cheap to call on every view change.
4433
+ * Keep physical wheelchair provision readable in screen space at every map
4434
+ * LOD. The active Wheelchair filter promotes it further. Iterates the small
4435
+ * wheelchair-seat set, never the full node graph.
3244
4436
  */
3245
4437
  updateAccessGlyphs(scale) {
3246
4438
  if (!this.accessGlyphById.size) return;
3247
- this.accessGlyphVisible = this.seatR * scale >= SEAT_GLYPH_MIN_PX;
3248
4439
  for (const [id, glyph] of this.accessGlyphById) {
3249
- glyph.visible(this.accessGlyphVisible && this.accessGlyphEligible(id));
4440
+ const perspectiveScale = this.viewMode === "perspective" ? this.perspectiveSeatScale.get(id) ?? 1 : 1;
4441
+ const baseScale = this.seatR * 1.5 / ACCESS_GLYPH_VIEWBOX * perspectiveScale;
4442
+ const emphasized = this.accessGlyphFilterEmphasized(id);
4443
+ const minimumScreenPx = emphasized ? FILTERED_WHEELCHAIR_GLYPH_MIN_PX : WHEELCHAIR_GLYPH_MIN_PX;
4444
+ const glyphScale = Math.max(
4445
+ baseScale,
4446
+ minimumScreenPx / (ACCESS_GLYPH_VIEWBOX * Math.max(scale, 1e-3))
4447
+ );
4448
+ glyph.scale({ x: glyphScale, y: glyphScale });
4449
+ glyph.visible(this.accessGlyphShouldShow(id));
3250
4450
  }
3251
4451
  }
4452
+ /** True only for a physical wheelchair provision matching an active buyer filter. */
4453
+ accessGlyphFilterEmphasized(id) {
4454
+ const seat = this.seatById.get(id);
4455
+ const filter = this.accessFilter;
4456
+ return !!seat && filter !== null && !!seat.accessibility?.includes("wheelchair") && (filter.length === 0 || filter.includes("wheelchair")) && seatMatchesAccess(seat, filter);
4457
+ }
4458
+ accessGlyphShouldShow(id) {
4459
+ return this.accessGlyphById.has(id) && this.accessGlyphEligible(id);
4460
+ }
3252
4461
  /**
3253
4462
  * Whether an accessible seat's glyph should show for its current status. It is
3254
4463
  * hidden on seats a buyer cannot take (sold, or another buyer's hold) where the
@@ -3328,6 +4537,11 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3328
4537
  c.dash([2, 2]);
3329
4538
  break;
3330
4539
  }
4540
+ if (seat.wheelchairSpaceType === "no-seat" && status === "free") {
4541
+ c.stroke(accessibilityRingColor(seat.accessibility));
4542
+ c.strokeWidth(selected ? 3 : 2);
4543
+ c.dash([3, 2]);
4544
+ }
3331
4545
  if (boothLabel) {
3332
4546
  boothLabel.text(status === "booked" ? "SOLD" : status === "held" ? "HELD" : seat.label);
3333
4547
  boothLabel.fontSize(status === "free" ? 10 : Math.min(10, Math.max(6, this.boothDims.get(seat.rowId)?.width ?? 40) / 6));
@@ -3361,11 +4575,6 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3361
4575
  c.dash([]);
3362
4576
  c.opacity(CLOSED_SEAT_OPACITY);
3363
4577
  }
3364
- if (this.focusedSectionId) {
3365
- const sec = this.seatSection.get(id);
3366
- const inFocus = !!sec && (sec.id === this.focusedSectionId || sec.logicalId === this.focusedSectionId || sec.zone === this.focusedSectionId);
3367
- if (!inFocus) c.opacity(FOCUS_DIM_OPACITY);
3368
- }
3369
4578
  if (this.selectionFocusId && id !== this.selectionFocusId) c.opacity(Math.min(c.opacity(), 0.16));
3370
4579
  const bookableLabel = this.boothLabelById.get(id) ?? this.seatLabelById.get(id);
3371
4580
  if (bookableLabel) {
@@ -3379,7 +4588,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3379
4588
  const fill = c.fill();
3380
4589
  accessGlyph.fill(stateAwareBookableLabelInk(typeof fill === "string" ? fill : "", "#ffffff"));
3381
4590
  accessGlyph.opacity(c.opacity());
3382
- accessGlyph.visible(this.accessGlyphVisible && this.accessGlyphEligible(id));
4591
+ accessGlyph.visible(this.accessGlyphShouldShow(id));
3383
4592
  }
3384
4593
  this.accessRingById.get(id)?.opacity(c.opacity());
3385
4594
  }
@@ -3448,7 +4657,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3448
4657
  if (!this.sections.some((section) => section.id === id || section.logicalId === id)) return;
3449
4658
  this.focusedSectionId = id;
3450
4659
  this.drawFocusBackdrop(id);
3451
- this.repaintSectionsAndSeats();
4660
+ this.bgLayer.batchDraw();
4661
+ this.overlayLayer.batchDraw();
3452
4662
  this.updateLOD();
3453
4663
  this.focusRegion(id, { minScale: SEAT_FOCUS_SCALE });
3454
4664
  }
@@ -3460,7 +4670,10 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3460
4670
  this.focusBackdrop.destroy();
3461
4671
  this.focusBackdrop = null;
3462
4672
  }
3463
- this.repaintSectionsAndSeats();
4673
+ this.focusDimOverlay?.destroy();
4674
+ this.focusDimOverlay = null;
4675
+ this.bgLayer.batchDraw();
4676
+ this.overlayLayer.batchDraw();
3464
4677
  this.updateLOD();
3465
4678
  }
3466
4679
  /** The currently AXS-focused section id, or null. */
@@ -3477,16 +4690,69 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3477
4690
  if (!sections.length) return;
3478
4691
  const backdrop = new import_Group.Group({ listening: false });
3479
4692
  for (const section of sections) {
3480
- backdrop.add(polygonWithHolesShape(section.outline, section.holes, {
4693
+ const perspective = this.viewMode === "perspective" && section.perspectiveAffine;
4694
+ const outline = perspective ? section.outline.map((point) => this.perspectiveLocalPoint(this.projectedSectionPoint(section, point))) : section.outline;
4695
+ const holes = perspective ? section.holes.map((hole) => hole.map((point) => this.perspectiveLocalPoint(this.projectedSectionPoint(section, point)))) : section.holes;
4696
+ backdrop.add(polygonWithHolesShape(outline, holes, {
3481
4697
  fill: FOCUS_BACKDROP_FILL,
3482
4698
  stroke: rgba("#ffffff", 0.1),
3483
4699
  strokeWidth: 1,
3484
4700
  listening: false
3485
- }, section.outlinePath));
4701
+ }, perspective ? void 0 : section.outlinePath));
3486
4702
  }
3487
4703
  this.bgLayer.add(backdrop);
3488
4704
  backdrop.moveToTop();
3489
4705
  this.focusBackdrop = backdrop;
4706
+ this.drawFocusDimOverlay(sections);
4707
+ }
4708
+ /** Dim everything outside the active section with one paint-only shape. Group
4709
+ * opacity would make Konva recursively invalidate absolute-opacity caches on
4710
+ * all 14k descendants. The cut-out follows the live elevation offset and is
4711
+ * non-listening, so direct seat hit-testing is unchanged. */
4712
+ drawFocusDimOverlay(sections) {
4713
+ this.focusDimOverlay?.destroy();
4714
+ const span = Math.max(this.bounds.width, this.bounds.height, 1);
4715
+ const padding = span * 4 + 1e3;
4716
+ const outer = [
4717
+ { x: this.bounds.x - padding, y: this.bounds.y - padding },
4718
+ { x: this.bounds.x + this.bounds.width + padding, y: this.bounds.y - padding },
4719
+ { x: this.bounds.x + this.bounds.width + padding, y: this.bounds.y + this.bounds.height + padding },
4720
+ { x: this.bounds.x - padding, y: this.bounds.y + this.bounds.height + padding }
4721
+ ];
4722
+ const signedArea = (points) => points.reduce((sum, point, index) => {
4723
+ const next = points[(index + 1) % points.length];
4724
+ return sum + point.x * next.y - next.x * point.y;
4725
+ }, 0);
4726
+ const overlay = new import_Shape.Shape({
4727
+ fill: this.canvasBackground,
4728
+ opacity: 1 - FOCUS_DIM_OPACITY,
4729
+ listening: false,
4730
+ perfectDrawEnabled: false,
4731
+ sceneFunc: (context, shape) => {
4732
+ const polygonPath = (points) => {
4733
+ if (!points.length) return;
4734
+ context.moveTo(points[0].x, points[0].y);
4735
+ for (let index = 1; index < points.length; index++) context.lineTo(points[index].x, points[index].y);
4736
+ context.closePath();
4737
+ };
4738
+ context.beginPath();
4739
+ polygonPath(outer);
4740
+ for (const section of sections) {
4741
+ const hole = this.viewMode === "perspective" && section.perspectiveAffine ? section.outline.map((point) => this.perspectiveLocalPoint(this.projectedSectionPoint(section, point))) : section.outline.map((point) => {
4742
+ const offset = section.liftWorld > 0 ? this.isoLiftLocal(section.liftWorld) : { x: 0, y: 0 };
4743
+ return { x: point.x + offset.x, y: point.y + offset.y };
4744
+ });
4745
+ polygonPath(signedArea(hole) > 0 ? [...hole].reverse() : hole);
4746
+ }
4747
+ context.fillStrokeShape(shape);
4748
+ }
4749
+ });
4750
+ this.overlayLayer.add(overlay);
4751
+ overlay.moveToTop();
4752
+ for (const marker of this.selectionMarkers.values()) marker.moveToTop();
4753
+ this.hoverRing.moveToTop();
4754
+ this.focusRing.moveToTop();
4755
+ this.focusDimOverlay = overlay;
3490
4756
  }
3491
4757
  /** Repaint every seat + section block to reflect closed/focus state, then redraw. */
3492
4758
  repaintSectionsAndSeats() {
@@ -3550,7 +4816,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3550
4816
  this.applyGAFilterState();
3551
4817
  }
3552
4818
  renderBackground(doc) {
3553
- if (doc.backgroundImage) this.renderBackgroundImage(doc.backgroundImage);
4819
+ const buyerBackground = buyerBackgroundImage(doc);
4820
+ if (buyerBackground) this.renderBackgroundImage(buyerBackground);
3554
4821
  for (const obj of doc.objects) if (obj.type === "decorImage") this.renderDecorImage(obj);
3555
4822
  for (const obj of doc.objects) if (obj.type === "section") this.renderSection(obj);
3556
4823
  this.renderZones(doc);
@@ -3630,10 +4897,12 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3630
4897
  listening: false,
3631
4898
  perfectDrawEnabled: false
3632
4899
  });
3633
- this.bgLayer.add(node);
4900
+ if (obj.layer === "foreground") this.fgDecorGroup.add(node);
4901
+ else this.bgLayer.add(node);
3634
4902
  img.onload = () => {
3635
- if (!node.getLayer()) return;
3636
- this.bgLayer.batchDraw();
4903
+ const layer = node.getLayer();
4904
+ if (!layer) return;
4905
+ layer.batchDraw();
3637
4906
  };
3638
4907
  img.src = obj.href;
3639
4908
  }
@@ -3670,7 +4939,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3670
4939
  })
3671
4940
  );
3672
4941
  }
3673
- const label = this.addCentredLabel(this.bgLayer, obj.label, obj.center.x, obj.center.y, "#cbd5e1", 12, true);
4942
+ const label = this.addCentredLabel(this.bgLayer, obj.displayLabel ?? obj.label, obj.center.x, obj.center.y, "#cbd5e1", 12, true);
3674
4943
  this.freeTextById.set(obj.id, { node: label, background: "#232c40", kind: "table" });
3675
4944
  }
3676
4945
  renderText(obj) {
@@ -3682,11 +4951,12 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3682
4951
  text: obj.text,
3683
4952
  fontSize: obj.fontSize,
3684
4953
  rotation: obj.rotation,
4954
+ fontStyle: konvaFontStyle(obj.bold, obj.italic, "normal"),
3685
4955
  // Authored ink remains preferred, but an embed/theme surface can change
3686
4956
  // the actual canvas. Fail over to readable black/white instead of
3687
4957
  // painting an otherwise valid caption invisibly on that active surface.
3688
4958
  fill: stateAwareBookableLabelInk(background, preferredInk),
3689
- fontFamily: this.labelFont(),
4959
+ fontFamily: obj.fontFamily ?? this.labelFont(),
3690
4960
  listening: false,
3691
4961
  perfectDrawEnabled: false
3692
4962
  });
@@ -3704,11 +4974,40 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3704
4974
  const isDecor = !!obj.role && !isStage;
3705
4975
  const palette = overviewPalette(this.canvasBackground);
3706
4976
  const fill = referenceFocal ? palette.focalFill : authoredFill;
3707
- const stroke = isStage ? lighten(fill, 0.28) : referenceFocal ? palette.focalStroke : void 0;
3708
- const strokeWidth = isStage ? 1 : referenceFocal ? 2 : 0;
4977
+ const isOpenPath = obj.kind === "line" || obj.kind === "polyline";
4978
+ const stroke = obj.stroke?.color ?? (isStage ? lighten(fill, 0.28) : referenceFocal ? palette.focalStroke : isOpenPath ? "#9aa3b5" : void 0);
4979
+ const strokeWidth = obj.stroke?.width ?? (isStage ? 1 : referenceFocal ? 2 : isOpenPath ? 2 : 0);
4980
+ const opacity = obj.opacity != null ? clamp(obj.opacity, 0.1, 1) : 1;
3709
4981
  let cx = 0;
3710
4982
  let cy = 0;
3711
- if (obj.kind === "rect" && obj.x != null && obj.y != null && obj.width != null && obj.height != null) {
4983
+ if (isOpenPath && obj.points && obj.points.length >= 2) {
4984
+ cx = obj.points.reduce((a, p) => a + p.x, 0) / obj.points.length;
4985
+ cy = obj.points.reduce((a, p) => a + p.y, 0) / obj.points.length;
4986
+ const lineStyle = resolvedShapeLineStyle(obj);
4987
+ const arrow = shapeArrowMetrics(strokeWidth);
4988
+ const path = new import_Arrow.Arrow({
4989
+ points: obj.points.flatMap((p) => [p.x, p.y]),
4990
+ closed: false,
4991
+ x: cx,
4992
+ y: cy,
4993
+ offsetX: cx,
4994
+ offsetY: cy,
4995
+ rotation: obj.rotation ?? 0,
4996
+ stroke,
4997
+ fill: stroke,
4998
+ strokeWidth,
4999
+ opacity,
5000
+ lineCap: lineStyle.lineCap,
5001
+ lineJoin: lineStyle.lineJoin,
5002
+ pointerAtBeginning: lineStyle.startEnding === "arrow",
5003
+ pointerAtEnding: lineStyle.endEnding === "arrow",
5004
+ ...arrow,
5005
+ listening: false,
5006
+ name: "buyer-shape-open-path"
5007
+ });
5008
+ path.setAttr("shapeObjectId", obj.id);
5009
+ this.bgLayer.add(path);
5010
+ } else if (obj.kind === "rect" && obj.x != null && obj.y != null && obj.width != null && obj.height != null) {
3712
5011
  const grad = isStage ? {
3713
5012
  fillLinearGradientStartPoint: { x: 0, y: 0 },
3714
5013
  fillLinearGradientEndPoint: { x: 0, y: obj.height },
@@ -3728,7 +5027,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3728
5027
  ...grad,
3729
5028
  stroke,
3730
5029
  strokeWidth,
3731
- cornerRadius: 4,
5030
+ cornerRadius: clamp(obj.cornerRadius ?? 4, 0, Math.min(obj.width, obj.height) / 2),
5031
+ opacity,
3732
5032
  listening: false
3733
5033
  })
3734
5034
  );
@@ -3741,7 +5041,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3741
5041
  fillLinearGradientColorStops: [0, darken(fill, 0.3), 1, lighten(fill, 0.12)]
3742
5042
  } : { fill };
3743
5043
  this.bgLayer.add(
3744
- new import_Ellipse.Ellipse({ x: cx, y: cy, rotation: obj.rotation ?? 0, radiusX: obj.width / 2, radiusY: obj.height / 2, ...grad, stroke, strokeWidth, listening: false })
5044
+ new import_Ellipse.Ellipse({ x: cx, y: cy, rotation: obj.rotation ?? 0, radiusX: obj.width / 2, radiusY: obj.height / 2, ...grad, stroke, strokeWidth, opacity, listening: false })
3745
5045
  );
3746
5046
  } else if (obj.kind === "polygon" && obj.points && obj.points.length) {
3747
5047
  const pts = obj.points.flatMap((p) => [p.x, p.y]);
@@ -3756,7 +5056,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3756
5056
  fillLinearGradientColorStops: [0, darken(fill, 0.3), 1, lighten(fill, 0.12)]
3757
5057
  } : { fill };
3758
5058
  this.bgLayer.add(
3759
- new import_Line.Line({ points: pts, closed: true, x: cx, y: cy, offsetX: cx, offsetY: cy, rotation: obj.rotation ?? 0, ...grad, stroke, strokeWidth, listening: false })
5059
+ new import_Line.Line({ points: pts, closed: true, x: cx, y: cy, offsetX: cx, offsetY: cy, rotation: obj.rotation ?? 0, ...grad, stroke, strokeWidth, opacity, listening: false })
3760
5060
  );
3761
5061
  }
3762
5062
  if (obj.label) {
@@ -3815,7 +5115,10 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3815
5115
  strokeWidth: 1.5
3816
5116
  });
3817
5117
  poly.setAttr("gaId", obj.id);
3818
- poly.on("click tap", () => this.opts.onGAClick?.(obj.id));
5118
+ poly.on("click tap", (e) => {
5119
+ if (this.isGhostClick(e)) return;
5120
+ this.opts.onGAClick?.(obj.id);
5121
+ });
3819
5122
  poly.on("mouseenter", () => {
3820
5123
  this.container.style.cursor = "pointer";
3821
5124
  });
@@ -3825,7 +5128,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3825
5128
  this.bgLayer.add(poly);
3826
5129
  const labelPoint = polygonLabelPoint(obj.points, obj.holes);
3827
5130
  const containingSection = this.sections.find((section) => pointInPolygonWithHoles(labelPoint, section.outline, section.holes));
3828
- const label = this.addCentredLabel(this.bgLayer, obj.label, labelPoint.x, labelPoint.y - 8, ink, GA_LABEL_FONT_SIZE, false);
5131
+ const label = this.addCentredLabel(this.bgLayer, obj.displayLabel ?? obj.label, labelPoint.x, labelPoint.y - 8, ink, GA_LABEL_FONT_SIZE, false);
3829
5132
  const capacity = this.addCentredLabel(this.bgLayer, `cap ${obj.capacity}`, labelPoint.x, labelPoint.y + 10, ink, GA_CAPACITY_LABEL_FONT_SIZE, false);
3830
5133
  this.freeTextById.set(`${obj.id}:label`, {
3831
5134
  objectId: obj.id,
@@ -3842,7 +5145,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3842
5145
  categoryKey: obj.categoryKey
3843
5146
  });
3844
5147
  this.gaById.set(obj.id, {
3845
- label: obj.label,
5148
+ label: obj.displayLabel ?? obj.label,
3846
5149
  capacity: obj.capacity,
3847
5150
  categoryKey: obj.categoryKey,
3848
5151
  points: obj.points,
@@ -3865,7 +5168,9 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3865
5168
  const memberIds = [];
3866
5169
  const catCounts = /* @__PURE__ */ new Map();
3867
5170
  let free = 0;
3868
- for (const seat of this.seats) {
5171
+ const bounds2 = polyBounds(obj.outline);
5172
+ const candidates = this.seatIndex ? queryRect(this.seatIndex, bounds2).map((id) => this.seatById.get(id)).filter((seat) => !!seat) : this.seats;
5173
+ for (const seat of candidates) {
3869
5174
  if (this.seatSection.has(seat.id)) continue;
3870
5175
  if (!pointInPolygonWithHoles(seat, obj.outline, obj.holes)) continue;
3871
5176
  memberIds.push(seat.id);
@@ -3876,11 +5181,19 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3876
5181
  [...catCounts].map(([key, w]) => ({ hex: this.catColor.get(key) ?? "#6e7bff", w })),
3877
5182
  "#3a4358"
3878
5183
  );
3879
- const elevation = Math.max(0, Math.round(obj.elevation ?? 0));
3880
- let liftGroupBg = null;
3881
- let liftGroupSeat = null;
5184
+ const elevation = sectionElevationTier(obj.elevation);
5185
+ const geometry = sectionGeometry(obj, {
5186
+ floorBaseHeightM: this.floorBaseHeightMFor(obj.id)
5187
+ });
5188
+ const liftWorld = geometry.height * CHART_UNITS_PER_METRE;
5189
+ const rootGroupBg = new import_Group.Group({ listening: false });
5190
+ this.bgLayer.add(rootGroupBg);
5191
+ const liftGroupBg = new import_Group.Group({ listening: false });
5192
+ rootGroupBg.add(liftGroupBg);
5193
+ const liftGroupSeat = new ViewportGroup({ listening: true });
5194
+ this.seatLayer.add(liftGroupSeat);
3882
5195
  const sideFaces = [];
3883
- if (elevation > 0) {
5196
+ if (liftWorld > 0) {
3884
5197
  const faceFill = darken(this.zoneColor.get(obj.zone ?? "") ?? baseFill, 0.42);
3885
5198
  for (let i = 0; i < obj.outline.length; i++) {
3886
5199
  const face = new import_Line.Line({
@@ -3894,14 +5207,11 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3894
5207
  perfectDrawEnabled: false
3895
5208
  });
3896
5209
  sideFaces.push(face);
3897
- this.bgLayer.add(face);
5210
+ rootGroupBg.add(face);
3898
5211
  }
3899
- liftGroupBg = new import_Group.Group({ listening: false });
3900
- this.bgLayer.add(liftGroupBg);
3901
- liftGroupSeat = new import_Group.Group({ listening: false });
3902
- this.seatLayer.add(liftGroupSeat);
3903
5212
  }
3904
- const bgTarget = liftGroupBg ?? this.bgLayer;
5213
+ liftGroupBg.moveToTop();
5214
+ const bgTarget = liftGroupBg;
3905
5215
  const outlineTint = obj.color ?? this.zoneColor.get(obj.zone ?? "") ?? "#3a4358";
3906
5216
  const outlinePoly = polygonWithHolesShape(obj.outline, obj.holes, {
3907
5217
  stroke: rgba(outlineTint, 0.5),
@@ -3975,10 +5285,23 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3975
5285
  nameLabelFits: true,
3976
5286
  subLabelFits: true,
3977
5287
  elevation,
5288
+ liftWorld,
5289
+ rakeDeg: geometry.rake,
5290
+ surfaceFocal: memberIds.length ? { ...this.seatById.get(memberIds[0])?.focalPoint ?? this.isoCentre } : { ...this.chartDoc?.focalPoint ?? this.isoCentre },
5291
+ frontDistanceWorld: 0,
5292
+ rootGroupBg,
5293
+ bgZIndex: rootGroupBg.zIndex(),
5294
+ seatZIndex: liftGroupSeat.zIndex(),
3978
5295
  liftGroupBg,
3979
5296
  liftGroupSeat,
3980
5297
  sideFaces
3981
5298
  };
5299
+ const surfaceCandidates = memberIds.map((id) => this.seatById.get(id)).filter((seat) => !!seat);
5300
+ const distanceCandidates = surfaceCandidates.length ? surfaceCandidates : obj.outline;
5301
+ sec.frontDistanceWorld = Math.min(...distanceCandidates.map((point) => Math.hypot(
5302
+ point.x - sec.surfaceFocal.x,
5303
+ point.y - sec.surfaceFocal.y
5304
+ )));
3982
5305
  for (const id of memberIds) this.seatSection.set(id, sec);
3983
5306
  this.refreshSectionFill(sec);
3984
5307
  this.refreshSectionHeat(sec);
@@ -4124,15 +5447,16 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4124
5447
  const sectionOverview = scale < CACHE_THRESHOLD;
4125
5448
  if (sectionOverview) blockT = 1;
4126
5449
  if (!this.zones.length) zoneT = 0;
4127
- this.seatLayer.opacity(sectionOverview ? 0 : 1 - blockT);
5450
+ const deferPerspectiveSeatReveal = this.viewMode === "perspective" && this.glideInProgress && this.seats.length > 2500;
5451
+ this.seatLayer.opacity(sectionOverview || deferPerspectiveSeatReveal ? 0 : 1 - blockT);
4128
5452
  const sx = this.stage.scaleX();
4129
- const rescale = this.lodScale === 0 || Math.abs(scale - this.lodScale) / (this.lodScale || 1) > 0.02;
5453
+ const rescale = !this.glideInProgress && (this.lodScale === 0 || Math.abs(scale - this.lodScale) / (this.lodScale || 1) > 0.08);
4130
5454
  if (rescale) this.lodScale = scale;
4131
5455
  const focus = this.focusedSectionId;
4132
5456
  const palette = overviewPalette(this.canvasBackground);
4133
5457
  const sectionLabelT = clamp((blockT - 0.2) / 0.8, 0, 1);
4134
5458
  for (const sec of this.sections) {
4135
- const dim = focus && sec.id !== focus && sec.logicalId !== focus && sec.zone !== focus ? FOCUS_DIM_OPACITY : 1;
5459
+ const dim = this.focusDimOverlay ? 1 : focus && sec.id !== focus && sec.logicalId !== focus && sec.zone !== focus ? FOCUS_DIM_OPACITY : 1;
4136
5460
  sec.outlinePoly.opacity(sectionOverview ? 0 : (1 - blockT) * dim);
4137
5461
  sec.blockPoly.opacity(BLOCK_FILL_ALPHA * blockT * dim);
4138
5462
  sec.blockPoly.stroke(palette.sectionStroke);
@@ -4144,7 +5468,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4144
5468
  );
4145
5469
  sec.nameLabel.fill(sectionInk);
4146
5470
  sec.subLabel.fill(sectionInk);
4147
- if (rescale) this.fitSectionRungLabels(sec, sx);
5471
+ if (rescale && sectionLabelT > 0.01) this.fitSectionRungLabels(sec, sx);
4148
5472
  const labelOpacity = sectionLabelT * (1 - zoneT) * dim;
4149
5473
  sec.nameLabel.opacity(sec.nameLabelFits ? labelOpacity : 0);
4150
5474
  sec.subLabel.opacity(0);
@@ -4156,12 +5480,14 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4156
5480
  if (zone.sub) zone.sub.opacity(zoneOpacity);
4157
5481
  if (rescale) this.sizeZonePill(zone, sx);
4158
5482
  }
4159
- this.decollideRungLabels(sx);
4160
- this.dedupeLogicalSectionLabels();
4161
- for (const zone of this.zones) {
4162
- const opacity = zone.label.opacity();
4163
- zone.back.opacity(opacity);
4164
- if (zone.sub) zone.sub.opacity(opacity);
5483
+ if (!this.glideInProgress && (sectionLabelT > 0.01 || zoneOpacity > 0.01)) {
5484
+ this.decollideRungLabels(sx);
5485
+ this.dedupeLogicalSectionLabels();
5486
+ for (const zone of this.zones) {
5487
+ const opacity = zone.label.opacity();
5488
+ zone.back.opacity(opacity);
5489
+ if (zone.sub) zone.sub.opacity(opacity);
5490
+ }
4165
5491
  }
4166
5492
  this.bgLayer.batchDraw();
4167
5493
  }
@@ -4288,13 +5614,30 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4288
5614
  screenToWorld(clientPoint) {
4289
5615
  const s = this.stage.scaleX();
4290
5616
  const iso = { x: (clientPoint.x - this.stage.x()) / s, y: (clientPoint.y - this.stage.y()) / s };
5617
+ if (this.viewMode === "perspective" && this.perspectiveBaseInverse) {
5618
+ return applyAffine(this.perspectiveBaseInverse, iso);
5619
+ }
4291
5620
  return this.isoT === 0 ? iso : this.isoInverse(iso);
4292
5621
  }
4293
5622
  /** Section id under a container-relative screen point, or null (Slice 5 tap-to-zoom). */
4294
5623
  sectionAt(clientPoint) {
4295
5624
  if (!this.sections.length) return null;
4296
5625
  const world = this.screenToWorld(clientPoint);
4297
- const hit = this.sections.find((sec) => pointInPolygonWithHoles(world, sec.outline, sec.holes));
5626
+ const hit = this.sections.find((sec) => {
5627
+ if (this.viewMode === "perspective" && sec.perspectiveAffine) {
5628
+ return pointInPolygonWithHoles(
5629
+ world,
5630
+ sec.outline.map((point) => this.perspectiveLocalPoint(this.projectedSectionPoint(sec, point))),
5631
+ sec.holes.map((hole) => hole.map((point) => this.perspectiveLocalPoint(this.projectedSectionPoint(sec, point))))
5632
+ );
5633
+ }
5634
+ const offset = sec.liftWorld > 0 ? this.isoLiftLocal(sec.liftWorld) : { x: 0, y: 0 };
5635
+ return pointInPolygonWithHoles(
5636
+ { x: world.x - offset.x, y: world.y - offset.y },
5637
+ sec.outline,
5638
+ sec.holes
5639
+ );
5640
+ });
4298
5641
  return hit ? hit.logicalId : null;
4299
5642
  }
4300
5643
  /** Seat ids belonging to a section (Slice 5 section-summary card). */
@@ -4390,15 +5733,20 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4390
5733
  if (!seat) return;
4391
5734
  const candidate = this.selectionFocusId === id;
4392
5735
  const dims = this.boothDims.get(seat.rowId);
5736
+ const rendered = this.renderedSeatPoint(seat);
4393
5737
  const marker = new import_Group.Group({
4394
5738
  name: "selection-ring",
4395
- x: seat.x,
4396
- y: seat.y,
5739
+ x: rendered.x,
5740
+ y: rendered.y,
4397
5741
  rotation: dims?.rotation ?? 0,
4398
5742
  listening: false,
4399
5743
  perfectDrawEnabled: false,
4400
5744
  opacity: this.selectionFocusId && !candidate ? 0.2 : 1
4401
5745
  });
5746
+ if (this.viewMode === "perspective") {
5747
+ const projectionScale = this.perspectiveSeatScale.get(id) ?? 1;
5748
+ marker.scale({ x: projectionScale, y: projectionScale });
5749
+ }
4402
5750
  marker.setAttr("seatId", id);
4403
5751
  const common = {
4404
5752
  stroke: this.effSelection,
@@ -4477,12 +5825,22 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4477
5825
  * must fall through and pick.
4478
5826
  */
4479
5827
  sectionBounds(id) {
4480
- const bounds = this.sections.filter((section) => section.id === id || section.logicalId === id).map((section) => polyBounds(section.outline));
4481
- if (!bounds.length) return null;
4482
- const left = Math.min(...bounds.map((box) => box.x));
4483
- const top = Math.min(...bounds.map((box) => box.y));
4484
- const right = Math.max(...bounds.map((box) => box.x + box.width));
4485
- const bottom = Math.max(...bounds.map((box) => box.y + box.height));
5828
+ const bounds2 = this.sections.filter((section) => section.id === id || section.logicalId === id).map((section) => {
5829
+ if (this.viewMode === "perspective" && section.perspectiveAffine) {
5830
+ return polyBounds(section.outline.map((point) => this.projectedSectionPoint(section, point)));
5831
+ }
5832
+ const offset = section.liftWorld > 0 ? this.isoLiftLocal(section.liftWorld) : { x: 0, y: 0 };
5833
+ const points = section.outline.map((point) => {
5834
+ const local = { x: point.x + offset.x, y: point.y + offset.y };
5835
+ return this.isoT === 0 ? local : this.isoForward(local);
5836
+ });
5837
+ return polyBounds(points);
5838
+ });
5839
+ if (!bounds2.length) return null;
5840
+ const left = Math.min(...bounds2.map((box) => box.x));
5841
+ const top = Math.min(...bounds2.map((box) => box.y));
5842
+ const right = Math.max(...bounds2.map((box) => box.x + box.width));
5843
+ const bottom = Math.max(...bounds2.map((box) => box.y + box.height));
4486
5844
  return { x: left, y: top, width: right - left, height: bottom - top };
4487
5845
  }
4488
5846
  sectionFrameScale(id) {
@@ -4535,15 +5893,16 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4535
5893
  * hijacking a clean tap on empty space beyond the slop.
4536
5894
  */
4537
5895
  nearestSeatToScreen(screen, slopPx) {
4538
- const s = this.stage.scaleX() || 1;
4539
- const reachWorld = this.seatR + slopPx / s;
4540
- const world = this.screenToWorld(screen);
4541
5896
  let best = null;
4542
- let bestD = reachWorld;
5897
+ let bestD = Infinity;
5898
+ const stageScale = this.stage.scaleX() || 1;
4543
5899
  for (const seat of this.seats) {
4544
5900
  if (!this.selection.has(seat.id) && !this.isSelectable(seat.id)) continue;
4545
- const d = Math.hypot(seat.x - world.x, seat.y - world.y);
4546
- if (d < bestD) {
5901
+ const centre = this.worldToScreen(seat);
5902
+ const perspectiveScale = this.viewMode === "perspective" ? this.perspectiveSeatScale.get(seat.id) ?? 1 : 1;
5903
+ const reachPx = this.seatR * stageScale * perspectiveScale + slopPx;
5904
+ const d = Math.hypot(centre.x - screen.x, centre.y - screen.y);
5905
+ if (d <= reachPx && d < bestD) {
4547
5906
  bestD = d;
4548
5907
  best = seat.id;
4549
5908
  }
@@ -4553,6 +5912,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4553
5912
  wireInteraction() {
4554
5913
  this.seatLayer.on("click tap", (e) => {
4555
5914
  if (this.moved > PAN_START_SLOP_PX) return;
5915
+ if (this.isGhostClick(e)) return;
4556
5916
  const id = seatIdOf(e.target);
4557
5917
  if (!id) return;
4558
5918
  this.handleSeatTap(id);
@@ -4562,13 +5922,17 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4562
5922
  if (!id) return;
4563
5923
  const seat = this.seatById.get(id);
4564
5924
  if (!seat) return;
4565
- this.hoverRing.position({ x: seat.x, y: seat.y });
5925
+ this.hoveredId = id;
5926
+ this.hoverRing.position(this.renderedSeatPoint(seat));
5927
+ const projectionScale = this.viewMode === "perspective" ? this.perspectiveSeatScale.get(id) ?? 1 : 1;
5928
+ this.hoverRing.scale({ x: projectionScale, y: projectionScale });
4566
5929
  this.hoverRing.visible(true);
4567
5930
  this.overlayLayer.batchDraw();
4568
5931
  this.container.style.cursor = "pointer";
4569
5932
  this.opts.onHover?.(seat);
4570
5933
  });
4571
5934
  this.seatLayer.on("mouseout", () => {
5935
+ this.hoveredId = null;
4572
5936
  this.hoverRing.visible(false);
4573
5937
  this.overlayLayer.batchDraw();
4574
5938
  this.container.style.cursor = "default";
@@ -4583,6 +5947,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4583
5947
  });
4584
5948
  this.stage.on("click tap", (e) => {
4585
5949
  if (this.moved > PAN_START_SLOP_PX) return;
5950
+ if (this.isGhostClick(e)) return;
4586
5951
  const pointer = this.stage.getPointerPosition();
4587
5952
  if (!pointer) return;
4588
5953
  if (!this.cached) {
@@ -4599,11 +5964,10 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4599
5964
  }
4600
5965
  }
4601
5966
  if (this.sections.length) {
4602
- const world = this.screenToWorld(pointer);
4603
- const hit = this.sections.find((sn) => pointInPolygonWithHoles(world, sn.outline, sn.holes));
4604
- if (hit) {
4605
- if (this.opts.onSectionTap) this.opts.onSectionTap(hit.logicalId);
4606
- else this.focusRegion(hit.logicalId);
5967
+ const sectionId = this.sectionAt(pointer);
5968
+ if (sectionId) {
5969
+ if (this.opts.onSectionTap) this.opts.onSectionTap(sectionId);
5970
+ else this.focusRegion(sectionId);
4607
5971
  return;
4608
5972
  }
4609
5973
  }
@@ -4620,13 +5984,15 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4620
5984
  */
4621
5985
  deckFloorAt() {
4622
5986
  if (!this.objectFloor.size) return null;
4623
- const p = this.seatLayer.getRelativePointerPosition();
4624
- if (!p) return null;
5987
+ const pointer = this.stage.getPointerPosition();
5988
+ if (!pointer) return null;
5989
+ const p = this.screenToWorld(pointer);
4625
5990
  let best = null;
4626
5991
  let bestD = Infinity;
4627
5992
  for (const s of this.seats) {
4628
- const dx = s.x - p.x;
4629
- const dy = s.y - p.y;
5993
+ const rendered = this.renderedSeatPoint(s);
5994
+ const dx = rendered.x - p.x;
5995
+ const dy = rendered.y - p.y;
4630
5996
  const d = dx * dx + dy * dy;
4631
5997
  if (d < bestD) {
4632
5998
  bestD = d;
@@ -4713,9 +6079,11 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4713
6079
  }
4714
6080
  const start = performance.now();
4715
6081
  const duration = Math.max(180, Math.min(1200, opts?.durationMs ?? CAMERA_GLIDE_MS));
6082
+ this.glideInProgress = true;
4716
6083
  const step = (now) => {
4717
6084
  if (this.destroyed) {
4718
6085
  this.glideRaf = 0;
6086
+ this.glideInProgress = false;
4719
6087
  return;
4720
6088
  }
4721
6089
  const raw = Math.min(1, (now - start) / duration);
@@ -4723,6 +6091,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4723
6091
  const sc = fromScale + (toScale - fromScale) * e;
4724
6092
  this.stage.scale({ x: sc, y: sc });
4725
6093
  this.stage.position({ x: fromX + (toX - fromX) * e, y: fromY + (toY - fromY) * e });
6094
+ this.updateSeatGroupVisibility();
4726
6095
  this.updateLOD();
4727
6096
  this.scheduleViewChange();
4728
6097
  this.stage.batchDraw();
@@ -4730,6 +6099,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4730
6099
  this.glideRaf = requestAnimationFrame(step);
4731
6100
  } else {
4732
6101
  this.glideRaf = 0;
6102
+ this.glideInProgress = false;
4733
6103
  this.afterViewChange();
4734
6104
  }
4735
6105
  };
@@ -4741,6 +6111,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4741
6111
  cancelAnimationFrame(this.glideRaf);
4742
6112
  this.glideRaf = 0;
4743
6113
  }
6114
+ this.glideInProgress = false;
4744
6115
  }
4745
6116
  /** Current LOD rung derived from effective zoom — drives the ZONES/SECTIONS/SEATS pill. */
4746
6117
  getRung() {
@@ -4757,11 +6128,14 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4757
6128
  const labels = this.seats.map((seat) => {
4758
6129
  const shape = this.circleById.get(seat.id);
4759
6130
  const label = this.boothLabelById.get(seat.id) ?? this.seatLabelById.get(seat.id);
6131
+ const localPerspectiveScale = this.viewMode === "perspective" ? this.perspectiveSeatScale.get(seat.id) ?? 1 : 1;
6132
+ const seatEffectiveScale = (this.viewMode === "perspective" ? stageScale : effectiveScale) * localPerspectiveScale;
4760
6133
  const authoredFontSize = seat.kind === "booth" ? BOOTH_LABEL_FONT_SIZE : SEAT_LABEL_FONT_SIZE * this.seatLabelScale(seat);
4761
- const renderedFontPx = rounded((label?.fontSize() ?? authoredFontSize) * effectiveScale);
6134
+ const labelEffectiveScale = this.viewMode === "perspective" && seat.kind !== "booth" ? effectiveScale * localPerspectiveScale : seatEffectiveScale;
6135
+ const renderedFontPx = rounded((label?.fontSize() ?? authoredFontSize) * labelEffectiveScale);
4762
6136
  const screen = this.worldToScreen(seat);
4763
6137
  const outside = screen.x < 0 || screen.x > viewport.width || screen.y < 0 || screen.y > viewport.height;
4764
- const opacity = shape?.opacity() ?? 0;
6138
+ const opacity = this.focusedSeatOpacity(seat.id, shape?.getAbsoluteOpacity() ?? 0);
4765
6139
  const section = this.seatSection.get(seat.id);
4766
6140
  const visible = Boolean(label?.isVisible()) && opacity >= 0.5 && !outside;
4767
6141
  let hiddenReason;
@@ -4772,17 +6146,21 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4772
6146
  else if (!label) hiddenReason = "clutter-or-fit";
4773
6147
  else hiddenReason = "renderer-hidden";
4774
6148
  }
4775
- const labelWidth = label ? label.width() * stageScale : 0;
4776
- const labelHeight = label ? label.height() * effectiveScale : 0;
4777
- const directWidthPx = shape instanceof import_Rect.Rect ? shape.width() * stageScale : this.seatR * 2 * effectiveScale;
4778
- const directHeightPx = shape instanceof import_Rect.Rect ? shape.height() * stageScale : this.seatR * 2 * effectiveScale;
4779
- const assistedDiameterPx = 2 * (this.seatR * effectiveScale + SEAT_TAP_SLOP_PX);
6149
+ const labelWidth = label ? label.width() * stageScale * localPerspectiveScale : 0;
6150
+ const labelHeight = label ? label.height() * (this.viewMode === "perspective" && seat.kind === "booth" ? stageScale : effectiveScale) * localPerspectiveScale : 0;
6151
+ const directWidthPx = shape instanceof import_Rect.Rect ? shape.width() * stageScale * localPerspectiveScale : this.seatR * 2 * seatEffectiveScale;
6152
+ const directHeightPx = shape instanceof import_Rect.Rect ? shape.height() * (this.viewMode === "perspective" ? stageScale : effectiveScale) * localPerspectiveScale : this.seatR * 2 * seatEffectiveScale;
6153
+ const assistedDiameterPx = 2 * (this.seatR * seatEffectiveScale + SEAT_TAP_SLOP_PX);
4780
6154
  const fill = shape?.fill();
4781
6155
  const ink = label?.fill();
6156
+ const accessGlyph = this.accessGlyphById.get(seat.id);
6157
+ const accessGlyphScale = accessGlyph?.getAbsoluteScale();
4782
6158
  return {
4783
6159
  seatId: seat.id,
4784
6160
  label: seat.label,
4785
6161
  kind: seat.kind === "booth" ? "booth" : "seat",
6162
+ markerShape: seat.kind === "booth" ? "booth" : seat.wheelchairSpaceType === "no-seat" ? "square" : "circle",
6163
+ ...seat.wheelchairSpaceType ? { wheelchairSpaceType: seat.wheelchairSpaceType } : {},
4786
6164
  categoryKey: seat.categoryKey,
4787
6165
  ...section ? { sectionId: section.id } : {},
4788
6166
  ...section?.zone ? { zoneId: section.zone } : {},
@@ -4793,8 +6171,15 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4793
6171
  fill: typeof fill === "string" ? fill : "",
4794
6172
  ink: typeof ink === "string" ? ink : this.theme.seatLabelColor ?? DEF_SEAT_LABEL,
4795
6173
  opacity: rounded(opacity),
6174
+ ...accessGlyph ? {
6175
+ accessibilityMarker: {
6176
+ glyphVisible: accessGlyph.isVisible(),
6177
+ glyphWidthPx: rounded(ACCESS_GLYPH_VIEWBOX * Math.abs(accessGlyphScale?.x ?? 0)),
6178
+ emphasizedByFilter: this.accessGlyphFilterEmphasized(seat.id)
6179
+ }
6180
+ } : {},
4796
6181
  pointerTarget: {
4797
- active: !this.cached && this.isSelectable(seat.id),
6182
+ active: !this.cached && !this.seatViewportCulled(seat.id) && Boolean(shape?.isVisible()) && Boolean(shape?.isListening()) && this.isSelectable(seat.id),
4798
6183
  directWidthPx: rounded(directWidthPx),
4799
6184
  directHeightPx: rounded(directHeightPx),
4800
6185
  effectiveMinimumPx: rounded(Math.max(
@@ -4822,7 +6207,11 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4822
6207
  node.height(),
4823
6208
  node.rotation()
4824
6209
  );
4825
- const screenBounds = pointsBounds(worldCorners.map((corner) => this.worldToScreen(corner)));
6210
+ const lift = section && section.liftWorld > 0 ? this.isoLiftLocal(section.liftWorld) : { x: 0, y: 0 };
6211
+ const screenBounds = pointsBounds(worldCorners.map((corner) => this.worldToScreen({
6212
+ x: corner.x + lift.x,
6213
+ y: corner.y + lift.y
6214
+ })));
4826
6215
  const opacity = rounded(node.opacity());
4827
6216
  const ink = node.fill();
4828
6217
  const outside = screenBounds.x + screenBounds.width < 0 || screenBounds.x > viewport.width || screenBounds.y + screenBounds.height < 0 || screenBounds.y > viewport.height;
@@ -4961,6 +6350,15 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4961
6350
  const visibleSectionShells = this.sections.filter((section) => section.blockPoly.opacity() > 0.05);
4962
6351
  return {
4963
6352
  viewport,
6353
+ projection: this.viewMode,
6354
+ ...this.viewMode === "perspective" ? {
6355
+ perspective: {
6356
+ model: "pinhole-exact-seat-anchors",
6357
+ sectionSurfaceModel: "tangent-plane",
6358
+ exactSeatAnchorCount: this.perspectiveSeatProjected.size,
6359
+ depthSorted: true
6360
+ }
6361
+ } : {},
4964
6362
  canvasBackground: this.canvasBackground,
4965
6363
  effectiveScale: rounded(effectiveScale),
4966
6364
  rung: this.getRung(),
@@ -5015,10 +6413,10 @@ var _SeatmapRenderer = class _SeatmapRenderer {
5015
6413
  let cy = viewCentre.y;
5016
6414
  if (rung === "sections" && this.sections.length > 0) {
5017
6415
  const sectionCentres = this.sections.map((section) => {
5018
- const bounds = polyBounds(section.outline);
6416
+ const bounds2 = polyBounds(section.outline);
5019
6417
  return {
5020
- x: bounds.x + bounds.width / 2,
5021
- y: bounds.y + bounds.height / 2
6418
+ x: bounds2.x + bounds2.width / 2,
6419
+ y: bounds2.y + bounds2.height / 2
5022
6420
  };
5023
6421
  });
5024
6422
  const halfWidth = w / (target * 2);
@@ -5085,6 +6483,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
5085
6483
  }
5086
6484
  /** Recompute LOD (cache/labels) after any pan/zoom settles. */
5087
6485
  afterViewChange() {
6486
+ this.updateSeatGroupVisibility();
5088
6487
  this.updateLOD();
5089
6488
  this.updateFreeTextVisibility();
5090
6489
  this.updateLabels();
@@ -5109,10 +6508,15 @@ var _SeatmapRenderer = class _SeatmapRenderer {
5109
6508
  this.paintGAStateForView();
5110
6509
  this.updateAccessGlyphs(scale);
5111
6510
  const shouldCache = scale < CACHE_THRESHOLD;
6511
+ const suppressPerspectiveSeatCache = this.viewMode === "perspective" && this.hasSections && this.seats.length > 2500 && shouldCache;
6512
+ if (suppressPerspectiveSeatCache) {
6513
+ this.seatLayer.listening(false);
6514
+ return;
6515
+ }
5112
6516
  if (shouldCache && !this.cached) {
5113
6517
  this.cacheSeatLayer();
5114
- } else if (!shouldCache && this.cached) {
5115
- this.seatLayer.clearCache();
6518
+ } else if (!shouldCache && (this.cached || !this.seatLayer.listening())) {
6519
+ if (this.cached) this.seatLayer.clearCache();
5116
6520
  this.seatLayer.listening(true);
5117
6521
  this.cached = false;
5118
6522
  this.seatLayer.batchDraw();
@@ -5121,6 +6525,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
5121
6525
  /** Rebuild the seat-layer bitmap synchronously (no paint). Shared by the
5122
6526
  * debounced cacheSeatLayer() and the synchronous forceDraw() catch-up. */
5123
6527
  rebuildSeatCache() {
6528
+ this.updateSeatGroupVisibility();
5124
6529
  const pr = clamp(this.stage.scaleX() * this.dpr, 0.15, 2);
5125
6530
  this.seatLayer.clearCache();
5126
6531
  this.seatLayer.cache({ pixelRatio: pr });
@@ -5172,31 +6577,31 @@ var _SeatmapRenderer = class _SeatmapRenderer {
5172
6577
  for (const [id, label] of this.boothLabelById) {
5173
6578
  const shape = this.circleById.get(id);
5174
6579
  label.visible(
5175
- isBookableLabelLegibleAtScale(label.fontSize(), effectiveScale) && (shape?.opacity() ?? 1) >= 0.5
6580
+ isBookableLabelLegibleAtScale(label.fontSize(), effectiveScale) && Boolean(shape?.isVisible()) && this.focusedSeatOpacity(id, shape?.getAbsoluteOpacity() ?? 1) >= 0.5
5176
6581
  );
5177
6582
  }
5178
6583
  this.labelGroup.destroyChildren();
6584
+ this.labelLiftGroups.clear();
5179
6585
  this.seatLabelById.clear();
5180
6586
  if (!show) {
5181
6587
  this.overlayLayer.batchDraw();
5182
6588
  return;
5183
6589
  }
5184
- const s = this.stage.scaleX();
5185
- const x0 = -this.stage.x() / s;
5186
- const y0 = -this.stage.y() / s;
5187
- const x1 = (this.stage.width() - this.stage.x()) / s;
5188
- const y1 = (this.stage.height() - this.stage.y()) / s;
5189
6590
  let count = 0;
5190
- for (const seat of this.seats) {
6591
+ for (const seat of this.visibleSeatCandidates()) {
5191
6592
  if (seat.kind === "booth") continue;
5192
- if (seat.x < x0 || seat.x > x1 || seat.y < y0 || seat.y > y1) continue;
5193
- if (seat.accessible && this.accessGlyphVisible) continue;
6593
+ const screen = this.worldToScreen(seat);
6594
+ if (screen.x < -20 || screen.x > this.stage.width() + 20 || screen.y < -20 || screen.y > this.stage.height() + 20) continue;
6595
+ if (this.accessGlyphById.has(seat.id) && this.accessGlyphShouldShow(seat.id)) continue;
5194
6596
  const shape = this.circleById.get(seat.id);
5195
- if ((shape?.opacity() ?? 1) < 0.5) continue;
6597
+ if (!shape?.isVisible() || this.focusedSeatOpacity(seat.id, shape.getAbsoluteOpacity()) < 0.5) continue;
5196
6598
  const status = this.statusById.get(seat.id) ?? "free";
6599
+ const labelPoint = this.renderedSeatPoint(seat);
6600
+ const perspectiveScale = this.viewMode === "perspective" ? this.perspectiveSeatScale.get(seat.id) ?? 1 : 1;
5197
6601
  const unavailable = status === "booked" || status === "held" && !this.ownedHold.has(seat.id) && !this.opts.manageMode;
5198
6602
  if (unavailable) {
5199
- const cue = new import_Group.Group({ x: seat.x, y: seat.y, listening: false });
6603
+ const cue = new import_Group.Group({ x: labelPoint.x, y: labelPoint.y, listening: false });
6604
+ cue.scale({ x: perspectiveScale, y: perspectiveScale });
5200
6605
  if (status === "held") {
5201
6606
  cue.add(new import_Rect.Rect({
5202
6607
  x: -4.2,
@@ -5225,14 +6630,14 @@ var _SeatmapRenderer = class _SeatmapRenderer {
5225
6630
  listening: false
5226
6631
  }));
5227
6632
  }
5228
- this.labelGroup.add(cue);
6633
+ this.seatLabelContainer(seat.id).add(cue);
5229
6634
  if (++count >= MAX_LABELS) break;
5230
6635
  continue;
5231
6636
  }
5232
6637
  const authoredFontSize = SEAT_LABEL_FONT_SIZE * this.seatLabelScale(seat);
5233
6638
  const t2 = new import_Text.Text({
5234
- x: seat.x,
5235
- y: seat.y,
6639
+ x: labelPoint.x,
6640
+ y: labelPoint.y,
5236
6641
  text: bookableMarkerLabel(seat.displayLabel ?? seat.label),
5237
6642
  fontSize: authoredFontSize,
5238
6643
  fontStyle: "600",
@@ -5253,11 +6658,12 @@ var _SeatmapRenderer = class _SeatmapRenderer {
5253
6658
  }
5254
6659
  t2.offsetX(t2.width() / 2);
5255
6660
  t2.offsetY(t2.height() / 2);
5256
- this.labelGroup.add(t2);
6661
+ t2.scale({ x: perspectiveScale, y: perspectiveScale });
6662
+ this.seatLabelContainer(seat.id).add(t2);
5257
6663
  this.seatLabelById.set(seat.id, t2);
5258
6664
  if (++count >= MAX_LABELS) break;
5259
6665
  }
5260
- if (this.isoT > 0) this.applyUprightLabels();
6666
+ if (this.isoT > 0 && this.viewMode !== "perspective") this.applyUprightLabels();
5261
6667
  this.overlayLayer.batchDraw();
5262
6668
  }
5263
6669
  handleResize() {
@@ -5323,6 +6729,7 @@ function strandedSingles(seats, statusOf, selectedIds) {
5323
6729
 
5324
6730
  // src/core/bestAvailable.ts
5325
6731
  function seatIndex2(seat, fallback) {
6732
+ if (Number.isInteger(seat.logicalSeatIndex)) return seat.logicalSeatIndex;
5326
6733
  const i = seat.id.lastIndexOf(":");
5327
6734
  if (i < 0) return fallback;
5328
6735
  const n = Number(seat.id.slice(i + 1));
@@ -5342,21 +6749,25 @@ function centroid(seats) {
5342
6749
  }
5343
6750
  return { x: x / seats.length, y: y / seats.length };
5344
6751
  }
6752
+ function candidateFocal(seats, fallback) {
6753
+ return seats.find((seat) => seat.focalPoint)?.focalPoint ?? fallback;
6754
+ }
5345
6755
  function isPremium(seat) {
5346
6756
  return seat.commercial?.premium === true;
5347
6757
  }
5348
6758
  function pickBestAvailable(seats, available, opts) {
5349
6759
  const qty = Math.floor(opts.qty);
5350
6760
  if (!Number.isFinite(qty) || qty <= 0) return { labels: [], reason: "sold_out" };
5351
- const { categoryKey, focal, preferPremium } = opts;
6761
+ const { categoryKey, zoneId, focal, preferPremium } = opts;
5352
6762
  const rows = /* @__PURE__ */ new Map();
5353
6763
  const eligibleAll = [];
5354
6764
  seats.forEach((seat, i) => {
5355
- const elig = available.has(seat.label) && (!categoryKey || seat.categoryKey === categoryKey);
5356
- let arr = rows.get(seat.rowId);
6765
+ const elig = available.has(seat.label) && (!categoryKey || seat.categoryKey === categoryKey) && (!zoneId || seat.zoneId === zoneId);
6766
+ const rowId = seat.logicalRowId ?? seat.rowId;
6767
+ let arr = rows.get(rowId);
5357
6768
  if (!arr) {
5358
6769
  arr = [];
5359
- rows.set(seat.rowId, arr);
6770
+ rows.set(rowId, arr);
5360
6771
  }
5361
6772
  arr.push({ seat, index: seatIndex2(seat, i), elig });
5362
6773
  if (elig) eligibleAll.push(seat);
@@ -5381,7 +6792,7 @@ function pickBestAvailable(seats, available, opts) {
5381
6792
  continue;
5382
6793
  }
5383
6794
  let j = i;
5384
- while (j < slots.length && slots[j].elig) j++;
6795
+ while (j < slots.length && slots[j].elig && (j === i || slots[j].index === slots[j - 1].index + 1)) j++;
5385
6796
  const segLen = j - i;
5386
6797
  for (let p = 0; p + qty <= segLen; p++) {
5387
6798
  const leftRem = p;
@@ -5395,7 +6806,7 @@ function pickBestAvailable(seats, available, opts) {
5395
6806
  startIndex: slots[i + p].index,
5396
6807
  orphan,
5397
6808
  nonPremium: preferPremium ? runSeats.reduce((n, s) => n + (isPremium(s) ? 0 : 1), 0) : 0,
5398
- d2: dist2(centroid(runSeats), focal)
6809
+ d2: dist2(centroid(runSeats), candidateFocal(runSeats, focal))
5399
6810
  };
5400
6811
  if (better(c)) best = c;
5401
6812
  }
@@ -5404,20 +6815,71 @@ function pickBestAvailable(seats, available, opts) {
5404
6815
  }
5405
6816
  if (best) return { labels: best.labels };
5406
6817
  if (eligibleAll.length < qty) return { labels: [], reason: "not_enough_together" };
5407
- const ranked = eligibleAll.map((seat, i) => ({ seat, i, d2: dist2(seat, focal) })).sort((a, b) => {
6818
+ const ranked = eligibleAll.map((seat, i) => ({ seat, i, d2: dist2(seat, seat.focalPoint ?? focal) })).sort((a, b) => {
5408
6819
  if (preferPremium) {
5409
6820
  const pa = isPremium(a.seat) ? 0 : 1;
5410
6821
  const pb = isPremium(b.seat) ? 0 : 1;
5411
6822
  if (pa !== pb) return pa - pb;
5412
6823
  }
5413
6824
  if (a.d2 !== b.d2) return a.d2 - b.d2;
5414
- const r = a.seat.rowId.localeCompare(b.seat.rowId);
6825
+ const r = (a.seat.logicalRowId ?? a.seat.rowId).localeCompare(b.seat.logicalRowId ?? b.seat.rowId);
5415
6826
  if (r !== 0) return r;
5416
6827
  return seatIndex2(a.seat, a.i) - seatIndex2(b.seat, b.i);
5417
6828
  });
5418
6829
  return { labels: ranked.slice(0, qty).map((r) => r.seat.label) };
5419
6830
  }
5420
6831
 
6832
+ // src/core/tableInventory.ts
6833
+ var GroupedTableProjectionError = class extends Error {
6834
+ constructor(objectId, message) {
6835
+ super(message);
6836
+ this.objectId = objectId;
6837
+ this.name = "GroupedTableProjectionError";
6838
+ }
6839
+ };
6840
+ function bounds(table) {
6841
+ const min = table.minOccupancy;
6842
+ const max = table.maxOccupancy;
6843
+ if (!Number.isInteger(min) || !Number.isInteger(max) || min < 1 || max < min || max > table.seatCount) {
6844
+ throw new GroupedTableProjectionError(
6845
+ table.id,
6846
+ `Table "${table.label}" has invalid variable-occupancy bounds`
6847
+ );
6848
+ }
6849
+ return { min, max };
6850
+ }
6851
+ function groupedTableInventory(doc, modelVersion) {
6852
+ if (modelVersion !== 2) return [];
6853
+ const units = [];
6854
+ for (const object of allObjects(doc)) {
6855
+ if (object.type !== "table" || !object.bookAsWhole && !object.variableOccupancy) continue;
6856
+ if (object.bookAsWhole && object.variableOccupancy) {
6857
+ throw new GroupedTableProjectionError(
6858
+ object.id,
6859
+ `Table "${object.label}" cannot be whole-table and variable-occupancy inventory`
6860
+ );
6861
+ }
6862
+ if (!Number.isInteger(object.seatCount) || object.seatCount < 1 || !object.label.trim()) {
6863
+ throw new GroupedTableProjectionError(object.id, "Grouped tables require a label and at least one chair");
6864
+ }
6865
+ const mode = object.variableOccupancy ? "variable" : "whole";
6866
+ const occupancy = mode === "variable" ? bounds(object) : { min: object.seatCount, max: object.seatCount };
6867
+ units.push({
6868
+ objectId: object.id,
6869
+ label: object.label,
6870
+ ...object.displayLabel ? { displayLabel: object.displayLabel } : {},
6871
+ ...object.displayType ? { displayType: object.displayType } : {},
6872
+ categoryKey: object.categoryKey,
6873
+ mode,
6874
+ capacity: object.seatCount,
6875
+ minOccupancy: occupancy.min,
6876
+ maxOccupancy: occupancy.max,
6877
+ chairs: expandTable(object)
6878
+ });
6879
+ }
6880
+ return units;
6881
+ }
6882
+
5421
6883
  // src/picker/PickerController.ts
5422
6884
  var DEFAULT_MAX_SELECTION = 10;
5423
6885
  var MAX_BACKOFF_MS = 15e3;
@@ -5449,6 +6911,14 @@ var PickerController = class {
5449
6911
  /** id → buyer-facing spatial metadata used by every tooltip/confirm surface. */
5450
6912
  this.seatContext = /* @__PURE__ */ new Map();
5451
6913
  this.allIds = [];
6914
+ /** Model-2 tables are one booking unit whose chairs remain renderer geometry. */
6915
+ this.groupedTablesByObject = /* @__PURE__ */ new Map();
6916
+ this.groupedTablesByLabel = /* @__PURE__ */ new Map();
6917
+ this.groupedTableBySeatId = /* @__PURE__ */ new Map();
6918
+ this.tableQuantities = /* @__PURE__ */ new Map();
6919
+ /** Variable quantity is a buyer contract, never an inferred default. */
6920
+ this.confirmedVariableTables = /* @__PURE__ */ new Set();
6921
+ this.groupedSelectionOverhead = 0;
5452
6922
  // realtime socket
5453
6923
  this.ws = null;
5454
6924
  this.reconnectTimer = null;
@@ -5510,6 +6980,20 @@ var PickerController = class {
5510
6980
  idForLabel(label) {
5511
6981
  return this.labelToId.get(label);
5512
6982
  }
6983
+ idsForLabel(label) {
6984
+ const table = this.groupedTablesByLabel.get(label);
6985
+ if (table) return table.chairs.map((chair) => chair.id);
6986
+ const id = this.labelToId.get(label);
6987
+ return id ? [id] : [];
6988
+ }
6989
+ idsForLabels(labels) {
6990
+ return [...new Set([...labels].flatMap((label) => this.idsForLabel(label)))];
6991
+ }
6992
+ /** Resolve a physical chair id (or table inventory label) to its table unit. */
6993
+ tableSelection(seatIdOrLabel) {
6994
+ const group = this.groupedTableBySeatId.get(seatIdOrLabel) ?? this.groupedTablesByLabel.get(seatIdOrLabel);
6995
+ return group ? this.toTableSeat(group) : null;
6996
+ }
5513
6997
  /** Fetch chart, build label maps, mount the renderer, seed statuses, go live. */
5514
6998
  async render(host) {
5515
6999
  if (this.renderer) return null;
@@ -5523,6 +7007,29 @@ var PickerController = class {
5523
7007
  }
5524
7008
  if (this.closed) return null;
5525
7009
  this._doc = res.doc;
7010
+ try {
7011
+ const groups = groupedTableInventory(
7012
+ res.doc,
7013
+ res.event.inventoryModelVersion === 2 ? 2 : 1
7014
+ );
7015
+ this.groupedTablesByObject = new Map(groups.map((group) => [group.objectId, group]));
7016
+ this.groupedTablesByLabel = new Map(groups.map((group) => [group.label, group]));
7017
+ this.groupedTableBySeatId = new Map(
7018
+ groups.flatMap((group) => group.chairs.map((chair) => [chair.id, group]))
7019
+ );
7020
+ this.tableQuantities = new Map(groups.map((group) => [
7021
+ group.label,
7022
+ group.mode === "whole" ? group.capacity : group.minOccupancy
7023
+ ]));
7024
+ this.confirmedVariableTables = /* @__PURE__ */ new Set();
7025
+ this.groupedSelectionOverhead = groups.reduce(
7026
+ (sum, group) => sum + Math.max(0, group.chairs.length - 1),
7027
+ 0
7028
+ );
7029
+ } catch (err) {
7030
+ this.emitError(err);
7031
+ return null;
7032
+ }
5526
7033
  this.labelToId = /* @__PURE__ */ new Map();
5527
7034
  this.labelToSeat = /* @__PURE__ */ new Map();
5528
7035
  this.seatById = /* @__PURE__ */ new Map();
@@ -5540,32 +7047,43 @@ var PickerController = class {
5540
7047
  this.allIds.push(s.id);
5541
7048
  const source = chartObjects.get(s.rowId);
5542
7049
  const sourceLabel = source && "label" in source && typeof source.label === "string" ? source.label : void 0;
5543
- const sourceDisplayLabel = source && "displayLabel" in source && typeof source.displayLabel === "string" && source.displayLabel ? source.displayLabel : sourceLabel;
5544
- const rowLabel = s.kind === "booth" ? void 0 : sourceDisplayLabel;
5545
- const rowType = source && "displayType" in source && typeof source.displayType === "string" && source.displayType.trim() ? source.displayType.trim() : void 0;
7050
+ const logicalRow = source?.type === "row" ? source.segmentedRow : void 0;
7051
+ const sourceDisplayLabel = logicalRow?.displayLabel ?? (source && "displayLabel" in source && typeof source.displayLabel === "string" && source.displayLabel ? source.displayLabel : sourceLabel);
7052
+ const groupedTable = this.groupedTablesByObject.get(s.rowId);
7053
+ const objectType = source?.type === "table" ? "table" : source?.type === "booth" ? "booth" : "seat";
7054
+ const rowLabel = sourceDisplayLabel;
7055
+ const rowType = logicalRow?.displayType?.trim() || (source && "displayType" in source && typeof source.displayType === "string" && source.displayType.trim() ? source.displayType.trim() : void 0);
5546
7056
  const visibleSeatLabel = s.displayLabel ?? s.label;
5547
7057
  const labelParts = visibleSeatLabel.split("-");
5548
- const seatNumber = sourceDisplayLabel && visibleSeatLabel.startsWith(`${sourceDisplayLabel}-`) ? visibleSeatLabel.slice(sourceDisplayLabel.length + 1) : s.kind === "booth" ? visibleSeatLabel : labelParts[labelParts.length - 1] ?? visibleSeatLabel;
7058
+ const seatNumber = groupedTable ? void 0 : s.kind === "booth" ? void 0 : sourceDisplayLabel && visibleSeatLabel.startsWith(`${sourceDisplayLabel}-`) ? visibleSeatLabel.slice(sourceDisplayLabel.length + 1) : labelParts[labelParts.length - 1] ?? visibleSeatLabel;
5549
7059
  this.seatContext.set(s.id, {
7060
+ objectId: s.rowId,
7061
+ objectType,
5550
7062
  sectionLabel: sectionLabels.get(membership.objectToSection.get(s.rowId) ?? ""),
5551
7063
  rowLabel,
5552
7064
  seatNumber,
5553
- rowType
7065
+ ...rowType ? { displayType: rowType, rowType } : {}
5554
7066
  });
5555
7067
  }
7068
+ for (const group of this.groupedTablesByLabel.values()) {
7069
+ const representative = group.chairs[0];
7070
+ if (!representative) continue;
7071
+ this.labelToId.set(group.label, representative.id);
7072
+ this.labelToSeat.set(group.label, representative);
7073
+ }
5556
7074
  const currency = res.event.currency ?? this.opts.currency;
5557
7075
  this.currency = currency ?? "USD";
5558
7076
  const renderer = createRenderer(host, {
5559
- maxSelection: this.maxSelection,
7077
+ // Group chairs are selected together for paint, but count as one logical
7078
+ // inventory line. The controller enforces the guest-weighted cap.
7079
+ maxSelection: this.rendererSelectionCap(),
5560
7080
  confirmSelection: this.opts.confirmSelection,
5561
7081
  currency,
5562
7082
  onSelect: (seat) => {
5563
- this.opts.onSelect?.(seat);
5564
- this.emitSelectionChange();
7083
+ this.handleRendererSelect(seat);
5565
7084
  },
5566
7085
  onDeselect: (seat) => {
5567
- this.opts.onDeselect?.(seat);
5568
- this.emitSelectionChange();
7086
+ this.handleRendererDeselect(seat);
5569
7087
  },
5570
7088
  onSelectionLimit: this.opts.onSelectionLimit,
5571
7089
  onHover: (seat) => {
@@ -5620,7 +7138,20 @@ var PickerController = class {
5620
7138
  // ---- selection ------------------------------------------------------------
5621
7139
  getSelection() {
5622
7140
  if (!this.renderer) return [];
5623
- return this.renderer.getSelection().map((s) => this.toSeat(s));
7141
+ const out = [];
7142
+ const grouped = /* @__PURE__ */ new Set();
7143
+ for (const seat of this.renderer.getSelection()) {
7144
+ const table = this.groupedTableBySeatId.get(seat.id);
7145
+ if (table) {
7146
+ if (!grouped.has(table.label)) {
7147
+ grouped.add(table.label);
7148
+ out.push(this.toTableSeat(table));
7149
+ }
7150
+ } else {
7151
+ out.push(this.toSeat(seat));
7152
+ }
7153
+ }
7154
+ return out;
5624
7155
  }
5625
7156
  /** Enriched metadata for a seat confirmation card or tooltip. */
5626
7157
  seatDetails(seatId) {
@@ -5629,20 +7160,168 @@ var PickerController = class {
5629
7160
  }
5630
7161
  clearSelection() {
5631
7162
  this.renderer?.clearSelection();
7163
+ this.resetUnheldTableQuantities();
5632
7164
  this.emitSelectionChange();
5633
7165
  }
5634
7166
  deselect(ids) {
5635
- this.renderer?.deselect(ids);
7167
+ const expanded = /* @__PURE__ */ new Set();
7168
+ for (const id of ids) {
7169
+ const table = this.groupedTableBySeatId.get(id) ?? this.groupedTablesByLabel.get(id);
7170
+ if (table) {
7171
+ table.chairs.forEach((chair) => expanded.add(chair.id));
7172
+ if (!this.hold_?.labels.includes(table.label)) {
7173
+ this.tableQuantities.set(
7174
+ table.label,
7175
+ table.mode === "whole" ? table.capacity : table.minOccupancy
7176
+ );
7177
+ if (table.mode === "variable") this.confirmedVariableTables.delete(table.label);
7178
+ }
7179
+ } else expanded.add(id);
7180
+ }
7181
+ this.renderer?.deselect([...expanded]);
5636
7182
  this.emitSelectionChange();
5637
7183
  }
5638
7184
  setMaxSelection(maxSelection) {
5639
7185
  this.maxSelection = Math.max(0, Math.floor(maxSelection));
5640
- this.renderer?.setMaxSelection?.(this.maxSelection);
7186
+ this.renderer?.setMaxSelection?.(this.rendererSelectionCap());
5641
7187
  }
5642
7188
  select(ids) {
5643
- const added = this.renderer?.select?.(ids) ?? [];
7189
+ const before = new Set(this.getSelection().map((seat) => seat.label));
7190
+ const expanded = /* @__PURE__ */ new Set();
7191
+ for (const id of ids) {
7192
+ const table = this.groupedTableBySeatId.get(id) ?? this.groupedTablesByLabel.get(id);
7193
+ if (table) table.chairs.forEach((chair) => expanded.add(chair.id));
7194
+ else expanded.add(id);
7195
+ }
7196
+ this.renderer?.select?.([...expanded]);
7197
+ if (this.selectionGuestCount() > this.maxSelection) {
7198
+ this.renderer?.deselect([...expanded]);
7199
+ this.opts.onSelectionLimit?.(this.maxSelection);
7200
+ return [];
7201
+ }
7202
+ const added = this.getSelection().filter((seat) => !before.has(seat.label));
5644
7203
  if (added.length) this.emitSelectionChange();
5645
- return added.map((seat) => this.toSeat(seat));
7204
+ return added;
7205
+ }
7206
+ /** Confirm/update the guest quantity for a selected variable table. */
7207
+ setTableQuantity(label, quantity) {
7208
+ const table = this.groupedTablesByLabel.get(label);
7209
+ if (!table) return false;
7210
+ const q = table.mode === "whole" ? table.capacity : Math.floor(quantity);
7211
+ if (!Number.isInteger(q) || q < table.minOccupancy || q > table.maxOccupancy) return false;
7212
+ const previous = this.tableQuantities.get(label) ?? table.minOccupancy;
7213
+ this.tableQuantities.set(label, q);
7214
+ if (this.selectionGuestCount() > this.maxSelection) {
7215
+ this.tableQuantities.set(label, previous);
7216
+ this.opts.onSelectionLimit?.(this.maxSelection);
7217
+ return false;
7218
+ }
7219
+ if (table.mode === "variable") this.confirmedVariableTables.add(label);
7220
+ this.emitSelectionChange();
7221
+ return true;
7222
+ }
7223
+ /** Atomically replace an active variable-table hold with a new guest count. */
7224
+ async replaceTableQuantity(label, quantity) {
7225
+ const table = this.groupedTablesByLabel.get(label);
7226
+ const hold = this.hold_;
7227
+ if (!table || table.mode !== "variable" || !hold?.labels.includes(label)) return null;
7228
+ const q = Math.floor(quantity);
7229
+ if (!Number.isInteger(q) || q < table.minOccupancy || q > table.maxOccupancy) return null;
7230
+ const otherGuests = (hold.items ?? []).filter((item) => item.label !== label).reduce((sum, item) => sum + (item.quantity ?? 1), 0);
7231
+ if (otherGuests + q > this.maxSelection) {
7232
+ this.opts.onSelectionLimit?.(this.maxSelection);
7233
+ return null;
7234
+ }
7235
+ const selections = (hold.items ?? []).map((item) => ({
7236
+ label: item.label,
7237
+ tierId: item.tierId,
7238
+ quantity: item.label === label ? q : item.quantity
7239
+ }));
7240
+ if (!selections.some((item) => item.label === label)) return null;
7241
+ const result = await this.api.hold(this.key, selections, void 0, hold.holdId);
7242
+ this.tableQuantities.set(label, q);
7243
+ this.setHold({
7244
+ holdId: result.holdId,
7245
+ labels: selections.map((item) => item.label),
7246
+ expiresAt: result.expiresAt,
7247
+ items: result.items
7248
+ });
7249
+ return this.hold_;
7250
+ }
7251
+ rendererSelectionCap() {
7252
+ return this.maxSelection + this.groupedSelectionOverhead;
7253
+ }
7254
+ selectionGuestCount() {
7255
+ return this.getSelection().reduce((sum, seat) => sum + (seat.quantity ?? 1), 0);
7256
+ }
7257
+ handleRendererSelect(seat) {
7258
+ const table = this.groupedTableBySeatId.get(seat.id);
7259
+ if (table) {
7260
+ if (this.selectionGuestCount() > this.maxSelection) {
7261
+ this.renderer?.deselect(table.chairs.map((chair) => chair.id));
7262
+ this.opts.onSelectionLimit?.(this.maxSelection);
7263
+ this.emitSelectionChange();
7264
+ return;
7265
+ }
7266
+ this.renderer?.select?.(table.chairs.map((chair) => chair.id));
7267
+ this.opts.onSelect?.(table.chairs[0] ?? seat);
7268
+ this.opts.onTableSelectionRequest?.(this.toTableSeat(table));
7269
+ this.emitSelectionChange();
7270
+ return;
7271
+ }
7272
+ if (this.selectionGuestCount() > this.maxSelection) {
7273
+ this.renderer?.deselect([seat.id]);
7274
+ this.opts.onSelectionLimit?.(this.maxSelection);
7275
+ this.emitSelectionChange();
7276
+ return;
7277
+ }
7278
+ this.opts.onSelect?.(seat);
7279
+ this.emitSelectionChange();
7280
+ }
7281
+ handleRendererDeselect(seat) {
7282
+ const table = this.groupedTableBySeatId.get(seat.id);
7283
+ if (table) {
7284
+ this.renderer?.deselect(table.chairs.map((chair) => chair.id));
7285
+ if (!this.hold_?.labels.includes(table.label)) {
7286
+ this.tableQuantities.set(
7287
+ table.label,
7288
+ table.mode === "whole" ? table.capacity : table.minOccupancy
7289
+ );
7290
+ if (table.mode === "variable") this.confirmedVariableTables.delete(table.label);
7291
+ }
7292
+ this.opts.onDeselect?.(table.chairs[0] ?? seat);
7293
+ } else {
7294
+ this.opts.onDeselect?.(seat);
7295
+ }
7296
+ this.emitSelectionChange();
7297
+ }
7298
+ resetUnheldTableQuantities() {
7299
+ for (const table of this.groupedTablesByLabel.values()) {
7300
+ if (this.hold_?.labels.includes(table.label)) continue;
7301
+ this.tableQuantities.set(
7302
+ table.label,
7303
+ table.mode === "whole" ? table.capacity : table.minOccupancy
7304
+ );
7305
+ if (table.mode === "variable") this.confirmedVariableTables.delete(table.label);
7306
+ }
7307
+ }
7308
+ resetTableQuantitiesForLabels(labels) {
7309
+ for (const label of labels) {
7310
+ const table = this.groupedTablesByLabel.get(label);
7311
+ if (!table) continue;
7312
+ this.tableQuantities.set(
7313
+ label,
7314
+ table.mode === "whole" ? table.capacity : table.minOccupancy
7315
+ );
7316
+ if (table.mode === "variable") this.confirmedVariableTables.delete(label);
7317
+ }
7318
+ }
7319
+ tableQuantityRequest(seat) {
7320
+ if (seat?.objectType !== "table") return {};
7321
+ if (seat.bookingMode === "variable" && !this.confirmedVariableTables.has(seat.label)) {
7322
+ throw new Error("picker: confirm a variable table guest quantity before holding");
7323
+ }
7324
+ return { quantity: seat.quantity };
5646
7325
  }
5647
7326
  // ---- booking machine ------------------------------------------------------
5648
7327
  /**
@@ -5654,7 +7333,7 @@ var PickerController = class {
5654
7333
  async hold(labelsArg, ttlMs) {
5655
7334
  const r = this.renderer;
5656
7335
  if (!r) return null;
5657
- const labels = labelsArg ?? r.getSelection().map((s) => s.label);
7336
+ const labels = labelsArg ?? this.getSelection().map((seat) => seat.label);
5658
7337
  const existingGA = (this.hold_?.items ?? []).filter((item) => item.objectType === "ga");
5659
7338
  const combinedLabels = [.../* @__PURE__ */ new Set([...existingGA.map((item) => item.label), ...labels])];
5660
7339
  if (!combinedLabels.length) return null;
@@ -5662,10 +7341,14 @@ var PickerController = class {
5662
7341
  try {
5663
7342
  const selections = combinedLabels.map((label) => {
5664
7343
  const ga = existingGA.find((item) => item.label === label);
5665
- if (ga) return { label, tierId: ga.tierId };
7344
+ if (ga) return { label, tierId: ga.tierId, quantity: ga.quantity };
5666
7345
  const seat = this.labelToSeat.get(label);
5667
7346
  const resolved = seat ? this.toSeat(seat) : null;
5668
- return { label, ...resolved?.tierId ? { tierId: resolved.tierId } : {} };
7347
+ return {
7348
+ label,
7349
+ ...resolved?.tierId ? { tierId: resolved.tierId } : {},
7350
+ ...this.tableQuantityRequest(resolved)
7351
+ };
5669
7352
  });
5670
7353
  const result = await this.api.hold(this.key, selections, ttlMs, this.hold_?.holdId);
5671
7354
  this.setHold({ holdId: result.holdId, labels: combinedLabels, expiresAt: result.expiresAt, items: result.items });
@@ -5717,11 +7400,19 @@ var PickerController = class {
5717
7400
  const out = {};
5718
7401
  const closedMembers = this.closedMemberIds();
5719
7402
  for (const [id, s] of this.seatById) {
7403
+ if (this.groupedTableBySeatId.has(id)) continue;
5720
7404
  if (closedMembers.has(id)) continue;
5721
7405
  if ((this.getStatus(id) ?? "free") === "free") {
5722
7406
  out[s.categoryKey] = (out[s.categoryKey] ?? 0) + 1;
5723
7407
  }
5724
7408
  }
7409
+ for (const table of this.groupedTablesByLabel.values()) {
7410
+ if (table.chairs.some((chair) => closedMembers.has(chair.id))) continue;
7411
+ const representative = table.chairs[0];
7412
+ if (representative && (this.getStatus(representative.id) ?? "free") === "free") {
7413
+ out[table.categoryKey] = (out[table.categoryKey] ?? 0) + table.maxOccupancy;
7414
+ }
7415
+ }
5725
7416
  return out;
5726
7417
  }
5727
7418
  /** Seat ids belonging to a currently-closed section (excluded from counts). */
@@ -5740,6 +7431,8 @@ var PickerController = class {
5740
7431
  return {
5741
7432
  id: area.id,
5742
7433
  label: area.label,
7434
+ ...area.displayLabel ? { displayLabel: area.displayLabel } : {},
7435
+ ...area.displayType ? { displayType: area.displayType } : {},
5743
7436
  capacity: Math.max(0, Math.floor(area.capacity)),
5744
7437
  available: gaUnitLabels(area).filter((label) => (this.liveStatuses.get(label) ?? "free") === "free").length,
5745
7438
  categoryKey: area.categoryKey,
@@ -5758,14 +7451,20 @@ var PickerController = class {
5758
7451
  const labels = gaUnitLabels(area).filter((label) => !this.hold_?.labels.includes(label) && (this.liveStatuses.get(label) ?? "free") === "free").slice(0, Math.floor(qty));
5759
7452
  if (labels.length !== Math.floor(qty)) return null;
5760
7453
  const selections = /* @__PURE__ */ new Map();
5761
- for (const item of this.hold_?.items ?? []) selections.set(item.label, { label: item.label, tierId: item.tierId });
7454
+ for (const item of this.hold_?.items ?? []) selections.set(item.label, {
7455
+ label: item.label,
7456
+ tierId: item.tierId,
7457
+ quantity: item.quantity
7458
+ });
5762
7459
  if (this.hold_ && !this.hold_?.items?.length) {
5763
7460
  for (const seat of this.hold_.seats) selections.set(seat.label, { label: seat.label, ...seat.tierId ? { tierId: seat.tierId } : {} });
5764
7461
  }
5765
- for (const seat of this.renderer?.getSelection() ?? []) {
5766
- const resolved = this.labelToSeat.get(seat.label);
5767
- const chosen = resolved ? this.toSeat(resolved) : null;
5768
- selections.set(seat.label, { label: seat.label, ...chosen?.tierId ? { tierId: chosen.tierId } : {} });
7462
+ for (const seat of this.getSelection()) {
7463
+ selections.set(seat.label, {
7464
+ label: seat.label,
7465
+ ...seat.tierId ? { tierId: seat.tierId } : {},
7466
+ ...this.tableQuantityRequest(seat)
7467
+ });
5769
7468
  }
5770
7469
  for (const label of labels) selections.set(label, { label, ...options.tierId ? { tierId: options.tierId } : {} });
5771
7470
  const combined = [...selections.values()];
@@ -5782,6 +7481,15 @@ var PickerController = class {
5782
7481
  }
5783
7482
  return false;
5784
7483
  }
7484
+ /** Zones which still own visible buyer inventory, in authored order. */
7485
+ getBestAvailableZones() {
7486
+ const doc = this.visibleDoc();
7487
+ if (!doc?.zones?.length) return [];
7488
+ const used = new Set(
7489
+ computeSections(doc).sections.filter((section) => section.seatCount > 0 && !!section.zone).map((section) => section.zone)
7490
+ );
7491
+ return doc.zones.filter((zone) => used.has(zone.id)).map((zone) => ({ id: zone.id, label: zone.label }));
7492
+ }
5785
7493
  /**
5786
7494
  * Client-side premium pre-pass: find the best contiguous block of `qty`
5787
7495
  * PREMIUM-flagged free seats (orphan-avoiding, closest to the focal point)
@@ -5790,11 +7498,12 @@ var PickerController = class {
5790
7498
  * seat status. Returns a FULL premium block of `qty`, or null when none
5791
7499
  * exists (the caller then falls back to the normal server pick).
5792
7500
  */
5793
- pickPremiumBlock(qty, categoryKey) {
7501
+ pickPremiumBlock(qty, categoryKey, zoneId) {
5794
7502
  const doc = this.visibleDoc();
5795
7503
  if (!doc?.objects?.length) return null;
5796
7504
  const focal = doc.focalPoint ?? { x: 0, y: 0 };
5797
- const seats = expandChart(doc);
7505
+ const groupedObjectIds = new Set(this.groupedTablesByObject.keys());
7506
+ const seats = expandChart(doc).filter((seat) => !groupedObjectIds.has(seat.rowId));
5798
7507
  const held = new Set(this.hold_?.labels ?? []);
5799
7508
  const available = /* @__PURE__ */ new Set();
5800
7509
  for (const seat of seats) {
@@ -5803,7 +7512,7 @@ var PickerController = class {
5803
7512
  const status = id ? this.getStatus(id) : void 0;
5804
7513
  if ((status ?? "free") === "free") available.add(seat.label);
5805
7514
  }
5806
- const pick = pickBestAvailable(seats, available, { qty, categoryKey, focal, preferPremium: true });
7515
+ const pick = pickBestAvailable(seats, available, { qty, categoryKey, zoneId, focal, preferPremium: true });
5807
7516
  if (pick.labels.length !== qty) return null;
5808
7517
  const allPremium = pick.labels.every((l) => this.labelToSeat.get(l)?.commercial?.premium);
5809
7518
  return allPremium ? [...pick.labels] : null;
@@ -5818,7 +7527,7 @@ var PickerController = class {
5818
7527
  const r = this.renderer;
5819
7528
  if (!r) return null;
5820
7529
  if (opts.preferPremium) {
5821
- const block = this.pickPremiumBlock(qty, categoryKey);
7530
+ const block = this.pickPremiumBlock(qty, categoryKey, opts.zoneId);
5822
7531
  if (block) {
5823
7532
  if (this.hold_ && !await this.release()) return null;
5824
7533
  try {
@@ -5829,7 +7538,7 @@ var PickerController = class {
5829
7538
  });
5830
7539
  const result = await this.api.hold(this.key, selection);
5831
7540
  r.clearSelection();
5832
- const ids = block.map((l) => this.labelToId.get(l)).filter((v) => !!v);
7541
+ const ids = this.idsForLabels(block);
5833
7542
  if (ids.length) r.setStatus(ids, "held");
5834
7543
  this.setHold({ holdId: result.holdId, labels: [...block], expiresAt: result.expiresAt, items: result.items });
5835
7544
  const seats = block.map((l) => this.labelToSeat.get(l)).filter((s) => !!s).map((s) => this.toSeat(s));
@@ -5846,9 +7555,9 @@ var PickerController = class {
5846
7555
  }
5847
7556
  if (this.hold_ && !await this.release()) return null;
5848
7557
  try {
5849
- const result = await this.api.bestAvailable(this.key, qty, categoryKey);
7558
+ const result = await this.api.bestAvailable(this.key, qty, categoryKey, opts.zoneId);
5850
7559
  r.clearSelection();
5851
- const ids = result.labels.map((l) => this.labelToId.get(l)).filter((v) => !!v);
7560
+ const ids = this.idsForLabels(result.labels);
5852
7561
  if (ids.length) r.setStatus(ids, "held");
5853
7562
  this.setHold({ holdId: result.holdId, labels: [...result.labels], expiresAt: result.expiresAt, items: result.items });
5854
7563
  const seats = result.labels.map((l) => this.labelToSeat.get(l)).filter((s) => !!s).map((s) => this.toSeat(s));
@@ -5872,7 +7581,7 @@ var PickerController = class {
5872
7581
  const r = this.renderer;
5873
7582
  if (!r) return null;
5874
7583
  if (!this.api.book) throw new Error("picker: transport has no book() \u2014 hold-only mode");
5875
- const labels = labelsArg ?? r.getSelection().map((s) => s.label);
7584
+ const labels = labelsArg ?? this.getSelection().map((seat) => seat.label);
5876
7585
  if (!labels.length) return null;
5877
7586
  let holdId;
5878
7587
  try {
@@ -5885,7 +7594,11 @@ var PickerController = class {
5885
7594
  labels.map((label) => {
5886
7595
  const seat = this.labelToSeat.get(label);
5887
7596
  const resolved = seat ? this.toSeat(seat) : null;
5888
- return { label, ...resolved?.tierId ? { tierId: resolved.tierId } : {} };
7597
+ return {
7598
+ label,
7599
+ ...resolved?.tierId ? { tierId: resolved.tierId } : {},
7600
+ ...this.tableQuantityRequest(resolved)
7601
+ };
5889
7602
  }),
5890
7603
  void 0,
5891
7604
  replaceHoldId
@@ -5901,10 +7614,11 @@ var PickerController = class {
5901
7614
  this.opts.onSalesClosed?.();
5902
7615
  } else if (status === 409 && conflicts?.length) {
5903
7616
  const takenLabels = new Set(conflicts.map((c) => c.label));
5904
- const takenIds = [...takenLabels].map((l) => this.labelToId.get(l)).filter((v) => !!v);
7617
+ const takenIds = this.idsForLabels(takenLabels);
5905
7618
  if (takenIds.length) {
5906
7619
  r.setStatus(takenIds, "booked");
5907
7620
  r.deselect(takenIds);
7621
+ this.resetTableQuantitiesForLabels(takenLabels);
5908
7622
  }
5909
7623
  const stillFree = labels.filter((l) => !takenLabels.has(l));
5910
7624
  if (stillFree.length && holdId) void this.api.release(this.key, stillFree, holdId).catch(() => {
@@ -5916,12 +7630,13 @@ var PickerController = class {
5916
7630
  }
5917
7631
  throw err;
5918
7632
  }
5919
- const ids = labels.map((l) => this.labelToId.get(l)).filter((v) => !!v);
7633
+ const ids = this.idsForLabels(labels);
5920
7634
  if (ids.length) {
5921
7635
  r.setStatus(ids, "booked");
5922
7636
  r.deselect(ids);
5923
7637
  }
5924
7638
  this.clearHold();
7639
+ this.resetTableQuantitiesForLabels(labels);
5925
7640
  this.emitSelectionChange();
5926
7641
  this.opts.onBook?.(bookingRef);
5927
7642
  return labels;
@@ -5942,7 +7657,8 @@ var PickerController = class {
5942
7657
  }
5943
7658
  if (this.hold_?.holdId !== hold.holdId) return true;
5944
7659
  this.clearHold();
5945
- const ids = hold.labels.map((l) => this.labelToId.get(l)).filter((v) => !!v);
7660
+ this.resetTableQuantitiesForLabels(hold.labels);
7661
+ const ids = this.idsForLabels(hold.labels);
5946
7662
  if (ids.length) {
5947
7663
  this.renderer?.deselect(ids);
5948
7664
  this.renderer?.setStatus(ids, "free");
@@ -5973,7 +7689,8 @@ var PickerController = class {
5973
7689
  const remainingItems = hold.items?.filter((item) => !drop.includes(item.label));
5974
7690
  if (remaining.length) this.setHold({ ...hold, labels: remaining, items: remainingItems });
5975
7691
  else this.clearHold();
5976
- const ids = drop.map((l) => this.labelToId.get(l)).filter((v) => !!v);
7692
+ this.resetTableQuantitiesForLabels(drop);
7693
+ const ids = this.idsForLabels(drop);
5977
7694
  if (ids.length) {
5978
7695
  this.renderer?.deselect(ids);
5979
7696
  this.renderer?.setStatus(ids, "free");
@@ -6062,7 +7779,7 @@ var PickerController = class {
6062
7779
  this.opts.onDeckTap?.(floorId);
6063
7780
  }
6064
7781
  // ---- big-venue: sections / rungs / projection (Slice 5) -------------------
6065
- /** Switch the map projection (2D flat ⇄ 3D isometric). No-op on a flat renderer. */
7782
+ /** Switch the map projection. No-op on a flat-only renderer. */
6066
7783
  setViewMode(mode) {
6067
7784
  this.renderer?.setViewMode?.(mode);
6068
7785
  }
@@ -6161,12 +7878,21 @@ var PickerController = class {
6161
7878
  if (!sec) return null;
6162
7879
  const memberIds = r.sectionMembers?.(id) ?? [];
6163
7880
  const byCat = /* @__PURE__ */ new Map();
7881
+ const seenTables = /* @__PURE__ */ new Set();
6164
7882
  let seatsLeft = 0;
6165
7883
  for (const sid of memberIds) {
6166
7884
  const seat = this.seatById.get(sid);
6167
7885
  if (!seat) continue;
6168
- byCat.set(seat.categoryKey, (byCat.get(seat.categoryKey) ?? 0) + 1);
6169
- if (r.getStatus(sid) === "free") seatsLeft++;
7886
+ const table = this.groupedTableBySeatId.get(sid);
7887
+ if (table) {
7888
+ if (seenTables.has(table.label)) continue;
7889
+ seenTables.add(table.label);
7890
+ byCat.set(table.categoryKey, (byCat.get(table.categoryKey) ?? 0) + table.maxOccupancy);
7891
+ if (r.getStatus(table.chairs[0]?.id ?? sid) === "free") seatsLeft += table.maxOccupancy;
7892
+ } else {
7893
+ byCat.set(seat.categoryKey, (byCat.get(seat.categoryKey) ?? 0) + 1);
7894
+ if (r.getStatus(sid) === "free") seatsLeft++;
7895
+ }
6170
7896
  }
6171
7897
  const categories = [...byCat.entries()].map(([key, count]) => {
6172
7898
  const cat = doc.categories.find((c) => c.key === key);
@@ -6216,22 +7942,54 @@ var PickerController = class {
6216
7942
  }
6217
7943
  // ---- internals ------------------------------------------------------------
6218
7944
  toSeat(s) {
7945
+ const table = this.groupedTableBySeatId.get(s.id);
7946
+ if (table) return this.toTableSeat(table);
6219
7947
  const commercial = s.commercial ? { commercial: s.commercial } : void 0;
6220
7948
  const display = s.displayLabel ? { displayLabel: s.displayLabel } : void 0;
7949
+ const accessibility = s.accessibility?.length ? { accessibility: s.accessibility } : void 0;
7950
+ const wheelchair = s.wheelchairSpaceType ? { wheelchairSpaceType: s.wheelchairSpaceType } : void 0;
7951
+ const context = this.seatContext.get(s.id);
6221
7952
  const tiers = this.tiersFor(s.categoryKey);
6222
7953
  if (!tiers) {
6223
- return { id: s.id, label: s.label, ...display, categoryKey: s.categoryKey, price: this.priceFor(s.categoryKey), ...commercial };
7954
+ return { id: s.id, label: s.label, ...display, ...context, categoryKey: s.categoryKey, price: this.priceFor(s.categoryKey), ...commercial, ...accessibility, ...wheelchair };
6224
7955
  }
6225
7956
  const chosen = tiers.find((t2) => t2.id === this.seatTiers.get(s.id)) ?? tiers[0];
6226
7957
  return {
6227
7958
  id: s.id,
6228
7959
  label: s.label,
6229
7960
  ...display,
7961
+ ...context,
6230
7962
  categoryKey: s.categoryKey,
6231
7963
  price: chosen.price,
6232
7964
  tiers,
6233
7965
  tierId: chosen.id,
6234
- ...commercial
7966
+ ...commercial,
7967
+ ...accessibility,
7968
+ ...wheelchair
7969
+ };
7970
+ }
7971
+ toTableSeat(table) {
7972
+ const representative = table.chairs[0];
7973
+ const tiers = this.tiersFor(table.categoryKey);
7974
+ const chosen = tiers?.find((tier) => tier.id === this.seatTiers.get(representative?.id ?? "")) ?? tiers?.[0];
7975
+ return {
7976
+ id: representative?.id ?? table.objectId,
7977
+ label: table.label,
7978
+ displayLabel: table.displayLabel ?? table.label,
7979
+ ...table.displayType ? { displayType: table.displayType, rowType: table.displayType } : {},
7980
+ objectId: table.objectId,
7981
+ sectionLabel: representative ? this.seatContext.get(representative.id)?.sectionLabel : void 0,
7982
+ rowLabel: table.displayLabel ?? table.label,
7983
+ categoryKey: table.categoryKey,
7984
+ price: chosen?.price ?? this.priceFor(table.categoryKey),
7985
+ ...tiers ? { tiers, tierId: chosen?.id } : {},
7986
+ objectType: "table",
7987
+ bookingMode: table.mode,
7988
+ quantity: this.tableQuantities.get(table.label) ?? (table.mode === "whole" ? table.capacity : table.minOccupancy),
7989
+ capacity: table.capacity,
7990
+ minOccupancy: table.minOccupancy,
7991
+ maxOccupancy: table.maxOccupancy,
7992
+ physicalSeatIds: table.chairs.map((chair) => chair.id)
6235
7993
  };
6236
7994
  }
6237
7995
  /** Tooltip payload for a hovered seat — see PickerCallbacks.onSeatHover. */
@@ -6315,10 +8073,13 @@ var PickerController = class {
6315
8073
  const h = this.hold_;
6316
8074
  if (!h || h.labels.length !== labels.length || !labels.every((l) => h.labels.includes(l))) return false;
6317
8075
  const tiers = new Map((h.items ?? []).map((item) => [item.label, item.tierId]));
8076
+ const quantities = new Map((h.items ?? []).map((item) => [item.label, item.quantity ?? 1]));
6318
8077
  return labels.every((label) => {
6319
8078
  const seat = this.labelToSeat.get(label);
6320
- const currentTier = seat ? this.toSeat(seat).tierId ?? null : null;
6321
- return !tiers.has(label) || tiers.get(label) === currentTier;
8079
+ const resolved = seat ? this.toSeat(seat) : null;
8080
+ const currentTier = resolved?.tierId ?? null;
8081
+ const quantityMatches = resolved?.objectType !== "table" || (resolved.bookingMode !== "variable" || this.confirmedVariableTables.has(label)) && (!quantities.has(label) || quantities.get(label) === resolved.quantity);
8082
+ return quantityMatches && (!tiers.has(label) || tiers.get(label) === currentTier);
6322
8083
  });
6323
8084
  }
6324
8085
  handle409Conflicts(err) {
@@ -6326,19 +8087,28 @@ var PickerController = class {
6326
8087
  if (status !== 409 || !conflicts?.length) return;
6327
8088
  const r = this.renderer;
6328
8089
  if (!r) return;
6329
- const takenIds = conflicts.map((c) => this.labelToId.get(c.label)).filter((v) => !!v);
8090
+ const takenIds = this.idsForLabels(conflicts.map((conflict) => conflict.label));
6330
8091
  if (takenIds.length) {
6331
8092
  r.deselect(takenIds);
6332
8093
  r.setStatus(takenIds, "held");
8094
+ this.resetTableQuantitiesForLabels(conflicts.map((conflict) => conflict.label));
6333
8095
  }
6334
8096
  this.emitSelectionChange();
6335
8097
  }
6336
8098
  /** Set the open hold + (re)arm the server-authoritative expiry timer. */
6337
8099
  setHold(hold, source = "created") {
8100
+ for (const item of hold.items ?? []) {
8101
+ if (this.groupedTablesByLabel.has(item.label) && item.quantity != null) {
8102
+ this.tableQuantities.set(item.label, item.quantity);
8103
+ if (this.groupedTablesByLabel.get(item.label)?.mode === "variable") {
8104
+ this.confirmedVariableTables.add(item.label);
8105
+ }
8106
+ }
8107
+ }
6338
8108
  const full = { ...hold, seats: this.seatsForLabels(hold.labels) };
6339
8109
  this.hold_ = full;
6340
8110
  this.renderer?.setOwnedHold?.(
6341
- full.labels.map((label) => this.labelToId.get(label)).filter((id) => !!id)
8111
+ this.idsForLabels(full.labels)
6342
8112
  );
6343
8113
  if (this.expiryTimer) clearTimeout(this.expiryTimer);
6344
8114
  const ms = Math.max(0, full.expiresAt - Date.now());
@@ -6381,12 +8151,11 @@ var PickerController = class {
6381
8151
  this.liveStatuses = new Map(Object.entries(seats));
6382
8152
  if (this.allIds.length) r.setStatus(this.allIds, "free");
6383
8153
  r.setOwnedHold?.(
6384
- (this.hold_?.labels ?? []).map((label) => this.labelToId.get(label)).filter((id) => !!id)
8154
+ this.idsForLabels(this.hold_?.labels ?? [])
6385
8155
  );
6386
8156
  const byStatus = { free: [], held: [], booked: [], not_for_sale: [] };
6387
8157
  for (const [label, st] of Object.entries(seats)) {
6388
- const id = this.labelToId.get(label);
6389
- if (id) byStatus[mapStatus(st)].push(id);
8158
+ byStatus[mapStatus(st)].push(...this.idsForLabel(label));
6390
8159
  }
6391
8160
  ["held", "booked", "not_for_sale"].forEach((st) => {
6392
8161
  if (byStatus[st].length) r.setStatus(byStatus[st], st);
@@ -6395,7 +8164,11 @@ var PickerController = class {
6395
8164
  this.clearBookedHoldIfSettled();
6396
8165
  }
6397
8166
  clearBookedHoldIfSettled() {
6398
- if (this.hold_?.labels.every((label) => this.liveStatuses.get(label) === "booked")) this.clearHold();
8167
+ const hold = this.hold_;
8168
+ if (hold?.labels.every((label) => this.liveStatuses.get(label) === "booked")) {
8169
+ this.clearHold();
8170
+ this.resetTableQuantitiesForLabels(hold.labels);
8171
+ }
6399
8172
  }
6400
8173
  async resnapshot() {
6401
8174
  try {
@@ -6484,13 +8257,13 @@ var PickerController = class {
6484
8257
  } else if (Array.isArray(m.changes)) {
6485
8258
  for (const ch of m.changes) {
6486
8259
  this.liveStatuses.set(ch.label, ch.status);
6487
- const id = this.labelToId.get(ch.label);
6488
- if (!id) continue;
8260
+ const ids = this.idsForLabel(ch.label);
8261
+ if (!ids.length) continue;
6489
8262
  const next = mapStatus(ch.status);
6490
- if (this.opts.flashOnLiveChange && next !== "free" && r.getStatus(id) === "free" && !this.hold_?.labels.includes(ch.label)) {
6491
- r.flashSeat(id, next === "held" ? "#f4b740" : "#f43f5e");
8263
+ if (this.opts.flashOnLiveChange && next !== "free" && ids.some((id) => r.getStatus(id) === "free") && !this.hold_?.labels.includes(ch.label)) {
8264
+ ids.forEach((id) => r.flashSeat(id, next === "held" ? "#f4b740" : "#f43f5e"));
6492
8265
  }
6493
- r.setStatus([id], next);
8266
+ r.setStatus(ids, next);
6494
8267
  }
6495
8268
  if (this.opts.keepLiveWhileHidden && typeof document !== "undefined" && document.visibilityState === "hidden") {
6496
8269
  r.forceDraw();
@@ -6521,10 +8294,25 @@ var PickerController = class {
6521
8294
  }
6522
8295
  };
6523
8296
 
8297
+ // src/view/sightline.ts
8298
+ var STAGE_TOP_M = 5;
8299
+ var STAGE_BASE_M = -1.1;
8300
+ var MAX_LOOKDOWN_DEG = 35;
8301
+ function stageSightlinePitch(eyeHeightM, distM) {
8302
+ const extraEyeM = eyeHeightM - SEATED_EYE_HEIGHT_M;
8303
+ let topPitch = Math.atan2(STAGE_TOP_M - extraEyeM, distM) * 180 / Math.PI;
8304
+ let basePitch = Math.atan2(STAGE_BASE_M - extraEyeM, distM) * 180 / Math.PI;
8305
+ if (basePitch < -MAX_LOOKDOWN_DEG) {
8306
+ topPitch += -MAX_LOOKDOWN_DEG - basePitch;
8307
+ basePitch = -MAX_LOOKDOWN_DEG;
8308
+ }
8309
+ return { topPitch, basePitch };
8310
+ }
8311
+
6524
8312
  // src/view/generatePanorama.ts
6525
8313
  var W = 2048;
6526
8314
  var H = 1024;
6527
- var UNIT = 0.55 / 24;
8315
+ var UNIT = METRES_PER_CHART_UNIT;
6528
8316
  var yawToX = (yawDeg) => (yawDeg + 180) / 360 * W;
6529
8317
  var pitchToY = (pitchDeg) => (90 - pitchDeg) / 180 * H;
6530
8318
  function generateSeatPanorama(seat, focalPoint, neighborSeats) {
@@ -6544,9 +8332,11 @@ function generateSeatPanorama(seat, focalPoint, neighborSeats) {
6544
8332
  sky.addColorStop(1, "#07090f");
6545
8333
  ctx.fillStyle = sky;
6546
8334
  ctx.fillRect(0, 0, W, H);
8335
+ const eyeM = seat.eyeHeightM ?? SEATED_EYE_HEIGHT_M;
6547
8336
  const stageHalfYaw = Math.min(80, Math.atan2(4, distM) * 180 / Math.PI);
6548
- const stageTopPitch = Math.min(45, Math.atan2(5, distM) * 180 / Math.PI);
6549
- const stageBasePitch = Math.max(-30, -Math.atan2(1.1, distM) * 180 / Math.PI);
8337
+ const sightline = stageSightlinePitch(eyeM, distM);
8338
+ const stageTopPitch = Math.min(45, sightline.topPitch);
8339
+ const stageBasePitch = sightline.basePitch;
6550
8340
  const sx0 = yawToX(-stageHalfYaw);
6551
8341
  const sx1 = yawToX(stageHalfYaw);
6552
8342
  const sy0 = pitchToY(stageTopPitch);
@@ -6653,9 +8443,11 @@ function generateSeatThumb(seat, focalPoint, _neighborSeats) {
6653
8443
  sky.addColorStop(1, "#070910");
6654
8444
  ctx.fillStyle = sky;
6655
8445
  ctx.fillRect(0, 0, TW, TH);
8446
+ const eyeM = seat.eyeHeightM ?? SEATED_EYE_HEIGHT_M;
8447
+ const sightline = stageSightlinePitch(eyeM, distM);
6656
8448
  const stageHalfYaw = Math.min(HALF_HFOV - 2, Math.atan2(4, distM) * 180 / Math.PI);
6657
- const stageTopPitch = Math.min(HALF_VFOV - 2, Math.atan2(5, distM) * 180 / Math.PI);
6658
- const stageBasePitch = Math.max(-HALF_VFOV + 2, -Math.atan2(1.1, distM) * 180 / Math.PI);
8449
+ const stageTopPitch = Math.min(HALF_VFOV - 2, sightline.topPitch);
8450
+ const stageBasePitch = Math.max(-HALF_VFOV + 2, sightline.basePitch);
6659
8451
  const sx0 = yawToTX(-stageHalfYaw);
6660
8452
  const sx1 = yawToTX(stageHalfYaw);
6661
8453
  const sy0 = pitchToTY(stageTopPitch);
@@ -6753,7 +8545,9 @@ async function loadLocale(code) {
6753
8545
  PickerController,
6754
8546
  RENDERED_QUALITY_REPORT_VERSION,
6755
8547
  SUPPORTED_LOCALES,
8548
+ SURROUNDINGS_SHAPE_ROLES,
6756
8549
  SeatmapRenderer,
8550
+ TIER_HEIGHT_M,
6757
8551
  UNGROUPED_ID,
6758
8552
  accessibilityMeta,
6759
8553
  accessibilityRingColor,
@@ -6768,16 +8562,19 @@ async function loadLocale(code) {
6768
8562
  expandRow,
6769
8563
  expandRowSlots,
6770
8564
  expandTable,
8565
+ expandTableSlots,
6771
8566
  floorObjects,
6772
8567
  floorsOf,
6773
8568
  formatDate,
6774
8569
  formatMoney,
6775
8570
  gaAreasOf,
8571
+ gaInventorySegments,
6776
8572
  gaUnitLabel,
6777
8573
  gaUnitLabels,
6778
8574
  generateSeatPanorama,
6779
8575
  generateSeatThumb,
6780
8576
  getLocale,
8577
+ growJoinedGAInventory,
6781
8578
  hiddenObjectIds,
6782
8579
  inspectRenderedQualityEvidence,
6783
8580
  isGaUnitLabel,
@@ -6785,17 +8582,23 @@ async function loadLocale(code) {
6785
8582
  layerOf,
6786
8583
  loadLocale,
6787
8584
  objectCenter,
8585
+ owningSectionForObject,
6788
8586
  pointInPolygon,
6789
8587
  pointInPolygonWithHoles,
6790
8588
  polygonLabelPoint,
6791
8589
  resolveLocale,
8590
+ rowInventoryCount,
6792
8591
  rowSeatPositions,
6793
8592
  seatLabelPart,
8593
+ sectionGeometry,
6794
8594
  setLocale,
6795
8595
  setMoneyLocale,
6796
8596
  setStringOverrides,
6797
8597
  stackFloors,
6798
8598
  t,
6799
- tCount
8599
+ tCount,
8600
+ tableInventoryCount,
8601
+ tableSeatCountsBySide,
8602
+ validGAInventorySegments
6800
8603
  });
6801
8604
  //# sourceMappingURL=index.cjs.map