@seatlayer/core 0.24.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
@@ -65,6 +65,7 @@ var init_es = __esm({
65
65
  "picker.seatsLeftInSection.one": "{count} asiento disponible",
66
66
  "picker.seatsLeftInSection.other": "{count} asientos disponibles",
67
67
  "picker.overview": "Descripci\xF3n general",
68
+ "picker.entrance": "Entrada",
68
69
  "picker.tapSeatHint": "Toca cualquier asiento para ver su vista",
69
70
  "picker.ticketTierFor": "Categor\xEDa de entrada para {label}",
70
71
  "picker.viewFromSeat": "Vista desde el asiento {label}",
@@ -123,6 +124,7 @@ var init_de = __esm({
123
124
  "picker.seatsLeftInSection.one": "{count} Platz verf\xFCgbar",
124
125
  "picker.seatsLeftInSection.other": "{count} Pl\xE4tze verf\xFCgbar",
125
126
  "picker.overview": "\xDCbersicht",
127
+ "picker.entrance": "Eingang",
126
128
  "picker.tapSeatHint": "Tippen Sie auf einen Platz, um die Ansicht zu pr\xFCfen",
127
129
  "picker.ticketTierFor": "Ticketklasse f\xFCr {label}",
128
130
  "picker.viewFromSeat": "Ansicht von Platz {label}",
@@ -181,6 +183,7 @@ var init_fr = __esm({
181
183
  "picker.seatsLeftInSection.one": "{count} si\xE8ge restant",
182
184
  "picker.seatsLeftInSection.other": "{count} si\xE8ges restants",
183
185
  "picker.overview": "Vue d'ensemble",
186
+ "picker.entrance": "Entr\xE9e",
184
187
  "picker.tapSeatHint": "Appuyez sur un si\xE8ge pour v\xE9rifier sa vue",
185
188
  "picker.ticketTierFor": "Cat\xE9gorie de billet pour {label}",
186
189
  "picker.viewFromSeat": "Vue depuis le si\xE8ge {label}",
@@ -211,8 +214,11 @@ __export(index_exports, {
211
214
  MAX_EVENT_INVENTORY: () => MAX_EVENT_INVENTORY,
212
215
  MAX_GA_CAPACITY: () => MAX_GA_CAPACITY,
213
216
  PickerController: () => PickerController,
217
+ RENDERED_QUALITY_REPORT_VERSION: () => RENDERED_QUALITY_REPORT_VERSION,
214
218
  SUPPORTED_LOCALES: () => SUPPORTED_LOCALES,
219
+ SURROUNDINGS_SHAPE_ROLES: () => SURROUNDINGS_SHAPE_ROLES,
215
220
  SeatmapRenderer: () => SeatmapRenderer,
221
+ TIER_HEIGHT_M: () => TIER_HEIGHT_M,
216
222
  UNGROUPED_ID: () => UNGROUPED_ID,
217
223
  accessibilityMeta: () => accessibilityMeta,
218
224
  accessibilityRingColor: () => accessibilityRingColor,
@@ -227,34 +233,44 @@ __export(index_exports, {
227
233
  expandRow: () => expandRow,
228
234
  expandRowSlots: () => expandRowSlots,
229
235
  expandTable: () => expandTable,
236
+ expandTableSlots: () => expandTableSlots,
230
237
  floorObjects: () => floorObjects,
231
238
  floorsOf: () => floorsOf,
232
239
  formatDate: () => formatDate,
233
240
  formatMoney: () => formatMoney,
234
241
  gaAreasOf: () => gaAreasOf,
242
+ gaInventorySegments: () => gaInventorySegments,
235
243
  gaUnitLabel: () => gaUnitLabel,
236
244
  gaUnitLabels: () => gaUnitLabels,
237
245
  generateSeatPanorama: () => generateSeatPanorama,
238
246
  generateSeatThumb: () => generateSeatThumb,
239
247
  getLocale: () => getLocale,
248
+ growJoinedGAInventory: () => growJoinedGAInventory,
240
249
  hiddenObjectIds: () => hiddenObjectIds,
250
+ inspectRenderedQualityEvidence: () => inspectRenderedQualityEvidence,
241
251
  isGaUnitLabel: () => isGaUnitLabel,
242
252
  isSectionHidden: () => isSectionHidden,
243
253
  layerOf: () => layerOf,
244
254
  loadLocale: () => loadLocale,
245
255
  objectCenter: () => objectCenter,
256
+ owningSectionForObject: () => owningSectionForObject,
246
257
  pointInPolygon: () => pointInPolygon,
247
258
  pointInPolygonWithHoles: () => pointInPolygonWithHoles,
248
259
  polygonLabelPoint: () => polygonLabelPoint,
249
260
  resolveLocale: () => resolveLocale,
261
+ rowInventoryCount: () => rowInventoryCount,
250
262
  rowSeatPositions: () => rowSeatPositions,
251
263
  seatLabelPart: () => seatLabelPart,
264
+ sectionGeometry: () => sectionGeometry,
252
265
  setLocale: () => setLocale,
253
266
  setMoneyLocale: () => setMoneyLocale,
254
267
  setStringOverrides: () => setStringOverrides,
255
268
  stackFloors: () => stackFloors,
256
269
  t: () => t,
257
- tCount: () => tCount
270
+ tCount: () => tCount,
271
+ tableInventoryCount: () => tableInventoryCount,
272
+ tableSeatCountsBySide: () => tableSeatCountsBySide,
273
+ validGAInventorySegments: () => validGAInventorySegments
258
274
  });
259
275
  module.exports = __toCommonJS(index_exports);
260
276
 
@@ -264,6 +280,7 @@ var ACCESSIBILITY_TYPES = [
264
280
  { key: "companion", label: "Companion seat", short: "Companion", icon: "\u{1F9D1}\u200D\u{1F91D}\u200D\u{1F9D1}" },
265
281
  { key: "semi-ambulatory", label: "Semi-ambulatory (limited mobility)", short: "Limited mobility", icon: "\u{1F9AF}" },
266
282
  { key: "hearing", label: "Assistive listening", short: "Hearing", icon: "\u{1F9BB}" },
283
+ { key: "cart", label: "CART live-caption view", short: "CART captions", icon: "CC" },
267
284
  { key: "sign-language", label: "Sign-language view", short: "Sign language", icon: "\u{1F91F}" },
268
285
  { key: "plus-size", label: "Plus-size seat", short: "Plus-size", icon: "\u{1F4BA}" },
269
286
  { key: "lift-armrest", label: "Lift-up armrest", short: "Lift armrest", icon: "\u2195\uFE0F" }
@@ -277,6 +294,7 @@ var ACCESSIBILITY_RING_COLOR = {
277
294
  companion: "#8b5cf6",
278
295
  "semi-ambulatory": "#0ea5e9",
279
296
  hearing: "#14b8a6",
297
+ cart: "#7c3aed",
280
298
  "sign-language": "#f59e0b",
281
299
  "plus-size": "#ec4899",
282
300
  "lift-armrest": "#22c55e"
@@ -287,6 +305,19 @@ function accessibilityRingColor(types) {
287
305
  }
288
306
  var LABEL_STYLE_MIN_SIZE = 8;
289
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);
290
321
  function layerOf(obj) {
291
322
  switch (obj.type) {
292
323
  case "row":
@@ -295,10 +326,17 @@ function layerOf(obj) {
295
326
  case "booth":
296
327
  case "section":
297
328
  return "interactive";
298
- // 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.
299
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.
300
339
  case "text":
301
- case "decorImage":
302
340
  return "background";
303
341
  default:
304
342
  return "interactive";
@@ -418,10 +456,57 @@ function toRoman(value) {
418
456
  return out;
419
457
  }
420
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
+
421
503
  // src/core/layout.ts
422
504
  function overrideAccessibility(o) {
423
505
  if (!o) return [];
424
- 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"];
425
510
  return o.accessible ? ["wheelchair"] : [];
426
511
  }
427
512
  var DEG = Math.PI / 180;
@@ -463,6 +548,10 @@ function overrideMap(row) {
463
548
  if (row.overrides) for (const o of row.overrides) m.set(o.index, o);
464
549
  return m;
465
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
+ }
466
555
  function centerRank(n) {
467
556
  const rank = new Array(n);
468
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);
@@ -476,9 +565,9 @@ function seatLabelPart(row, i) {
476
565
  const prefix = row.seatNumbering?.prefix ?? "";
477
566
  const endAt = row.seatNumbering?.endAt;
478
567
  const n = row.seatCount;
479
- if (scheme === "updown") {
568
+ if (scheme === "updown" || scheme === "updown-descending") {
480
569
  const half = Math.ceil(n / 2);
481
- 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);
482
571
  return `${prefix}${core2}`;
483
572
  }
484
573
  const effStep = scheme === "odd" || scheme === "even" ? 2 : step;
@@ -531,6 +620,7 @@ function expandRowSlots(row) {
531
620
  skipped: !!o?.skip,
532
621
  accessible: accessibility.length > 0,
533
622
  accessibility,
623
+ wheelchairSpaceType: o?.wheelchairSpaceType,
534
624
  commercial: Object.values(commercial).some((value) => value !== void 0 && value !== false && value !== "") ? commercial : void 0,
535
625
  viewUrl: o?.viewFromSeatUrl ?? row.viewFromSeatUrl,
536
626
  labelStyle: o?.labelStyle
@@ -551,6 +641,7 @@ function expandRow(row) {
551
641
  categoryKey: slot.categoryKey,
552
642
  accessible: slot.accessible || void 0,
553
643
  accessibility: slot.accessibility.length ? slot.accessibility : void 0,
644
+ wheelchairSpaceType: slot.wheelchairSpaceType,
554
645
  commercial: slot.commercial,
555
646
  viewUrl: slot.viewUrl,
556
647
  labelStyle: slot.labelStyle
@@ -558,18 +649,43 @@ function expandRow(row) {
558
649
  }
559
650
  return seats;
560
651
  }
561
- 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) {
562
663
  const seats = [];
563
664
  const n = Math.max(0, Math.round(t2.seatCount));
564
665
  if (n === 0) return seats;
565
- const mk = (i, x, y) => ({
566
- id: `${t2.id}:${i}`,
567
- label: `${t2.label}-${i + 1}`,
568
- x,
569
- y,
570
- rowId: t2.id,
571
- categoryKey: t2.categoryKey
572
- });
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
+ };
573
689
  if (t2.shape === "round") {
574
690
  const R = (t2.radius ?? 40) + TABLE_SEAT_OFFSET;
575
691
  const base = t2.rotation * DEG;
@@ -592,17 +708,11 @@ function expandTable(t2) {
592
708
  }
593
709
  const w = t2.width ?? 80;
594
710
  const h = t2.height ?? 50;
595
- const enabled = t2.sides && t2.sides.length ? t2.sides : ["top", "bottom"];
596
- const order = ["top", "bottom", "left", "right"].filter((s) => enabled.includes(s));
597
- if (!order.length) return seats;
598
- const counts = new Map(order.map((s) => [s, 0]));
599
- for (let i = 0; i < n; i++) {
600
- const s = order[i % order.length];
601
- counts.set(s, counts.get(s) + 1);
602
- }
711
+ const counts = tableSeatCountsBySide(t2);
712
+ const order = ["top", "bottom", "left", "right"];
603
713
  let idx = 0;
604
714
  for (const side of order) {
605
- const count = counts.get(side);
715
+ const count = counts[side];
606
716
  for (let j = 0; j < count; j++) {
607
717
  let localX;
608
718
  let localY;
@@ -619,17 +729,39 @@ function expandTable(t2) {
619
729
  localX = w / 2 + TABLE_SEAT_OFFSET;
620
730
  localY = -h / 2 + (j + 0.5) * h / count;
621
731
  }
622
- const p = place(localX, localY, t2.rotation, t2.center);
623
- 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));
624
734
  }
625
735
  }
626
736
  return seats;
627
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
+ }
628
759
  function expandBooth(b) {
629
760
  return [
630
761
  {
631
762
  id: `${b.id}:0`,
632
763
  label: b.label,
764
+ ...b.displayLabel && b.displayLabel !== b.label ? { displayLabel: b.displayLabel } : {},
633
765
  x: b.center.x,
634
766
  y: b.center.y,
635
767
  rowId: b.id,
@@ -667,7 +799,7 @@ function polygonLabelPoint(outer, holes) {
667
799
  if (!outer.length) return { x: 0, y: 0 };
668
800
  const xs = outer.map((point) => point.x);
669
801
  const ys = outer.map((point) => point.y);
670
- 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) };
671
803
  const centroid2 = polygonCentroid(outer);
672
804
  if (pointInPolygonWithHoles(centroid2, outer, holes)) return centroid2;
673
805
  let best = outer[0];
@@ -676,8 +808,8 @@ function polygonLabelPoint(outer, holes) {
676
808
  for (let row = 1; row < 24; row += 1) {
677
809
  for (let column = 1; column < 24; column += 1) {
678
810
  const point = {
679
- x: bounds.minX + (bounds.maxX - bounds.minX) * column / 24,
680
- 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
681
813
  };
682
814
  if (!pointInPolygonWithHoles(point, outer, holes)) continue;
683
815
  const score = Math.min(...rings.flatMap((ring) => ring.map((start, index) => {
@@ -739,9 +871,32 @@ function objectCenter(o) {
739
871
  return { x: o.x + o.width / 2, y: o.y + o.height / 2 };
740
872
  }
741
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
+ }
742
890
  function floorsOf(doc) {
743
891
  if (doc.floors && doc.floors.length) return doc.floors;
744
- 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
+ }];
745
900
  }
746
901
  function floorObjects(doc, floorId) {
747
902
  const floors = floorsOf(doc);
@@ -790,15 +945,138 @@ function stackFloors(doc, spread = 900) {
790
945
  });
791
946
  return { ...doc, objects, floors: void 0 };
792
947
  }
793
- function expandChart(doc) {
948
+ function expandFloorObjects(objects, zones, fallbackFocal) {
794
949
  const out = [];
795
- for (const obj of allObjects(doc)) {
796
- if (obj.type === "row") out.push(...expandRow(obj));
797
- else if (obj.type === "table") out.push(...expandTable(obj));
798
- 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);
799
1023
  }
800
1024
  return out;
801
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
+ }
802
1080
  var PAD = 40;
803
1081
  function chartBounds(doc) {
804
1082
  let minX = Infinity;
@@ -842,8 +1120,9 @@ function chartBounds(doc) {
842
1120
  acc(obj.position.x + w, obj.position.y + obj.fontSize);
843
1121
  }
844
1122
  }
845
- if (doc.backgroundImage) {
846
- const { center, width } = doc.backgroundImage;
1123
+ for (const image of [doc.referenceImage, doc.backgroundImage]) {
1124
+ if (!image) continue;
1125
+ const { center, width } = image;
847
1126
  const bh = width * 3 / 4;
848
1127
  acc(center.x - width / 2, center.y - bh / 2);
849
1128
  acc(center.x + width / 2, center.y + bh / 2);
@@ -867,9 +1146,55 @@ var MAX_EVENT_INVENTORY = 1e5;
867
1146
  function gaUnitLabel(areaId, index) {
868
1147
  return `${PREFIX}${encodeURIComponent(areaId)}__${index + 1}`;
869
1148
  }
870
- 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) {
871
1168
  const capacity = Math.min(MAX_GA_CAPACITY, Math.max(0, Math.floor(area.capacity)));
872
- 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;
873
1198
  }
874
1199
  function gaAreasOf(doc) {
875
1200
  return allObjects(doc).filter((o) => o.type === "gaArea");
@@ -890,15 +1215,6 @@ function objectSeatLabels(o) {
890
1215
  function isSeatObject(o) {
891
1216
  return o.type === "row" || o.type === "table" || o.type === "booth" || o.type === "gaArea";
892
1217
  }
893
- function samePoints(left, right) {
894
- return left.length === right.length && left.every((point, index) => point.x === right[index].x && point.y === right[index].y);
895
- }
896
- function sameGASurfaceAsSection(object, section) {
897
- if (object.type !== "gaArea" || !samePoints(object.points, section.outline)) return false;
898
- const objectHoles = object.holes ?? [];
899
- const sectionHoles = section.holes ?? [];
900
- return objectHoles.length === sectionHoles.length && objectHoles.every((hole, index) => samePoints(hole, sectionHoles[index]));
901
- }
902
1218
  function computeSections(doc) {
903
1219
  const objs = allObjects(doc);
904
1220
  const sectionObjs = objs.filter((o) => o.type === "section");
@@ -924,10 +1240,7 @@ function computeSections(doc) {
924
1240
  if (!isSeatObject(obj)) continue;
925
1241
  const labels = objectSeatLabels(obj);
926
1242
  if (labels.length === 0) continue;
927
- const referencedLogicalId = obj.referenceInventorySource?.logicalSectionId;
928
- const c = objectCenter(obj);
929
- const referencedOwner = referencedLogicalId ? sectionObjs.find((section) => (section.logicalSectionId ?? section.id) === referencedLogicalId && (sameGASurfaceAsSection(obj, section) || pointInPolygonWithHoles(c, section.outline, section.holes))) : void 0;
930
- const owner = referencedOwner ?? sectionObjs.find((s) => pointInPolygonWithHoles(c, s.outline, s.holes));
1243
+ const owner = owningSectionForObject(objs, obj);
931
1244
  const node = owner ? nodes.get(owner.logicalSectionId ?? owner.id) : ungrouped;
932
1245
  node.seatCount += labels.length;
933
1246
  node.objectIds.push(obj.id);
@@ -983,20 +1296,6 @@ function applyHidden(doc, hidden) {
983
1296
  return { ...doc, objects };
984
1297
  }
985
1298
 
986
- // src/engine/SeatmapRenderer.ts
987
- var import_Core = require("konva/lib/Core");
988
- var import_Stage = require("konva/lib/Stage");
989
- var import_Layer = require("konva/lib/Layer");
990
- var import_Group = require("konva/lib/Group");
991
- var import_Circle = require("konva/lib/shapes/Circle");
992
- var import_Rect = require("konva/lib/shapes/Rect");
993
- var import_Ellipse = require("konva/lib/shapes/Ellipse");
994
- var import_Line = require("konva/lib/shapes/Line");
995
- var import_Text = require("konva/lib/shapes/Text");
996
- var import_Path = require("konva/lib/shapes/Path");
997
- var import_Image = require("konva/lib/shapes/Image");
998
- var import_Shape = require("konva/lib/Shape");
999
-
1000
1299
  // src/core/chartRenderRules.ts
1001
1300
  var SEAT_LABEL_FONT_SIZE = 7;
1002
1301
  var BOOTH_LABEL_FONT_SIZE = 10;
@@ -1053,6 +1352,769 @@ function stateAwareBookableLabelInk(fill, preferred) {
1053
1352
  return darkContrast >= lightContrast ? DARK_BOOKABLE_LABEL_INK : LIGHT_BOOKABLE_LABEL_INK;
1054
1353
  }
1055
1354
 
1355
+ // src/core/renderedQuality.ts
1356
+ var RENDERED_QUALITY_REPORT_VERSION = 3;
1357
+ var MIN_TEXT_CONTRAST = 4.5;
1358
+ var MIN_GRAPHICAL_CONTRAST = 3;
1359
+ var MIN_POINTER_TARGET_PX = 24;
1360
+ var MAX_SAMPLES_PER_FINDING = 20;
1361
+ function visibleWithBox(items) {
1362
+ return items.filter((item) => item.visible && Boolean(item.screenBox));
1363
+ }
1364
+ function intersects(left, right) {
1365
+ return left.x < right.x + right.width && left.x + left.width > right.x && left.y < right.y + right.height && left.y + left.height > right.y;
1366
+ }
1367
+ function minimum(values) {
1368
+ return values.length ? Math.round(Math.min(...values) * 100) / 100 : null;
1369
+ }
1370
+ function finding(code, message, samples) {
1371
+ if (!samples.length) return null;
1372
+ return {
1373
+ code,
1374
+ message,
1375
+ count: samples.length,
1376
+ samples: samples.slice(0, MAX_SAMPLES_PER_FINDING)
1377
+ };
1378
+ }
1379
+ function labelSample(label, measured, minimumValue) {
1380
+ return {
1381
+ primaryId: label.seatId,
1382
+ primaryLabel: label.label,
1383
+ ...measured == null ? {} : { measured: Math.round(measured * 100) / 100 },
1384
+ ...minimumValue == null ? {} : { minimum: minimumValue }
1385
+ };
1386
+ }
1387
+ function hierarchySample(label, measured, minimumValue) {
1388
+ return {
1389
+ primaryId: label.id,
1390
+ primaryLabel: label.label,
1391
+ ...measured == null ? {} : { measured: Math.round(measured * 100) / 100 },
1392
+ ...minimumValue == null ? {} : { minimum: minimumValue }
1393
+ };
1394
+ }
1395
+ function freeTextSample(label, measured, minimumValue) {
1396
+ return {
1397
+ primaryId: label.objectId,
1398
+ primaryLabel: label.text,
1399
+ ...measured == null ? {} : { measured: Math.round(measured * 100) / 100 },
1400
+ ...minimumValue == null ? {} : { minimum: minimumValue }
1401
+ };
1402
+ }
1403
+ function collisionSample(primaryId, primaryLabel, secondaryId, secondaryLabel) {
1404
+ return { primaryId, primaryLabel, secondaryId, secondaryLabel };
1405
+ }
1406
+ function inspectRenderedQualityEvidence(evidence, state = "overview", targetCategoryKey = null) {
1407
+ const bookable = visibleWithBox(evidence.labels);
1408
+ const hierarchy = visibleWithBox(evidence.hierarchyLabels);
1409
+ const freeText = visibleWithBox(evidence.freeTextLabels);
1410
+ const visibleGA = evidence.gaAreas.filter((area) => area.visible);
1411
+ const bookableContrast = bookable.map((label) => renderedTextContrast(label.ink, label.fill) ?? 0);
1412
+ const hierarchyContrast = hierarchy.map((label) => renderedTextContrast(label.ink, label.fill) ?? 0);
1413
+ const freeTextContrast = freeText.map((label) => renderedTextContrast(label.ink, label.background) ?? 0);
1414
+ const gaContrast = visibleGA.map((area) => renderedTextContrast(area.effectiveBackground, evidence.canvasBackground) ?? 0);
1415
+ const targetLabels = targetCategoryKey ? evidence.labels.filter((label) => label.categoryKey === targetCategoryKey) : evidence.labels;
1416
+ const targetGAAreas = targetCategoryKey ? evidence.gaAreas.filter((area) => area.categoryKey === targetCategoryKey) : evidence.gaAreas;
1417
+ const selected = targetLabels.filter((label) => label.selected);
1418
+ const held = targetLabels.filter((label) => label.status === "held");
1419
+ const booked = targetLabels.filter((label) => label.status === "booked");
1420
+ const activePointerTargets = targetLabels.filter((label) => label.pointerTarget.active);
1421
+ const activeCategoryKeys = /* @__PURE__ */ new Set([
1422
+ ...evidence.labels.filter((label) => label.visible && (label.opacity > 0.25 || label.selected || label.status !== "free")).map((label) => label.categoryKey),
1423
+ ...evidence.gaAreas.filter((area) => area.visible && area.opacity > 0.1).map((area) => area.categoryKey)
1424
+ ]);
1425
+ const selectedRingContrast = selected.length ? minimum(selected.flatMap((label) => [
1426
+ renderedTextContrast(evidence.selectionRingColor, evidence.canvasBackground) ?? 0,
1427
+ renderedTextContrast(evidence.selectionRingColor, label.fill) ?? 0
1428
+ ])) : null;
1429
+ const findings = [];
1430
+ const overviewCountSamples = (count, label) => Array.from({ length: count }, (_, index) => ({
1431
+ primaryId: `overview:${index + 1}`,
1432
+ primaryLabel: label
1433
+ }));
1434
+ if (state === "overview" && evidence.rung === "sections") {
1435
+ findings.push(finding(
1436
+ "overview-section-category-paint",
1437
+ "Section overview shells must use the neutral hierarchy palette, not category paint",
1438
+ overviewCountSamples(evidence.overviewStyle.categoryPaintedSectionShells, "category-painted section shell")
1439
+ ));
1440
+ findings.push(finding(
1441
+ "overview-category-detail-visible",
1442
+ "Category-tinted section detail must wait until section focus or seat zoom",
1443
+ overviewCountSamples(evidence.overviewStyle.visibleCategoryDetailOutlines, "category detail outline")
1444
+ ));
1445
+ findings.push(finding(
1446
+ "overview-row-hints-visible",
1447
+ "Row and seat patterns must not clutter the section overview",
1448
+ overviewCountSamples(evidence.overviewStyle.visibleSectionRowHints, "section row hint")
1449
+ ));
1450
+ findings.push(finding(
1451
+ "overview-availability-clutter",
1452
+ "Live availability counts belong in focused detail, not on section overview shells",
1453
+ overviewCountSamples(evidence.overviewStyle.visibleSectionAvailabilityLabels, "section availability label")
1454
+ ));
1455
+ findings.push(finding(
1456
+ "overview-ga-detail-visible",
1457
+ "Section-contained standing paint belongs in focused detail, not on section overview shells",
1458
+ overviewCountSamples(evidence.overviewStyle.visibleSectionGADetails, "section-contained GA detail")
1459
+ ));
1460
+ }
1461
+ findings.push(finding(
1462
+ "bookable-label-undersized",
1463
+ "Visible bookable labels must meet the rendered small-text size floor",
1464
+ bookable.filter((label) => label.renderedFontPx < evidence.minimumVisibleLabelPx).map((label) => labelSample(label, label.renderedFontPx, evidence.minimumVisibleLabelPx))
1465
+ ));
1466
+ if (state === "interaction") {
1467
+ const labelledInventory = targetLabels.length > 0;
1468
+ const gaInventory = targetGAAreas.length > 0;
1469
+ const detailInventory = labelledInventory || gaInventory;
1470
+ const visibleTargetGA = targetGAAreas.filter((area) => area.visible);
1471
+ const sections = /* @__PURE__ */ new Set([
1472
+ ...targetLabels.flatMap((label) => label.sectionId ? [label.sectionId] : []),
1473
+ ...targetGAAreas.flatMap((area) => area.sectionId ? [area.sectionId] : [])
1474
+ ]);
1475
+ const categories = /* @__PURE__ */ new Set([
1476
+ ...evidence.labels.map((label) => label.categoryKey),
1477
+ ...evidence.gaAreas.map((area) => area.categoryKey)
1478
+ ]);
1479
+ const inViewport = (label) => label.screenCenter.x >= 0 && label.screenCenter.x <= evidence.viewport.width && label.screenCenter.y >= 0 && label.screenCenter.y <= evidence.viewport.height;
1480
+ findings.push(finding(
1481
+ "detail-rung-missing",
1482
+ "Interaction evidence with bookable inventory must render the seat-detail rung",
1483
+ detailInventory && evidence.rung !== "seats" ? [{ primaryId: "renderer", primaryLabel: evidence.rung }] : []
1484
+ ));
1485
+ findings.push(finding(
1486
+ "detail-inventory-not-visible",
1487
+ "Interaction evidence must frame at least one real bookable unit",
1488
+ detailInventory && !targetLabels.some(inViewport) && !visibleTargetGA.length ? [{ primaryId: "renderer", primaryLabel: "no target inventory in viewport" }] : []
1489
+ ));
1490
+ findings.push(finding(
1491
+ "pointer-target-inactive",
1492
+ "Interaction evidence must expose a live production pointer target",
1493
+ labelledInventory && !activePointerTargets.some(inViewport) || !labelledInventory && gaInventory && !visibleTargetGA.some((area) => area.interactive) ? [{ primaryId: "renderer", primaryLabel: "no active pointer target in viewport" }] : []
1494
+ ));
1495
+ findings.push(finding(
1496
+ "pointer-target-undersized",
1497
+ "Every active production pointer target must reach at least 24 CSS pixels",
1498
+ activePointerTargets.filter((label) => inViewport(label) && label.pointerTarget.effectiveMinimumPx < MIN_POINTER_TARGET_PX).map((label) => labelSample(label, label.pointerTarget.effectiveMinimumPx, MIN_POINTER_TARGET_PX))
1499
+ ));
1500
+ findings.push(finding(
1501
+ "selected-state-missing",
1502
+ "Interaction evidence must paint a selected unit and its renderer-owned ring",
1503
+ labelledInventory && (!selected.length || !selected.some((label) => evidence.selectionRingSeatIds.includes(label.seatId))) ? [{ primaryId: "renderer", primaryLabel: "selected state" }] : []
1504
+ ));
1505
+ findings.push(finding(
1506
+ "selected-state-contrast-low",
1507
+ "The selected-state ring must maintain 3:1 graphical contrast with the canvas",
1508
+ selected.length && (selectedRingContrast ?? 0) < MIN_GRAPHICAL_CONTRAST ? [{
1509
+ primaryId: selected[0].seatId,
1510
+ primaryLabel: selected[0].label,
1511
+ measured: Math.round((selectedRingContrast ?? 0) * 100) / 100,
1512
+ minimum: MIN_GRAPHICAL_CONTRAST
1513
+ }] : []
1514
+ ));
1515
+ findings.push(finding(
1516
+ "held-state-missing",
1517
+ "Interaction evidence must paint a held state when the floor has at least two status-managed units",
1518
+ targetLabels.length >= 2 && !held.length ? [{ primaryId: "renderer", primaryLabel: "held state" }] : []
1519
+ ));
1520
+ findings.push(finding(
1521
+ "booked-state-missing",
1522
+ "Interaction evidence must paint a taken state when the floor has at least three status-managed units",
1523
+ targetLabels.length >= 3 && !booked.length ? [{ primaryId: "renderer", primaryLabel: "booked state" }] : []
1524
+ ));
1525
+ const heldSignatures = new Set(held.map((label) => `${label.fill.toLowerCase()}:${label.opacity}`));
1526
+ const bookedSignatures = new Set(booked.map((label) => `${label.fill.toLowerCase()}:${label.opacity}`));
1527
+ findings.push(finding(
1528
+ "status-state-indistinct",
1529
+ "Held and taken evidence must resolve to distinct renderer paint",
1530
+ held.length && booked.length && [...heldSignatures].some((signature) => bookedSignatures.has(signature)) ? [{ primaryId: held[0].seatId, primaryLabel: held[0].label, secondaryId: booked[0].seatId, secondaryLabel: booked[0].label }] : []
1531
+ ));
1532
+ findings.push(finding(
1533
+ "section-focus-missing",
1534
+ "Interaction evidence must exercise section focus and its backdrop when section membership exists",
1535
+ sections.size && (!evidence.focusedSectionId || !evidence.focusBackdropVisible) ? [{ primaryId: "renderer", primaryLabel: "section focus" }] : []
1536
+ ));
1537
+ findings.push(finding(
1538
+ "category-filter-missing",
1539
+ "Interaction evidence must exercise a category filter when multiple categories exist",
1540
+ categories.size >= 2 && (!evidence.categoryFilterKeys || !evidence.categoryFilterKeys.length) ? [{ primaryId: "renderer", primaryLabel: "category filter" }] : []
1541
+ ));
1542
+ const excludedFree = evidence.labels.filter((label) => label.status === "free" && !label.selected && evidence.categoryFilterKeys != null && !evidence.categoryFilterKeys.includes(label.categoryKey));
1543
+ const excludedGA = evidence.gaAreas.filter((area) => evidence.categoryFilterKeys != null && !evidence.categoryFilterKeys.includes(area.categoryKey));
1544
+ findings.push(finding(
1545
+ "category-filter-ineffective",
1546
+ "The active category filter must visibly dim excluded free inventory",
1547
+ (excludedFree.length || excludedGA.length) && !excludedFree.some((label) => label.opacity <= 0.25) && !excludedGA.some((area) => area.opacity <= 0.1) ? [
1548
+ ...excludedFree.map((label) => labelSample(label, label.opacity)),
1549
+ ...excludedGA.map((area) => ({ primaryId: area.areaId, primaryLabel: area.label, measured: area.opacity }))
1550
+ ] : []
1551
+ ));
1552
+ findings.push(finding(
1553
+ "target-category-not-visible",
1554
+ "A category-specific interaction scene must visibly paint its exact target category",
1555
+ targetCategoryKey && !activeCategoryKeys.has(targetCategoryKey) ? [{ primaryId: targetCategoryKey, primaryLabel: targetCategoryKey }] : []
1556
+ ));
1557
+ findings.push(finding(
1558
+ "target-category-filter-mismatch",
1559
+ "A category-specific interaction scene must bind its filter to only the exact target category",
1560
+ targetCategoryKey && (evidence.categoryFilterKeys?.length !== 1 || evidence.categoryFilterKeys[0] !== targetCategoryKey) ? [{ primaryId: targetCategoryKey, primaryLabel: targetCategoryKey }] : []
1561
+ ));
1562
+ }
1563
+ findings.push(finding(
1564
+ "bookable-label-contrast-low",
1565
+ "Visible bookable labels must meet 4.5:1 contrast against their actual paint",
1566
+ bookable.flatMap((label) => {
1567
+ const ratio = renderedTextContrast(label.ink, label.fill) ?? 0;
1568
+ return ratio < MIN_TEXT_CONTRAST ? [labelSample(label, ratio, MIN_TEXT_CONTRAST)] : [];
1569
+ })
1570
+ ));
1571
+ const bookableCollisions = [];
1572
+ for (let index = 0; index < bookable.length; index += 1) {
1573
+ for (let other = 0; other < index; other += 1) {
1574
+ if (intersects(bookable[index].screenBox, bookable[other].screenBox)) {
1575
+ bookableCollisions.push(collisionSample(
1576
+ bookable[other].seatId,
1577
+ bookable[other].label,
1578
+ bookable[index].seatId,
1579
+ bookable[index].label
1580
+ ));
1581
+ }
1582
+ }
1583
+ }
1584
+ findings.push(finding(
1585
+ "bookable-label-collision",
1586
+ "Visible bookable labels must not overlap",
1587
+ bookableCollisions
1588
+ ));
1589
+ findings.push(finding(
1590
+ "hierarchy-label-undersized",
1591
+ "Visible section and zone labels must meet the rendered small-text size floor",
1592
+ hierarchy.filter((label) => label.renderedFontPx < MIN_VISIBLE_BOOKABLE_LABEL_PX).map((label) => hierarchySample(label, label.renderedFontPx, MIN_VISIBLE_BOOKABLE_LABEL_PX))
1593
+ ));
1594
+ findings.push(finding(
1595
+ "hierarchy-label-contrast-low",
1596
+ "Visible section and zone labels must meet 4.5:1 contrast against their backing paint",
1597
+ hierarchy.flatMap((label) => {
1598
+ const ratio = renderedTextContrast(label.ink, label.fill) ?? 0;
1599
+ return ratio < MIN_TEXT_CONTRAST ? [hierarchySample(label, ratio, MIN_TEXT_CONTRAST)] : [];
1600
+ })
1601
+ ));
1602
+ findings.push(finding(
1603
+ "hierarchy-label-outside-section",
1604
+ "Visible section labels must remain inside the filled section surface and outside holes",
1605
+ hierarchy.filter((label) => label.kind === "section" && label.fitsContainer === false).map((label) => hierarchySample(label))
1606
+ ));
1607
+ const hierarchyCollisions = [];
1608
+ for (let index = 0; index < hierarchy.length; index += 1) {
1609
+ for (let other = 0; other < index; other += 1) {
1610
+ if (intersects(hierarchy[index].screenBox, hierarchy[other].screenBox)) {
1611
+ hierarchyCollisions.push(collisionSample(
1612
+ hierarchy[other].id,
1613
+ hierarchy[other].label,
1614
+ hierarchy[index].id,
1615
+ hierarchy[index].label
1616
+ ));
1617
+ }
1618
+ }
1619
+ }
1620
+ findings.push(finding(
1621
+ "hierarchy-label-collision",
1622
+ "Visible hierarchy labels must not overlap each other",
1623
+ hierarchyCollisions
1624
+ ));
1625
+ const hierarchyBookableCollisions = [];
1626
+ for (const upper of hierarchy) {
1627
+ for (const unit of bookable) {
1628
+ if (intersects(upper.screenBox, unit.screenBox)) {
1629
+ hierarchyBookableCollisions.push(collisionSample(upper.id, upper.label, unit.seatId, unit.label));
1630
+ }
1631
+ }
1632
+ }
1633
+ findings.push(finding(
1634
+ "hierarchy-bookable-collision",
1635
+ "Visible hierarchy labels must not overlap bookable labels",
1636
+ hierarchyBookableCollisions
1637
+ ));
1638
+ findings.push(finding(
1639
+ "free-text-undersized",
1640
+ "Visible chart text must meet the rendered small-text size floor",
1641
+ freeText.filter((label) => label.renderedFontPx < evidence.minimumVisibleLabelPx).map((label) => freeTextSample(label, label.renderedFontPx, evidence.minimumVisibleLabelPx))
1642
+ ));
1643
+ findings.push(finding(
1644
+ "free-text-contrast-low",
1645
+ "Visible chart text must meet 4.5:1 contrast against its measured background",
1646
+ freeText.flatMap((label) => {
1647
+ const ratio = renderedTextContrast(label.ink, label.background) ?? 0;
1648
+ return ratio < MIN_TEXT_CONTRAST ? [freeTextSample(label, ratio, MIN_TEXT_CONTRAST)] : [];
1649
+ })
1650
+ ));
1651
+ const freeTextCollisions = [];
1652
+ for (let index = 0; index < freeText.length; index += 1) {
1653
+ for (let other = 0; other < index; other += 1) {
1654
+ if (intersects(freeText[index].screenBox, freeText[other].screenBox)) {
1655
+ freeTextCollisions.push(collisionSample(
1656
+ freeText[other].objectId,
1657
+ freeText[other].text,
1658
+ freeText[index].objectId,
1659
+ freeText[index].text
1660
+ ));
1661
+ }
1662
+ }
1663
+ }
1664
+ findings.push(finding(
1665
+ "free-text-collision",
1666
+ "Visible chart text labels must not overlap each other",
1667
+ freeTextCollisions
1668
+ ));
1669
+ const freeTextBookableCollisions = [];
1670
+ for (const text of freeText) {
1671
+ for (const unit of bookable) {
1672
+ if (intersects(text.screenBox, unit.screenBox)) {
1673
+ freeTextBookableCollisions.push(collisionSample(text.objectId, text.text, unit.seatId, unit.label));
1674
+ }
1675
+ }
1676
+ }
1677
+ findings.push(finding(
1678
+ "free-text-bookable-collision",
1679
+ "Visible chart text must not overlap bookable labels",
1680
+ freeTextBookableCollisions
1681
+ ));
1682
+ const freeTextHierarchyCollisions = [];
1683
+ for (const text of freeText) {
1684
+ for (const upper of hierarchy) {
1685
+ if (intersects(text.screenBox, upper.screenBox)) {
1686
+ freeTextHierarchyCollisions.push(collisionSample(text.objectId, text.text, upper.id, upper.label));
1687
+ }
1688
+ }
1689
+ }
1690
+ findings.push(finding(
1691
+ "free-text-hierarchy-collision",
1692
+ "Visible chart text must not overlap hierarchy labels",
1693
+ freeTextHierarchyCollisions
1694
+ ));
1695
+ findings.push(finding(
1696
+ "ga-contrast-low",
1697
+ "Visible GA surfaces must maintain 3:1 graphical contrast with the canvas",
1698
+ visibleGA.flatMap((area) => {
1699
+ const ratio = renderedTextContrast(area.effectiveBackground, evidence.canvasBackground) ?? 0;
1700
+ return ratio < MIN_GRAPHICAL_CONTRAST ? [{
1701
+ primaryId: area.areaId,
1702
+ primaryLabel: area.label,
1703
+ measured: Math.round(ratio * 100) / 100,
1704
+ minimum: MIN_GRAPHICAL_CONTRAST
1705
+ }] : [];
1706
+ })
1707
+ ));
1708
+ const materialFindings = findings.filter((item) => Boolean(item));
1709
+ return {
1710
+ version: RENDERED_QUALITY_REPORT_VERSION,
1711
+ passed: materialFindings.length === 0,
1712
+ state,
1713
+ targetCategoryKey,
1714
+ resolvedRules: [
1715
+ "rendered-overview-label-size",
1716
+ "rendered-overview-label-contrast",
1717
+ "rendered-overview-label-collision",
1718
+ "rendered-overview-hierarchy-containment",
1719
+ "rendered-overview-section-first-style",
1720
+ "rendered-overview-ga-contrast",
1721
+ ...state === "interaction" ? [
1722
+ "rendered-detail-inventory",
1723
+ "rendered-pointer-target",
1724
+ "rendered-selected-held-taken-states",
1725
+ "rendered-section-focus",
1726
+ "rendered-category-filter"
1727
+ ] : []
1728
+ ],
1729
+ viewport: evidence.viewport,
1730
+ canvasBackground: evidence.canvasBackground,
1731
+ effectiveScale: evidence.effectiveScale,
1732
+ rung: evidence.rung,
1733
+ inventory: {
1734
+ totalBookableUnits: evidence.totalBookableUnits,
1735
+ totalLabelledBookableUnits: evidence.totalLabelledBookableUnits,
1736
+ visibleBookableLabels: bookable.length,
1737
+ hiddenBookableLabels: evidence.hiddenLabels,
1738
+ visibleHierarchyLabels: hierarchy.length,
1739
+ visibleFreeTextLabels: freeText.length,
1740
+ visibleGAAreas: visibleGA.length
1741
+ },
1742
+ overviewStyle: evidence.overviewStyle,
1743
+ composition: {
1744
+ hierarchy: evidence.hierarchyLabels.filter((label) => label.role === "name").map((label) => ({
1745
+ id: label.id,
1746
+ kind: label.kind,
1747
+ label: label.label,
1748
+ visible: label.visible
1749
+ })).sort((left, right) => left.kind.localeCompare(right.kind) || left.id.localeCompare(right.id)),
1750
+ labelledObjects: evidence.freeTextLabels.map((label) => ({
1751
+ objectId: label.objectId,
1752
+ kind: label.kind,
1753
+ text: label.text,
1754
+ visible: label.visible
1755
+ })).sort((left, right) => left.objectId.localeCompare(right.objectId) || left.kind.localeCompare(right.kind)),
1756
+ gaAreas: evidence.gaAreas.map((area) => ({
1757
+ areaId: area.areaId,
1758
+ label: area.label,
1759
+ categoryKey: area.categoryKey,
1760
+ ...area.sectionId ? { sectionId: area.sectionId } : {},
1761
+ visible: area.visible
1762
+ })).sort((left, right) => left.areaId.localeCompare(right.areaId)),
1763
+ bookableSectionIds: [...new Set(evidence.labels.flatMap((label) => label.sectionId ? [label.sectionId] : []))].sort(),
1764
+ categoryKeys: [.../* @__PURE__ */ new Set([
1765
+ ...evidence.labels.map((label) => label.categoryKey),
1766
+ ...evidence.gaAreas.map((area) => area.categoryKey)
1767
+ ])].sort(),
1768
+ activeCategoryKeys: [...activeCategoryKeys].sort()
1769
+ },
1770
+ metrics: {
1771
+ minimumRenderedBookableLabelPx: minimum(bookable.map((label) => label.renderedFontPx)),
1772
+ minimumBookableLabelContrast: minimum(bookableContrast),
1773
+ minimumRenderedHierarchyLabelPx: minimum(hierarchy.map((label) => label.renderedFontPx)),
1774
+ minimumHierarchyLabelContrast: minimum(hierarchyContrast),
1775
+ minimumRenderedFreeTextPx: minimum(freeText.map((label) => label.renderedFontPx)),
1776
+ minimumFreeTextContrast: minimum(freeTextContrast),
1777
+ minimumGAContrast: minimum(gaContrast),
1778
+ minimumEffectivePointerTargetPx: minimum(activePointerTargets.map((label) => label.pointerTarget.effectiveMinimumPx)),
1779
+ selectedRingContrast: selectedRingContrast == null ? null : Math.round(selectedRingContrast * 100) / 100
1780
+ },
1781
+ interaction: {
1782
+ applicable: {
1783
+ detail: targetLabels.length > 0 || targetGAAreas.length > 0,
1784
+ pointer: targetLabels.length > 0 || targetGAAreas.length > 0,
1785
+ held: targetLabels.length >= 2,
1786
+ booked: targetLabels.length >= 3,
1787
+ sectionFocus: targetLabels.some((label) => Boolean(label.sectionId)) || targetGAAreas.some((area) => Boolean(area.sectionId)),
1788
+ categoryFilter: (/* @__PURE__ */ new Set([
1789
+ ...evidence.labels.map((label) => label.categoryKey),
1790
+ ...evidence.gaAreas.map((area) => area.categoryKey)
1791
+ ])).size >= 2
1792
+ },
1793
+ selectedUnits: selected.length,
1794
+ heldUnits: held.length,
1795
+ bookedUnits: booked.length,
1796
+ activePointerTargets: activePointerTargets.length,
1797
+ focusedSectionId: evidence.focusedSectionId,
1798
+ focusBackdropVisible: evidence.focusBackdropVisible,
1799
+ categoryFilterKeys: evidence.categoryFilterKeys
1800
+ },
1801
+ findings: materialFindings
1802
+ };
1803
+ }
1804
+
1805
+ // src/engine/SeatmapRenderer.ts
1806
+ var import_Core = require("konva/lib/Core");
1807
+ var import_Stage = require("konva/lib/Stage");
1808
+ var import_Layer = require("konva/lib/Layer");
1809
+ var import_Group = require("konva/lib/Group");
1810
+ var import_Circle = require("konva/lib/shapes/Circle");
1811
+ var import_Rect = require("konva/lib/shapes/Rect");
1812
+ var import_Ellipse = require("konva/lib/shapes/Ellipse");
1813
+ var import_Line = require("konva/lib/shapes/Line");
1814
+ var import_Arrow = require("konva/lib/shapes/Arrow");
1815
+ var import_Text = require("konva/lib/shapes/Text");
1816
+ var import_Path = require("konva/lib/shapes/Path");
1817
+ var import_Image = require("konva/lib/shapes/Image");
1818
+ var import_Shape = require("konva/lib/Shape");
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
+
1056
2118
  // src/lib/money.ts
1057
2119
  var DEFAULT_CURRENCY = "USD";
1058
2120
  var displayLocale;
@@ -1115,6 +2177,7 @@ var en = {
1115
2177
  "picker.seatsLeftInSection.one": "{count} seat left",
1116
2178
  "picker.seatsLeftInSection.other": "{count} seats left",
1117
2179
  "picker.overview": "Overview",
2180
+ "picker.entrance": "Entrance",
1118
2181
  "picker.tapSeatHint": "Tap any seat to check its view",
1119
2182
  "picker.ticketTierFor": "Ticket tier for {label}",
1120
2183
  "picker.viewFromSeat": "View from seat {label}",
@@ -1172,6 +2235,25 @@ function formatDate(value, opts) {
1172
2235
  return new Intl.DateTimeFormat(active, opts ?? { dateStyle: "medium", timeStyle: "short" }).format(value);
1173
2236
  }
1174
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
+
1175
2257
  // src/engine/SeatmapRenderer.ts
1176
2258
  var SEAT_RADIUS = 9;
1177
2259
  var SEAT_LEGIBLE_SCALE = 0.9;
@@ -1179,17 +2261,18 @@ var CACHE_THRESHOLD = 0.55 * SEAT_LEGIBLE_SCALE;
1179
2261
  var LABEL_SCALE = MIN_VISIBLE_BOOKABLE_LABEL_PX / SEAT_LABEL_FONT_SIZE;
1180
2262
  var MIN_FITTED_SEAT_LABEL_FONT_SIZE = 4;
1181
2263
  var SEAT_TAP_SLOP_PX = 14;
1182
- var SEAT_GLYPH_MIN_PX = 6.5;
2264
+ var WHEELCHAIR_GLYPH_MIN_PX = 10;
2265
+ var FILTERED_WHEELCHAIR_GLYPH_MIN_PX = 14;
1183
2266
  var SECTION_PROMINENT_SCALE = 0.45 * SEAT_LEGIBLE_SCALE;
1184
2267
  var BLOCK_MELT_TOP = 0.9 * SEAT_LEGIBLE_SCALE;
1185
2268
  var SEAT_FOCUS_SCALE = Math.max(SEAT_LEGIBLE_SCALE * 1.1, BLOCK_MELT_TOP);
1186
2269
  var PAN_START_SLOP_PX = 8;
2270
+ var GHOST_CLICK_MS = 700;
1187
2271
  var ZONE_PROMINENT_SCALE = 0.55 * SECTION_PROMINENT_SCALE;
1188
2272
  var MAX_LABELS = 700;
1189
2273
  var MARQUEE_RING_CAP = 2500;
1190
2274
  var ISO_ANGLE_DEG = -11.5;
1191
2275
  var ISO_SQUASH = 0.58;
1192
- var LIFT_PER_STEP = 58;
1193
2276
  var ISO_TWEEN_MS = 320;
1194
2277
  var CAMERA_GLIDE_MS = 650;
1195
2278
  var BLOCK_FILL_ALPHA = 1;
@@ -1226,6 +2309,10 @@ var DEF_SELECTION = "#ffffff";
1226
2309
  var DEF_SELECTION_ON_LIGHT = "#0b1220";
1227
2310
  var DEF_DECOR_FILL = "#232c40";
1228
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
+ }
1229
2316
  var DEF_CANVAS_BACKGROUND = "#0e1117";
1230
2317
  function colorLuminance(color) {
1231
2318
  const s = color.trim();
@@ -1331,8 +2418,8 @@ function rotatedRectPoints(center, width, height, rotation) {
1331
2418
  }));
1332
2419
  }
1333
2420
  function pointsBounds(points) {
1334
- const bounds = polyBounds(points);
1335
- 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 };
1336
2423
  }
1337
2424
  function rotatedRectFitsPolygon(center, width, height, rotation, outer, holes) {
1338
2425
  const radians = rotation * Math.PI / 180;
@@ -1352,14 +2439,14 @@ function rotatedRectFitsPolygon(center, width, height, rotation, outer, holes) {
1352
2439
  return true;
1353
2440
  }
1354
2441
  function polygonLabelCandidates(outer, holes, preferred) {
1355
- const bounds = polyBounds(outer);
1356
- 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 };
1357
2444
  const points = [preferred];
1358
2445
  for (let row = 1; row < 12; row += 1) {
1359
2446
  for (let column = 1; column < 12; column += 1) {
1360
2447
  const point = {
1361
- x: bounds.x + bounds.width * column / 12,
1362
- y: bounds.y + bounds.height * row / 12
2448
+ x: bounds2.x + bounds2.width * column / 12,
2449
+ y: bounds2.y + bounds2.height * row / 12
1363
2450
  };
1364
2451
  if (pointInPolygonWithHoles(point, outer, holes)) points.push(point);
1365
2452
  }
@@ -1424,6 +2511,33 @@ function rgba(hex, a) {
1424
2511
  const n = parseInt(m[1], 16);
1425
2512
  return `rgba(${n >> 16 & 255},${n >> 8 & 255},${n & 255},${a})`;
1426
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
+ };
1427
2541
  function hexToRgb(hex) {
1428
2542
  const m = /^#?([\da-f]{6})$/i.exec(hex.trim());
1429
2543
  if (!m) return null;
@@ -1454,8 +2568,15 @@ function lerpColor(a, b, t2) {
1454
2568
  }
1455
2569
  var _SeatmapRenderer = class _SeatmapRenderer {
1456
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();
1457
2575
  this.seats = [];
1458
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;
1459
2580
  /** Multi-floor (Batch 5): the last-set chart + which floor we're rendering. */
1460
2581
  this.chartDoc = null;
1461
2582
  this.activeFloorId = "";
@@ -1471,13 +2592,11 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1471
2592
  this.seatLabelById = /* @__PURE__ */ new Map();
1472
2593
  /** Coloured accommodation ring per accessible seat (few per chart). */
1473
2594
  this.accessRingById = /* @__PURE__ */ new Map();
1474
- /** Centred accessibility glyph per accessible seat shown once the seat is
1475
- * big enough on-screen (see {@link SEAT_GLYPH_MIN_PX}); the ring is the
1476
- * smaller-zoom fallback. Kept in a map so zoom toggles touch only the handful
1477
- * 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. */
1478
2599
  this.accessGlyphById = /* @__PURE__ */ new Map();
1479
- /** Whether the accessibility glyph is legible at the current camera scale. */
1480
- this.accessGlyphVisible = false;
1481
2600
  /** Authored free-text nodes obey the same rendered-size visibility floor. */
1482
2601
  this.freeTextById = /* @__PURE__ */ new Map();
1483
2602
  /** Stage/rink landmarks retain a readable screen-space caption at overview. */
@@ -1502,6 +2621,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1502
2621
  this.ownedHold = /* @__PURE__ */ new Set();
1503
2622
  /** One selected seat being inspected before it is committed to the cart. */
1504
2623
  this.selectionFocusId = null;
2624
+ /** Seat currently owning the shared hover ring (null while not hovering). */
2625
+ this.hoveredId = null;
1505
2626
  this.focusedId = null;
1506
2627
  /**
1507
2628
  * Accessibility filter: `null` = off; `[]` = dim all non-accessible free seats;
@@ -1520,6 +2641,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1520
2641
  this.sections = [];
1521
2642
  this.zones = [];
1522
2643
  this.seatSection = /* @__PURE__ */ new Map();
2644
+ /** Seats outside every authored section retain the legacy path in one group. */
2645
+ this.unsectionedSeatGroup = null;
1523
2646
  this.catPrice = /* @__PURE__ */ new Map();
1524
2647
  /** Section/zone ids to render dimmed (organizer manager: held-back inventory). */
1525
2648
  this.dimmedSections = /* @__PURE__ */ new Set();
@@ -1532,6 +2655,9 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1532
2655
  this.focusedSectionId = null;
1533
2656
  /** Light backdrop panel drawn behind the focused section (removed on clear). */
1534
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;
1535
2661
  /** Object id → floor id (multi-floor only) — resolves a deck tap in the 3D stack. */
1536
2662
  this.objectFloor = /* @__PURE__ */ new Map();
1537
2663
  /** Zone id → colour (drives extruded side faces in iso view). */
@@ -1544,10 +2670,33 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1544
2670
  this.isoT = 0;
1545
2671
  this.isoTarget = 0;
1546
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;
1547
2692
  /** Chart centre the iso projection pivots about (bounds centre). */
1548
2693
  this.isoCentre = { x: 0, y: 0 };
1549
2694
  /** rAF for an in-flight camera glide (focusRegion / setRung); 0 = none. */
1550
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;
1551
2700
  /** Set in destroy() so an in-flight iso tween bails. */
1552
2701
  this.destroyed = false;
1553
2702
  /** Cached scale the section/zone labels were last sized for (scale-compensation). */
@@ -1577,6 +2726,19 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1577
2726
  this.panStarted = false;
1578
2727
  /** Maximum displacement from gesture start — suppress taps only after a real pan/pinch. */
1579
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;
1580
2742
  /**
1581
2743
  * Manage-mode rubber-band marquee (option-gated). `start`/`cur` are WORLD-space
1582
2744
  * points (overlayLayer rides the stage transform); `rect` is the on-canvas
@@ -1673,6 +2835,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1673
2835
  x: mid.x - this.pinch.worldMid.x * scale,
1674
2836
  y: mid.y - this.pinch.worldMid.y * scale
1675
2837
  });
2838
+ this.updateSeatGroupVisibility();
1676
2839
  this.stage.batchDraw();
1677
2840
  this.scheduleViewChange();
1678
2841
  } else if (this.panLast && this.pointers.size === 1) {
@@ -1685,6 +2848,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1685
2848
  y: this.stage.y() + (p.y - this.panLast.y)
1686
2849
  });
1687
2850
  this.panLast = p;
2851
+ this.updateSeatGroupVisibility();
1688
2852
  this.stage.batchDraw();
1689
2853
  this.scheduleViewChange();
1690
2854
  }
@@ -1737,6 +2901,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1737
2901
  this.overlayLayer = new import_Layer.Layer({ listening: false });
1738
2902
  this.labelGroup = new import_Group.Group({ listening: false });
1739
2903
  this.overlayLayer.add(this.labelGroup);
2904
+ this.fgDecorGroup = new import_Group.Group({ listening: false });
2905
+ this.overlayLayer.add(this.fgDecorGroup);
1740
2906
  this.hoverRing = new import_Circle.Circle({
1741
2907
  radius: SEAT_RADIUS + 2,
1742
2908
  stroke: "#ffffff",
@@ -1771,6 +2937,21 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1771
2937
  this.resizeObs.observe(container);
1772
2938
  }
1773
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
+ }
1774
2955
  // ---- ISeatmapRenderer -----------------------------------------------------
1775
2956
  setChart(doc, opts) {
1776
2957
  if (doc !== this.chartDoc) this.stacked = false;
@@ -1784,8 +2965,15 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1784
2965
  this.focusedId = null;
1785
2966
  this.focusRing.visible(false);
1786
2967
  this.bgLayer.destroyChildren();
2968
+ this.focusDimOverlay?.destroy();
2969
+ this.focusDimOverlay = null;
2970
+ this.seatLayer.clearCache();
2971
+ this.seatLayer.listening(true);
1787
2972
  this.seatLayer.destroyChildren();
2973
+ this.unsectionedSeatGroup = null;
1788
2974
  this.labelGroup.destroyChildren();
2975
+ this.labelLiftGroups.clear();
2976
+ this.fgDecorGroup.destroyChildren();
1789
2977
  this.circleById.clear();
1790
2978
  this.boothDims.clear();
1791
2979
  this.boothLabelById.clear();
@@ -1802,6 +2990,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1802
2990
  this.statusById.clear();
1803
2991
  this.selection.clear();
1804
2992
  this.seatById.clear();
2993
+ this.seatIndex = null;
1805
2994
  this.cached = false;
1806
2995
  this.accessFilter = null;
1807
2996
  this.sections = [];
@@ -1819,11 +3008,22 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1819
3008
  }
1820
3009
  this.isoT = 0;
1821
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;
1822
3021
  this.resetLayerTransforms();
1823
3022
  this.hasSections = view.objects.some((o) => o.type === "section");
1824
3023
  for (const z of doc.zones ?? []) if (z.color) this.zoneColor.set(z.id, z.color);
1825
3024
  this.seatLayer.opacity(1);
1826
3025
  this.hoverRing.visible(false);
3026
+ this.hoveredId = null;
1827
3027
  this.theme = doc.theme ?? {};
1828
3028
  this.seatR = clamp(this.theme.seatScale ?? 1, 0.7, 1.6) * SEAT_RADIUS;
1829
3029
  this.container.style.background = "";
@@ -1844,26 +3044,61 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1844
3044
  this.boothDims.set(obj.id, { width: obj.width, height: obj.height, rotation: obj.rotation });
1845
3045
  }
1846
3046
  }
1847
- 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
+ });
1848
3052
  for (const s of this.seats) {
1849
3053
  this.seatById.set(s.id, s);
1850
3054
  this.statusById.set(s.id, "free");
1851
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);
1852
3060
  this.renderBackground(view);
3061
+ this.unsectionedSeatGroup = new import_Group.Group({ listening: true });
3062
+ this.seatLayer.add(this.unsectionedSeatGroup);
1853
3063
  this.renderSeats();
1854
3064
  this.overlayLayer.add(this.labelGroup);
3065
+ this.overlayLayer.add(this.fgDecorGroup);
1855
3066
  this.overlayLayer.add(this.hoverRing);
1856
- this.bounds = chartBounds(view);
1857
- this.isoCentre = { x: this.bounds.x + this.bounds.width / 2, y: this.bounds.y + this.bounds.height / 2 };
3067
+ this.overlayLayer.add(this.focusRing);
1858
3068
  this.zoomToFit();
1859
3069
  }
1860
3070
  /** The chart to render: all floors stacked (3D overview), the active floor, or
1861
3071
  * the whole chart for single-floor charts. */
1862
3072
  floorView(doc) {
1863
- if (!doc.floors || !doc.floors.length) return doc;
1864
- 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
+ }
1865
3085
  const floor = doc.floors.find((f) => f.id === this.activeFloorId) ?? doc.floors[0];
1866
- 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;
1867
3102
  }
1868
3103
  /** Toggle the 3D all-floors stacked overview (Batch 5). Re-renders; no-op on
1869
3104
  * single-floor charts. The caller re-applies statuses + animates the iso view. */
@@ -2176,7 +3411,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2176
3411
  }
2177
3412
  const ids = [];
2178
3413
  for (const seat of this.seats) {
2179
- 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;
2180
3416
  if (!this.isSelectable(seat.id)) continue;
2181
3417
  ids.push(seat.id);
2182
3418
  }
@@ -2186,9 +3422,10 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2186
3422
  flashSeat(seatId, color = "#f43f5e") {
2187
3423
  const seat = this.seatById.get(seatId);
2188
3424
  if (!seat) return;
3425
+ const rendered = this.renderedSeatPoint(seat);
2189
3426
  const ring = new import_Circle.Circle({
2190
- x: seat.x,
2191
- y: seat.y,
3427
+ x: rendered.x,
3428
+ y: rendered.y,
2192
3429
  radius: this.seatR,
2193
3430
  stroke: color,
2194
3431
  strokeWidth: 3,
@@ -2197,6 +3434,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2197
3434
  perfectDrawEnabled: false,
2198
3435
  shadowForStrokeEnabled: false
2199
3436
  });
3437
+ const projectionScale = this.viewMode === "perspective" ? this.perspectiveSeatScale.get(seatId) ?? 1 : 1;
3438
+ ring.scale({ x: projectionScale, y: projectionScale });
2200
3439
  this.overlayLayer.add(ring);
2201
3440
  const start = performance.now();
2202
3441
  const dur = 620;
@@ -2223,17 +3462,20 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2223
3462
  const matches = this.sections.filter((section) => section.id === sectionId || section.zone === sectionId);
2224
3463
  if (!matches.length) return;
2225
3464
  for (const section of matches) {
2226
- 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(
2227
3470
  (sum, point) => ({ x: sum.x + point.x, y: sum.y + point.y }),
2228
3471
  { x: 0, y: 0 }
2229
3472
  );
2230
- centre.x /= section.outline.length;
2231
- centre.y /= section.outline.length;
2232
- const lift = section.elevation > 0 ? this.isoLiftLocal(section.elevation) : { x: 0, y: 0 };
3473
+ centre.x /= outline.length;
3474
+ centre.y /= outline.length;
2233
3475
  const halo = new import_Line.Line({
2234
- x: centre.x + lift.x,
2235
- y: centre.y + lift.y,
2236
- 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]),
2237
3479
  closed: true,
2238
3480
  stroke: color,
2239
3481
  strokeWidth: 3,
@@ -2277,12 +3519,14 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2277
3519
  nearestSeat(fromId, dir) {
2278
3520
  const from = this.seatById.get(fromId);
2279
3521
  if (!from) return null;
3522
+ const fromPoint = this.viewMode === "perspective" ? this.perspectiveSeatProjected.get(from.id) ?? from : from;
2280
3523
  let best = null;
2281
3524
  let bestScore = Infinity;
2282
3525
  for (const s of this.seats) {
2283
3526
  if (s.id === fromId) continue;
2284
- const dx = s.x - from.x;
2285
- 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;
2286
3530
  const proj = dx * dir.x + dy * dir.y;
2287
3531
  if (proj <= 0.5) continue;
2288
3532
  const perp = Math.abs(dx * dir.y - dy * dir.x);
@@ -2299,7 +3543,9 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2299
3543
  if (!seat) return;
2300
3544
  this.focusedId = id;
2301
3545
  this.focusRing.radius(this.seatR + 3);
2302
- 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 });
2303
3549
  this.focusRing.visible(true);
2304
3550
  this.ensureVisible(seat);
2305
3551
  this.overlayLayer.batchDraw();
@@ -2314,7 +3560,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2314
3560
  const margin = 70;
2315
3561
  const offscreen = p.x < margin || p.x > w - margin || p.y < margin || p.y > h - margin;
2316
3562
  if (this.stage.scaleX() < target || offscreen) {
2317
- 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);
2318
3565
  this.stage.scale({ x: target, y: target });
2319
3566
  this.stage.position({ x: w / 2 - ip.x * target, y: h / 2 - ip.y * target });
2320
3567
  this.afterViewChange();
@@ -2324,7 +3571,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2324
3571
  zoomToFit() {
2325
3572
  const w = this.stage.width();
2326
3573
  const h = this.stage.height();
2327
- const b = this.bounds;
3574
+ const b = this.viewMode === "perspective" && this.perspectiveBounds ? this.perspectiveBounds : this.bounds;
2328
3575
  this.fitScale = Math.min(w / b.width, h / b.height) || 1;
2329
3576
  const s = this.fitScale;
2330
3577
  this.stage.scale({ x: s, y: s });
@@ -2353,7 +3600,14 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2353
3600
  }
2354
3601
  worldToScreen(point) {
2355
3602
  const s = this.stage.scaleX();
2356
- 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);
2357
3611
  return { x: p.x * s + this.stage.x(), y: p.y * s + this.stage.y() };
2358
3612
  }
2359
3613
  setAccessibleFilter(on) {
@@ -2363,6 +3617,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2363
3617
  const next = types === null ? null : [...types];
2364
3618
  if (this.sameAccessFilter(next)) return;
2365
3619
  this.accessFilter = next;
3620
+ this.updateAccessGlyphs(this.effScale());
2366
3621
  for (const seat of this.seats) {
2367
3622
  const c = this.circleById.get(seat.id);
2368
3623
  if (c) this.paintSeat(c, seat.id);
@@ -2461,10 +3716,11 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2461
3716
  let minY = Infinity;
2462
3717
  let maxY = -Infinity;
2463
3718
  for (const seat of matching) {
2464
- minX = Math.min(minX, seat.x);
2465
- maxX = Math.max(maxX, seat.x);
2466
- minY = Math.min(minY, seat.y);
2467
- 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);
2468
3724
  }
2469
3725
  const pad = Math.max(24, this.seatR * 3);
2470
3726
  minX -= pad;
@@ -2485,12 +3741,37 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2485
3741
  * inverse) keeps landing on the projected seats/sections.
2486
3742
  */
2487
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
+ }
2488
3761
  const target = mode === "isometric" ? 1 : 0;
3762
+ const leavingPerspective = this.viewMode === "perspective";
3763
+ this.viewMode = mode;
2489
3764
  this.isoTarget = target;
2490
3765
  if (this.isoRaf) {
2491
3766
  cancelAnimationFrame(this.isoRaf);
2492
3767
  this.isoRaf = 0;
2493
3768
  }
3769
+ if (leavingPerspective) {
3770
+ this.isoT = target;
3771
+ this.applyIso();
3772
+ this.afterViewChange();
3773
+ return;
3774
+ }
2494
3775
  if (this.reducedMotion) {
2495
3776
  this.isoT = target;
2496
3777
  this.applyIso();
@@ -2524,7 +3805,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2524
3805
  }
2525
3806
  /** Current projection — reflects the tween target, not the mid-tween isoT. */
2526
3807
  getViewMode() {
2527
- return this.isoTarget === 1 ? "isometric" : "flat";
3808
+ return this.viewMode;
2528
3809
  }
2529
3810
  /** Iso angle (rad) + y-squash for the current isoT. */
2530
3811
  isoParams() {
@@ -2532,6 +3813,11 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2532
3813
  }
2533
3814
  /** Effective vertical scale = stage scale × iso squash — legibility math uses this. */
2534
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
+ }
2535
3821
  return this.stage.scaleX() * (1 - (1 - ISO_SQUASH) * this.isoT);
2536
3822
  }
2537
3823
  /** Project a world point through the iso affine about the chart centre (→ iso-world). */
@@ -2555,19 +3841,113 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2555
3841
  const wy = -dx * Math.sin(th) + uy * Math.cos(th);
2556
3842
  return { x: c.x + wx, y: c.y + wy };
2557
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
+ }
2558
3933
  /**
2559
3934
  * Local-space offset that, once the layer applies the iso affine, lifts an
2560
- * object straight UP in iso-world by `elevation × LIFT_PER_STEP × isoT`
2561
- * (= 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).
2562
3938
  */
2563
- isoLiftLocal(elevation) {
3939
+ isoLiftLocal(liftWorld) {
2564
3940
  const { th, sg } = this.isoParams();
2565
- const delta = elevation * LIFT_PER_STEP * this.isoT;
3941
+ const delta = liftWorld * this.isoT;
2566
3942
  return { x: -(delta / sg) * Math.sin(th), y: -(delta / sg) * Math.cos(th) };
2567
3943
  }
2568
- /** Restore the three layers to identity (flat) — byte-for-byte the original. */
2569
- resetLayerTransforms() {
2570
- 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) {
2571
3951
  layer.position({ x: 0, y: 0 });
2572
3952
  layer.offset({ x: 0, y: 0 });
2573
3953
  layer.scale({ x: 1, y: 1 });
@@ -2576,6 +3956,117 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2576
3956
  layer.skewY(0);
2577
3957
  }
2578
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
+ }
2579
4070
  /**
2580
4071
  * Apply the current isoT to the scene: the base rotate+squash as a decomposed
2581
4072
  * layer transform (so seats/décor/rings project together and Konva's own
@@ -2583,6 +4074,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2583
4074
  * lift + extruded side faces on elevated sections.
2584
4075
  */
2585
4076
  applyIso() {
4077
+ this.resetSectionProjection();
4078
+ this.applyExactSeatAnchors(false);
2586
4079
  const t2 = this.isoT;
2587
4080
  if (t2 === 0) {
2588
4081
  this.resetLayerTransforms();
@@ -2611,6 +4104,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2611
4104
  }
2612
4105
  this.applyUprightLabels();
2613
4106
  this.applyElevation();
4107
+ this.updateSeatGroupVisibility();
2614
4108
  this.bgLayer.batchDraw();
2615
4109
  this.seatLayer.batchDraw();
2616
4110
  this.overlayLayer.batchDraw();
@@ -2639,10 +4133,11 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2639
4133
  applyElevation() {
2640
4134
  const t2 = this.isoT;
2641
4135
  for (const sec of this.sections) {
2642
- if (sec.elevation <= 0) continue;
2643
- const off = this.isoLiftLocal(sec.elevation);
4136
+ if (sec.liftWorld <= 0) continue;
4137
+ const off = this.isoLiftLocal(sec.liftWorld);
2644
4138
  sec.liftGroupBg?.position(off);
2645
- sec.liftGroupSeat?.position(off);
4139
+ sec.liftGroupSeat.position(off);
4140
+ this.labelLiftGroups.get(sec.id)?.position(off);
2646
4141
  const alpha = 0.9 * t2;
2647
4142
  for (let i = 0; i < sec.sideFaces.length; i++) {
2648
4143
  const face = sec.sideFaces[i];
@@ -2652,6 +4147,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2652
4147
  face.opacity(alpha);
2653
4148
  }
2654
4149
  }
4150
+ this.syncSeatOverlayPositions();
2655
4151
  }
2656
4152
  destroy() {
2657
4153
  this.destroyed = true;
@@ -2678,16 +4174,166 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2678
4174
  * tier shifts with one offset in iso view) or the seat layer directly.
2679
4175
  */
2680
4176
  seatContainer(id) {
2681
- return this.seatSection.get(id)?.liftGroupSeat ?? this.seatLayer;
4177
+ return this.seatSection.get(id)?.liftGroupSeat ?? this.unsectionedSeatGroup ?? this.seatLayer;
2682
4178
  }
2683
- 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
+ }
2684
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) {
2685
4320
  if (seat.kind === "booth") {
2686
4321
  this.renderBoothUnit(seat);
2687
4322
  continue;
2688
4323
  }
2689
4324
  const target = this.seatContainer(seat.id);
2690
- 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({
2691
4337
  x: seat.x,
2692
4338
  y: seat.y,
2693
4339
  radius: this.seatR,
@@ -2700,21 +4346,25 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2700
4346
  this.paintSeat(c, seat.id);
2701
4347
  target.add(c);
2702
4348
  if (seat.accessible) {
2703
- const ring = new import_Circle.Circle({
2704
- x: seat.x,
2705
- y: seat.y,
2706
- radius: this.seatR + 1.5,
2707
- stroke: accessibilityRingColor(seat.accessibility),
2708
- strokeWidth: 2.5,
2709
- listening: false,
2710
- perfectDrawEnabled: false,
2711
- shadowForStrokeEnabled: false
2712
- });
2713
- this.accessRingById.set(seat.id, ring);
2714
- target.add(ring);
2715
- const glyph = this.buildAccessGlyph(seat, typeof c.fill() === "string" ? c.fill() : "");
2716
- this.accessGlyphById.set(seat.id, glyph);
2717
- 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
+ }
2718
4368
  }
2719
4369
  }
2720
4370
  }
@@ -2752,7 +4402,6 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2752
4402
  t2.offsetX(t2.width() / 2);
2753
4403
  t2.offsetY(t2.height() / 2);
2754
4404
  t2.visible(false);
2755
- this.boothLabelById.set(seat.id, t2);
2756
4405
  this.hasBoothText = true;
2757
4406
  this.boothLabelById.set(seat.id, t2);
2758
4407
  target.add(t2);
@@ -2775,24 +4424,40 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2775
4424
  scaleY: k,
2776
4425
  fill: stateAwareBookableLabelInk(seatFill, "#ffffff"),
2777
4426
  listening: false,
2778
- visible: this.accessGlyphVisible,
4427
+ visible: false,
2779
4428
  perfectDrawEnabled: false,
2780
4429
  shadowForStrokeEnabled: false
2781
4430
  });
2782
4431
  }
2783
4432
  /**
2784
- * Toggle the accessibility glyphs for the current camera scale: shown once the
2785
- * effective on-screen seat radius clears {@link SEAT_GLYPH_MIN_PX}, otherwise
2786
- * hidden so only the ring remains. Iterates the (small) accessible-seat set,
2787
- * 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.
2788
4436
  */
2789
4437
  updateAccessGlyphs(scale) {
2790
4438
  if (!this.accessGlyphById.size) return;
2791
- this.accessGlyphVisible = this.seatR * scale >= SEAT_GLYPH_MIN_PX;
2792
4439
  for (const [id, glyph] of this.accessGlyphById) {
2793
- 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));
2794
4450
  }
2795
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
+ }
2796
4461
  /**
2797
4462
  * Whether an accessible seat's glyph should show for its current status. It is
2798
4463
  * hidden on seats a buyer cannot take (sold, or another buyer's hold) where the
@@ -2872,6 +4537,11 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2872
4537
  c.dash([2, 2]);
2873
4538
  break;
2874
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
+ }
2875
4545
  if (boothLabel) {
2876
4546
  boothLabel.text(status === "booked" ? "SOLD" : status === "held" ? "HELD" : seat.label);
2877
4547
  boothLabel.fontSize(status === "free" ? 10 : Math.min(10, Math.max(6, this.boothDims.get(seat.rowId)?.width ?? 40) / 6));
@@ -2905,11 +4575,6 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2905
4575
  c.dash([]);
2906
4576
  c.opacity(CLOSED_SEAT_OPACITY);
2907
4577
  }
2908
- if (this.focusedSectionId) {
2909
- const sec = this.seatSection.get(id);
2910
- const inFocus = !!sec && (sec.id === this.focusedSectionId || sec.logicalId === this.focusedSectionId || sec.zone === this.focusedSectionId);
2911
- if (!inFocus) c.opacity(FOCUS_DIM_OPACITY);
2912
- }
2913
4578
  if (this.selectionFocusId && id !== this.selectionFocusId) c.opacity(Math.min(c.opacity(), 0.16));
2914
4579
  const bookableLabel = this.boothLabelById.get(id) ?? this.seatLabelById.get(id);
2915
4580
  if (bookableLabel) {
@@ -2923,7 +4588,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2923
4588
  const fill = c.fill();
2924
4589
  accessGlyph.fill(stateAwareBookableLabelInk(typeof fill === "string" ? fill : "", "#ffffff"));
2925
4590
  accessGlyph.opacity(c.opacity());
2926
- accessGlyph.visible(this.accessGlyphVisible && this.accessGlyphEligible(id));
4591
+ accessGlyph.visible(this.accessGlyphShouldShow(id));
2927
4592
  }
2928
4593
  this.accessRingById.get(id)?.opacity(c.opacity());
2929
4594
  }
@@ -2992,7 +4657,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2992
4657
  if (!this.sections.some((section) => section.id === id || section.logicalId === id)) return;
2993
4658
  this.focusedSectionId = id;
2994
4659
  this.drawFocusBackdrop(id);
2995
- this.repaintSectionsAndSeats();
4660
+ this.bgLayer.batchDraw();
4661
+ this.overlayLayer.batchDraw();
2996
4662
  this.updateLOD();
2997
4663
  this.focusRegion(id, { minScale: SEAT_FOCUS_SCALE });
2998
4664
  }
@@ -3004,7 +4670,10 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3004
4670
  this.focusBackdrop.destroy();
3005
4671
  this.focusBackdrop = null;
3006
4672
  }
3007
- this.repaintSectionsAndSeats();
4673
+ this.focusDimOverlay?.destroy();
4674
+ this.focusDimOverlay = null;
4675
+ this.bgLayer.batchDraw();
4676
+ this.overlayLayer.batchDraw();
3008
4677
  this.updateLOD();
3009
4678
  }
3010
4679
  /** The currently AXS-focused section id, or null. */
@@ -3021,16 +4690,69 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3021
4690
  if (!sections.length) return;
3022
4691
  const backdrop = new import_Group.Group({ listening: false });
3023
4692
  for (const section of sections) {
3024
- 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, {
3025
4697
  fill: FOCUS_BACKDROP_FILL,
3026
4698
  stroke: rgba("#ffffff", 0.1),
3027
4699
  strokeWidth: 1,
3028
4700
  listening: false
3029
- }, section.outlinePath));
4701
+ }, perspective ? void 0 : section.outlinePath));
3030
4702
  }
3031
4703
  this.bgLayer.add(backdrop);
3032
4704
  backdrop.moveToTop();
3033
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;
3034
4756
  }
3035
4757
  /** Repaint every seat + section block to reflect closed/focus state, then redraw. */
3036
4758
  repaintSectionsAndSeats() {
@@ -3094,7 +4816,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3094
4816
  this.applyGAFilterState();
3095
4817
  }
3096
4818
  renderBackground(doc) {
3097
- if (doc.backgroundImage) this.renderBackgroundImage(doc.backgroundImage);
4819
+ const buyerBackground = buyerBackgroundImage(doc);
4820
+ if (buyerBackground) this.renderBackgroundImage(buyerBackground);
3098
4821
  for (const obj of doc.objects) if (obj.type === "decorImage") this.renderDecorImage(obj);
3099
4822
  for (const obj of doc.objects) if (obj.type === "section") this.renderSection(obj);
3100
4823
  this.renderZones(doc);
@@ -3174,10 +4897,12 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3174
4897
  listening: false,
3175
4898
  perfectDrawEnabled: false
3176
4899
  });
3177
- this.bgLayer.add(node);
4900
+ if (obj.layer === "foreground") this.fgDecorGroup.add(node);
4901
+ else this.bgLayer.add(node);
3178
4902
  img.onload = () => {
3179
- if (!node.getLayer()) return;
3180
- this.bgLayer.batchDraw();
4903
+ const layer = node.getLayer();
4904
+ if (!layer) return;
4905
+ layer.batchDraw();
3181
4906
  };
3182
4907
  img.src = obj.href;
3183
4908
  }
@@ -3214,7 +4939,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3214
4939
  })
3215
4940
  );
3216
4941
  }
3217
- 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);
3218
4943
  this.freeTextById.set(obj.id, { node: label, background: "#232c40", kind: "table" });
3219
4944
  }
3220
4945
  renderText(obj) {
@@ -3226,11 +4951,12 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3226
4951
  text: obj.text,
3227
4952
  fontSize: obj.fontSize,
3228
4953
  rotation: obj.rotation,
4954
+ fontStyle: konvaFontStyle(obj.bold, obj.italic, "normal"),
3229
4955
  // Authored ink remains preferred, but an embed/theme surface can change
3230
4956
  // the actual canvas. Fail over to readable black/white instead of
3231
4957
  // painting an otherwise valid caption invisibly on that active surface.
3232
4958
  fill: stateAwareBookableLabelInk(background, preferredInk),
3233
- fontFamily: this.labelFont(),
4959
+ fontFamily: obj.fontFamily ?? this.labelFont(),
3234
4960
  listening: false,
3235
4961
  perfectDrawEnabled: false
3236
4962
  });
@@ -3248,11 +4974,40 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3248
4974
  const isDecor = !!obj.role && !isStage;
3249
4975
  const palette = overviewPalette(this.canvasBackground);
3250
4976
  const fill = referenceFocal ? palette.focalFill : authoredFill;
3251
- const stroke = isStage ? lighten(fill, 0.28) : referenceFocal ? palette.focalStroke : void 0;
3252
- 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;
3253
4981
  let cx = 0;
3254
4982
  let cy = 0;
3255
- 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) {
3256
5011
  const grad = isStage ? {
3257
5012
  fillLinearGradientStartPoint: { x: 0, y: 0 },
3258
5013
  fillLinearGradientEndPoint: { x: 0, y: obj.height },
@@ -3272,7 +5027,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3272
5027
  ...grad,
3273
5028
  stroke,
3274
5029
  strokeWidth,
3275
- cornerRadius: 4,
5030
+ cornerRadius: clamp(obj.cornerRadius ?? 4, 0, Math.min(obj.width, obj.height) / 2),
5031
+ opacity,
3276
5032
  listening: false
3277
5033
  })
3278
5034
  );
@@ -3285,7 +5041,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3285
5041
  fillLinearGradientColorStops: [0, darken(fill, 0.3), 1, lighten(fill, 0.12)]
3286
5042
  } : { fill };
3287
5043
  this.bgLayer.add(
3288
- 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 })
3289
5045
  );
3290
5046
  } else if (obj.kind === "polygon" && obj.points && obj.points.length) {
3291
5047
  const pts = obj.points.flatMap((p) => [p.x, p.y]);
@@ -3300,7 +5056,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3300
5056
  fillLinearGradientColorStops: [0, darken(fill, 0.3), 1, lighten(fill, 0.12)]
3301
5057
  } : { fill };
3302
5058
  this.bgLayer.add(
3303
- 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 })
3304
5060
  );
3305
5061
  }
3306
5062
  if (obj.label) {
@@ -3359,7 +5115,10 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3359
5115
  strokeWidth: 1.5
3360
5116
  });
3361
5117
  poly.setAttr("gaId", obj.id);
3362
- 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
+ });
3363
5122
  poly.on("mouseenter", () => {
3364
5123
  this.container.style.cursor = "pointer";
3365
5124
  });
@@ -3369,7 +5128,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3369
5128
  this.bgLayer.add(poly);
3370
5129
  const labelPoint = polygonLabelPoint(obj.points, obj.holes);
3371
5130
  const containingSection = this.sections.find((section) => pointInPolygonWithHoles(labelPoint, section.outline, section.holes));
3372
- 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);
3373
5132
  const capacity = this.addCentredLabel(this.bgLayer, `cap ${obj.capacity}`, labelPoint.x, labelPoint.y + 10, ink, GA_CAPACITY_LABEL_FONT_SIZE, false);
3374
5133
  this.freeTextById.set(`${obj.id}:label`, {
3375
5134
  objectId: obj.id,
@@ -3386,7 +5145,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3386
5145
  categoryKey: obj.categoryKey
3387
5146
  });
3388
5147
  this.gaById.set(obj.id, {
3389
- label: obj.label,
5148
+ label: obj.displayLabel ?? obj.label,
3390
5149
  capacity: obj.capacity,
3391
5150
  categoryKey: obj.categoryKey,
3392
5151
  points: obj.points,
@@ -3409,7 +5168,9 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3409
5168
  const memberIds = [];
3410
5169
  const catCounts = /* @__PURE__ */ new Map();
3411
5170
  let free = 0;
3412
- 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) {
3413
5174
  if (this.seatSection.has(seat.id)) continue;
3414
5175
  if (!pointInPolygonWithHoles(seat, obj.outline, obj.holes)) continue;
3415
5176
  memberIds.push(seat.id);
@@ -3420,11 +5181,19 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3420
5181
  [...catCounts].map(([key, w]) => ({ hex: this.catColor.get(key) ?? "#6e7bff", w })),
3421
5182
  "#3a4358"
3422
5183
  );
3423
- const elevation = Math.max(0, Math.round(obj.elevation ?? 0));
3424
- let liftGroupBg = null;
3425
- 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);
3426
5195
  const sideFaces = [];
3427
- if (elevation > 0) {
5196
+ if (liftWorld > 0) {
3428
5197
  const faceFill = darken(this.zoneColor.get(obj.zone ?? "") ?? baseFill, 0.42);
3429
5198
  for (let i = 0; i < obj.outline.length; i++) {
3430
5199
  const face = new import_Line.Line({
@@ -3438,14 +5207,11 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3438
5207
  perfectDrawEnabled: false
3439
5208
  });
3440
5209
  sideFaces.push(face);
3441
- this.bgLayer.add(face);
5210
+ rootGroupBg.add(face);
3442
5211
  }
3443
- liftGroupBg = new import_Group.Group({ listening: false });
3444
- this.bgLayer.add(liftGroupBg);
3445
- liftGroupSeat = new import_Group.Group({ listening: false });
3446
- this.seatLayer.add(liftGroupSeat);
3447
5212
  }
3448
- const bgTarget = liftGroupBg ?? this.bgLayer;
5213
+ liftGroupBg.moveToTop();
5214
+ const bgTarget = liftGroupBg;
3449
5215
  const outlineTint = obj.color ?? this.zoneColor.get(obj.zone ?? "") ?? "#3a4358";
3450
5216
  const outlinePoly = polygonWithHolesShape(obj.outline, obj.holes, {
3451
5217
  stroke: rgba(outlineTint, 0.5),
@@ -3519,10 +5285,23 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3519
5285
  nameLabelFits: true,
3520
5286
  subLabelFits: true,
3521
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(),
3522
5295
  liftGroupBg,
3523
5296
  liftGroupSeat,
3524
5297
  sideFaces
3525
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
+ )));
3526
5305
  for (const id of memberIds) this.seatSection.set(id, sec);
3527
5306
  this.refreshSectionFill(sec);
3528
5307
  this.refreshSectionHeat(sec);
@@ -3668,15 +5447,16 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3668
5447
  const sectionOverview = scale < CACHE_THRESHOLD;
3669
5448
  if (sectionOverview) blockT = 1;
3670
5449
  if (!this.zones.length) zoneT = 0;
3671
- 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);
3672
5452
  const sx = this.stage.scaleX();
3673
- 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);
3674
5454
  if (rescale) this.lodScale = scale;
3675
5455
  const focus = this.focusedSectionId;
3676
5456
  const palette = overviewPalette(this.canvasBackground);
3677
5457
  const sectionLabelT = clamp((blockT - 0.2) / 0.8, 0, 1);
3678
5458
  for (const sec of this.sections) {
3679
- 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;
3680
5460
  sec.outlinePoly.opacity(sectionOverview ? 0 : (1 - blockT) * dim);
3681
5461
  sec.blockPoly.opacity(BLOCK_FILL_ALPHA * blockT * dim);
3682
5462
  sec.blockPoly.stroke(palette.sectionStroke);
@@ -3688,7 +5468,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3688
5468
  );
3689
5469
  sec.nameLabel.fill(sectionInk);
3690
5470
  sec.subLabel.fill(sectionInk);
3691
- if (rescale) this.fitSectionRungLabels(sec, sx);
5471
+ if (rescale && sectionLabelT > 0.01) this.fitSectionRungLabels(sec, sx);
3692
5472
  const labelOpacity = sectionLabelT * (1 - zoneT) * dim;
3693
5473
  sec.nameLabel.opacity(sec.nameLabelFits ? labelOpacity : 0);
3694
5474
  sec.subLabel.opacity(0);
@@ -3700,12 +5480,14 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3700
5480
  if (zone.sub) zone.sub.opacity(zoneOpacity);
3701
5481
  if (rescale) this.sizeZonePill(zone, sx);
3702
5482
  }
3703
- this.decollideRungLabels(sx);
3704
- this.dedupeLogicalSectionLabels();
3705
- for (const zone of this.zones) {
3706
- const opacity = zone.label.opacity();
3707
- zone.back.opacity(opacity);
3708
- 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
+ }
3709
5491
  }
3710
5492
  this.bgLayer.batchDraw();
3711
5493
  }
@@ -3832,13 +5614,30 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3832
5614
  screenToWorld(clientPoint) {
3833
5615
  const s = this.stage.scaleX();
3834
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
+ }
3835
5620
  return this.isoT === 0 ? iso : this.isoInverse(iso);
3836
5621
  }
3837
5622
  /** Section id under a container-relative screen point, or null (Slice 5 tap-to-zoom). */
3838
5623
  sectionAt(clientPoint) {
3839
5624
  if (!this.sections.length) return null;
3840
5625
  const world = this.screenToWorld(clientPoint);
3841
- 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
+ });
3842
5641
  return hit ? hit.logicalId : null;
3843
5642
  }
3844
5643
  /** Seat ids belonging to a section (Slice 5 section-summary card). */
@@ -3934,15 +5733,20 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3934
5733
  if (!seat) return;
3935
5734
  const candidate = this.selectionFocusId === id;
3936
5735
  const dims = this.boothDims.get(seat.rowId);
5736
+ const rendered = this.renderedSeatPoint(seat);
3937
5737
  const marker = new import_Group.Group({
3938
5738
  name: "selection-ring",
3939
- x: seat.x,
3940
- y: seat.y,
5739
+ x: rendered.x,
5740
+ y: rendered.y,
3941
5741
  rotation: dims?.rotation ?? 0,
3942
5742
  listening: false,
3943
5743
  perfectDrawEnabled: false,
3944
5744
  opacity: this.selectionFocusId && !candidate ? 0.2 : 1
3945
5745
  });
5746
+ if (this.viewMode === "perspective") {
5747
+ const projectionScale = this.perspectiveSeatScale.get(id) ?? 1;
5748
+ marker.scale({ x: projectionScale, y: projectionScale });
5749
+ }
3946
5750
  marker.setAttr("seatId", id);
3947
5751
  const common = {
3948
5752
  stroke: this.effSelection,
@@ -4021,12 +5825,22 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4021
5825
  * must fall through and pick.
4022
5826
  */
4023
5827
  sectionBounds(id) {
4024
- const bounds = this.sections.filter((section) => section.id === id || section.logicalId === id).map((section) => polyBounds(section.outline));
4025
- if (!bounds.length) return null;
4026
- const left = Math.min(...bounds.map((box) => box.x));
4027
- const top = Math.min(...bounds.map((box) => box.y));
4028
- const right = Math.max(...bounds.map((box) => box.x + box.width));
4029
- 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));
4030
5844
  return { x: left, y: top, width: right - left, height: bottom - top };
4031
5845
  }
4032
5846
  sectionFrameScale(id) {
@@ -4079,15 +5893,16 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4079
5893
  * hijacking a clean tap on empty space beyond the slop.
4080
5894
  */
4081
5895
  nearestSeatToScreen(screen, slopPx) {
4082
- const s = this.stage.scaleX() || 1;
4083
- const reachWorld = this.seatR + slopPx / s;
4084
- const world = this.screenToWorld(screen);
4085
5896
  let best = null;
4086
- let bestD = reachWorld;
5897
+ let bestD = Infinity;
5898
+ const stageScale = this.stage.scaleX() || 1;
4087
5899
  for (const seat of this.seats) {
4088
5900
  if (!this.selection.has(seat.id) && !this.isSelectable(seat.id)) continue;
4089
- const d = Math.hypot(seat.x - world.x, seat.y - world.y);
4090
- 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) {
4091
5906
  bestD = d;
4092
5907
  best = seat.id;
4093
5908
  }
@@ -4097,6 +5912,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4097
5912
  wireInteraction() {
4098
5913
  this.seatLayer.on("click tap", (e) => {
4099
5914
  if (this.moved > PAN_START_SLOP_PX) return;
5915
+ if (this.isGhostClick(e)) return;
4100
5916
  const id = seatIdOf(e.target);
4101
5917
  if (!id) return;
4102
5918
  this.handleSeatTap(id);
@@ -4106,13 +5922,17 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4106
5922
  if (!id) return;
4107
5923
  const seat = this.seatById.get(id);
4108
5924
  if (!seat) return;
4109
- 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 });
4110
5929
  this.hoverRing.visible(true);
4111
5930
  this.overlayLayer.batchDraw();
4112
5931
  this.container.style.cursor = "pointer";
4113
5932
  this.opts.onHover?.(seat);
4114
5933
  });
4115
5934
  this.seatLayer.on("mouseout", () => {
5935
+ this.hoveredId = null;
4116
5936
  this.hoverRing.visible(false);
4117
5937
  this.overlayLayer.batchDraw();
4118
5938
  this.container.style.cursor = "default";
@@ -4127,6 +5947,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4127
5947
  });
4128
5948
  this.stage.on("click tap", (e) => {
4129
5949
  if (this.moved > PAN_START_SLOP_PX) return;
5950
+ if (this.isGhostClick(e)) return;
4130
5951
  const pointer = this.stage.getPointerPosition();
4131
5952
  if (!pointer) return;
4132
5953
  if (!this.cached) {
@@ -4143,11 +5964,10 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4143
5964
  }
4144
5965
  }
4145
5966
  if (this.sections.length) {
4146
- const world = this.screenToWorld(pointer);
4147
- const hit = this.sections.find((sn) => pointInPolygonWithHoles(world, sn.outline, sn.holes));
4148
- if (hit) {
4149
- if (this.opts.onSectionTap) this.opts.onSectionTap(hit.logicalId);
4150
- 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);
4151
5971
  return;
4152
5972
  }
4153
5973
  }
@@ -4164,13 +5984,15 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4164
5984
  */
4165
5985
  deckFloorAt() {
4166
5986
  if (!this.objectFloor.size) return null;
4167
- const p = this.seatLayer.getRelativePointerPosition();
4168
- if (!p) return null;
5987
+ const pointer = this.stage.getPointerPosition();
5988
+ if (!pointer) return null;
5989
+ const p = this.screenToWorld(pointer);
4169
5990
  let best = null;
4170
5991
  let bestD = Infinity;
4171
5992
  for (const s of this.seats) {
4172
- const dx = s.x - p.x;
4173
- 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;
4174
5996
  const d = dx * dx + dy * dy;
4175
5997
  if (d < bestD) {
4176
5998
  bestD = d;
@@ -4257,9 +6079,11 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4257
6079
  }
4258
6080
  const start = performance.now();
4259
6081
  const duration = Math.max(180, Math.min(1200, opts?.durationMs ?? CAMERA_GLIDE_MS));
6082
+ this.glideInProgress = true;
4260
6083
  const step = (now) => {
4261
6084
  if (this.destroyed) {
4262
6085
  this.glideRaf = 0;
6086
+ this.glideInProgress = false;
4263
6087
  return;
4264
6088
  }
4265
6089
  const raw = Math.min(1, (now - start) / duration);
@@ -4267,6 +6091,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4267
6091
  const sc = fromScale + (toScale - fromScale) * e;
4268
6092
  this.stage.scale({ x: sc, y: sc });
4269
6093
  this.stage.position({ x: fromX + (toX - fromX) * e, y: fromY + (toY - fromY) * e });
6094
+ this.updateSeatGroupVisibility();
4270
6095
  this.updateLOD();
4271
6096
  this.scheduleViewChange();
4272
6097
  this.stage.batchDraw();
@@ -4274,6 +6099,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4274
6099
  this.glideRaf = requestAnimationFrame(step);
4275
6100
  } else {
4276
6101
  this.glideRaf = 0;
6102
+ this.glideInProgress = false;
4277
6103
  this.afterViewChange();
4278
6104
  }
4279
6105
  };
@@ -4285,6 +6111,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4285
6111
  cancelAnimationFrame(this.glideRaf);
4286
6112
  this.glideRaf = 0;
4287
6113
  }
6114
+ this.glideInProgress = false;
4288
6115
  }
4289
6116
  /** Current LOD rung derived from effective zoom — drives the ZONES/SECTIONS/SEATS pill. */
4290
6117
  getRung() {
@@ -4301,11 +6128,14 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4301
6128
  const labels = this.seats.map((seat) => {
4302
6129
  const shape = this.circleById.get(seat.id);
4303
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;
4304
6133
  const authoredFontSize = seat.kind === "booth" ? BOOTH_LABEL_FONT_SIZE : SEAT_LABEL_FONT_SIZE * this.seatLabelScale(seat);
4305
- 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);
4306
6136
  const screen = this.worldToScreen(seat);
4307
6137
  const outside = screen.x < 0 || screen.x > viewport.width || screen.y < 0 || screen.y > viewport.height;
4308
- const opacity = shape?.opacity() ?? 0;
6138
+ const opacity = this.focusedSeatOpacity(seat.id, shape?.getAbsoluteOpacity() ?? 0);
4309
6139
  const section = this.seatSection.get(seat.id);
4310
6140
  const visible = Boolean(label?.isVisible()) && opacity >= 0.5 && !outside;
4311
6141
  let hiddenReason;
@@ -4316,17 +6146,21 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4316
6146
  else if (!label) hiddenReason = "clutter-or-fit";
4317
6147
  else hiddenReason = "renderer-hidden";
4318
6148
  }
4319
- const labelWidth = label ? label.width() * stageScale : 0;
4320
- const labelHeight = label ? label.height() * effectiveScale : 0;
4321
- const directWidthPx = shape instanceof import_Rect.Rect ? shape.width() * stageScale : this.seatR * 2 * effectiveScale;
4322
- const directHeightPx = shape instanceof import_Rect.Rect ? shape.height() * stageScale : this.seatR * 2 * effectiveScale;
4323
- 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);
4324
6154
  const fill = shape?.fill();
4325
6155
  const ink = label?.fill();
6156
+ const accessGlyph = this.accessGlyphById.get(seat.id);
6157
+ const accessGlyphScale = accessGlyph?.getAbsoluteScale();
4326
6158
  return {
4327
6159
  seatId: seat.id,
4328
6160
  label: seat.label,
4329
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 } : {},
4330
6164
  categoryKey: seat.categoryKey,
4331
6165
  ...section ? { sectionId: section.id } : {},
4332
6166
  ...section?.zone ? { zoneId: section.zone } : {},
@@ -4337,8 +6171,15 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4337
6171
  fill: typeof fill === "string" ? fill : "",
4338
6172
  ink: typeof ink === "string" ? ink : this.theme.seatLabelColor ?? DEF_SEAT_LABEL,
4339
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
+ } : {},
4340
6181
  pointerTarget: {
4341
- 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),
4342
6183
  directWidthPx: rounded(directWidthPx),
4343
6184
  directHeightPx: rounded(directHeightPx),
4344
6185
  effectiveMinimumPx: rounded(Math.max(
@@ -4366,7 +6207,11 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4366
6207
  node.height(),
4367
6208
  node.rotation()
4368
6209
  );
4369
- 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
+ })));
4370
6215
  const opacity = rounded(node.opacity());
4371
6216
  const ink = node.fill();
4372
6217
  const outside = screenBounds.x + screenBounds.width < 0 || screenBounds.x > viewport.width || screenBounds.y + screenBounds.height < 0 || screenBounds.y > viewport.height;
@@ -4505,6 +6350,15 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4505
6350
  const visibleSectionShells = this.sections.filter((section) => section.blockPoly.opacity() > 0.05);
4506
6351
  return {
4507
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
+ } : {},
4508
6362
  canvasBackground: this.canvasBackground,
4509
6363
  effectiveScale: rounded(effectiveScale),
4510
6364
  rung: this.getRung(),
@@ -4559,10 +6413,10 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4559
6413
  let cy = viewCentre.y;
4560
6414
  if (rung === "sections" && this.sections.length > 0) {
4561
6415
  const sectionCentres = this.sections.map((section) => {
4562
- const bounds = polyBounds(section.outline);
6416
+ const bounds2 = polyBounds(section.outline);
4563
6417
  return {
4564
- x: bounds.x + bounds.width / 2,
4565
- y: bounds.y + bounds.height / 2
6418
+ x: bounds2.x + bounds2.width / 2,
6419
+ y: bounds2.y + bounds2.height / 2
4566
6420
  };
4567
6421
  });
4568
6422
  const halfWidth = w / (target * 2);
@@ -4629,6 +6483,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4629
6483
  }
4630
6484
  /** Recompute LOD (cache/labels) after any pan/zoom settles. */
4631
6485
  afterViewChange() {
6486
+ this.updateSeatGroupVisibility();
4632
6487
  this.updateLOD();
4633
6488
  this.updateFreeTextVisibility();
4634
6489
  this.updateLabels();
@@ -4653,10 +6508,15 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4653
6508
  this.paintGAStateForView();
4654
6509
  this.updateAccessGlyphs(scale);
4655
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
+ }
4656
6516
  if (shouldCache && !this.cached) {
4657
6517
  this.cacheSeatLayer();
4658
- } else if (!shouldCache && this.cached) {
4659
- this.seatLayer.clearCache();
6518
+ } else if (!shouldCache && (this.cached || !this.seatLayer.listening())) {
6519
+ if (this.cached) this.seatLayer.clearCache();
4660
6520
  this.seatLayer.listening(true);
4661
6521
  this.cached = false;
4662
6522
  this.seatLayer.batchDraw();
@@ -4665,6 +6525,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4665
6525
  /** Rebuild the seat-layer bitmap synchronously (no paint). Shared by the
4666
6526
  * debounced cacheSeatLayer() and the synchronous forceDraw() catch-up. */
4667
6527
  rebuildSeatCache() {
6528
+ this.updateSeatGroupVisibility();
4668
6529
  const pr = clamp(this.stage.scaleX() * this.dpr, 0.15, 2);
4669
6530
  this.seatLayer.clearCache();
4670
6531
  this.seatLayer.cache({ pixelRatio: pr });
@@ -4716,31 +6577,31 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4716
6577
  for (const [id, label] of this.boothLabelById) {
4717
6578
  const shape = this.circleById.get(id);
4718
6579
  label.visible(
4719
- 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
4720
6581
  );
4721
6582
  }
4722
6583
  this.labelGroup.destroyChildren();
6584
+ this.labelLiftGroups.clear();
4723
6585
  this.seatLabelById.clear();
4724
6586
  if (!show) {
4725
6587
  this.overlayLayer.batchDraw();
4726
6588
  return;
4727
6589
  }
4728
- const s = this.stage.scaleX();
4729
- const x0 = -this.stage.x() / s;
4730
- const y0 = -this.stage.y() / s;
4731
- const x1 = (this.stage.width() - this.stage.x()) / s;
4732
- const y1 = (this.stage.height() - this.stage.y()) / s;
4733
6590
  let count = 0;
4734
- for (const seat of this.seats) {
6591
+ for (const seat of this.visibleSeatCandidates()) {
4735
6592
  if (seat.kind === "booth") continue;
4736
- if (seat.x < x0 || seat.x > x1 || seat.y < y0 || seat.y > y1) continue;
4737
- 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;
4738
6596
  const shape = this.circleById.get(seat.id);
4739
- if ((shape?.opacity() ?? 1) < 0.5) continue;
6597
+ if (!shape?.isVisible() || this.focusedSeatOpacity(seat.id, shape.getAbsoluteOpacity()) < 0.5) continue;
4740
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;
4741
6601
  const unavailable = status === "booked" || status === "held" && !this.ownedHold.has(seat.id) && !this.opts.manageMode;
4742
6602
  if (unavailable) {
4743
- 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 });
4744
6605
  if (status === "held") {
4745
6606
  cue.add(new import_Rect.Rect({
4746
6607
  x: -4.2,
@@ -4769,14 +6630,14 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4769
6630
  listening: false
4770
6631
  }));
4771
6632
  }
4772
- this.labelGroup.add(cue);
6633
+ this.seatLabelContainer(seat.id).add(cue);
4773
6634
  if (++count >= MAX_LABELS) break;
4774
6635
  continue;
4775
6636
  }
4776
6637
  const authoredFontSize = SEAT_LABEL_FONT_SIZE * this.seatLabelScale(seat);
4777
6638
  const t2 = new import_Text.Text({
4778
- x: seat.x,
4779
- y: seat.y,
6639
+ x: labelPoint.x,
6640
+ y: labelPoint.y,
4780
6641
  text: bookableMarkerLabel(seat.displayLabel ?? seat.label),
4781
6642
  fontSize: authoredFontSize,
4782
6643
  fontStyle: "600",
@@ -4797,11 +6658,12 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4797
6658
  }
4798
6659
  t2.offsetX(t2.width() / 2);
4799
6660
  t2.offsetY(t2.height() / 2);
4800
- this.labelGroup.add(t2);
6661
+ t2.scale({ x: perspectiveScale, y: perspectiveScale });
6662
+ this.seatLabelContainer(seat.id).add(t2);
4801
6663
  this.seatLabelById.set(seat.id, t2);
4802
6664
  if (++count >= MAX_LABELS) break;
4803
6665
  }
4804
- if (this.isoT > 0) this.applyUprightLabels();
6666
+ if (this.isoT > 0 && this.viewMode !== "perspective") this.applyUprightLabels();
4805
6667
  this.overlayLayer.batchDraw();
4806
6668
  }
4807
6669
  handleResize() {
@@ -4867,6 +6729,7 @@ function strandedSingles(seats, statusOf, selectedIds) {
4867
6729
 
4868
6730
  // src/core/bestAvailable.ts
4869
6731
  function seatIndex2(seat, fallback) {
6732
+ if (Number.isInteger(seat.logicalSeatIndex)) return seat.logicalSeatIndex;
4870
6733
  const i = seat.id.lastIndexOf(":");
4871
6734
  if (i < 0) return fallback;
4872
6735
  const n = Number(seat.id.slice(i + 1));
@@ -4886,21 +6749,25 @@ function centroid(seats) {
4886
6749
  }
4887
6750
  return { x: x / seats.length, y: y / seats.length };
4888
6751
  }
6752
+ function candidateFocal(seats, fallback) {
6753
+ return seats.find((seat) => seat.focalPoint)?.focalPoint ?? fallback;
6754
+ }
4889
6755
  function isPremium(seat) {
4890
6756
  return seat.commercial?.premium === true;
4891
6757
  }
4892
6758
  function pickBestAvailable(seats, available, opts) {
4893
6759
  const qty = Math.floor(opts.qty);
4894
6760
  if (!Number.isFinite(qty) || qty <= 0) return { labels: [], reason: "sold_out" };
4895
- const { categoryKey, focal, preferPremium } = opts;
6761
+ const { categoryKey, zoneId, focal, preferPremium } = opts;
4896
6762
  const rows = /* @__PURE__ */ new Map();
4897
6763
  const eligibleAll = [];
4898
6764
  seats.forEach((seat, i) => {
4899
- const elig = available.has(seat.label) && (!categoryKey || seat.categoryKey === categoryKey);
4900
- 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);
4901
6768
  if (!arr) {
4902
6769
  arr = [];
4903
- rows.set(seat.rowId, arr);
6770
+ rows.set(rowId, arr);
4904
6771
  }
4905
6772
  arr.push({ seat, index: seatIndex2(seat, i), elig });
4906
6773
  if (elig) eligibleAll.push(seat);
@@ -4925,7 +6792,7 @@ function pickBestAvailable(seats, available, opts) {
4925
6792
  continue;
4926
6793
  }
4927
6794
  let j = i;
4928
- 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++;
4929
6796
  const segLen = j - i;
4930
6797
  for (let p = 0; p + qty <= segLen; p++) {
4931
6798
  const leftRem = p;
@@ -4939,7 +6806,7 @@ function pickBestAvailable(seats, available, opts) {
4939
6806
  startIndex: slots[i + p].index,
4940
6807
  orphan,
4941
6808
  nonPremium: preferPremium ? runSeats.reduce((n, s) => n + (isPremium(s) ? 0 : 1), 0) : 0,
4942
- d2: dist2(centroid(runSeats), focal)
6809
+ d2: dist2(centroid(runSeats), candidateFocal(runSeats, focal))
4943
6810
  };
4944
6811
  if (better(c)) best = c;
4945
6812
  }
@@ -4948,20 +6815,71 @@ function pickBestAvailable(seats, available, opts) {
4948
6815
  }
4949
6816
  if (best) return { labels: best.labels };
4950
6817
  if (eligibleAll.length < qty) return { labels: [], reason: "not_enough_together" };
4951
- 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) => {
4952
6819
  if (preferPremium) {
4953
6820
  const pa = isPremium(a.seat) ? 0 : 1;
4954
6821
  const pb = isPremium(b.seat) ? 0 : 1;
4955
6822
  if (pa !== pb) return pa - pb;
4956
6823
  }
4957
6824
  if (a.d2 !== b.d2) return a.d2 - b.d2;
4958
- 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);
4959
6826
  if (r !== 0) return r;
4960
6827
  return seatIndex2(a.seat, a.i) - seatIndex2(b.seat, b.i);
4961
6828
  });
4962
6829
  return { labels: ranked.slice(0, qty).map((r) => r.seat.label) };
4963
6830
  }
4964
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
+
4965
6883
  // src/picker/PickerController.ts
4966
6884
  var DEFAULT_MAX_SELECTION = 10;
4967
6885
  var MAX_BACKOFF_MS = 15e3;
@@ -4993,6 +6911,14 @@ var PickerController = class {
4993
6911
  /** id → buyer-facing spatial metadata used by every tooltip/confirm surface. */
4994
6912
  this.seatContext = /* @__PURE__ */ new Map();
4995
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;
4996
6922
  // realtime socket
4997
6923
  this.ws = null;
4998
6924
  this.reconnectTimer = null;
@@ -5054,6 +6980,20 @@ var PickerController = class {
5054
6980
  idForLabel(label) {
5055
6981
  return this.labelToId.get(label);
5056
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
+ }
5057
6997
  /** Fetch chart, build label maps, mount the renderer, seed statuses, go live. */
5058
6998
  async render(host) {
5059
6999
  if (this.renderer) return null;
@@ -5067,6 +7007,29 @@ var PickerController = class {
5067
7007
  }
5068
7008
  if (this.closed) return null;
5069
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
+ }
5070
7033
  this.labelToId = /* @__PURE__ */ new Map();
5071
7034
  this.labelToSeat = /* @__PURE__ */ new Map();
5072
7035
  this.seatById = /* @__PURE__ */ new Map();
@@ -5084,30 +7047,43 @@ var PickerController = class {
5084
7047
  this.allIds.push(s.id);
5085
7048
  const source = chartObjects.get(s.rowId);
5086
7049
  const sourceLabel = source && "label" in source && typeof source.label === "string" ? source.label : void 0;
5087
- const sourceDisplayLabel = source && "displayLabel" in source && typeof source.displayLabel === "string" && source.displayLabel ? source.displayLabel : sourceLabel;
5088
- const rowLabel = s.kind === "booth" ? void 0 : sourceDisplayLabel;
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);
5089
7056
  const visibleSeatLabel = s.displayLabel ?? s.label;
5090
7057
  const labelParts = visibleSeatLabel.split("-");
5091
- 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;
5092
7059
  this.seatContext.set(s.id, {
7060
+ objectId: s.rowId,
7061
+ objectType,
5093
7062
  sectionLabel: sectionLabels.get(membership.objectToSection.get(s.rowId) ?? ""),
5094
7063
  rowLabel,
5095
- seatNumber
7064
+ seatNumber,
7065
+ ...rowType ? { displayType: rowType, rowType } : {}
5096
7066
  });
5097
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
+ }
5098
7074
  const currency = res.event.currency ?? this.opts.currency;
5099
7075
  this.currency = currency ?? "USD";
5100
7076
  const renderer = createRenderer(host, {
5101
- 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(),
5102
7080
  confirmSelection: this.opts.confirmSelection,
5103
7081
  currency,
5104
7082
  onSelect: (seat) => {
5105
- this.opts.onSelect?.(seat);
5106
- this.emitSelectionChange();
7083
+ this.handleRendererSelect(seat);
5107
7084
  },
5108
7085
  onDeselect: (seat) => {
5109
- this.opts.onDeselect?.(seat);
5110
- this.emitSelectionChange();
7086
+ this.handleRendererDeselect(seat);
5111
7087
  },
5112
7088
  onSelectionLimit: this.opts.onSelectionLimit,
5113
7089
  onHover: (seat) => {
@@ -5162,7 +7138,20 @@ var PickerController = class {
5162
7138
  // ---- selection ------------------------------------------------------------
5163
7139
  getSelection() {
5164
7140
  if (!this.renderer) return [];
5165
- 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;
5166
7155
  }
5167
7156
  /** Enriched metadata for a seat confirmation card or tooltip. */
5168
7157
  seatDetails(seatId) {
@@ -5171,20 +7160,168 @@ var PickerController = class {
5171
7160
  }
5172
7161
  clearSelection() {
5173
7162
  this.renderer?.clearSelection();
7163
+ this.resetUnheldTableQuantities();
5174
7164
  this.emitSelectionChange();
5175
7165
  }
5176
7166
  deselect(ids) {
5177
- 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]);
5178
7182
  this.emitSelectionChange();
5179
7183
  }
5180
7184
  setMaxSelection(maxSelection) {
5181
7185
  this.maxSelection = Math.max(0, Math.floor(maxSelection));
5182
- this.renderer?.setMaxSelection?.(this.maxSelection);
7186
+ this.renderer?.setMaxSelection?.(this.rendererSelectionCap());
5183
7187
  }
5184
7188
  select(ids) {
5185
- 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));
5186
7203
  if (added.length) this.emitSelectionChange();
5187
- 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 };
5188
7325
  }
5189
7326
  // ---- booking machine ------------------------------------------------------
5190
7327
  /**
@@ -5196,7 +7333,7 @@ var PickerController = class {
5196
7333
  async hold(labelsArg, ttlMs) {
5197
7334
  const r = this.renderer;
5198
7335
  if (!r) return null;
5199
- const labels = labelsArg ?? r.getSelection().map((s) => s.label);
7336
+ const labels = labelsArg ?? this.getSelection().map((seat) => seat.label);
5200
7337
  const existingGA = (this.hold_?.items ?? []).filter((item) => item.objectType === "ga");
5201
7338
  const combinedLabels = [.../* @__PURE__ */ new Set([...existingGA.map((item) => item.label), ...labels])];
5202
7339
  if (!combinedLabels.length) return null;
@@ -5204,10 +7341,14 @@ var PickerController = class {
5204
7341
  try {
5205
7342
  const selections = combinedLabels.map((label) => {
5206
7343
  const ga = existingGA.find((item) => item.label === label);
5207
- if (ga) return { label, tierId: ga.tierId };
7344
+ if (ga) return { label, tierId: ga.tierId, quantity: ga.quantity };
5208
7345
  const seat = this.labelToSeat.get(label);
5209
7346
  const resolved = seat ? this.toSeat(seat) : null;
5210
- return { label, ...resolved?.tierId ? { tierId: resolved.tierId } : {} };
7347
+ return {
7348
+ label,
7349
+ ...resolved?.tierId ? { tierId: resolved.tierId } : {},
7350
+ ...this.tableQuantityRequest(resolved)
7351
+ };
5211
7352
  });
5212
7353
  const result = await this.api.hold(this.key, selections, ttlMs, this.hold_?.holdId);
5213
7354
  this.setHold({ holdId: result.holdId, labels: combinedLabels, expiresAt: result.expiresAt, items: result.items });
@@ -5259,11 +7400,19 @@ var PickerController = class {
5259
7400
  const out = {};
5260
7401
  const closedMembers = this.closedMemberIds();
5261
7402
  for (const [id, s] of this.seatById) {
7403
+ if (this.groupedTableBySeatId.has(id)) continue;
5262
7404
  if (closedMembers.has(id)) continue;
5263
7405
  if ((this.getStatus(id) ?? "free") === "free") {
5264
7406
  out[s.categoryKey] = (out[s.categoryKey] ?? 0) + 1;
5265
7407
  }
5266
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
+ }
5267
7416
  return out;
5268
7417
  }
5269
7418
  /** Seat ids belonging to a currently-closed section (excluded from counts). */
@@ -5282,6 +7431,8 @@ var PickerController = class {
5282
7431
  return {
5283
7432
  id: area.id,
5284
7433
  label: area.label,
7434
+ ...area.displayLabel ? { displayLabel: area.displayLabel } : {},
7435
+ ...area.displayType ? { displayType: area.displayType } : {},
5285
7436
  capacity: Math.max(0, Math.floor(area.capacity)),
5286
7437
  available: gaUnitLabels(area).filter((label) => (this.liveStatuses.get(label) ?? "free") === "free").length,
5287
7438
  categoryKey: area.categoryKey,
@@ -5300,14 +7451,20 @@ var PickerController = class {
5300
7451
  const labels = gaUnitLabels(area).filter((label) => !this.hold_?.labels.includes(label) && (this.liveStatuses.get(label) ?? "free") === "free").slice(0, Math.floor(qty));
5301
7452
  if (labels.length !== Math.floor(qty)) return null;
5302
7453
  const selections = /* @__PURE__ */ new Map();
5303
- 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
+ });
5304
7459
  if (this.hold_ && !this.hold_?.items?.length) {
5305
7460
  for (const seat of this.hold_.seats) selections.set(seat.label, { label: seat.label, ...seat.tierId ? { tierId: seat.tierId } : {} });
5306
7461
  }
5307
- for (const seat of this.renderer?.getSelection() ?? []) {
5308
- const resolved = this.labelToSeat.get(seat.label);
5309
- const chosen = resolved ? this.toSeat(resolved) : null;
5310
- 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
+ });
5311
7468
  }
5312
7469
  for (const label of labels) selections.set(label, { label, ...options.tierId ? { tierId: options.tierId } : {} });
5313
7470
  const combined = [...selections.values()];
@@ -5324,6 +7481,15 @@ var PickerController = class {
5324
7481
  }
5325
7482
  return false;
5326
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
+ }
5327
7493
  /**
5328
7494
  * Client-side premium pre-pass: find the best contiguous block of `qty`
5329
7495
  * PREMIUM-flagged free seats (orphan-avoiding, closest to the focal point)
@@ -5332,11 +7498,12 @@ var PickerController = class {
5332
7498
  * seat status. Returns a FULL premium block of `qty`, or null when none
5333
7499
  * exists (the caller then falls back to the normal server pick).
5334
7500
  */
5335
- pickPremiumBlock(qty, categoryKey) {
7501
+ pickPremiumBlock(qty, categoryKey, zoneId) {
5336
7502
  const doc = this.visibleDoc();
5337
7503
  if (!doc?.objects?.length) return null;
5338
7504
  const focal = doc.focalPoint ?? { x: 0, y: 0 };
5339
- const seats = expandChart(doc);
7505
+ const groupedObjectIds = new Set(this.groupedTablesByObject.keys());
7506
+ const seats = expandChart(doc).filter((seat) => !groupedObjectIds.has(seat.rowId));
5340
7507
  const held = new Set(this.hold_?.labels ?? []);
5341
7508
  const available = /* @__PURE__ */ new Set();
5342
7509
  for (const seat of seats) {
@@ -5345,7 +7512,7 @@ var PickerController = class {
5345
7512
  const status = id ? this.getStatus(id) : void 0;
5346
7513
  if ((status ?? "free") === "free") available.add(seat.label);
5347
7514
  }
5348
- const pick = pickBestAvailable(seats, available, { qty, categoryKey, focal, preferPremium: true });
7515
+ const pick = pickBestAvailable(seats, available, { qty, categoryKey, zoneId, focal, preferPremium: true });
5349
7516
  if (pick.labels.length !== qty) return null;
5350
7517
  const allPremium = pick.labels.every((l) => this.labelToSeat.get(l)?.commercial?.premium);
5351
7518
  return allPremium ? [...pick.labels] : null;
@@ -5360,7 +7527,7 @@ var PickerController = class {
5360
7527
  const r = this.renderer;
5361
7528
  if (!r) return null;
5362
7529
  if (opts.preferPremium) {
5363
- const block = this.pickPremiumBlock(qty, categoryKey);
7530
+ const block = this.pickPremiumBlock(qty, categoryKey, opts.zoneId);
5364
7531
  if (block) {
5365
7532
  if (this.hold_ && !await this.release()) return null;
5366
7533
  try {
@@ -5371,7 +7538,7 @@ var PickerController = class {
5371
7538
  });
5372
7539
  const result = await this.api.hold(this.key, selection);
5373
7540
  r.clearSelection();
5374
- const ids = block.map((l) => this.labelToId.get(l)).filter((v) => !!v);
7541
+ const ids = this.idsForLabels(block);
5375
7542
  if (ids.length) r.setStatus(ids, "held");
5376
7543
  this.setHold({ holdId: result.holdId, labels: [...block], expiresAt: result.expiresAt, items: result.items });
5377
7544
  const seats = block.map((l) => this.labelToSeat.get(l)).filter((s) => !!s).map((s) => this.toSeat(s));
@@ -5388,9 +7555,9 @@ var PickerController = class {
5388
7555
  }
5389
7556
  if (this.hold_ && !await this.release()) return null;
5390
7557
  try {
5391
- const result = await this.api.bestAvailable(this.key, qty, categoryKey);
7558
+ const result = await this.api.bestAvailable(this.key, qty, categoryKey, opts.zoneId);
5392
7559
  r.clearSelection();
5393
- const ids = result.labels.map((l) => this.labelToId.get(l)).filter((v) => !!v);
7560
+ const ids = this.idsForLabels(result.labels);
5394
7561
  if (ids.length) r.setStatus(ids, "held");
5395
7562
  this.setHold({ holdId: result.holdId, labels: [...result.labels], expiresAt: result.expiresAt, items: result.items });
5396
7563
  const seats = result.labels.map((l) => this.labelToSeat.get(l)).filter((s) => !!s).map((s) => this.toSeat(s));
@@ -5414,7 +7581,7 @@ var PickerController = class {
5414
7581
  const r = this.renderer;
5415
7582
  if (!r) return null;
5416
7583
  if (!this.api.book) throw new Error("picker: transport has no book() \u2014 hold-only mode");
5417
- const labels = labelsArg ?? r.getSelection().map((s) => s.label);
7584
+ const labels = labelsArg ?? this.getSelection().map((seat) => seat.label);
5418
7585
  if (!labels.length) return null;
5419
7586
  let holdId;
5420
7587
  try {
@@ -5427,7 +7594,11 @@ var PickerController = class {
5427
7594
  labels.map((label) => {
5428
7595
  const seat = this.labelToSeat.get(label);
5429
7596
  const resolved = seat ? this.toSeat(seat) : null;
5430
- return { label, ...resolved?.tierId ? { tierId: resolved.tierId } : {} };
7597
+ return {
7598
+ label,
7599
+ ...resolved?.tierId ? { tierId: resolved.tierId } : {},
7600
+ ...this.tableQuantityRequest(resolved)
7601
+ };
5431
7602
  }),
5432
7603
  void 0,
5433
7604
  replaceHoldId
@@ -5443,10 +7614,11 @@ var PickerController = class {
5443
7614
  this.opts.onSalesClosed?.();
5444
7615
  } else if (status === 409 && conflicts?.length) {
5445
7616
  const takenLabels = new Set(conflicts.map((c) => c.label));
5446
- const takenIds = [...takenLabels].map((l) => this.labelToId.get(l)).filter((v) => !!v);
7617
+ const takenIds = this.idsForLabels(takenLabels);
5447
7618
  if (takenIds.length) {
5448
7619
  r.setStatus(takenIds, "booked");
5449
7620
  r.deselect(takenIds);
7621
+ this.resetTableQuantitiesForLabels(takenLabels);
5450
7622
  }
5451
7623
  const stillFree = labels.filter((l) => !takenLabels.has(l));
5452
7624
  if (stillFree.length && holdId) void this.api.release(this.key, stillFree, holdId).catch(() => {
@@ -5458,12 +7630,13 @@ var PickerController = class {
5458
7630
  }
5459
7631
  throw err;
5460
7632
  }
5461
- const ids = labels.map((l) => this.labelToId.get(l)).filter((v) => !!v);
7633
+ const ids = this.idsForLabels(labels);
5462
7634
  if (ids.length) {
5463
7635
  r.setStatus(ids, "booked");
5464
7636
  r.deselect(ids);
5465
7637
  }
5466
7638
  this.clearHold();
7639
+ this.resetTableQuantitiesForLabels(labels);
5467
7640
  this.emitSelectionChange();
5468
7641
  this.opts.onBook?.(bookingRef);
5469
7642
  return labels;
@@ -5484,7 +7657,8 @@ var PickerController = class {
5484
7657
  }
5485
7658
  if (this.hold_?.holdId !== hold.holdId) return true;
5486
7659
  this.clearHold();
5487
- 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);
5488
7662
  if (ids.length) {
5489
7663
  this.renderer?.deselect(ids);
5490
7664
  this.renderer?.setStatus(ids, "free");
@@ -5515,7 +7689,8 @@ var PickerController = class {
5515
7689
  const remainingItems = hold.items?.filter((item) => !drop.includes(item.label));
5516
7690
  if (remaining.length) this.setHold({ ...hold, labels: remaining, items: remainingItems });
5517
7691
  else this.clearHold();
5518
- const ids = drop.map((l) => this.labelToId.get(l)).filter((v) => !!v);
7692
+ this.resetTableQuantitiesForLabels(drop);
7693
+ const ids = this.idsForLabels(drop);
5519
7694
  if (ids.length) {
5520
7695
  this.renderer?.deselect(ids);
5521
7696
  this.renderer?.setStatus(ids, "free");
@@ -5604,7 +7779,7 @@ var PickerController = class {
5604
7779
  this.opts.onDeckTap?.(floorId);
5605
7780
  }
5606
7781
  // ---- big-venue: sections / rungs / projection (Slice 5) -------------------
5607
- /** 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. */
5608
7783
  setViewMode(mode) {
5609
7784
  this.renderer?.setViewMode?.(mode);
5610
7785
  }
@@ -5703,12 +7878,21 @@ var PickerController = class {
5703
7878
  if (!sec) return null;
5704
7879
  const memberIds = r.sectionMembers?.(id) ?? [];
5705
7880
  const byCat = /* @__PURE__ */ new Map();
7881
+ const seenTables = /* @__PURE__ */ new Set();
5706
7882
  let seatsLeft = 0;
5707
7883
  for (const sid of memberIds) {
5708
7884
  const seat = this.seatById.get(sid);
5709
7885
  if (!seat) continue;
5710
- byCat.set(seat.categoryKey, (byCat.get(seat.categoryKey) ?? 0) + 1);
5711
- 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
+ }
5712
7896
  }
5713
7897
  const categories = [...byCat.entries()].map(([key, count]) => {
5714
7898
  const cat = doc.categories.find((c) => c.key === key);
@@ -5727,6 +7911,7 @@ var PickerController = class {
5727
7911
  id,
5728
7912
  label: sec.label,
5729
7913
  zoneLabel: zone?.label ?? "",
7914
+ ...sec.entrance && sec.entrance.trim() ? { entrance: sec.entrance.trim() } : {},
5730
7915
  color,
5731
7916
  seatsLeft,
5732
7917
  priceMin: prices.length ? prices[0] : 0,
@@ -5757,22 +7942,54 @@ var PickerController = class {
5757
7942
  }
5758
7943
  // ---- internals ------------------------------------------------------------
5759
7944
  toSeat(s) {
7945
+ const table = this.groupedTableBySeatId.get(s.id);
7946
+ if (table) return this.toTableSeat(table);
5760
7947
  const commercial = s.commercial ? { commercial: s.commercial } : void 0;
5761
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);
5762
7952
  const tiers = this.tiersFor(s.categoryKey);
5763
7953
  if (!tiers) {
5764
- 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 };
5765
7955
  }
5766
7956
  const chosen = tiers.find((t2) => t2.id === this.seatTiers.get(s.id)) ?? tiers[0];
5767
7957
  return {
5768
7958
  id: s.id,
5769
7959
  label: s.label,
5770
7960
  ...display,
7961
+ ...context,
5771
7962
  categoryKey: s.categoryKey,
5772
7963
  price: chosen.price,
5773
7964
  tiers,
5774
7965
  tierId: chosen.id,
5775
- ...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)
5776
7993
  };
5777
7994
  }
5778
7995
  /** Tooltip payload for a hovered seat — see PickerCallbacks.onSeatHover. */
@@ -5856,10 +8073,13 @@ var PickerController = class {
5856
8073
  const h = this.hold_;
5857
8074
  if (!h || h.labels.length !== labels.length || !labels.every((l) => h.labels.includes(l))) return false;
5858
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]));
5859
8077
  return labels.every((label) => {
5860
8078
  const seat = this.labelToSeat.get(label);
5861
- const currentTier = seat ? this.toSeat(seat).tierId ?? null : null;
5862
- 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);
5863
8083
  });
5864
8084
  }
5865
8085
  handle409Conflicts(err) {
@@ -5867,19 +8087,28 @@ var PickerController = class {
5867
8087
  if (status !== 409 || !conflicts?.length) return;
5868
8088
  const r = this.renderer;
5869
8089
  if (!r) return;
5870
- const takenIds = conflicts.map((c) => this.labelToId.get(c.label)).filter((v) => !!v);
8090
+ const takenIds = this.idsForLabels(conflicts.map((conflict) => conflict.label));
5871
8091
  if (takenIds.length) {
5872
8092
  r.deselect(takenIds);
5873
8093
  r.setStatus(takenIds, "held");
8094
+ this.resetTableQuantitiesForLabels(conflicts.map((conflict) => conflict.label));
5874
8095
  }
5875
8096
  this.emitSelectionChange();
5876
8097
  }
5877
8098
  /** Set the open hold + (re)arm the server-authoritative expiry timer. */
5878
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
+ }
5879
8108
  const full = { ...hold, seats: this.seatsForLabels(hold.labels) };
5880
8109
  this.hold_ = full;
5881
8110
  this.renderer?.setOwnedHold?.(
5882
- full.labels.map((label) => this.labelToId.get(label)).filter((id) => !!id)
8111
+ this.idsForLabels(full.labels)
5883
8112
  );
5884
8113
  if (this.expiryTimer) clearTimeout(this.expiryTimer);
5885
8114
  const ms = Math.max(0, full.expiresAt - Date.now());
@@ -5922,12 +8151,11 @@ var PickerController = class {
5922
8151
  this.liveStatuses = new Map(Object.entries(seats));
5923
8152
  if (this.allIds.length) r.setStatus(this.allIds, "free");
5924
8153
  r.setOwnedHold?.(
5925
- (this.hold_?.labels ?? []).map((label) => this.labelToId.get(label)).filter((id) => !!id)
8154
+ this.idsForLabels(this.hold_?.labels ?? [])
5926
8155
  );
5927
8156
  const byStatus = { free: [], held: [], booked: [], not_for_sale: [] };
5928
8157
  for (const [label, st] of Object.entries(seats)) {
5929
- const id = this.labelToId.get(label);
5930
- if (id) byStatus[mapStatus(st)].push(id);
8158
+ byStatus[mapStatus(st)].push(...this.idsForLabel(label));
5931
8159
  }
5932
8160
  ["held", "booked", "not_for_sale"].forEach((st) => {
5933
8161
  if (byStatus[st].length) r.setStatus(byStatus[st], st);
@@ -5936,7 +8164,11 @@ var PickerController = class {
5936
8164
  this.clearBookedHoldIfSettled();
5937
8165
  }
5938
8166
  clearBookedHoldIfSettled() {
5939
- 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
+ }
5940
8172
  }
5941
8173
  async resnapshot() {
5942
8174
  try {
@@ -6025,13 +8257,13 @@ var PickerController = class {
6025
8257
  } else if (Array.isArray(m.changes)) {
6026
8258
  for (const ch of m.changes) {
6027
8259
  this.liveStatuses.set(ch.label, ch.status);
6028
- const id = this.labelToId.get(ch.label);
6029
- if (!id) continue;
8260
+ const ids = this.idsForLabel(ch.label);
8261
+ if (!ids.length) continue;
6030
8262
  const next = mapStatus(ch.status);
6031
- if (this.opts.flashOnLiveChange && next !== "free" && r.getStatus(id) === "free" && !this.hold_?.labels.includes(ch.label)) {
6032
- 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"));
6033
8265
  }
6034
- r.setStatus([id], next);
8266
+ r.setStatus(ids, next);
6035
8267
  }
6036
8268
  if (this.opts.keepLiveWhileHidden && typeof document !== "undefined" && document.visibilityState === "hidden") {
6037
8269
  r.forceDraw();
@@ -6062,10 +8294,25 @@ var PickerController = class {
6062
8294
  }
6063
8295
  };
6064
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
+
6065
8312
  // src/view/generatePanorama.ts
6066
8313
  var W = 2048;
6067
8314
  var H = 1024;
6068
- var UNIT = 0.55 / 24;
8315
+ var UNIT = METRES_PER_CHART_UNIT;
6069
8316
  var yawToX = (yawDeg) => (yawDeg + 180) / 360 * W;
6070
8317
  var pitchToY = (pitchDeg) => (90 - pitchDeg) / 180 * H;
6071
8318
  function generateSeatPanorama(seat, focalPoint, neighborSeats) {
@@ -6085,9 +8332,11 @@ function generateSeatPanorama(seat, focalPoint, neighborSeats) {
6085
8332
  sky.addColorStop(1, "#07090f");
6086
8333
  ctx.fillStyle = sky;
6087
8334
  ctx.fillRect(0, 0, W, H);
8335
+ const eyeM = seat.eyeHeightM ?? SEATED_EYE_HEIGHT_M;
6088
8336
  const stageHalfYaw = Math.min(80, Math.atan2(4, distM) * 180 / Math.PI);
6089
- const stageTopPitch = Math.min(45, Math.atan2(5, distM) * 180 / Math.PI);
6090
- 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;
6091
8340
  const sx0 = yawToX(-stageHalfYaw);
6092
8341
  const sx1 = yawToX(stageHalfYaw);
6093
8342
  const sy0 = pitchToY(stageTopPitch);
@@ -6194,9 +8443,11 @@ function generateSeatThumb(seat, focalPoint, _neighborSeats) {
6194
8443
  sky.addColorStop(1, "#070910");
6195
8444
  ctx.fillStyle = sky;
6196
8445
  ctx.fillRect(0, 0, TW, TH);
8446
+ const eyeM = seat.eyeHeightM ?? SEATED_EYE_HEIGHT_M;
8447
+ const sightline = stageSightlinePitch(eyeM, distM);
6197
8448
  const stageHalfYaw = Math.min(HALF_HFOV - 2, Math.atan2(4, distM) * 180 / Math.PI);
6198
- const stageTopPitch = Math.min(HALF_VFOV - 2, Math.atan2(5, distM) * 180 / Math.PI);
6199
- 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);
6200
8451
  const sx0 = yawToTX(-stageHalfYaw);
6201
8452
  const sx1 = yawToTX(stageHalfYaw);
6202
8453
  const sy0 = pitchToTY(stageTopPitch);
@@ -6292,8 +8543,11 @@ async function loadLocale(code) {
6292
8543
  MAX_EVENT_INVENTORY,
6293
8544
  MAX_GA_CAPACITY,
6294
8545
  PickerController,
8546
+ RENDERED_QUALITY_REPORT_VERSION,
6295
8547
  SUPPORTED_LOCALES,
8548
+ SURROUNDINGS_SHAPE_ROLES,
6296
8549
  SeatmapRenderer,
8550
+ TIER_HEIGHT_M,
6297
8551
  UNGROUPED_ID,
6298
8552
  accessibilityMeta,
6299
8553
  accessibilityRingColor,
@@ -6308,33 +8562,43 @@ async function loadLocale(code) {
6308
8562
  expandRow,
6309
8563
  expandRowSlots,
6310
8564
  expandTable,
8565
+ expandTableSlots,
6311
8566
  floorObjects,
6312
8567
  floorsOf,
6313
8568
  formatDate,
6314
8569
  formatMoney,
6315
8570
  gaAreasOf,
8571
+ gaInventorySegments,
6316
8572
  gaUnitLabel,
6317
8573
  gaUnitLabels,
6318
8574
  generateSeatPanorama,
6319
8575
  generateSeatThumb,
6320
8576
  getLocale,
8577
+ growJoinedGAInventory,
6321
8578
  hiddenObjectIds,
8579
+ inspectRenderedQualityEvidence,
6322
8580
  isGaUnitLabel,
6323
8581
  isSectionHidden,
6324
8582
  layerOf,
6325
8583
  loadLocale,
6326
8584
  objectCenter,
8585
+ owningSectionForObject,
6327
8586
  pointInPolygon,
6328
8587
  pointInPolygonWithHoles,
6329
8588
  polygonLabelPoint,
6330
8589
  resolveLocale,
8590
+ rowInventoryCount,
6331
8591
  rowSeatPositions,
6332
8592
  seatLabelPart,
8593
+ sectionGeometry,
6333
8594
  setLocale,
6334
8595
  setMoneyLocale,
6335
8596
  setStringOverrides,
6336
8597
  stackFloors,
6337
8598
  t,
6338
- tCount
8599
+ tCount,
8600
+ tableInventoryCount,
8601
+ tableSeatCountsBySide,
8602
+ validGAInventorySegments
6339
8603
  });
6340
8604
  //# sourceMappingURL=index.cjs.map