@seatlayer/core 0.25.0 → 0.27.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
@@ -48,6 +48,7 @@ var init_es = __esm({
48
48
  // renderer (drawn on the Konva map — shared by the embed SDK)
49
49
  "map.aria": "Mapa de asientos. Usa las flechas para moverte entre asientos, Intro para seleccionar.",
50
50
  "map.seatsLeft": "{count} LIBRES",
51
+ "map.soldOut": "AGOTADO",
51
52
  "map.statusHeld": "En espera",
52
53
  "map.statusTaken": "Ocupado",
53
54
  "map.fromPrice": "DESDE {price}",
@@ -55,7 +56,7 @@ var init_es = __esm({
55
56
  "picker.floor": "Piso",
56
57
  "picker.zoomLevel": "Nivel de zoom",
57
58
  "picker.rungTip.zones": "Vista general \u2014 grupos de secciones como Grada norte o VIP",
58
- "picker.rungTip.sections": "Bloques de secci\xF3n \u2014 mezcla de categor\xEDas + matiz de disponibilidad",
59
+ "picker.rungTip.sections": "Bloques de secci\xF3n \u2014 las secciones agotadas y casi llenas se aten\xFAan para ver la disponibilidad de un vistazo",
59
60
  "picker.rungTip.seats": "Asientos individuales \u2014 los bloques se convierten en puntos",
60
61
  "picker.rungLabel.zones": "ZONAS",
61
62
  "picker.rungLabel.sections": "SECCIONES",
@@ -107,6 +108,7 @@ var init_de = __esm({
107
108
  // renderer (drawn on the Konva map — shared by the embed SDK)
108
109
  "map.aria": "Sitzplan. Mit Pfeiltasten zwischen Pl\xE4tzen navigieren, Eingabe zum Ausw\xE4hlen.",
109
110
  "map.seatsLeft": "{count} FREI",
111
+ "map.soldOut": "AUSVERKAUFT",
110
112
  "map.statusHeld": "Reserviert",
111
113
  "map.statusTaken": "Vergeben",
112
114
  "map.fromPrice": "AB {price}",
@@ -114,7 +116,7 @@ var init_de = __esm({
114
116
  "picker.floor": "Etage",
115
117
  "picker.zoomLevel": "Zoomstufe",
116
118
  "picker.rungTip.zones": "\xDCbersicht \u2014 Bereichsgruppen wie Nordtrib\xFCne oder VIP",
117
- "picker.rungTip.sections": "Bereichs-Bl\xF6cke \u2014 Kategoriesortierung + Verf\xFCgbarkeitst\xF6nung",
119
+ "picker.rungTip.sections": "Bereichs-Bl\xF6cke \u2014 ausverkaufte und fast volle Bereiche werden abgedunkelt, um die Verf\xFCgbarkeit auf einen Blick zu zeigen",
118
120
  "picker.rungTip.seats": "Einzelne Pl\xE4tze \u2014 Bl\xF6cke l\xF6sen sich zu Punkten auf",
119
121
  "picker.rungLabel.zones": "ZONEN",
120
122
  "picker.rungLabel.sections": "BEREICHE",
@@ -166,6 +168,7 @@ var init_fr = __esm({
166
168
  // renderer (drawn on the Konva map — shared by the embed SDK)
167
169
  "map.aria": "Plan de salle. Utilisez les fl\xE8ches pour naviguer entre les si\xE8ges, Entr\xE9e pour s\xE9lectionner.",
168
170
  "map.seatsLeft": "{count} LIBRES",
171
+ "map.soldOut": "COMPLET",
169
172
  "map.statusHeld": "En attente",
170
173
  "map.statusTaken": "Pris",
171
174
  "map.fromPrice": "D\xC8S {price}",
@@ -173,7 +176,7 @@ var init_fr = __esm({
173
176
  "picker.floor": "\xC9tage",
174
177
  "picker.zoomLevel": "Niveau de zoom",
175
178
  "picker.rungTip.zones": "Vue g\xE9n\xE9rale \u2014 groupes de sections comme Tribune nord ou VIP",
176
- "picker.rungTip.sections": "Blocs de sections \u2014 m\xE9lange de cat\xE9gories + teinte de disponibilit\xE9",
179
+ "picker.rungTip.sections": "Blocs de sections \u2014 les sections compl\xE8tes ou presque pleines sont estomp\xE9es pour voir la disponibilit\xE9 en un coup d\u2019\u0153il",
177
180
  "picker.rungTip.seats": "Si\xE8ges individuels \u2014 les blocs se transforment en points",
178
181
  "picker.rungLabel.zones": "ZONES",
179
182
  "picker.rungLabel.sections": "SECTIONS",
@@ -216,7 +219,9 @@ __export(index_exports, {
216
219
  PickerController: () => PickerController,
217
220
  RENDERED_QUALITY_REPORT_VERSION: () => RENDERED_QUALITY_REPORT_VERSION,
218
221
  SUPPORTED_LOCALES: () => SUPPORTED_LOCALES,
222
+ SURROUNDINGS_SHAPE_ROLES: () => SURROUNDINGS_SHAPE_ROLES,
219
223
  SeatmapRenderer: () => SeatmapRenderer,
224
+ TIER_HEIGHT_M: () => TIER_HEIGHT_M,
220
225
  UNGROUPED_ID: () => UNGROUPED_ID,
221
226
  accessibilityMeta: () => accessibilityMeta,
222
227
  accessibilityRingColor: () => accessibilityRingColor,
@@ -231,16 +236,19 @@ __export(index_exports, {
231
236
  expandRow: () => expandRow,
232
237
  expandRowSlots: () => expandRowSlots,
233
238
  expandTable: () => expandTable,
239
+ expandTableSlots: () => expandTableSlots,
234
240
  floorObjects: () => floorObjects,
235
241
  floorsOf: () => floorsOf,
236
242
  formatDate: () => formatDate,
237
243
  formatMoney: () => formatMoney,
238
244
  gaAreasOf: () => gaAreasOf,
245
+ gaInventorySegments: () => gaInventorySegments,
239
246
  gaUnitLabel: () => gaUnitLabel,
240
247
  gaUnitLabels: () => gaUnitLabels,
241
248
  generateSeatPanorama: () => generateSeatPanorama,
242
249
  generateSeatThumb: () => generateSeatThumb,
243
250
  getLocale: () => getLocale,
251
+ growJoinedGAInventory: () => growJoinedGAInventory,
244
252
  hiddenObjectIds: () => hiddenObjectIds,
245
253
  inspectRenderedQualityEvidence: () => inspectRenderedQualityEvidence,
246
254
  isGaUnitLabel: () => isGaUnitLabel,
@@ -248,18 +256,24 @@ __export(index_exports, {
248
256
  layerOf: () => layerOf,
249
257
  loadLocale: () => loadLocale,
250
258
  objectCenter: () => objectCenter,
259
+ owningSectionForObject: () => owningSectionForObject,
251
260
  pointInPolygon: () => pointInPolygon,
252
261
  pointInPolygonWithHoles: () => pointInPolygonWithHoles,
253
262
  polygonLabelPoint: () => polygonLabelPoint,
254
263
  resolveLocale: () => resolveLocale,
264
+ rowInventoryCount: () => rowInventoryCount,
255
265
  rowSeatPositions: () => rowSeatPositions,
256
266
  seatLabelPart: () => seatLabelPart,
267
+ sectionGeometry: () => sectionGeometry,
257
268
  setLocale: () => setLocale,
258
269
  setMoneyLocale: () => setMoneyLocale,
259
270
  setStringOverrides: () => setStringOverrides,
260
271
  stackFloors: () => stackFloors,
261
272
  t: () => t,
262
- tCount: () => tCount
273
+ tCount: () => tCount,
274
+ tableInventoryCount: () => tableInventoryCount,
275
+ tableSeatCountsBySide: () => tableSeatCountsBySide,
276
+ validGAInventorySegments: () => validGAInventorySegments
263
277
  });
264
278
  module.exports = __toCommonJS(index_exports);
265
279
 
@@ -269,6 +283,7 @@ var ACCESSIBILITY_TYPES = [
269
283
  { key: "companion", label: "Companion seat", short: "Companion", icon: "\u{1F9D1}\u200D\u{1F91D}\u200D\u{1F9D1}" },
270
284
  { key: "semi-ambulatory", label: "Semi-ambulatory (limited mobility)", short: "Limited mobility", icon: "\u{1F9AF}" },
271
285
  { key: "hearing", label: "Assistive listening", short: "Hearing", icon: "\u{1F9BB}" },
286
+ { key: "cart", label: "CART live-caption view", short: "CART captions", icon: "CC" },
272
287
  { key: "sign-language", label: "Sign-language view", short: "Sign language", icon: "\u{1F91F}" },
273
288
  { key: "plus-size", label: "Plus-size seat", short: "Plus-size", icon: "\u{1F4BA}" },
274
289
  { key: "lift-armrest", label: "Lift-up armrest", short: "Lift armrest", icon: "\u2195\uFE0F" }
@@ -282,6 +297,7 @@ var ACCESSIBILITY_RING_COLOR = {
282
297
  companion: "#8b5cf6",
283
298
  "semi-ambulatory": "#0ea5e9",
284
299
  hearing: "#14b8a6",
300
+ cart: "#7c3aed",
285
301
  "sign-language": "#f59e0b",
286
302
  "plus-size": "#ec4899",
287
303
  "lift-armrest": "#22c55e"
@@ -292,6 +308,19 @@ function accessibilityRingColor(types) {
292
308
  }
293
309
  var LABEL_STYLE_MIN_SIZE = 8;
294
310
  var LABEL_STYLE_MAX_SIZE = 24;
311
+ var SURROUNDINGS_SHAPE_ROLES = [
312
+ "reference-focal",
313
+ "bar",
314
+ "entrance",
315
+ "exit",
316
+ "restroom",
317
+ "screen",
318
+ "sound",
319
+ "concession",
320
+ "coat",
321
+ "wall"
322
+ ];
323
+ var SURROUNDINGS_SHAPE_ROLE_SET = new Set(SURROUNDINGS_SHAPE_ROLES);
295
324
  function layerOf(obj) {
296
325
  switch (obj.type) {
297
326
  case "row":
@@ -300,10 +329,17 @@ function layerOf(obj) {
300
329
  case "booth":
301
330
  case "section":
302
331
  return "interactive";
303
- // stage / décor live on 'shape'; free text + decor images are background furniture.
332
+ // Raster décor follows its explicit authored z-layer. Absence retains the
333
+ // legacy Background default; bitmap content is never guessed semantically.
334
+ case "decorImage":
335
+ return obj.layer === "foreground" ? "foreground" : "background";
336
+ // Source-backed focal geometry and curated venue landmarks help an author
337
+ // orient around the sellable plan. A stage and an ordinary authored shape
338
+ // remain Background even when they carry an arbitrary/custom role.
304
339
  case "shape":
340
+ return obj.role && SURROUNDINGS_SHAPE_ROLE_SET.has(obj.role) ? "surroundings" : "background";
341
+ // Free text — including semantic icon text — is background furniture.
305
342
  case "text":
306
- case "decorImage":
307
343
  return "background";
308
344
  default:
309
345
  return "interactive";
@@ -423,10 +459,57 @@ function toRoman(value) {
423
459
  return out;
424
460
  }
425
461
 
462
+ // src/core/units.ts
463
+ var METRES_PER_CHART_UNIT = 0.55 / 24;
464
+ var CHART_UNITS_PER_METRE = 1 / METRES_PER_CHART_UNIT;
465
+ var LIFT_PER_STEP_WORLD = 58;
466
+ var TIER_HEIGHT_M = LIFT_PER_STEP_WORLD * METRES_PER_CHART_UNIT;
467
+ var SECTION_ELEVATION_TIER_MIN = 0;
468
+ var SECTION_ELEVATION_TIER_MAX = 3;
469
+ var SECTION_HEIGHT_MIN_M = 0;
470
+ var SECTION_HEIGHT_MAX_M = 120;
471
+ var SECTION_RAKE_MIN_DEG = 0;
472
+ var SECTION_RAKE_MAX_DEG = 45;
473
+ var LEGACY_SECTION_ELEVATION_TIER_MAX = 7;
474
+ var SEATED_EYE_HEIGHT_M = 1.2;
475
+ function finiteClamped(value, min, max, fallback) {
476
+ return Number.isFinite(value) ? Math.max(min, Math.min(max, value)) : fallback;
477
+ }
478
+ function sectionElevationTier(value) {
479
+ if (!Number.isFinite(value)) return SECTION_ELEVATION_TIER_MIN;
480
+ return Math.max(
481
+ SECTION_ELEVATION_TIER_MIN,
482
+ Math.min(SECTION_ELEVATION_TIER_MAX, Math.round(value))
483
+ );
484
+ }
485
+ function compatibleAutomaticTier(value) {
486
+ if (!Number.isFinite(value) || !Number.isInteger(value) || value < 0) return 0;
487
+ if (value <= LEGACY_SECTION_ELEVATION_TIER_MAX) return value;
488
+ return SECTION_ELEVATION_TIER_MAX;
489
+ }
490
+ function sectionGeometry(section, context = {}) {
491
+ const floorBaseHeightM = finiteClamped(
492
+ context.floorBaseHeightM,
493
+ SECTION_HEIGHT_MIN_M,
494
+ SECTION_HEIGHT_MAX_M,
495
+ 0
496
+ );
497
+ const automaticHeight = Math.min(
498
+ SECTION_HEIGHT_MAX_M,
499
+ floorBaseHeightM + compatibleAutomaticTier(section.elevation) * TIER_HEIGHT_M
500
+ );
501
+ const height = section.height === void 0 ? automaticHeight : finiteClamped(section.height, SECTION_HEIGHT_MIN_M, SECTION_HEIGHT_MAX_M, automaticHeight);
502
+ const rake = finiteClamped(section.rake, SECTION_RAKE_MIN_DEG, SECTION_RAKE_MAX_DEG, 0);
503
+ return { height, rake };
504
+ }
505
+
426
506
  // src/core/layout.ts
427
507
  function overrideAccessibility(o) {
428
508
  if (!o) return [];
429
- if (o.accessibility && o.accessibility.length) return o.accessibility;
509
+ if (o.accessibility && o.accessibility.length) {
510
+ return o.wheelchairSpaceType && !o.accessibility.includes("wheelchair") ? ["wheelchair", ...o.accessibility] : o.accessibility;
511
+ }
512
+ if (o.wheelchairSpaceType) return ["wheelchair"];
430
513
  return o.accessible ? ["wheelchair"] : [];
431
514
  }
432
515
  var DEG = Math.PI / 180;
@@ -468,6 +551,10 @@ function overrideMap(row) {
468
551
  if (row.overrides) for (const o of row.overrides) m.set(o.index, o);
469
552
  return m;
470
553
  }
554
+ function rowInventoryCount(row) {
555
+ 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));
556
+ return Math.max(0, row.seatCount - skipped.size);
557
+ }
471
558
  function centerRank(n) {
472
559
  const rank = new Array(n);
473
560
  Array.from({ length: n }, (_, i) => i).sort((a, b) => Math.abs(2 * a - (n - 1)) - Math.abs(2 * b - (n - 1)) || a - b).forEach((idx, k) => rank[idx] = k);
@@ -481,9 +568,9 @@ function seatLabelPart(row, i) {
481
568
  const prefix = row.seatNumbering?.prefix ?? "";
482
569
  const endAt = row.seatNumbering?.endAt;
483
570
  const n = row.seatCount;
484
- if (scheme === "updown") {
571
+ if (scheme === "updown" || scheme === "updown-descending") {
485
572
  const half = Math.ceil(n / 2);
486
- const core2 = i < half ? rawStart + 2 * i : rawStart - 1 + 2 * (n - i);
573
+ const core2 = scheme === "updown" ? i < half ? rawStart + 2 * i : rawStart - 1 + 2 * (n - i) : i < half ? rawStart + 2 * (half - 1 - i) : rawStart + 1 + 2 * (i - half);
487
574
  return `${prefix}${core2}`;
488
575
  }
489
576
  const effStep = scheme === "odd" || scheme === "even" ? 2 : step;
@@ -536,6 +623,7 @@ function expandRowSlots(row) {
536
623
  skipped: !!o?.skip,
537
624
  accessible: accessibility.length > 0,
538
625
  accessibility,
626
+ wheelchairSpaceType: o?.wheelchairSpaceType,
539
627
  commercial: Object.values(commercial).some((value) => value !== void 0 && value !== false && value !== "") ? commercial : void 0,
540
628
  viewUrl: o?.viewFromSeatUrl ?? row.viewFromSeatUrl,
541
629
  labelStyle: o?.labelStyle
@@ -556,6 +644,7 @@ function expandRow(row) {
556
644
  categoryKey: slot.categoryKey,
557
645
  accessible: slot.accessible || void 0,
558
646
  accessibility: slot.accessibility.length ? slot.accessibility : void 0,
647
+ wheelchairSpaceType: slot.wheelchairSpaceType,
559
648
  commercial: slot.commercial,
560
649
  viewUrl: slot.viewUrl,
561
650
  labelStyle: slot.labelStyle
@@ -563,18 +652,43 @@ function expandRow(row) {
563
652
  }
564
653
  return seats;
565
654
  }
566
- function expandTable(t2) {
655
+ function tableSeatCountsBySide(t2) {
656
+ if (t2.seatCountsBySide) return { ...t2.seatCountsBySide };
657
+ const enabled = t2.sides && t2.sides.length ? t2.sides : ["top", "bottom"];
658
+ const order = ["top", "bottom", "left", "right"].filter((side) => enabled.includes(side));
659
+ const counts = { top: 0, bottom: 0, left: 0, right: 0 };
660
+ if (!order.length) return counts;
661
+ const n = Math.max(0, Math.round(t2.seatCount));
662
+ for (let index = 0; index < n; index++) counts[order[index % order.length]] += 1;
663
+ return counts;
664
+ }
665
+ function expandTableSlots(t2) {
567
666
  const seats = [];
568
667
  const n = Math.max(0, Math.round(t2.seatCount));
569
668
  if (n === 0) return seats;
570
- const mk = (i, x, y) => ({
571
- id: `${t2.id}:${i}`,
572
- label: `${t2.label}-${i + 1}`,
573
- x,
574
- y,
575
- rowId: t2.id,
576
- categoryKey: t2.categoryKey
577
- });
669
+ const overrides2 = new Map((t2.overrides ?? []).map((override) => [override.index, override]));
670
+ const mk = (index, x, y, side) => {
671
+ const override = overrides2.get(index);
672
+ const accessibility = overrideAccessibility(override);
673
+ const label = override?.label ?? `${t2.label}-${index + 1}`;
674
+ const displayPrefix = t2.displayLabel ?? t2.label;
675
+ return {
676
+ index,
677
+ label,
678
+ displayLabel: override?.displayLabel ?? `${displayPrefix}-${index + 1}`,
679
+ x: x + (override?.dx ?? 0),
680
+ y: y + (override?.dy ?? 0),
681
+ categoryKey: override?.categoryKey ?? t2.categoryKey,
682
+ skipped: !!override?.skip,
683
+ accessible: accessibility.length > 0,
684
+ accessibility,
685
+ wheelchairSpaceType: override?.wheelchairSpaceType,
686
+ commercial: override?.commercial,
687
+ viewUrl: override?.viewFromSeatUrl,
688
+ labelStyle: override?.labelStyle,
689
+ ...side ? { side } : {}
690
+ };
691
+ };
578
692
  if (t2.shape === "round") {
579
693
  const R = (t2.radius ?? 40) + TABLE_SEAT_OFFSET;
580
694
  const base = t2.rotation * DEG;
@@ -597,17 +711,11 @@ function expandTable(t2) {
597
711
  }
598
712
  const w = t2.width ?? 80;
599
713
  const h = t2.height ?? 50;
600
- const enabled = t2.sides && t2.sides.length ? t2.sides : ["top", "bottom"];
601
- const order = ["top", "bottom", "left", "right"].filter((s) => enabled.includes(s));
602
- if (!order.length) return seats;
603
- const counts = new Map(order.map((s) => [s, 0]));
604
- for (let i = 0; i < n; i++) {
605
- const s = order[i % order.length];
606
- counts.set(s, counts.get(s) + 1);
607
- }
714
+ const counts = tableSeatCountsBySide(t2);
715
+ const order = ["top", "bottom", "left", "right"];
608
716
  let idx = 0;
609
717
  for (const side of order) {
610
- const count = counts.get(side);
718
+ const count = counts[side];
611
719
  for (let j = 0; j < count; j++) {
612
720
  let localX;
613
721
  let localY;
@@ -624,17 +732,39 @@ function expandTable(t2) {
624
732
  localX = w / 2 + TABLE_SEAT_OFFSET;
625
733
  localY = -h / 2 + (j + 0.5) * h / count;
626
734
  }
627
- const p = place(localX, localY, t2.rotation, t2.center);
628
- seats.push(mk(idx++, p.x, p.y));
735
+ const point = place(localX, localY, t2.rotation, t2.center);
736
+ seats.push(mk(idx++, point.x, point.y, side));
629
737
  }
630
738
  }
631
739
  return seats;
632
740
  }
741
+ function tableInventoryCount(t2) {
742
+ if (t2.bookAsWhole || t2.variableOccupancy) return Math.max(0, Math.round(t2.seatCount));
743
+ return expandTableSlots(t2).filter((slot) => !slot.skipped).length;
744
+ }
745
+ function expandTable(t2) {
746
+ return expandTableSlots(t2).filter((slot) => !slot.skipped).map((slot) => ({
747
+ id: `${t2.id}:${slot.index}`,
748
+ label: slot.label,
749
+ ...slot.displayLabel !== slot.label ? { displayLabel: slot.displayLabel } : {},
750
+ x: slot.x,
751
+ y: slot.y,
752
+ rowId: t2.id,
753
+ categoryKey: slot.categoryKey,
754
+ ...slot.accessible ? { accessible: true } : {},
755
+ ...slot.accessibility.length ? { accessibility: slot.accessibility } : {},
756
+ ...slot.wheelchairSpaceType ? { wheelchairSpaceType: slot.wheelchairSpaceType } : {},
757
+ ...slot.commercial ? { commercial: slot.commercial } : {},
758
+ ...slot.viewUrl ? { viewUrl: slot.viewUrl } : {},
759
+ ...slot.labelStyle ? { labelStyle: slot.labelStyle } : {}
760
+ }));
761
+ }
633
762
  function expandBooth(b) {
634
763
  return [
635
764
  {
636
765
  id: `${b.id}:0`,
637
766
  label: b.label,
767
+ ...b.displayLabel && b.displayLabel !== b.label ? { displayLabel: b.displayLabel } : {},
638
768
  x: b.center.x,
639
769
  y: b.center.y,
640
770
  rowId: b.id,
@@ -672,7 +802,7 @@ function polygonLabelPoint(outer, holes) {
672
802
  if (!outer.length) return { x: 0, y: 0 };
673
803
  const xs = outer.map((point) => point.x);
674
804
  const ys = outer.map((point) => point.y);
675
- const bounds = { minX: Math.min(...xs), maxX: Math.max(...xs), minY: Math.min(...ys), maxY: Math.max(...ys) };
805
+ const bounds2 = { minX: Math.min(...xs), maxX: Math.max(...xs), minY: Math.min(...ys), maxY: Math.max(...ys) };
676
806
  const centroid2 = polygonCentroid(outer);
677
807
  if (pointInPolygonWithHoles(centroid2, outer, holes)) return centroid2;
678
808
  let best = outer[0];
@@ -681,8 +811,8 @@ function polygonLabelPoint(outer, holes) {
681
811
  for (let row = 1; row < 24; row += 1) {
682
812
  for (let column = 1; column < 24; column += 1) {
683
813
  const point = {
684
- x: bounds.minX + (bounds.maxX - bounds.minX) * column / 24,
685
- y: bounds.minY + (bounds.maxY - bounds.minY) * row / 24
814
+ x: bounds2.minX + (bounds2.maxX - bounds2.minX) * column / 24,
815
+ y: bounds2.minY + (bounds2.maxY - bounds2.minY) * row / 24
686
816
  };
687
817
  if (!pointInPolygonWithHoles(point, outer, holes)) continue;
688
818
  const score = Math.min(...rings.flatMap((ring) => ring.map((start, index) => {
@@ -744,9 +874,32 @@ function objectCenter(o) {
744
874
  return { x: o.x + o.width / 2, y: o.y + o.height / 2 };
745
875
  }
746
876
  }
877
+ function samePoints(left, right) {
878
+ return left.length === right.length && left.every((point, index) => point.x === right[index].x && point.y === right[index].y);
879
+ }
880
+ function sameGASurfaceAsSection(object, section) {
881
+ if (object.type !== "gaArea" || !samePoints(object.points, section.outline)) return false;
882
+ const objectHoles = object.holes ?? [];
883
+ const sectionHoles = section.holes ?? [];
884
+ return objectHoles.length === sectionHoles.length && objectHoles.every((hole, index) => samePoints(hole, sectionHoles[index]));
885
+ }
886
+ function owningSectionForObject(objects, object) {
887
+ const sections = objects.filter((candidate) => candidate.type === "section");
888
+ const referencedLogicalId = "referenceInventorySource" in object ? object.referenceInventorySource?.logicalSectionId : void 0;
889
+ const center = objectCenter(object);
890
+ const referencedOwner = referencedLogicalId ? sections.find((section) => (section.logicalSectionId ?? section.id) === referencedLogicalId && (sameGASurfaceAsSection(object, section) || pointInPolygonWithHoles(center, section.outline, section.holes))) : void 0;
891
+ return referencedOwner ?? sections.find((section) => pointInPolygonWithHoles(center, section.outline, section.holes));
892
+ }
747
893
  function floorsOf(doc) {
748
894
  if (doc.floors && doc.floors.length) return doc.floors;
749
- return [{ id: "floor-0", name: "Main", objects: doc.objects, focalPoint: doc.focalPoint, backgroundImage: doc.backgroundImage }];
895
+ return [{
896
+ id: "floor-0",
897
+ name: "Main",
898
+ objects: doc.objects,
899
+ focalPoint: doc.focalPoint,
900
+ referenceImage: doc.referenceImage,
901
+ backgroundImage: doc.backgroundImage
902
+ }];
750
903
  }
751
904
  function floorObjects(doc, floorId) {
752
905
  const floors = floorsOf(doc);
@@ -795,15 +948,138 @@ function stackFloors(doc, spread = 900) {
795
948
  });
796
949
  return { ...doc, objects, floors: void 0 };
797
950
  }
798
- function expandChart(doc) {
951
+ function expandFloorObjects(objects, zones, fallbackFocal) {
799
952
  const out = [];
800
- for (const obj of allObjects(doc)) {
801
- if (obj.type === "row") out.push(...expandRow(obj));
802
- else if (obj.type === "table") out.push(...expandTable(obj));
803
- else if (obj.type === "booth") out.push(...expandBooth(obj));
953
+ const segmented = /* @__PURE__ */ new Map();
954
+ const grouped = /* @__PURE__ */ new Map();
955
+ for (const object of objects) {
956
+ if (object.type !== "row" || !object.segmentedRow) continue;
957
+ const list = grouped.get(object.segmentedRow.groupId) ?? [];
958
+ list.push(object);
959
+ grouped.set(object.segmentedRow.groupId, list);
960
+ }
961
+ for (const [groupId, members] of grouped) {
962
+ const ordered = members.slice().sort((left, right) => left.segmentedRow.componentIndex - right.segmentedRow.componentIndex);
963
+ const expectedCount = ordered[0]?.segmentedRow?.componentCount ?? 0;
964
+ const first = ordered[0]?.segmentedRow;
965
+ if (!first) continue;
966
+ 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);
967
+ if (!valid) continue;
968
+ const totalSeats = ordered.reduce((sum, row) => sum + row.seatCount, 0);
969
+ let adjacencyOffset = 0;
970
+ let displayOffset = 0;
971
+ for (const row of ordered) {
972
+ if (row.segmentedRow.boundaryBefore === "break") adjacencyOffset += 1;
973
+ segmented.set(row.id, {
974
+ groupId,
975
+ adjacencyOffset,
976
+ displayOffset,
977
+ displayLabel: first.displayLabel,
978
+ totalSeats,
979
+ canonical: ordered[0],
980
+ viewFromSeatUrl: first.viewFromSeatUrl
981
+ });
982
+ adjacencyOffset += row.seatCount;
983
+ displayOffset += row.seatCount;
984
+ }
985
+ }
986
+ for (const obj of objects) {
987
+ let seats = [];
988
+ if (obj.type === "row") seats = expandRow(obj);
989
+ else if (obj.type === "table") seats = expandTable(obj);
990
+ else if (obj.type === "booth") seats = expandBooth(obj);
991
+ if (!seats.length) continue;
992
+ if (obj.type === "row") {
993
+ const logical = segmented.get(obj.id);
994
+ if (logical) {
995
+ const overrides2 = new Map((obj.overrides ?? []).map((override) => [override.index, override]));
996
+ for (const seat of seats) {
997
+ const physicalIndex = Number(seat.id.slice(seat.id.lastIndexOf(":") + 1));
998
+ if (!Number.isInteger(physicalIndex)) continue;
999
+ const displayOrdinal = logical.displayOffset + physicalIndex;
1000
+ seat.logicalRowId = logical.groupId;
1001
+ seat.logicalSeatIndex = logical.adjacencyOffset + physicalIndex;
1002
+ if (!overrides2.get(physicalIndex)?.displayLabel) {
1003
+ const numberingRow = {
1004
+ ...logical.canonical,
1005
+ seatCount: logical.totalSeats,
1006
+ label: logical.displayLabel,
1007
+ displayLabel: logical.displayLabel
1008
+ };
1009
+ seat.displayLabel = `${logical.displayLabel}-${seatLabelPart(numberingRow, displayOrdinal)}`;
1010
+ }
1011
+ seat.viewUrl ??= logical.viewFromSeatUrl;
1012
+ }
1013
+ }
1014
+ }
1015
+ const owner = owningSectionForObject(objects, obj);
1016
+ const inheritedView = owner?.viewFromSeatUrl;
1017
+ const zone = owner?.zone ? zones?.find((candidate) => candidate.id === owner.zone) : void 0;
1018
+ const resolvedFocal = zone?.focalPoint ?? fallbackFocal;
1019
+ for (const seat of seats) {
1020
+ if (inheritedView) seat.viewUrl ??= inheritedView;
1021
+ if (owner) seat.sectionId = owner.logicalSectionId ?? owner.id;
1022
+ if (owner?.zone) seat.zoneId = owner.zone;
1023
+ if (resolvedFocal) seat.focalPoint = { ...resolvedFocal };
1024
+ }
1025
+ out.push(...seats);
804
1026
  }
805
1027
  return out;
806
1028
  }
1029
+ function expandChart(doc, options = {}) {
1030
+ if (doc.floors?.length) {
1031
+ const out2 = [];
1032
+ for (const floor of doc.floors) {
1033
+ const floorFocal = floor.focalPoint ?? doc.focalPoint;
1034
+ const seats = expandFloorObjects(floor.objects, doc.zones, floorFocal);
1035
+ assignEyeHeights(floor.objects, floor.focalPoint ?? doc.focalPoint, floor.baseHeightM ?? 0, seats);
1036
+ out2.push(...seats);
1037
+ }
1038
+ return out2;
1039
+ }
1040
+ const out = expandFloorObjects(doc.objects, doc.zones, doc.focalPoint);
1041
+ assignEyeHeights(doc.objects, doc.focalPoint, options.floorBaseHeightM ?? 0, out);
1042
+ return out;
1043
+ }
1044
+ function assignEyeHeights(objects, focal, floorBaseHeightM, seats) {
1045
+ const sections = objects.filter((o) => o.type === "section");
1046
+ const hasGeometry = floorBaseHeightM > 0 || sections.some((s) => s.height !== void 0 || s.rake !== void 0 || (s.elevation ?? 0) > 0);
1047
+ if (!sections.length || !hasGeometry || !focal) {
1048
+ for (const seat of seats) seat.eyeHeightM = floorBaseHeightM + SEATED_EYE_HEIGHT_M;
1049
+ return;
1050
+ }
1051
+ const owner = new Array(seats.length);
1052
+ const geo = /* @__PURE__ */ new Map();
1053
+ const frontDistU = /* @__PURE__ */ new Map();
1054
+ for (let i = 0; i < seats.length; i++) {
1055
+ const seat = seats[i];
1056
+ const sec = sections.find((s) => pointInPolygonWithHoles({ x: seat.x, y: seat.y }, s.outline, s.holes)) ?? null;
1057
+ owner[i] = sec;
1058
+ if (!sec) continue;
1059
+ if (!geo.has(sec.id)) geo.set(sec.id, sectionGeometry(sec, { floorBaseHeightM }));
1060
+ const seatFocal = seat.focalPoint ?? focal;
1061
+ const d = Math.hypot(seat.x - seatFocal.x, seat.y - seatFocal.y);
1062
+ const cur = frontDistU.get(sec.id);
1063
+ if (cur === void 0 || d < cur) frontDistU.set(sec.id, d);
1064
+ }
1065
+ for (let i = 0; i < seats.length; i++) {
1066
+ const seat = seats[i];
1067
+ const sec = owner[i];
1068
+ if (!sec) {
1069
+ seat.eyeHeightM = floorBaseHeightM + SEATED_EYE_HEIGHT_M;
1070
+ continue;
1071
+ }
1072
+ const g = geo.get(sec.id);
1073
+ let riseM = 0;
1074
+ if (g.rake > 0) {
1075
+ const seatFocal = seat.focalPoint ?? focal;
1076
+ const d = Math.hypot(seat.x - seatFocal.x, seat.y - seatFocal.y);
1077
+ const depthU = Math.max(0, d - (frontDistU.get(sec.id) ?? d));
1078
+ riseM = depthU * METRES_PER_CHART_UNIT * Math.tan(g.rake * Math.PI / 180);
1079
+ }
1080
+ seat.eyeHeightM = g.height + riseM + SEATED_EYE_HEIGHT_M;
1081
+ }
1082
+ }
807
1083
  var PAD = 40;
808
1084
  function chartBounds(doc) {
809
1085
  let minX = Infinity;
@@ -847,8 +1123,9 @@ function chartBounds(doc) {
847
1123
  acc(obj.position.x + w, obj.position.y + obj.fontSize);
848
1124
  }
849
1125
  }
850
- if (doc.backgroundImage) {
851
- const { center, width } = doc.backgroundImage;
1126
+ for (const image of [doc.referenceImage, doc.backgroundImage]) {
1127
+ if (!image) continue;
1128
+ const { center, width } = image;
852
1129
  const bh = width * 3 / 4;
853
1130
  acc(center.x - width / 2, center.y - bh / 2);
854
1131
  acc(center.x + width / 2, center.y + bh / 2);
@@ -872,9 +1149,55 @@ var MAX_EVENT_INVENTORY = 1e5;
872
1149
  function gaUnitLabel(areaId, index) {
873
1150
  return `${PREFIX}${encodeURIComponent(areaId)}__${index + 1}`;
874
1151
  }
875
- function gaUnitLabels(area) {
1152
+ function validGAInventorySegments(area) {
1153
+ const segments = area.inventorySegments;
1154
+ if (segments === void 0) return true;
1155
+ if (!Array.isArray(segments) || segments.length < 1 || segments.length > 256) return false;
1156
+ let total = 0;
1157
+ const ranges = /* @__PURE__ */ new Map();
1158
+ for (const segment of segments) {
1159
+ 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;
1160
+ total += segment.count;
1161
+ if (total > MAX_GA_CAPACITY) return false;
1162
+ const sourceRanges = ranges.get(segment.sourceAreaId) ?? [];
1163
+ const end = segment.startIndex + segment.count;
1164
+ if (sourceRanges.some((range) => segment.startIndex < range.end && end > range.start)) return false;
1165
+ sourceRanges.push({ start: segment.startIndex, end });
1166
+ ranges.set(segment.sourceAreaId, sourceRanges);
1167
+ }
1168
+ return total === area.capacity;
1169
+ }
1170
+ function gaInventorySegments(area) {
876
1171
  const capacity = Math.min(MAX_GA_CAPACITY, Math.max(0, Math.floor(area.capacity)));
877
- return Array.from({ length: capacity }, (_, i) => gaUnitLabel(area.id, i));
1172
+ if (area.inventorySegments === void 0) {
1173
+ return capacity ? [{ sourceAreaId: area.id, startIndex: 0, count: capacity }] : [];
1174
+ }
1175
+ if (!validGAInventorySegments(area)) return [];
1176
+ return area.inventorySegments.map((segment) => ({ ...segment }));
1177
+ }
1178
+ function gaUnitLabels(area) {
1179
+ return gaInventorySegments(area).flatMap((segment) => Array.from(
1180
+ { length: segment.count },
1181
+ (_, offset) => gaUnitLabel(segment.sourceAreaId, segment.startIndex + offset)
1182
+ ));
1183
+ }
1184
+ function growJoinedGAInventory(area, nextCapacity) {
1185
+ if (area.inventorySegments === void 0 || nextCapacity === area.capacity) return area.inventorySegments;
1186
+ if (nextCapacity < area.capacity) return void 0;
1187
+ const segments = gaInventorySegments(area);
1188
+ if (!segments.length) return void 0;
1189
+ const extra = nextCapacity - area.capacity;
1190
+ if (!extra) return segments;
1191
+ const ownRanges = segments.filter((segment) => segment.sourceAreaId === area.id);
1192
+ const startIndex = ownRanges.reduce((max, segment) => Math.max(max, segment.startIndex + segment.count), 0);
1193
+ const last = segments[segments.length - 1];
1194
+ if (last?.sourceAreaId === area.id && last.startIndex + last.count === startIndex) {
1195
+ last.count += extra;
1196
+ } else {
1197
+ if (segments.length >= 256) return void 0;
1198
+ segments.push({ sourceAreaId: area.id, startIndex, count: extra });
1199
+ }
1200
+ return segments;
878
1201
  }
879
1202
  function gaAreasOf(doc) {
880
1203
  return allObjects(doc).filter((o) => o.type === "gaArea");
@@ -895,15 +1218,6 @@ function objectSeatLabels(o) {
895
1218
  function isSeatObject(o) {
896
1219
  return o.type === "row" || o.type === "table" || o.type === "booth" || o.type === "gaArea";
897
1220
  }
898
- function samePoints(left, right) {
899
- return left.length === right.length && left.every((point, index) => point.x === right[index].x && point.y === right[index].y);
900
- }
901
- function sameGASurfaceAsSection(object, section) {
902
- if (object.type !== "gaArea" || !samePoints(object.points, section.outline)) return false;
903
- const objectHoles = object.holes ?? [];
904
- const sectionHoles = section.holes ?? [];
905
- return objectHoles.length === sectionHoles.length && objectHoles.every((hole, index) => samePoints(hole, sectionHoles[index]));
906
- }
907
1221
  function computeSections(doc) {
908
1222
  const objs = allObjects(doc);
909
1223
  const sectionObjs = objs.filter((o) => o.type === "section");
@@ -929,10 +1243,7 @@ function computeSections(doc) {
929
1243
  if (!isSeatObject(obj)) continue;
930
1244
  const labels = objectSeatLabels(obj);
931
1245
  if (labels.length === 0) continue;
932
- const referencedLogicalId = obj.referenceInventorySource?.logicalSectionId;
933
- const c = objectCenter(obj);
934
- const referencedOwner = referencedLogicalId ? sectionObjs.find((section) => (section.logicalSectionId ?? section.id) === referencedLogicalId && (sameGASurfaceAsSection(obj, section) || pointInPolygonWithHoles(c, section.outline, section.holes))) : void 0;
935
- const owner = referencedOwner ?? sectionObjs.find((s) => pointInPolygonWithHoles(c, s.outline, s.holes));
1246
+ const owner = owningSectionForObject(objs, obj);
936
1247
  const node = owner ? nodes.get(owner.logicalSectionId ?? owner.id) : ungrouped;
937
1248
  node.seatCount += labels.length;
938
1249
  node.objectIds.push(obj.id);
@@ -1503,11 +1814,473 @@ var import_Circle = require("konva/lib/shapes/Circle");
1503
1814
  var import_Rect = require("konva/lib/shapes/Rect");
1504
1815
  var import_Ellipse = require("konva/lib/shapes/Ellipse");
1505
1816
  var import_Line = require("konva/lib/shapes/Line");
1817
+ var import_Arrow = require("konva/lib/shapes/Arrow");
1506
1818
  var import_Text = require("konva/lib/shapes/Text");
1507
1819
  var import_Path = require("konva/lib/shapes/Path");
1508
1820
  var import_Image = require("konva/lib/shapes/Image");
1509
1821
  var import_Shape = require("konva/lib/Shape");
1510
1822
 
1823
+ // src/core/chartBackgrounds.ts
1824
+ function buyerBackgroundImage(owner) {
1825
+ const background = owner.backgroundImage;
1826
+ return background?.url && !background.assetId ? background : void 0;
1827
+ }
1828
+
1829
+ // src/core/icons.ts
1830
+ var VENUE_ICON_VIEWBOX = 24;
1831
+ var VENUE_ICON_STROKE = 2;
1832
+ var WHEELCHAIR = "M13 4.6a1.6 1.6 0 1 0-3.2 0 1.6 1.6 0 0 0 3.2 0 M11 6.5V13h5l2.4 6 M15 16.4A5 5 0 1 1 9 11.3";
1833
+ var VENUE_ICONS = [
1834
+ // ---- Facilities --------------------------------------------------------
1835
+ {
1836
+ key: "restroom-men",
1837
+ label: "Men's restroom",
1838
+ group: "facilities",
1839
+ path: "M16 7a4 4 0 1 0-8 0 4 4 0 0 0 8 0 M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"
1840
+ },
1841
+ {
1842
+ key: "restroom-women",
1843
+ label: "Women's restroom",
1844
+ group: "facilities",
1845
+ path: "M15 5a3 3 0 1 0-6 0 3 3 0 0 0 6 0 M12 8l-4 10h8l-4-10 M10 18v4 M14 18v4"
1846
+ },
1847
+ {
1848
+ key: "restroom-accessible",
1849
+ label: "Accessible restroom",
1850
+ group: "facilities",
1851
+ path: WHEELCHAIR
1852
+ },
1853
+ {
1854
+ key: "first-aid",
1855
+ label: "First aid",
1856
+ group: "facilities",
1857
+ path: "M5 4h14a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1Z M12 8v8 M8 12h8"
1858
+ },
1859
+ {
1860
+ key: "coat-check",
1861
+ label: "Coat check",
1862
+ group: "facilities",
1863
+ path: "M12 6a2 2 0 0 1 0-4 2 2 0 0 1 1.6 3.2L21 13H3l8.4-7.8 M3 13h18"
1864
+ },
1865
+ {
1866
+ key: "atm",
1867
+ label: "ATM / cash",
1868
+ group: "facilities",
1869
+ path: "M2 6h20v12H2V6Z M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0 M6 9h.01 M18 15h.01"
1870
+ },
1871
+ {
1872
+ key: "info",
1873
+ label: "Info point",
1874
+ group: "facilities",
1875
+ path: "M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18 M12 11v5 M12 8h.01"
1876
+ },
1877
+ {
1878
+ key: "lost-found",
1879
+ label: "Lost & found",
1880
+ group: "facilities",
1881
+ path: "M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14 M16 16l5 5 M9 8.5a2 2 0 1 1 3 1.7c-.8.5-1 .8-1 1.8 M11 15h.01"
1882
+ },
1883
+ {
1884
+ key: "charging",
1885
+ label: "Charging point",
1886
+ group: "facilities",
1887
+ path: "M3 8h13v8H3V8Z M16 10h3v4h-3 M9.6 9l-2 3.5h2.5l-1.5 3"
1888
+ },
1889
+ {
1890
+ key: "smoking",
1891
+ label: "Smoking area",
1892
+ group: "facilities",
1893
+ path: "M2 15h13v3H2v-3Z M18 16h2 M19 8c0 1 1 1.5 1 2.5S19 12 19 13"
1894
+ },
1895
+ {
1896
+ key: "no-smoking",
1897
+ label: "No smoking",
1898
+ group: "facilities",
1899
+ path: "M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18 M5.6 5.6l12.8 12.8 M7 13h6v2H7v-2Z"
1900
+ },
1901
+ // ---- Food & drink ------------------------------------------------------
1902
+ {
1903
+ key: "food",
1904
+ label: "Food",
1905
+ group: "food-drink",
1906
+ path: "M7 3v6a2 2 0 0 0 4 0V3 M9 11v10 M17 3c-1.6 1-2.2 3-2.2 6 0 2 .9 3.2 2.2 3.6V21"
1907
+ },
1908
+ {
1909
+ key: "bar",
1910
+ label: "Bar",
1911
+ group: "food-drink",
1912
+ path: "M4 5h16l-8 8-8-8Z M12 13v6 M8 20h8"
1913
+ },
1914
+ {
1915
+ key: "coffee",
1916
+ label: "Caf\xE9 / coffee",
1917
+ group: "food-drink",
1918
+ path: "M4 8h13v4a5 5 0 0 1-5 5H9a5 5 0 0 1-5-5V8Z M17 9h2a2 2 0 0 1 0 4h-2 M8 2v2 M11 2v2 M4 21h14"
1919
+ },
1920
+ {
1921
+ key: "water",
1922
+ label: "Water / drinks",
1923
+ group: "food-drink",
1924
+ path: "M12 22a7 7 0 0 1-7-7c0-5 7-12 7-12s7 7 7 12a7 7 0 0 1-7 7Z"
1925
+ },
1926
+ {
1927
+ key: "merch",
1928
+ label: "Merch / shop",
1929
+ group: "food-drink",
1930
+ path: "M6 8h12l-1 12H7L6 8Z M9 8V6a3 3 0 0 1 6 0v2"
1931
+ },
1932
+ // ---- Getting around ----------------------------------------------------
1933
+ {
1934
+ key: "entrance",
1935
+ label: "Entrance",
1936
+ group: "navigation",
1937
+ path: "M14 3h5a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1h-5 M3 12h11 M10 8l4 4-4 4"
1938
+ },
1939
+ {
1940
+ key: "exit",
1941
+ label: "Exit",
1942
+ group: "navigation",
1943
+ path: "M10 3H5a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h5 M14 12h7 M17 8l4 4-4 4"
1944
+ },
1945
+ {
1946
+ key: "emergency-exit",
1947
+ label: "Emergency exit",
1948
+ group: "navigation",
1949
+ path: "M14 4.6a1.5 1.5 0 1 0-3 0 1.5 1.5 0 0 0 3 0 M12.5 8l-3.5 2 1.6 3.6-2 4.4 M12.5 10l3.5 1.5 M9 10.5l-3.5 1 M15 12h6 M18 9l3 3-3 3"
1950
+ },
1951
+ {
1952
+ key: "stairs",
1953
+ label: "Stairs",
1954
+ group: "navigation",
1955
+ path: "M3 20v-4h4v-4h4v-4h4V4h5"
1956
+ },
1957
+ {
1958
+ key: "elevator",
1959
+ label: "Elevator",
1960
+ group: "navigation",
1961
+ path: "M5 3h14a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1Z M8 11l1.5-2 1.5 2 M8 14l1.5 2 1.5-2 M15 8v8"
1962
+ },
1963
+ {
1964
+ key: "parking",
1965
+ label: "Parking",
1966
+ group: "navigation",
1967
+ path: "M5 3h14a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1Z M9 17V7h4a3 3 0 0 1 0 6H9"
1968
+ },
1969
+ // ---- Accessibility -----------------------------------------------------
1970
+ {
1971
+ key: "wheelchair",
1972
+ label: "Wheelchair access",
1973
+ group: "accessibility",
1974
+ path: WHEELCHAIR
1975
+ },
1976
+ {
1977
+ key: "hearing",
1978
+ label: "Hearing assistance",
1979
+ group: "accessibility",
1980
+ path: "M8 20a4 4 0 0 1-2-3c0-1 .5-2 .5-3a4.5 4.5 0 1 1 9 0c0 1.4-1 2-2 2.4-1.2.5-1.5 1-1.5 2.2 M17 6a5 5 0 0 1 1 6 M19.5 4a8 8 0 0 1 1.2 9"
1981
+ }
1982
+ ];
1983
+ var BY_KEY = new Map(VENUE_ICONS.map((icon) => [icon.key, icon]));
1984
+ function venueIcon(key) {
1985
+ return key ? BY_KEY.get(key) : void 0;
1986
+ }
1987
+ function venueIconPath(key) {
1988
+ return venueIcon(key)?.path;
1989
+ }
1990
+ var VENUE_ICON_KEYS = VENUE_ICONS.map((icon) => icon.key);
1991
+
1992
+ // src/core/spatialIndex.ts
1993
+ var DEFAULT_SEAT_RADIUS = 9;
1994
+ function halfExtents(shape) {
1995
+ if (shape.kind === "circle") return { hx: shape.r, hy: shape.r };
1996
+ const hw = shape.width / 2;
1997
+ const hh = shape.height / 2;
1998
+ const rot = shape.rotation ?? 0;
1999
+ if (!rot) return { hx: hw, hy: hh };
2000
+ const rad = rot * Math.PI / 180;
2001
+ const c = Math.abs(Math.cos(rad));
2002
+ const s = Math.abs(Math.sin(rad));
2003
+ return { hx: hw * c + hh * s, hy: hw * s + hh * c };
2004
+ }
2005
+ function shapeIntersectsRect(e, x0, y0, x1, y1) {
2006
+ if (e.shape.kind === "circle") {
2007
+ const cx = Math.min(Math.max(e.seat.x, x0), x1);
2008
+ const cy = Math.min(Math.max(e.seat.y, y0), y1);
2009
+ const dx = e.seat.x - cx;
2010
+ const dy = e.seat.y - cy;
2011
+ return dx * dx + dy * dy <= e.shape.r * e.shape.r;
2012
+ }
2013
+ const rot = e.shape.rotation ?? 0;
2014
+ if (!rot) {
2015
+ 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;
2016
+ }
2017
+ const rad = rot * Math.PI / 180;
2018
+ const c = Math.cos(rad);
2019
+ const s = Math.sin(rad);
2020
+ const hw = e.shape.width / 2;
2021
+ const hh = e.shape.height / 2;
2022
+ const corners = [
2023
+ [-hw, -hh],
2024
+ [hw, -hh],
2025
+ [hw, hh],
2026
+ [-hw, hh]
2027
+ ].map(([lx, ly]) => [e.seat.x + lx * c - ly * s, e.seat.y + lx * s + ly * c]);
2028
+ const query = [
2029
+ [x0, y0],
2030
+ [x1, y0],
2031
+ [x1, y1],
2032
+ [x0, y1]
2033
+ ];
2034
+ const axes = [
2035
+ [1, 0],
2036
+ [0, 1],
2037
+ [c, s],
2038
+ [-s, c]
2039
+ ];
2040
+ for (const [ax, ay] of axes) {
2041
+ let minA = Infinity;
2042
+ let maxA = -Infinity;
2043
+ for (const [px, py] of corners) {
2044
+ const d = px * ax + py * ay;
2045
+ if (d < minA) minA = d;
2046
+ if (d > maxA) maxA = d;
2047
+ }
2048
+ let minB = Infinity;
2049
+ let maxB = -Infinity;
2050
+ for (const [px, py] of query) {
2051
+ const d = px * ax + py * ay;
2052
+ if (d < minB) minB = d;
2053
+ if (d > maxB) maxB = d;
2054
+ }
2055
+ if (maxA < minB || maxB < minA) return false;
2056
+ }
2057
+ return true;
2058
+ }
2059
+ function buildSeatIndex(seats, opts = {}) {
2060
+ const seatRadius = opts.seatRadius ?? DEFAULT_SEAT_RADIUS;
2061
+ const n = seats.length;
2062
+ const entries = new Array(n);
2063
+ let minX = Infinity;
2064
+ let minY = Infinity;
2065
+ let maxX = -Infinity;
2066
+ let maxY = -Infinity;
2067
+ let maxHalf = 0;
2068
+ for (let i = 0; i < n; i++) {
2069
+ const seat = seats[i];
2070
+ const shape = opts.shapeOf?.(seat) ?? { kind: "circle", r: seatRadius };
2071
+ const { hx, hy } = halfExtents(shape);
2072
+ entries[i] = { seat, shape, hx, hy };
2073
+ if (seat.x - hx < minX) minX = seat.x - hx;
2074
+ if (seat.y - hy < minY) minY = seat.y - hy;
2075
+ if (seat.x + hx > maxX) maxX = seat.x + hx;
2076
+ if (seat.y + hy > maxY) maxY = seat.y + hy;
2077
+ if (hx > maxHalf) maxHalf = hx;
2078
+ if (hy > maxHalf) maxHalf = hy;
2079
+ }
2080
+ if (n === 0) {
2081
+ minX = 0;
2082
+ minY = 0;
2083
+ maxX = 0;
2084
+ maxY = 0;
2085
+ }
2086
+ const nominal = maxHalf > 0 ? maxHalf * 4 : 1;
2087
+ const spanX = Math.max(maxX - minX, 0);
2088
+ const spanY = Math.max(maxY - minY, 0);
2089
+ const maxCells = Math.max(64, opts.maxCells ?? n * 4);
2090
+ let cell = nominal;
2091
+ let cols = Math.max(1, Math.ceil(spanX / cell) || 1);
2092
+ let rows = Math.max(1, Math.ceil(spanY / cell) || 1);
2093
+ while (cols * rows > maxCells && (cols > 1 || rows > 1)) {
2094
+ cell *= 2;
2095
+ cols = Math.max(1, Math.ceil(spanX / cell) || 1);
2096
+ rows = Math.max(1, Math.ceil(spanY / cell) || 1);
2097
+ }
2098
+ const cellCount = cols * rows;
2099
+ const cellStart = new Int32Array(cellCount + 1);
2100
+ let total = 0;
2101
+ for (let i = 0; i < n; i++) {
2102
+ const e = entries[i];
2103
+ const cx0 = clampInt(Math.floor((e.seat.x - e.hx - minX) / cell), 0, cols - 1);
2104
+ const cx1 = clampInt(Math.floor((e.seat.x + e.hx - minX) / cell), 0, cols - 1);
2105
+ const cy0 = clampInt(Math.floor((e.seat.y - e.hy - minY) / cell), 0, rows - 1);
2106
+ const cy1 = clampInt(Math.floor((e.seat.y + e.hy - minY) / cell), 0, rows - 1);
2107
+ for (let cy = cy0; cy <= cy1; cy++) {
2108
+ for (let cx = cx0; cx <= cx1; cx++) {
2109
+ cellStart[cy * cols + cx + 1]++;
2110
+ total++;
2111
+ }
2112
+ }
2113
+ }
2114
+ for (let c = 0; c < cellCount; c++) cellStart[c + 1] += cellStart[c];
2115
+ const items = new Int32Array(total);
2116
+ const cursor = cellStart.slice(0, cellCount);
2117
+ for (let i = 0; i < n; i++) {
2118
+ const e = entries[i];
2119
+ const cx0 = clampInt(Math.floor((e.seat.x - e.hx - minX) / cell), 0, cols - 1);
2120
+ const cx1 = clampInt(Math.floor((e.seat.x + e.hx - minX) / cell), 0, cols - 1);
2121
+ const cy0 = clampInt(Math.floor((e.seat.y - e.hy - minY) / cell), 0, rows - 1);
2122
+ const cy1 = clampInt(Math.floor((e.seat.y + e.hy - minY) / cell), 0, rows - 1);
2123
+ for (let cy = cy0; cy <= cy1; cy++) {
2124
+ for (let cx = cx0; cx <= cx1; cx++) {
2125
+ const c = cy * cols + cx;
2126
+ items[cursor[c]++] = i;
2127
+ }
2128
+ }
2129
+ }
2130
+ return {
2131
+ seats,
2132
+ entries,
2133
+ minX,
2134
+ minY,
2135
+ cell,
2136
+ cols,
2137
+ rows,
2138
+ cellStart,
2139
+ items,
2140
+ stamp: new Int32Array(n),
2141
+ epoch: 0
2142
+ };
2143
+ }
2144
+ function clampInt(v, lo, hi) {
2145
+ return v < lo ? lo : v > hi ? hi : v;
2146
+ }
2147
+ function queryRect(index, rect, opts = {}) {
2148
+ const x0 = Math.min(rect.x, rect.x + rect.width);
2149
+ const x1 = Math.max(rect.x, rect.x + rect.width);
2150
+ const y0 = Math.min(rect.y, rect.y + rect.height);
2151
+ const y1 = Math.max(rect.y, rect.y + rect.height);
2152
+ const mode = opts.mode ?? "center";
2153
+ const { cols, rows, cell, minX, minY, cellStart, items, entries } = index;
2154
+ const cx0 = Math.max(0, Math.floor((x0 - minX) / cell));
2155
+ const cx1 = Math.min(cols - 1, Math.floor((x1 - minX) / cell));
2156
+ const cy0 = Math.max(0, Math.floor((y0 - minY) / cell));
2157
+ const cy1 = Math.min(rows - 1, Math.floor((y1 - minY) / cell));
2158
+ if (cx0 > cx1 || cy0 > cy1) return [];
2159
+ const found = [];
2160
+ const stamp = index.stamp;
2161
+ const ep = ++index.epoch;
2162
+ for (let cy = cy0; cy <= cy1; cy++) {
2163
+ const rowBase = cy * cols;
2164
+ for (let cx = cx0; cx <= cx1; cx++) {
2165
+ const c = rowBase + cx;
2166
+ const end = cellStart[c + 1];
2167
+ for (let k = cellStart[c]; k < end; k++) {
2168
+ const i = items[k];
2169
+ if (stamp[i] === ep) continue;
2170
+ stamp[i] = ep;
2171
+ const e = entries[i];
2172
+ if (opts.filter && !opts.filter(e.seat.id, e.seat)) continue;
2173
+ 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);
2174
+ if (ok) found.push(i);
2175
+ }
2176
+ }
2177
+ }
2178
+ found.sort((a, b) => a - b);
2179
+ return found.map((i) => entries[i].seat.id);
2180
+ }
2181
+
2182
+ // src/core/perspectiveProjection.ts
2183
+ function createPerspectiveCamera(bounds2, maxSurfaceHeightWorld = 0) {
2184
+ const span = Math.max(1, bounds2.width, bounds2.height);
2185
+ const target = {
2186
+ x: bounds2.x + bounds2.width / 2,
2187
+ y: bounds2.y + bounds2.height / 2
2188
+ };
2189
+ const distance = span * 1.55;
2190
+ const height = Math.max(span * 0.82, Math.max(0, maxSurfaceHeightWorld) + span * 0.45);
2191
+ return {
2192
+ target,
2193
+ distance,
2194
+ height,
2195
+ focalLength: Math.hypot(distance, height),
2196
+ yawRad: -8 * Math.PI / 180
2197
+ };
2198
+ }
2199
+ function projectPerspectivePoint(camera, point, heightWorld = 0) {
2200
+ const dx = point.x - camera.target.x;
2201
+ const dy = point.y - camera.target.y;
2202
+ const cos = Math.cos(camera.yawRad);
2203
+ const sin = Math.sin(camera.yawRad);
2204
+ const x = dx * cos - dy * sin;
2205
+ const y = dx * sin + dy * cos;
2206
+ const translatedY = y - camera.distance;
2207
+ const translatedZ = heightWorld - camera.height;
2208
+ const cameraLength = Math.hypot(camera.distance, camera.height);
2209
+ const depth = (-camera.distance * translatedY - camera.height * translatedZ) / cameraLength;
2210
+ const up = (-camera.height * translatedY + camera.distance * translatedZ) / cameraLength;
2211
+ const safeDepth = Math.max(cameraLength * 0.08, depth);
2212
+ const scale = camera.focalLength / safeDepth;
2213
+ return {
2214
+ x: camera.target.x + x * scale,
2215
+ y: camera.target.y - up * scale,
2216
+ depth: safeDepth,
2217
+ scale
2218
+ };
2219
+ }
2220
+ function applyAffine(affine, point) {
2221
+ return {
2222
+ x: affine.a * point.x + affine.c * point.y + affine.e,
2223
+ y: affine.b * point.x + affine.d * point.y + affine.f
2224
+ };
2225
+ }
2226
+ function invertAffine(affine) {
2227
+ const determinant = affine.a * affine.d - affine.b * affine.c;
2228
+ if (!Number.isFinite(determinant) || Math.abs(determinant) < 1e-9) {
2229
+ throw new Error("perspective projection produced a singular affine");
2230
+ }
2231
+ const a = affine.d / determinant;
2232
+ const b = -affine.b / determinant;
2233
+ const c = -affine.c / determinant;
2234
+ const d = affine.a / determinant;
2235
+ return {
2236
+ a,
2237
+ b,
2238
+ c,
2239
+ d,
2240
+ e: -(a * affine.e + c * affine.f),
2241
+ f: -(b * affine.e + d * affine.f)
2242
+ };
2243
+ }
2244
+ function composeAffine(left, right) {
2245
+ return {
2246
+ a: left.a * right.a + left.c * right.b,
2247
+ b: left.b * right.a + left.d * right.b,
2248
+ c: left.a * right.c + left.c * right.d,
2249
+ d: left.b * right.c + left.d * right.d,
2250
+ e: left.a * right.e + left.c * right.f + left.e,
2251
+ f: left.b * right.e + left.d * right.f + left.f
2252
+ };
2253
+ }
2254
+ function perspectiveTangentAffine(camera, anchor, heightAt = () => 0) {
2255
+ const origin = projectPerspectivePoint(camera, anchor, heightAt(anchor));
2256
+ const alongXPoint = { x: anchor.x + 1, y: anchor.y };
2257
+ const alongYPoint = { x: anchor.x, y: anchor.y + 1 };
2258
+ const alongX = projectPerspectivePoint(camera, alongXPoint, heightAt(alongXPoint));
2259
+ const alongY = projectPerspectivePoint(camera, alongYPoint, heightAt(alongYPoint));
2260
+ const a = alongX.x - origin.x;
2261
+ const b = alongX.y - origin.y;
2262
+ const c = alongY.x - origin.x;
2263
+ const d = alongY.y - origin.y;
2264
+ return {
2265
+ a,
2266
+ b,
2267
+ c,
2268
+ d,
2269
+ e: origin.x - a * anchor.x - c * anchor.y,
2270
+ f: origin.y - b * anchor.x - d * anchor.y
2271
+ };
2272
+ }
2273
+ function decomposeAffineLinear(affine) {
2274
+ const scaleX = Math.hypot(affine.a, affine.b);
2275
+ const determinant = affine.a * affine.d - affine.b * affine.c;
2276
+ return {
2277
+ rotationDeg: Math.atan2(affine.b, affine.a) * 180 / Math.PI,
2278
+ scaleX,
2279
+ scaleY: determinant / Math.max(scaleX, 1e-9),
2280
+ skewX: (affine.a * affine.c + affine.b * affine.d) / Math.max(determinant, 1e-9)
2281
+ };
2282
+ }
2283
+
1511
2284
  // src/lib/money.ts
1512
2285
  var DEFAULT_CURRENCY = "USD";
1513
2286
  var displayLocale;
@@ -1553,6 +2326,7 @@ var en = {
1553
2326
  // renderer (drawn on the Konva map — shared by the embed SDK)
1554
2327
  "map.aria": "Seating map. Use arrow keys to move between seats, Enter to select.",
1555
2328
  "map.seatsLeft": "{count} LEFT",
2329
+ "map.soldOut": "SOLD OUT",
1556
2330
  "map.fromPrice": "FROM {price}",
1557
2331
  "map.statusHeld": "On hold",
1558
2332
  "map.statusTaken": "Taken",
@@ -1560,7 +2334,7 @@ var en = {
1560
2334
  "picker.floor": "Floor",
1561
2335
  "picker.zoomLevel": "Zoom level",
1562
2336
  "picker.rungTip.zones": "Venue overview \u2014 groups of sections such as North Stand or VIP",
1563
- "picker.rungTip.sections": "Section blocks \u2014 category-mix fill + availability tint",
2337
+ "picker.rungTip.sections": "Section blocks \u2014 sold-out and nearly-gone sections shade to show availability at a glance",
1564
2338
  "picker.rungTip.seats": "Individual seats \u2014 blocks melt into dots",
1565
2339
  "picker.rungLabel.zones": "ZONES",
1566
2340
  "picker.rungLabel.sections": "SECTIONS",
@@ -1628,6 +2402,25 @@ function formatDate(value, opts) {
1628
2402
  return new Intl.DateTimeFormat(active, opts ?? { dateStyle: "medium", timeStyle: "short" }).format(value);
1629
2403
  }
1630
2404
 
2405
+ // src/core/shapeLineStyle.ts
2406
+ var DEFAULT_SHAPE_LINE_CAP = "round";
2407
+ var DEFAULT_SHAPE_LINE_JOIN = "round";
2408
+ var DEFAULT_SHAPE_LINE_ENDING = "none";
2409
+ function resolvedShapeLineStyle(shape) {
2410
+ return {
2411
+ lineCap: shape.lineCap ?? DEFAULT_SHAPE_LINE_CAP,
2412
+ lineJoin: shape.lineJoin ?? DEFAULT_SHAPE_LINE_JOIN,
2413
+ startEnding: shape.startEnding ?? DEFAULT_SHAPE_LINE_ENDING,
2414
+ endEnding: shape.endEnding ?? DEFAULT_SHAPE_LINE_ENDING
2415
+ };
2416
+ }
2417
+ function shapeArrowMetrics(strokeWidth) {
2418
+ return {
2419
+ pointerLength: Math.min(48, Math.max(8, strokeWidth * 3)),
2420
+ pointerWidth: Math.min(40, Math.max(8, strokeWidth * 2.25))
2421
+ };
2422
+ }
2423
+
1631
2424
  // src/engine/SeatmapRenderer.ts
1632
2425
  var SEAT_RADIUS = 9;
1633
2426
  var SEAT_LEGIBLE_SCALE = 0.9;
@@ -1635,17 +2428,18 @@ var CACHE_THRESHOLD = 0.55 * SEAT_LEGIBLE_SCALE;
1635
2428
  var LABEL_SCALE = MIN_VISIBLE_BOOKABLE_LABEL_PX / SEAT_LABEL_FONT_SIZE;
1636
2429
  var MIN_FITTED_SEAT_LABEL_FONT_SIZE = 4;
1637
2430
  var SEAT_TAP_SLOP_PX = 14;
1638
- var SEAT_GLYPH_MIN_PX = 6.5;
2431
+ var WHEELCHAIR_GLYPH_MIN_PX = 10;
2432
+ var FILTERED_WHEELCHAIR_GLYPH_MIN_PX = 14;
1639
2433
  var SECTION_PROMINENT_SCALE = 0.45 * SEAT_LEGIBLE_SCALE;
1640
2434
  var BLOCK_MELT_TOP = 0.9 * SEAT_LEGIBLE_SCALE;
1641
2435
  var SEAT_FOCUS_SCALE = Math.max(SEAT_LEGIBLE_SCALE * 1.1, BLOCK_MELT_TOP);
1642
2436
  var PAN_START_SLOP_PX = 8;
2437
+ var GHOST_CLICK_MS = 700;
1643
2438
  var ZONE_PROMINENT_SCALE = 0.55 * SECTION_PROMINENT_SCALE;
1644
2439
  var MAX_LABELS = 700;
1645
2440
  var MARQUEE_RING_CAP = 2500;
1646
2441
  var ISO_ANGLE_DEG = -11.5;
1647
2442
  var ISO_SQUASH = 0.58;
1648
- var LIFT_PER_STEP = 58;
1649
2443
  var ISO_TWEEN_MS = 320;
1650
2444
  var CAMERA_GLIDE_MS = 650;
1651
2445
  var BLOCK_FILL_ALPHA = 1;
@@ -1682,6 +2476,10 @@ var DEF_SELECTION = "#ffffff";
1682
2476
  var DEF_SELECTION_ON_LIGHT = "#0b1220";
1683
2477
  var DEF_DECOR_FILL = "#232c40";
1684
2478
  var DEF_TEXT = "#8b93a7";
2479
+ function konvaFontStyle(bold, italic, defaultWeight = "normal") {
2480
+ const weight = bold ? "bold" : defaultWeight;
2481
+ return italic ? `italic ${weight}`.trim() : weight;
2482
+ }
1685
2483
  var DEF_CANVAS_BACKGROUND = "#0e1117";
1686
2484
  function colorLuminance(color) {
1687
2485
  const s = color.trim();
@@ -1737,6 +2535,22 @@ function overviewPalette(canvasBackground) {
1737
2535
  focalStroke: DARK_OVERVIEW_FOCAL_STROKE
1738
2536
  };
1739
2537
  }
2538
+ var SECTION_NEARLY_GONE_RATIO = 0.1;
2539
+ function overviewStateFill(palette, state) {
2540
+ const base = palette.sectionFill;
2541
+ const soldOut = desaturate(darken(base, 0.2), 0.85);
2542
+ switch (state) {
2543
+ case "closed":
2544
+ return darken(base, 0.12);
2545
+ case "sold-out":
2546
+ return soldOut;
2547
+ case "nearly-gone":
2548
+ return lerpColor(base, soldOut, 0.4);
2549
+ case "normal":
2550
+ default:
2551
+ return base;
2552
+ }
2553
+ }
1740
2554
  var clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
1741
2555
  function seatIdOf(target) {
1742
2556
  const n = target;
@@ -1759,6 +2573,13 @@ function darken(hex, amt) {
1759
2573
  const mix = (c) => Math.round(c * (1 - amt));
1760
2574
  return `#${(1 << 24 | mix(n >> 16 & 255) << 16 | mix(n >> 8 & 255) << 8 | mix(n & 255)).toString(16).slice(1)}`;
1761
2575
  }
2576
+ function desaturate(hex, amt) {
2577
+ const rgb2 = hexToRgb(hex);
2578
+ if (!rgb2) return hex;
2579
+ const [r, g, b] = rgb2;
2580
+ const grey = 0.299 * r + 0.587 * g + 0.114 * b;
2581
+ return toHex(r + (grey - r) * amt, g + (grey - g) * amt, b + (grey - b) * amt);
2582
+ }
1762
2583
  function polyBounds(pts) {
1763
2584
  let minX = Infinity;
1764
2585
  let minY = Infinity;
@@ -1787,8 +2608,8 @@ function rotatedRectPoints(center, width, height, rotation) {
1787
2608
  }));
1788
2609
  }
1789
2610
  function pointsBounds(points) {
1790
- const bounds = polyBounds(points);
1791
- return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height };
2611
+ const bounds2 = polyBounds(points);
2612
+ return { x: bounds2.x, y: bounds2.y, width: bounds2.width, height: bounds2.height };
1792
2613
  }
1793
2614
  function rotatedRectFitsPolygon(center, width, height, rotation, outer, holes) {
1794
2615
  const radians = rotation * Math.PI / 180;
@@ -1808,32 +2629,50 @@ function rotatedRectFitsPolygon(center, width, height, rotation, outer, holes) {
1808
2629
  return true;
1809
2630
  }
1810
2631
  function polygonLabelCandidates(outer, holes, preferred) {
1811
- const bounds = polyBounds(outer);
1812
- const centre = { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 };
2632
+ const bounds2 = polyBounds(outer);
2633
+ const centre = { x: bounds2.x + bounds2.width / 2, y: bounds2.y + bounds2.height / 2 };
1813
2634
  const points = [preferred];
1814
2635
  for (let row = 1; row < 12; row += 1) {
1815
2636
  for (let column = 1; column < 12; column += 1) {
1816
2637
  const point = {
1817
- x: bounds.x + bounds.width * column / 12,
1818
- y: bounds.y + bounds.height * row / 12
2638
+ x: bounds2.x + bounds2.width * column / 12,
2639
+ y: bounds2.y + bounds2.height * row / 12
1819
2640
  };
1820
2641
  if (pointInPolygonWithHoles(point, outer, holes)) points.push(point);
1821
2642
  }
1822
2643
  }
1823
2644
  return points.sort((left, right) => Math.hypot(left.x - centre.x, left.y - centre.y) - Math.hypot(right.x - centre.x, right.y - centre.y)).filter((point, index, all) => index === all.findIndex((other) => Math.abs(other.x - point.x) < 1e-6 && Math.abs(other.y - point.y) < 1e-6));
1824
2645
  }
2646
+ function roundedPolygonSubpath(context, points, radius) {
2647
+ const n = points.length;
2648
+ const start = { x: (points[n - 1].x + points[0].x) / 2, y: (points[n - 1].y + points[0].y) / 2 };
2649
+ context.moveTo(start.x, start.y);
2650
+ for (let i = 0; i < n; i += 1) {
2651
+ const curr = points[i];
2652
+ const next = points[(i + 1) % n];
2653
+ const prev = points[(i - 1 + n) % n];
2654
+ const rMax = Math.min(
2655
+ Math.hypot(curr.x - prev.x, curr.y - prev.y) / 2,
2656
+ Math.hypot(next.x - curr.x, next.y - curr.y) / 2
2657
+ );
2658
+ context.arcTo(curr.x, curr.y, next.x, next.y, Math.max(0, Math.min(radius, rMax)));
2659
+ }
2660
+ context.closePath();
2661
+ }
1825
2662
  function polygonWithHolesShape(outer, holes, attrs, outerPath) {
1826
2663
  const signedArea = (points) => points.reduce((sum, point, index) => {
1827
2664
  const next = points[(index + 1) % points.length];
1828
2665
  return sum + point.x * next.y - next.x * point.y;
1829
2666
  }, 0);
1830
2667
  const outerClockwise = signedArea(outer) > 0;
2668
+ const cornerRadius = attrs.cornerRadius && attrs.cornerRadius > 0 ? attrs.cornerRadius : 0;
1831
2669
  return new import_Shape.Shape({
1832
2670
  ...attrs,
1833
2671
  sceneFunc(context, shape) {
1834
2672
  context.beginPath();
1835
2673
  const polygonPath = (points) => {
1836
2674
  if (!points.length) return;
2675
+ if (cornerRadius > 0 && points.length >= 3) return roundedPolygonSubpath(context, points, cornerRadius);
1837
2676
  context.moveTo(points[0].x, points[0].y);
1838
2677
  for (let index = 1; index < points.length; index += 1) context.lineTo(points[index].x, points[index].y);
1839
2678
  context.closePath();
@@ -1880,6 +2719,33 @@ function rgba(hex, a) {
1880
2719
  const n = parseInt(m[1], 16);
1881
2720
  return `rgba(${n >> 16 & 255},${n >> 8 & 255},${n & 255},${a})`;
1882
2721
  }
2722
+ var ViewportGroup = class extends import_Group.Group {
2723
+ constructor() {
2724
+ super(...arguments);
2725
+ this.viewportCulled = false;
2726
+ }
2727
+ setViewportCulled(culled) {
2728
+ if (culled === this.viewportCulled) return;
2729
+ this.viewportCulled = culled;
2730
+ if (!culled) super._clearSelfAndDescendantCache();
2731
+ }
2732
+ isViewportCulled() {
2733
+ return this.viewportCulled;
2734
+ }
2735
+ drawScene(...args) {
2736
+ return this.viewportCulled ? this : super.drawScene(...args);
2737
+ }
2738
+ drawHit(...args) {
2739
+ return this.viewportCulled ? this : super.drawHit(...args);
2740
+ }
2741
+ _clearSelfAndDescendantCache(attr) {
2742
+ if (!this.viewportCulled) {
2743
+ super._clearSelfAndDescendantCache(attr);
2744
+ return;
2745
+ }
2746
+ this._clearCache(attr);
2747
+ }
2748
+ };
1883
2749
  function hexToRgb(hex) {
1884
2750
  const m = /^#?([\da-f]{6})$/i.exec(hex.trim());
1885
2751
  if (!m) return null;
@@ -1910,8 +2776,15 @@ function lerpColor(a, b, t2) {
1910
2776
  }
1911
2777
  var _SeatmapRenderer = class _SeatmapRenderer {
1912
2778
  constructor(container, options = {}) {
2779
+ /** Per-elevated-section label containers. Keeping the labels grouped lets the
2780
+ * Phase-B lift move thousands of seat labels with one transform per section
2781
+ * instead of rewriting every label on every tween frame. */
2782
+ this.labelLiftGroups = /* @__PURE__ */ new Map();
1913
2783
  this.seats = [];
1914
2784
  this.seatById = /* @__PURE__ */ new Map();
2785
+ /** One build per chart/floor; section membership queries only inspect seats in
2786
+ * the section bounds instead of rescanning the full 13k venue per section. */
2787
+ this.seatIndex = null;
1915
2788
  /** Multi-floor (Batch 5): the last-set chart + which floor we're rendering. */
1916
2789
  this.chartDoc = null;
1917
2790
  this.activeFloorId = "";
@@ -1927,15 +2800,15 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1927
2800
  this.seatLabelById = /* @__PURE__ */ new Map();
1928
2801
  /** Coloured accommodation ring per accessible seat (few per chart). */
1929
2802
  this.accessRingById = /* @__PURE__ */ new Map();
1930
- /** Centred accessibility glyph per accessible seat shown once the seat is
1931
- * big enough on-screen (see {@link SEAT_GLYPH_MIN_PX}); the ring is the
1932
- * smaller-zoom fallback. Kept in a map so zoom toggles touch only the handful
1933
- * of accessible seats, never all 13k nodes. */
2803
+ /** Centred glyph per physical wheelchair provision. It keeps a small
2804
+ * screen-space floor at every LOD and grows when the Wheelchair filter is
2805
+ * active. Kept in a map so zoom updates touch only the handful of matching
2806
+ * seats, never all 13k nodes. */
1934
2807
  this.accessGlyphById = /* @__PURE__ */ new Map();
1935
- /** Whether the accessibility glyph is legible at the current camera scale. */
1936
- this.accessGlyphVisible = false;
1937
2808
  /** Authored free-text nodes obey the same rendered-size visibility floor. */
1938
2809
  this.freeTextById = /* @__PURE__ */ new Map();
2810
+ /** Vector venue-icon Path nodes + authored size; obey the free-text size floor. */
2811
+ this.iconNodeById = /* @__PURE__ */ new Map();
1939
2812
  /** Stage/rink landmarks retain a readable screen-space caption at overview. */
1940
2813
  this.primaryFocalLabels = /* @__PURE__ */ new Map();
1941
2814
  /** GA paint and text share price/highlight filter state. */
@@ -1949,6 +2822,12 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1949
2822
  this.effSelection = DEF_SELECTION;
1950
2823
  /** Colorblind-safe mode (Okabe-Ito hues + hollow booked seats). */
1951
2824
  this.colorblind = false;
2825
+ /** Stable per-category-KEY colorblind hue (OV-46) — order-independent. */
2826
+ this.cbHueByKey = /* @__PURE__ */ new Map();
2827
+ /** OV-45(a): authored row-letter labels for the buyer/preview map, resolved in
2828
+ * world space (matching seat labels) and honouring each row's labelPresentation
2829
+ * (position/rotation/visibility/style). Painted at the seats LOD rung. */
2830
+ this.rowLabelPlan = [];
1952
2831
  /** Category order from the doc — the stable index into the CB palette. */
1953
2832
  this.catOrder = [];
1954
2833
  this.selection = /* @__PURE__ */ new Set();
@@ -1958,6 +2837,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1958
2837
  this.ownedHold = /* @__PURE__ */ new Set();
1959
2838
  /** One selected seat being inspected before it is committed to the cart. */
1960
2839
  this.selectionFocusId = null;
2840
+ /** Seat currently owning the shared hover ring (null while not hovering). */
2841
+ this.hoveredId = null;
1961
2842
  this.focusedId = null;
1962
2843
  /**
1963
2844
  * Accessibility filter: `null` = off; `[]` = dim all non-accessible free seats;
@@ -1976,6 +2857,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1976
2857
  this.sections = [];
1977
2858
  this.zones = [];
1978
2859
  this.seatSection = /* @__PURE__ */ new Map();
2860
+ /** Seats outside every authored section retain the legacy path in one group. */
2861
+ this.unsectionedSeatGroup = null;
1979
2862
  this.catPrice = /* @__PURE__ */ new Map();
1980
2863
  /** Section/zone ids to render dimmed (organizer manager: held-back inventory). */
1981
2864
  this.dimmedSections = /* @__PURE__ */ new Set();
@@ -1988,6 +2871,9 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1988
2871
  this.focusedSectionId = null;
1989
2872
  /** Light backdrop panel drawn behind the focused section (removed on clear). */
1990
2873
  this.focusBackdrop = null;
2874
+ /** Non-listening visual dim outside the focused section. One overlay replaces
2875
+ * descendant opacity mutations across every seat in the venue. */
2876
+ this.focusDimOverlay = null;
1991
2877
  /** Object id → floor id (multi-floor only) — resolves a deck tap in the 3D stack. */
1992
2878
  this.objectFloor = /* @__PURE__ */ new Map();
1993
2879
  /** Zone id → colour (drives extruded side faces in iso view). */
@@ -2000,10 +2886,33 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2000
2886
  this.isoT = 0;
2001
2887
  this.isoTarget = 0;
2002
2888
  this.isoRaf = 0;
2889
+ /** Public view target; perspective is a separate non-affine Phase-C lane. */
2890
+ this.viewMode = "flat";
2891
+ this.perspectiveCamera = null;
2892
+ /** Ground-plane tangent applied once to every renderer layer. */
2893
+ this.perspectiveBaseAffine = null;
2894
+ this.perspectiveBaseInverse = null;
2895
+ /** Exact pinhole anchor expressed in the overlay tangent-layer coordinates. */
2896
+ this.perspectiveSeatLocal = /* @__PURE__ */ new Map();
2897
+ /** Exact projected point (before Stage pan/zoom), keyed by seat. */
2898
+ this.perspectiveSeatProjected = /* @__PURE__ */ new Map();
2899
+ /** Positive camera depth, used for deterministic far→near paint order. */
2900
+ this.perspectiveSeatDepth = /* @__PURE__ */ new Map();
2901
+ /** Exact pinhole size ratio at each seat anchor. */
2902
+ this.perspectiveSeatScale = /* @__PURE__ */ new Map();
2903
+ /** Seats whose native Konva nodes currently carry the exact projected pose.
2904
+ * Large sectioned venues apply these lazily as a section enters the live-seat
2905
+ * viewport; the overview rung hides the seat layer completely. */
2906
+ this.perspectiveAppliedSeats = /* @__PURE__ */ new Set();
2907
+ this.perspectiveBounds = null;
2003
2908
  /** Chart centre the iso projection pivots about (bounds centre). */
2004
2909
  this.isoCentre = { x: 0, y: 0 };
2005
2910
  /** rAF for an in-flight camera glide (focusRegion / setRung); 0 = none. */
2006
2911
  this.glideRaf = 0;
2912
+ /** While the camera tween is active, defer polygon label fitting/collision to
2913
+ * the settled frame. Geometry remains smoothly stage-scaled, while expensive
2914
+ * point-in-polygon text work no longer repeats at 120 Hz. */
2915
+ this.glideInProgress = false;
2007
2916
  /** Set in destroy() so an in-flight iso tween bails. */
2008
2917
  this.destroyed = false;
2009
2918
  /** Cached scale the section/zone labels were last sized for (scale-compensation). */
@@ -2033,6 +2942,19 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2033
2942
  this.panStarted = false;
2034
2943
  /** Maximum displacement from gesture start — suppress taps only after a real pan/pinch. */
2035
2944
  this.moved = 0;
2945
+ /**
2946
+ * Ghost-click guard. Konva binds `_pointerup` to mouseup, touchend AND
2947
+ * pointerup, so `on('click tap')` is two handlers for one finger tap unless
2948
+ * the browser's synthesized compatibility click is suppressed. Konva only
2949
+ * suppresses it when the touch lands on a LISTENING SHAPE (Stage.js returns
2950
+ * early before its preventDefault() when `!shape || !shape.isListening()`),
2951
+ * so a near-miss rescued by nearestSeatToScreen used to fire `tap` (select)
2952
+ * then `click` (deselect) 1-3ms apart and net to nothing. With
2953
+ * SEAT_TAP_SLOP_PX=14 against a ~12px seat radius, that broken annulus is
2954
+ * ~3.7x the area of the seat itself — i.e. most successful mobile taps.
2955
+ * `touch-action: none` does NOT suppress compatibility mouse events.
2956
+ */
2957
+ this.lastTapAt = 0;
2036
2958
  /**
2037
2959
  * Manage-mode rubber-band marquee (option-gated). `start`/`cur` are WORLD-space
2038
2960
  * points (overlayLayer rides the stage transform); `rect` is the on-canvas
@@ -2129,6 +3051,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2129
3051
  x: mid.x - this.pinch.worldMid.x * scale,
2130
3052
  y: mid.y - this.pinch.worldMid.y * scale
2131
3053
  });
3054
+ this.updateSeatGroupVisibility();
2132
3055
  this.stage.batchDraw();
2133
3056
  this.scheduleViewChange();
2134
3057
  } else if (this.panLast && this.pointers.size === 1) {
@@ -2141,6 +3064,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2141
3064
  y: this.stage.y() + (p.y - this.panLast.y)
2142
3065
  });
2143
3066
  this.panLast = p;
3067
+ this.updateSeatGroupVisibility();
2144
3068
  this.stage.batchDraw();
2145
3069
  this.scheduleViewChange();
2146
3070
  }
@@ -2193,6 +3117,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2193
3117
  this.overlayLayer = new import_Layer.Layer({ listening: false });
2194
3118
  this.labelGroup = new import_Group.Group({ listening: false });
2195
3119
  this.overlayLayer.add(this.labelGroup);
3120
+ this.fgDecorGroup = new import_Group.Group({ listening: false });
3121
+ this.overlayLayer.add(this.fgDecorGroup);
2196
3122
  this.hoverRing = new import_Circle.Circle({
2197
3123
  radius: SEAT_RADIUS + 2,
2198
3124
  stroke: "#ffffff",
@@ -2227,6 +3153,21 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2227
3153
  this.resizeObs.observe(container);
2228
3154
  }
2229
3155
  }
3156
+ /**
3157
+ * True when this is the browser's synthesized compatibility click following a
3158
+ * finger tap we already handled. Discriminates on `e.type` deliberately —
3159
+ * `PointerEvent extends MouseEvent`, so an `instanceof MouseEvent` test
3160
+ * matches genuine pointer taps too. One shared timestamp across the layer and
3161
+ * stage handlers: a tap consumed at the layer must also suppress the ghost at
3162
+ * the stage, and only `click` is ever rejected, so bubbling stays intact.
3163
+ */
3164
+ isGhostClick(e) {
3165
+ if (e.type === "tap") {
3166
+ this.lastTapAt = performance.now();
3167
+ return false;
3168
+ }
3169
+ return this.lastTapAt > 0 && performance.now() - this.lastTapAt < GHOST_CLICK_MS;
3170
+ }
2230
3171
  // ---- ISeatmapRenderer -----------------------------------------------------
2231
3172
  setChart(doc, opts) {
2232
3173
  if (doc !== this.chartDoc) this.stacked = false;
@@ -2240,8 +3181,15 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2240
3181
  this.focusedId = null;
2241
3182
  this.focusRing.visible(false);
2242
3183
  this.bgLayer.destroyChildren();
3184
+ this.focusDimOverlay?.destroy();
3185
+ this.focusDimOverlay = null;
3186
+ this.seatLayer.clearCache();
3187
+ this.seatLayer.listening(true);
2243
3188
  this.seatLayer.destroyChildren();
3189
+ this.unsectionedSeatGroup = null;
2244
3190
  this.labelGroup.destroyChildren();
3191
+ this.labelLiftGroups.clear();
3192
+ this.fgDecorGroup.destroyChildren();
2245
3193
  this.circleById.clear();
2246
3194
  this.boothDims.clear();
2247
3195
  this.boothLabelById.clear();
@@ -2249,6 +3197,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2249
3197
  this.accessRingById.clear();
2250
3198
  this.accessGlyphById.clear();
2251
3199
  this.freeTextById.clear();
3200
+ this.iconNodeById.clear();
2252
3201
  this.primaryFocalLabels.clear();
2253
3202
  this.gaById.clear();
2254
3203
  for (const marker of this.selectionMarkers.values()) marker.destroy();
@@ -2258,6 +3207,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2258
3207
  this.statusById.clear();
2259
3208
  this.selection.clear();
2260
3209
  this.seatById.clear();
3210
+ this.seatIndex = null;
2261
3211
  this.cached = false;
2262
3212
  this.accessFilter = null;
2263
3213
  this.sections = [];
@@ -2275,11 +3225,22 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2275
3225
  }
2276
3226
  this.isoT = 0;
2277
3227
  this.isoTarget = 0;
3228
+ this.viewMode = "flat";
3229
+ this.perspectiveCamera = null;
3230
+ this.perspectiveBaseAffine = null;
3231
+ this.perspectiveBaseInverse = null;
3232
+ this.perspectiveSeatLocal.clear();
3233
+ this.perspectiveSeatProjected.clear();
3234
+ this.perspectiveSeatDepth.clear();
3235
+ this.perspectiveSeatScale.clear();
3236
+ this.perspectiveAppliedSeats.clear();
3237
+ this.perspectiveBounds = null;
2278
3238
  this.resetLayerTransforms();
2279
3239
  this.hasSections = view.objects.some((o) => o.type === "section");
2280
3240
  for (const z of doc.zones ?? []) if (z.color) this.zoneColor.set(z.id, z.color);
2281
3241
  this.seatLayer.opacity(1);
2282
3242
  this.hoverRing.visible(false);
3243
+ this.hoveredId = null;
2283
3244
  this.theme = doc.theme ?? {};
2284
3245
  this.seatR = clamp(this.theme.seatScale ?? 1, 0.7, 1.6) * SEAT_RADIUS;
2285
3246
  this.container.style.background = "";
@@ -2291,35 +3252,80 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2291
3252
  this.catColor.clear();
2292
3253
  this.catPrice.clear();
2293
3254
  this.catOrder = doc.categories.map((c) => c.key);
3255
+ this.cbHueByKey.clear();
3256
+ [...this.catOrder].sort().forEach((key, i) => {
3257
+ this.cbHueByKey.set(key, CB_PALETTE[i % CB_PALETTE.length]);
3258
+ });
2294
3259
  for (const c of doc.categories) {
2295
3260
  this.catColor.set(c.key, c.color);
2296
3261
  if (typeof c.price === "number") this.catPrice.set(c.key, c.price);
2297
3262
  }
2298
3263
  for (const obj of view.objects) {
2299
3264
  if (obj.type === "booth") {
2300
- this.boothDims.set(obj.id, { width: obj.width, height: obj.height, rotation: obj.rotation });
3265
+ this.boothDims.set(obj.id, {
3266
+ width: obj.width,
3267
+ height: obj.height,
3268
+ rotation: obj.rotation,
3269
+ ...obj.points && obj.points.length >= 3 ? { points: obj.points.map((p) => ({ x: p.x, y: p.y })) } : {}
3270
+ });
2301
3271
  }
2302
3272
  }
2303
- this.seats = expandChart(view);
3273
+ this.seats = expandChart(view, {
3274
+ // A stacked overview is presentation-only and never opens a seat POV. The
3275
+ // per-section renderer below still resolves each object's real floor base.
3276
+ floorBaseHeightM: this.stacked ? 0 : this.floorBaseHeightMFor()
3277
+ });
2304
3278
  for (const s of this.seats) {
2305
3279
  this.seatById.set(s.id, s);
2306
3280
  this.statusById.set(s.id, "free");
2307
3281
  }
3282
+ this.seatIndex = buildSeatIndex(this.seats, { seatRadius: this.seatR });
3283
+ this.bounds = chartBounds(view);
3284
+ this.isoCentre = { x: this.bounds.x + this.bounds.width / 2, y: this.bounds.y + this.bounds.height / 2 };
3285
+ this.buildPerspectiveProjection(view);
2308
3286
  this.renderBackground(view);
3287
+ this.unsectionedSeatGroup = new import_Group.Group({ listening: true });
3288
+ this.seatLayer.add(this.unsectionedSeatGroup);
2309
3289
  this.renderSeats();
3290
+ this.buildRowLabelPlan(view);
2310
3291
  this.overlayLayer.add(this.labelGroup);
3292
+ this.overlayLayer.add(this.fgDecorGroup);
2311
3293
  this.overlayLayer.add(this.hoverRing);
2312
- this.bounds = chartBounds(view);
2313
- this.isoCentre = { x: this.bounds.x + this.bounds.width / 2, y: this.bounds.y + this.bounds.height / 2 };
3294
+ this.overlayLayer.add(this.focusRing);
2314
3295
  this.zoomToFit();
2315
3296
  }
2316
3297
  /** The chart to render: all floors stacked (3D overview), the active floor, or
2317
3298
  * the whole chart for single-floor charts. */
2318
3299
  floorView(doc) {
2319
- if (!doc.floors || !doc.floors.length) return doc;
2320
- if (this.stacked && doc.floors.length >= 2) return stackFloors(doc);
3300
+ if (!doc.floors || !doc.floors.length) return {
3301
+ ...doc,
3302
+ referenceImage: void 0,
3303
+ backgroundImage: buyerBackgroundImage(doc)
3304
+ };
3305
+ if (this.stacked && doc.floors.length >= 2) {
3306
+ return {
3307
+ ...stackFloors(doc),
3308
+ referenceImage: void 0,
3309
+ backgroundImage: buyerBackgroundImage(doc)
3310
+ };
3311
+ }
2321
3312
  const floor = doc.floors.find((f) => f.id === this.activeFloorId) ?? doc.floors[0];
2322
- return { ...doc, objects: floor.objects, focalPoint: floor.focalPoint, backgroundImage: floor.backgroundImage, floors: void 0 };
3313
+ return {
3314
+ ...doc,
3315
+ objects: floor.objects,
3316
+ focalPoint: floor.focalPoint,
3317
+ referenceImage: void 0,
3318
+ backgroundImage: buyerBackgroundImage(floor),
3319
+ floors: void 0
3320
+ };
3321
+ }
3322
+ /** Physical floor height is authored data; the 900-unit stacked overview
3323
+ * spread is presentation-only and must never leak into real 3D geometry. */
3324
+ floorBaseHeightMFor(objectId) {
3325
+ const floors = this.chartDoc?.floors;
3326
+ if (!floors?.length) return 0;
3327
+ const floorId = this.stacked && objectId ? this.objectFloor.get(objectId) ?? this.activeFloorId : this.activeFloorId;
3328
+ return floors.find((floor) => floor.id === floorId)?.baseHeightM ?? 0;
2323
3329
  }
2324
3330
  /** Toggle the 3D all-floors stacked overview (Batch 5). Re-renders; no-op on
2325
3331
  * single-floor charts. The caller re-applies statuses + animates the iso view. */
@@ -2632,7 +3638,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2632
3638
  }
2633
3639
  const ids = [];
2634
3640
  for (const seat of this.seats) {
2635
- if (seat.x < x0 || seat.x > x1 || seat.y < y0 || seat.y > y1) continue;
3641
+ const rendered = this.renderedSeatPoint(seat);
3642
+ if (rendered.x < x0 || rendered.x > x1 || rendered.y < y0 || rendered.y > y1) continue;
2636
3643
  if (!this.isSelectable(seat.id)) continue;
2637
3644
  ids.push(seat.id);
2638
3645
  }
@@ -2642,9 +3649,10 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2642
3649
  flashSeat(seatId, color = "#f43f5e") {
2643
3650
  const seat = this.seatById.get(seatId);
2644
3651
  if (!seat) return;
3652
+ const rendered = this.renderedSeatPoint(seat);
2645
3653
  const ring = new import_Circle.Circle({
2646
- x: seat.x,
2647
- y: seat.y,
3654
+ x: rendered.x,
3655
+ y: rendered.y,
2648
3656
  radius: this.seatR,
2649
3657
  stroke: color,
2650
3658
  strokeWidth: 3,
@@ -2653,6 +3661,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2653
3661
  perfectDrawEnabled: false,
2654
3662
  shadowForStrokeEnabled: false
2655
3663
  });
3664
+ const projectionScale = this.viewMode === "perspective" ? this.perspectiveSeatScale.get(seatId) ?? 1 : 1;
3665
+ ring.scale({ x: projectionScale, y: projectionScale });
2656
3666
  this.overlayLayer.add(ring);
2657
3667
  const start = performance.now();
2658
3668
  const dur = 620;
@@ -2679,17 +3689,20 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2679
3689
  const matches = this.sections.filter((section) => section.id === sectionId || section.zone === sectionId);
2680
3690
  if (!matches.length) return;
2681
3691
  for (const section of matches) {
2682
- const centre = section.outline.reduce(
3692
+ const outline = this.viewMode === "perspective" && section.perspectiveAffine ? section.outline.map((point) => this.perspectiveLocalPoint(this.projectedSectionPoint(section, point))) : section.outline.map((point) => {
3693
+ const lift = section.liftWorld > 0 ? this.isoLiftLocal(section.liftWorld) : { x: 0, y: 0 };
3694
+ return { x: point.x + lift.x, y: point.y + lift.y };
3695
+ });
3696
+ const centre = outline.reduce(
2683
3697
  (sum, point) => ({ x: sum.x + point.x, y: sum.y + point.y }),
2684
3698
  { x: 0, y: 0 }
2685
3699
  );
2686
- centre.x /= section.outline.length;
2687
- centre.y /= section.outline.length;
2688
- const lift = section.elevation > 0 ? this.isoLiftLocal(section.elevation) : { x: 0, y: 0 };
3700
+ centre.x /= outline.length;
3701
+ centre.y /= outline.length;
2689
3702
  const halo = new import_Line.Line({
2690
- x: centre.x + lift.x,
2691
- y: centre.y + lift.y,
2692
- points: section.outline.flatMap((point) => [point.x - centre.x, point.y - centre.y]),
3703
+ x: centre.x,
3704
+ y: centre.y,
3705
+ points: outline.flatMap((point) => [point.x - centre.x, point.y - centre.y]),
2693
3706
  closed: true,
2694
3707
  stroke: color,
2695
3708
  strokeWidth: 3,
@@ -2733,12 +3746,14 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2733
3746
  nearestSeat(fromId, dir) {
2734
3747
  const from = this.seatById.get(fromId);
2735
3748
  if (!from) return null;
3749
+ const fromPoint = this.viewMode === "perspective" ? this.perspectiveSeatProjected.get(from.id) ?? from : from;
2736
3750
  let best = null;
2737
3751
  let bestScore = Infinity;
2738
3752
  for (const s of this.seats) {
2739
3753
  if (s.id === fromId) continue;
2740
- const dx = s.x - from.x;
2741
- const dy = s.y - from.y;
3754
+ const candidatePoint = this.viewMode === "perspective" ? this.perspectiveSeatProjected.get(s.id) ?? s : s;
3755
+ const dx = candidatePoint.x - fromPoint.x;
3756
+ const dy = candidatePoint.y - fromPoint.y;
2742
3757
  const proj = dx * dir.x + dy * dir.y;
2743
3758
  if (proj <= 0.5) continue;
2744
3759
  const perp = Math.abs(dx * dir.y - dy * dir.x);
@@ -2755,7 +3770,9 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2755
3770
  if (!seat) return;
2756
3771
  this.focusedId = id;
2757
3772
  this.focusRing.radius(this.seatR + 3);
2758
- this.focusRing.position({ x: seat.x, y: seat.y });
3773
+ this.focusRing.position(this.renderedSeatPoint(seat));
3774
+ const projectionScale = this.viewMode === "perspective" ? this.perspectiveSeatScale.get(id) ?? 1 : 1;
3775
+ this.focusRing.scale({ x: projectionScale, y: projectionScale });
2759
3776
  this.focusRing.visible(true);
2760
3777
  this.ensureVisible(seat);
2761
3778
  this.overlayLayer.batchDraw();
@@ -2770,7 +3787,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2770
3787
  const margin = 70;
2771
3788
  const offscreen = p.x < margin || p.x > w - margin || p.y < margin || p.y > h - margin;
2772
3789
  if (this.stage.scaleX() < target || offscreen) {
2773
- const ip = this.isoT === 0 ? seat : this.isoForward(seat);
3790
+ const rendered = this.renderedSeatPoint(seat);
3791
+ const ip = this.viewMode === "perspective" && this.perspectiveBaseAffine ? applyAffine(this.perspectiveBaseAffine, rendered) : this.isoT === 0 ? rendered : this.isoForward(rendered);
2774
3792
  this.stage.scale({ x: target, y: target });
2775
3793
  this.stage.position({ x: w / 2 - ip.x * target, y: h / 2 - ip.y * target });
2776
3794
  this.afterViewChange();
@@ -2780,7 +3798,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2780
3798
  zoomToFit() {
2781
3799
  const w = this.stage.width();
2782
3800
  const h = this.stage.height();
2783
- const b = this.bounds;
3801
+ const b = this.viewMode === "perspective" && this.perspectiveBounds ? this.perspectiveBounds : this.bounds;
2784
3802
  this.fitScale = Math.min(w / b.width, h / b.height) || 1;
2785
3803
  const s = this.fitScale;
2786
3804
  this.stage.scale({ x: s, y: s });
@@ -2809,7 +3827,14 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2809
3827
  }
2810
3828
  worldToScreen(point) {
2811
3829
  const s = this.stage.scaleX();
2812
- const p = this.isoT === 0 ? point : this.isoForward(point);
3830
+ const candidateId = point.id;
3831
+ const seat = typeof candidateId === "string" ? this.seatById.get(candidateId) : void 0;
3832
+ if (this.viewMode === "perspective" && this.perspectiveBaseAffine) {
3833
+ const projected = seat ? this.perspectiveSeatProjected.get(seat.id) ?? applyAffine(this.perspectiveBaseAffine, point) : applyAffine(this.perspectiveBaseAffine, point);
3834
+ return { x: projected.x * s + this.stage.x(), y: projected.y * s + this.stage.y() };
3835
+ }
3836
+ const localPoint = seat ? this.renderedSeatPoint(seat) : point;
3837
+ const p = this.isoT === 0 ? localPoint : this.isoForward(localPoint);
2813
3838
  return { x: p.x * s + this.stage.x(), y: p.y * s + this.stage.y() };
2814
3839
  }
2815
3840
  setAccessibleFilter(on) {
@@ -2819,6 +3844,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2819
3844
  const next = types === null ? null : [...types];
2820
3845
  if (this.sameAccessFilter(next)) return;
2821
3846
  this.accessFilter = next;
3847
+ this.updateAccessGlyphs(this.effScale());
2822
3848
  for (const seat of this.seats) {
2823
3849
  const c = this.circleById.get(seat.id);
2824
3850
  if (c) this.paintSeat(c, seat.id);
@@ -2917,10 +3943,11 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2917
3943
  let minY = Infinity;
2918
3944
  let maxY = -Infinity;
2919
3945
  for (const seat of matching) {
2920
- minX = Math.min(minX, seat.x);
2921
- maxX = Math.max(maxX, seat.x);
2922
- minY = Math.min(minY, seat.y);
2923
- maxY = Math.max(maxY, seat.y);
3946
+ const point = this.renderedSeatPoint(seat);
3947
+ minX = Math.min(minX, point.x);
3948
+ maxX = Math.max(maxX, point.x);
3949
+ minY = Math.min(minY, point.y);
3950
+ maxY = Math.max(maxY, point.y);
2924
3951
  }
2925
3952
  const pad = Math.max(24, this.seatR * 3);
2926
3953
  minX -= pad;
@@ -2941,12 +3968,37 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2941
3968
  * inverse) keeps landing on the projected seats/sections.
2942
3969
  */
2943
3970
  setViewMode(mode) {
3971
+ if (mode === "perspective") {
3972
+ if (this.viewMode === mode) {
3973
+ this.afterViewChange();
3974
+ return;
3975
+ }
3976
+ const resetElevatedSeatGroups = this.viewMode === "isometric" && this.isoT > 0;
3977
+ this.viewMode = mode;
3978
+ this.isoTarget = 1;
3979
+ this.isoT = 1;
3980
+ if (this.isoRaf) {
3981
+ cancelAnimationFrame(this.isoRaf);
3982
+ this.isoRaf = 0;
3983
+ }
3984
+ this.applyPerspective(resetElevatedSeatGroups);
3985
+ this.zoomToFit();
3986
+ return;
3987
+ }
2944
3988
  const target = mode === "isometric" ? 1 : 0;
3989
+ const leavingPerspective = this.viewMode === "perspective";
3990
+ this.viewMode = mode;
2945
3991
  this.isoTarget = target;
2946
3992
  if (this.isoRaf) {
2947
3993
  cancelAnimationFrame(this.isoRaf);
2948
3994
  this.isoRaf = 0;
2949
3995
  }
3996
+ if (leavingPerspective) {
3997
+ this.isoT = target;
3998
+ this.applyIso();
3999
+ this.afterViewChange();
4000
+ return;
4001
+ }
2950
4002
  if (this.reducedMotion) {
2951
4003
  this.isoT = target;
2952
4004
  this.applyIso();
@@ -2980,7 +4032,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2980
4032
  }
2981
4033
  /** Current projection — reflects the tween target, not the mid-tween isoT. */
2982
4034
  getViewMode() {
2983
- return this.isoTarget === 1 ? "isometric" : "flat";
4035
+ return this.viewMode;
2984
4036
  }
2985
4037
  /** Iso angle (rad) + y-squash for the current isoT. */
2986
4038
  isoParams() {
@@ -2988,6 +4040,11 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2988
4040
  }
2989
4041
  /** Effective vertical scale = stage scale × iso squash — legibility math uses this. */
2990
4042
  effScale() {
4043
+ if (this.viewMode === "perspective" && this.perspectiveBaseAffine) {
4044
+ const xScale = Math.hypot(this.perspectiveBaseAffine.a, this.perspectiveBaseAffine.b);
4045
+ const yScale = Math.hypot(this.perspectiveBaseAffine.c, this.perspectiveBaseAffine.d);
4046
+ return this.stage.scaleX() * Math.min(xScale, yScale);
4047
+ }
2991
4048
  return this.stage.scaleX() * (1 - (1 - ISO_SQUASH) * this.isoT);
2992
4049
  }
2993
4050
  /** Project a world point through the iso affine about the chart centre (→ iso-world). */
@@ -3011,19 +4068,113 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3011
4068
  const wy = -dx * Math.sin(th) + uy * Math.cos(th);
3012
4069
  return { x: c.x + wx, y: c.y + wy };
3013
4070
  }
4071
+ /** Precompute the Phase-C pinhole camera and every exact seat anchor. */
4072
+ buildPerspectiveProjection(view) {
4073
+ let maxSurfaceHeightWorld = 0;
4074
+ for (const seat of this.seats) {
4075
+ const surfaceM = Math.max(0, (seat.eyeHeightM ?? SEATED_EYE_HEIGHT_M) - SEATED_EYE_HEIGHT_M);
4076
+ maxSurfaceHeightWorld = Math.max(maxSurfaceHeightWorld, surfaceM * CHART_UNITS_PER_METRE);
4077
+ }
4078
+ for (const object of view.objects) {
4079
+ if (object.type !== "section") continue;
4080
+ maxSurfaceHeightWorld = Math.max(
4081
+ maxSurfaceHeightWorld,
4082
+ sectionGeometry(object, { floorBaseHeightM: this.floorBaseHeightMFor(object.id) }).height * CHART_UNITS_PER_METRE
4083
+ );
4084
+ }
4085
+ const camera = createPerspectiveCamera(this.bounds, maxSurfaceHeightWorld);
4086
+ const base = perspectiveTangentAffine(camera, camera.target);
4087
+ const inverse = invertAffine(base);
4088
+ this.perspectiveCamera = camera;
4089
+ this.perspectiveBaseAffine = base;
4090
+ this.perspectiveBaseInverse = inverse;
4091
+ this.perspectiveSeatLocal.clear();
4092
+ this.perspectiveSeatProjected.clear();
4093
+ this.perspectiveSeatDepth.clear();
4094
+ this.perspectiveSeatScale.clear();
4095
+ this.perspectiveBounds = null;
4096
+ let minX = Infinity;
4097
+ let minY = Infinity;
4098
+ let maxX = -Infinity;
4099
+ let maxY = -Infinity;
4100
+ for (const seat of this.seats) {
4101
+ const surfaceM = Math.max(0, (seat.eyeHeightM ?? SEATED_EYE_HEIGHT_M) - SEATED_EYE_HEIGHT_M);
4102
+ const projected = projectPerspectivePoint(camera, seat, surfaceM * CHART_UNITS_PER_METRE);
4103
+ const point = { x: projected.x, y: projected.y };
4104
+ this.perspectiveSeatProjected.set(seat.id, point);
4105
+ this.perspectiveSeatLocal.set(seat.id, applyAffine(inverse, point));
4106
+ this.perspectiveSeatDepth.set(seat.id, projected.depth);
4107
+ this.perspectiveSeatScale.set(seat.id, projected.scale);
4108
+ minX = Math.min(minX, projected.x);
4109
+ minY = Math.min(minY, projected.y);
4110
+ maxX = Math.max(maxX, projected.x);
4111
+ maxY = Math.max(maxY, projected.y);
4112
+ }
4113
+ for (const point of [
4114
+ { x: this.bounds.x, y: this.bounds.y },
4115
+ { x: this.bounds.x + this.bounds.width, y: this.bounds.y },
4116
+ { x: this.bounds.x + this.bounds.width, y: this.bounds.y + this.bounds.height },
4117
+ { x: this.bounds.x, y: this.bounds.y + this.bounds.height }
4118
+ ]) {
4119
+ const projected = projectPerspectivePoint(camera, point, 0);
4120
+ minX = Math.min(minX, projected.x);
4121
+ minY = Math.min(minY, projected.y);
4122
+ maxX = Math.max(maxX, projected.x);
4123
+ maxY = Math.max(maxY, projected.y);
4124
+ }
4125
+ const pad = Math.max(24, this.seatR * 3);
4126
+ 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;
4127
+ }
4128
+ /** Apply an affine to a Konva container while keeping `pivot` fixed logically. */
4129
+ setContainerAffine(node, affine, pivot) {
4130
+ const decomposed = decomposeAffineLinear(affine);
4131
+ node.offset(pivot);
4132
+ node.position(applyAffine(affine, pivot));
4133
+ node.rotation(decomposed.rotationDeg);
4134
+ node.scale({ x: decomposed.scaleX, y: decomposed.scaleY });
4135
+ node.skewX(decomposed.skewX);
4136
+ node.skewY(0);
4137
+ }
4138
+ resetContainerTransform(node) {
4139
+ node.position({ x: 0, y: 0 });
4140
+ node.offset({ x: 0, y: 0 });
4141
+ node.scale({ x: 1, y: 1 });
4142
+ node.rotation(0);
4143
+ node.skewX(0);
4144
+ node.skewY(0);
4145
+ }
4146
+ /** Exact raked surface height used for the bounded section tangent plane. */
4147
+ sectionSurfaceHeight(section, point) {
4148
+ const radial = Math.hypot(point.x - section.surfaceFocal.x, point.y - section.surfaceFocal.y);
4149
+ const depth = Math.max(0, radial - section.frontDistanceWorld);
4150
+ return section.liftWorld + depth * Math.tan(section.rakeDeg * Math.PI / 180);
4151
+ }
4152
+ /** Desired perspective-space point for a section surface, before Stage pan/zoom. */
4153
+ projectedSectionPoint(section, point) {
4154
+ return section.perspectiveAffine ? applyAffine(section.perspectiveAffine, point) : applyAffine(this.perspectiveBaseAffine, point);
4155
+ }
4156
+ /** Shared-layer local point which the ground affine paints at `projected`. */
4157
+ perspectiveLocalPoint(projected) {
4158
+ return this.perspectiveBaseInverse ? applyAffine(this.perspectiveBaseInverse, projected) : projected;
4159
+ }
3014
4160
  /**
3015
4161
  * Local-space offset that, once the layer applies the iso affine, lifts an
3016
- * object straight UP in iso-world by `elevation × LIFT_PER_STEP × isoT`
3017
- * (= inverse-linear of the pure vertical lift). Zero at isoT=0.
4162
+ * object straight UP in iso-world by `liftWorld × isoT` world units
4163
+ * (= inverse-linear of the pure vertical lift). Zero at isoT=0. `liftWorld` is
4164
+ * the section's resolved real height in world units (Phase B1).
3018
4165
  */
3019
- isoLiftLocal(elevation) {
4166
+ isoLiftLocal(liftWorld) {
3020
4167
  const { th, sg } = this.isoParams();
3021
- const delta = elevation * LIFT_PER_STEP * this.isoT;
4168
+ const delta = liftWorld * this.isoT;
3022
4169
  return { x: -(delta / sg) * Math.sin(th), y: -(delta / sg) * Math.cos(th) };
3023
4170
  }
3024
- /** Restore the three layers to identity (flat) — byte-for-byte the original. */
3025
- resetLayerTransforms() {
3026
- for (const layer of [this.bgLayer, this.seatLayer, this.overlayLayer]) {
4171
+ /** Restore projection layers to identity (flat) — byte-for-byte the original.
4172
+ * `seatLayer` can be skipped when entering perspective from flat: assigning a
4173
+ * top-level transform invalidates all 14k descendant absolute transforms even
4174
+ * while the semantic overview keeps that layer fully transparent. */
4175
+ resetLayerTransforms(includeSeatLayer = true) {
4176
+ const layers = includeSeatLayer ? [this.bgLayer, this.seatLayer, this.overlayLayer] : [this.bgLayer, this.overlayLayer];
4177
+ for (const layer of layers) {
3027
4178
  layer.position({ x: 0, y: 0 });
3028
4179
  layer.offset({ x: 0, y: 0 });
3029
4180
  layer.scale({ x: 1, y: 1 });
@@ -3032,6 +4183,117 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3032
4183
  layer.skewY(0);
3033
4184
  }
3034
4185
  }
4186
+ /** Restore section-owned containers before applying another projection. */
4187
+ resetSectionProjection(resetSeatGroups = true) {
4188
+ for (const section of this.sections) {
4189
+ this.resetContainerTransform(section.rootGroupBg);
4190
+ this.resetContainerTransform(section.liftGroupBg);
4191
+ if (resetSeatGroups) this.resetContainerTransform(section.liftGroupSeat);
4192
+ section.rootGroupBg.zIndex(section.bgZIndex);
4193
+ if (resetSeatGroups) section.liftGroupSeat.zIndex(section.seatZIndex);
4194
+ const labelGroup = this.labelLiftGroups.get(section.id);
4195
+ if (labelGroup && resetSeatGroups) this.resetContainerTransform(labelGroup);
4196
+ section.perspectiveAffine = void 0;
4197
+ section.perspectiveCorrection = void 0;
4198
+ section.perspectiveDepth = void 0;
4199
+ }
4200
+ }
4201
+ /** Move native seat/booth hit shapes to or from their exact pinhole anchors. */
4202
+ applyExactSeatAnchors(perspective, seatIds) {
4203
+ 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);
4204
+ for (const seat of seats) {
4205
+ if (perspective && this.perspectiveAppliedSeats.has(seat.id)) continue;
4206
+ const point = perspective ? this.perspectiveSeatProjected.get(seat.id) ?? seat : seat;
4207
+ const scale = perspective ? this.perspectiveSeatScale.get(seat.id) ?? 1 : 1;
4208
+ const shape = this.circleById.get(seat.id);
4209
+ shape?.position(point);
4210
+ shape?.scale({ x: scale, y: scale });
4211
+ const ring = this.accessRingById.get(seat.id);
4212
+ ring?.position(point);
4213
+ ring?.scale({ x: scale, y: scale });
4214
+ const glyph = this.accessGlyphById.get(seat.id);
4215
+ if (glyph) {
4216
+ const baseGlyphScale = this.seatR * 1.5 / ACCESS_GLYPH_VIEWBOX;
4217
+ glyph.position(point);
4218
+ glyph.scale({ x: baseGlyphScale * scale, y: baseGlyphScale * scale });
4219
+ }
4220
+ const boothLabel = this.boothLabelById.get(seat.id);
4221
+ boothLabel?.position(point);
4222
+ boothLabel?.scale({ x: scale, y: scale });
4223
+ if (perspective) this.perspectiveAppliedSeats.add(seat.id);
4224
+ }
4225
+ if (!perspective) this.perspectiveAppliedSeats.clear();
4226
+ }
4227
+ /**
4228
+ * Phase C projected 2.5D. Seat/booth anchors are exact pinhole projections;
4229
+ * section top surfaces use one explicitly bounded tangent plane per authored
4230
+ * section. Native Konva shapes move with the anchors, so direct hit testing is
4231
+ * the same graph that paints the buyer-visible unit.
4232
+ */
4233
+ applyPerspective(resetElevatedSeatGroups = true) {
4234
+ const camera = this.perspectiveCamera;
4235
+ const base = this.perspectiveBaseAffine;
4236
+ const inverse = this.perspectiveBaseInverse;
4237
+ if (!camera || !base || !inverse) return;
4238
+ const lazySectionSeats = this.seats.length > 2500 && this.sections.length > 0;
4239
+ if (this.cached && !lazySectionSeats) {
4240
+ this.seatLayer.clearCache();
4241
+ this.seatLayer.listening(true);
4242
+ this.cached = false;
4243
+ }
4244
+ this.resetLayerTransforms(resetElevatedSeatGroups);
4245
+ this.resetSectionProjection(resetElevatedSeatGroups);
4246
+ for (const layer of [this.bgLayer, this.overlayLayer]) {
4247
+ this.setContainerAffine(layer, base, camera.target);
4248
+ }
4249
+ this.perspectiveAppliedSeats.clear();
4250
+ if (!lazySectionSeats) this.applyExactSeatAnchors(true);
4251
+ for (const section of this.sections) {
4252
+ const heightAt = (point) => this.sectionSurfaceHeight(section, point);
4253
+ const desired = perspectiveTangentAffine(camera, section.centroid, heightAt);
4254
+ const correction = composeAffine(inverse, desired);
4255
+ section.perspectiveAffine = desired;
4256
+ section.perspectiveCorrection = correction;
4257
+ section.perspectiveDepth = projectPerspectivePoint(
4258
+ camera,
4259
+ section.centroid,
4260
+ heightAt(section.centroid)
4261
+ ).depth;
4262
+ this.setContainerAffine(section.liftGroupBg, correction, section.centroid);
4263
+ for (let index = 0; index < section.sideFaces.length; index++) {
4264
+ const p0 = section.outline[index];
4265
+ const p1 = section.outline[(index + 1) % section.outline.length];
4266
+ const base0 = this.perspectiveLocalPoint(projectPerspectivePoint(camera, p0, 0));
4267
+ const base1 = this.perspectiveLocalPoint(projectPerspectivePoint(camera, p1, 0));
4268
+ const top1 = this.perspectiveLocalPoint(projectPerspectivePoint(camera, p1, heightAt(p1)));
4269
+ const top0 = this.perspectiveLocalPoint(projectPerspectivePoint(camera, p0, heightAt(p0)));
4270
+ section.sideFaces[index].points([
4271
+ base0.x,
4272
+ base0.y,
4273
+ base1.x,
4274
+ base1.y,
4275
+ top1.x,
4276
+ top1.y,
4277
+ top0.x,
4278
+ top0.y
4279
+ ]);
4280
+ section.sideFaces[index].opacity(0.9);
4281
+ }
4282
+ }
4283
+ const farToNear = [...this.sections].sort(
4284
+ (left, right) => (right.perspectiveDepth ?? 0) - (left.perspectiveDepth ?? 0)
4285
+ );
4286
+ for (const section of farToNear) {
4287
+ section.rootGroupBg.moveToTop();
4288
+ section.liftGroupSeat.moveToTop();
4289
+ }
4290
+ this.unsectionedSeatGroup?.moveToTop();
4291
+ this.syncSeatOverlayPositions();
4292
+ if (!lazySectionSeats) this.updateSeatGroupVisibility();
4293
+ this.bgLayer.batchDraw();
4294
+ this.seatLayer.batchDraw();
4295
+ this.overlayLayer.batchDraw();
4296
+ }
3035
4297
  /**
3036
4298
  * Apply the current isoT to the scene: the base rotate+squash as a decomposed
3037
4299
  * layer transform (so seats/décor/rings project together and Konva's own
@@ -3039,6 +4301,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3039
4301
  * lift + extruded side faces on elevated sections.
3040
4302
  */
3041
4303
  applyIso() {
4304
+ this.resetSectionProjection();
4305
+ this.applyExactSeatAnchors(false);
3042
4306
  const t2 = this.isoT;
3043
4307
  if (t2 === 0) {
3044
4308
  this.resetLayerTransforms();
@@ -3067,6 +4331,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3067
4331
  }
3068
4332
  this.applyUprightLabels();
3069
4333
  this.applyElevation();
4334
+ this.updateSeatGroupVisibility();
3070
4335
  this.bgLayer.batchDraw();
3071
4336
  this.seatLayer.batchDraw();
3072
4337
  this.overlayLayer.batchDraw();
@@ -3095,10 +4360,11 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3095
4360
  applyElevation() {
3096
4361
  const t2 = this.isoT;
3097
4362
  for (const sec of this.sections) {
3098
- if (sec.elevation <= 0) continue;
3099
- const off = this.isoLiftLocal(sec.elevation);
4363
+ if (sec.liftWorld <= 0) continue;
4364
+ const off = this.isoLiftLocal(sec.liftWorld);
3100
4365
  sec.liftGroupBg?.position(off);
3101
- sec.liftGroupSeat?.position(off);
4366
+ sec.liftGroupSeat.position(off);
4367
+ this.labelLiftGroups.get(sec.id)?.position(off);
3102
4368
  const alpha = 0.9 * t2;
3103
4369
  for (let i = 0; i < sec.sideFaces.length; i++) {
3104
4370
  const face = sec.sideFaces[i];
@@ -3108,6 +4374,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3108
4374
  face.opacity(alpha);
3109
4375
  }
3110
4376
  }
4377
+ this.syncSeatOverlayPositions();
3111
4378
  }
3112
4379
  destroy() {
3113
4380
  this.destroyed = true;
@@ -3134,16 +4401,166 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3134
4401
  * tier shifts with one offset in iso view) or the seat layer directly.
3135
4402
  */
3136
4403
  seatContainer(id) {
3137
- return this.seatSection.get(id)?.liftGroupSeat ?? this.seatLayer;
4404
+ return this.seatSection.get(id)?.liftGroupSeat ?? this.unsectionedSeatGroup ?? this.seatLayer;
3138
4405
  }
3139
- renderSeats() {
4406
+ /** True when a section belongs to the active logical section/zone focus. */
4407
+ sectionMatchesFocus(section) {
4408
+ const focus = this.focusedSectionId;
4409
+ return focus == null || section.id === focus || section.logicalId === focus || section.zone === focus;
4410
+ }
4411
+ /** Visual opacity after the section-focus overlay is composed. */
4412
+ focusedSeatOpacity(id, localOpacity) {
4413
+ if (!this.focusedSectionId) return localOpacity;
4414
+ const section = this.seatSection.get(id);
4415
+ return localOpacity * (section && this.sectionMatchesFocus(section) ? 1 : FOCUS_DIM_OPACITY);
4416
+ }
4417
+ /** Project a section outline through elevation + the active affine view and
4418
+ * then into viewport pixels. The result is conservative (AABB), so a visible
4419
+ * section is never clipped even for rotated or isometric outlines. */
4420
+ sectionScreenBounds(section) {
4421
+ if (this.viewMode === "perspective" && section.perspectiveAffine) {
4422
+ const scale2 = this.stage.scaleX();
4423
+ const stageX2 = this.stage.x();
4424
+ const stageY2 = this.stage.y();
4425
+ return polyBounds(section.outline.map((point) => {
4426
+ const projected = this.projectedSectionPoint(section, point);
4427
+ return { x: projected.x * scale2 + stageX2, y: projected.y * scale2 + stageY2 };
4428
+ }));
4429
+ }
4430
+ const offset = section.liftWorld > 0 ? this.isoLiftLocal(section.liftWorld) : { x: 0, y: 0 };
4431
+ const scale = this.stage.scaleX();
4432
+ const stageX = this.stage.x();
4433
+ const stageY = this.stage.y();
4434
+ return polyBounds(section.outline.map((point) => {
4435
+ const local = { x: point.x + offset.x, y: point.y + offset.y };
4436
+ const projected = this.isoT === 0 ? local : this.isoForward(local);
4437
+ return { x: projected.x * scale + stageX, y: projected.y * scale + stageY };
4438
+ }));
4439
+ }
4440
+ /** Hide only section groups wholly outside a padded viewport at the live-seat
4441
+ * rung. Overview caching always restores all groups first, so panning a
4442
+ * cached whole-venue bitmap can never reveal missing inventory. */
4443
+ updateSeatGroupVisibility() {
4444
+ const liveSeats = this.effScale() >= CACHE_THRESHOLD;
4445
+ const deferPerspectiveSeatReveal = this.viewMode === "perspective" && this.glideInProgress && this.seats.length > 2500;
4446
+ const padding = 96;
4447
+ const width = this.stage.width();
4448
+ const height = this.stage.height();
4449
+ for (const section of this.sections) {
4450
+ let visible = true;
4451
+ if (liveSeats) {
4452
+ const bounds2 = this.sectionScreenBounds(section);
4453
+ visible = bounds2.x + bounds2.width >= -padding && bounds2.y + bounds2.height >= -padding && bounds2.x <= width + padding && bounds2.y <= height + padding;
4454
+ if (visible && this.viewMode === "perspective" && !deferPerspectiveSeatReveal) {
4455
+ this.applyExactSeatAnchors(true, section.memberIds);
4456
+ }
4457
+ }
4458
+ section.liftGroupSeat.setViewportCulled(!visible);
4459
+ const labelGroup = this.labelLiftGroups.get(section.id);
4460
+ labelGroup?.setViewportCulled(!visible);
4461
+ }
4462
+ if (this.unsectionedSeatGroup && !this.unsectionedSeatGroup.visible()) {
4463
+ this.unsectionedSeatGroup.visible(true);
4464
+ }
4465
+ if (liveSeats && this.viewMode === "perspective" && !deferPerspectiveSeatReveal) {
4466
+ this.applyExactSeatAnchors(true, this.seats.filter((seat) => !this.seatSection.has(seat.id)).map((seat) => seat.id));
4467
+ }
4468
+ }
4469
+ /** Seats worth considering for viewport labels. Section culling is a safe
4470
+ * coarse index; the later screen test remains the exact filter. */
4471
+ visibleSeatCandidates() {
4472
+ if (!this.sections.length) return this.seats;
4473
+ const candidates = [];
4474
+ for (const section of this.sections) {
4475
+ if (section.liftGroupSeat.isViewportCulled()) continue;
4476
+ for (const id of section.memberIds) {
4477
+ const seat = this.seatById.get(id);
4478
+ if (seat) candidates.push(seat);
4479
+ }
4480
+ }
3140
4481
  for (const seat of this.seats) {
4482
+ if (!this.seatSection.has(seat.id)) candidates.push(seat);
4483
+ }
4484
+ return candidates;
4485
+ }
4486
+ seatViewportCulled(id) {
4487
+ return this.seatSection.get(id)?.liftGroupSeat.isViewportCulled() ?? false;
4488
+ }
4489
+ /** Local world coordinate at which an elevated seat is actually painted for
4490
+ * the current iso tween. Flat seats and the 2D endpoint are unchanged. */
4491
+ renderedSeatPoint(seat) {
4492
+ if (this.viewMode === "perspective") return this.perspectiveSeatLocal.get(seat.id) ?? seat;
4493
+ const section = this.seatSection.get(seat.id);
4494
+ if (!section || section.liftWorld <= 0 || this.isoT === 0) return seat;
4495
+ const offset = this.isoLiftLocal(section.liftWorld);
4496
+ return { x: seat.x + offset.x, y: seat.y + offset.y };
4497
+ }
4498
+ /** Overlay labels mirror the seat-layer lift without an O(seats) per-frame
4499
+ * rewrite. The groups are rebuilt with the zoom-dependent label set. */
4500
+ seatLabelContainer(id) {
4501
+ const section = this.seatSection.get(id);
4502
+ if (!section) return this.labelGroup;
4503
+ let group = this.labelLiftGroups.get(section.id);
4504
+ if (!group) {
4505
+ group = new ViewportGroup({
4506
+ listening: false
4507
+ });
4508
+ group.setViewportCulled(section.liftGroupSeat.isViewportCulled());
4509
+ group.position(this.viewMode === "perspective" ? { x: 0, y: 0 } : this.isoLiftLocal(section.liftWorld));
4510
+ this.labelGroup.add(group);
4511
+ this.labelLiftGroups.set(section.id, group);
4512
+ }
4513
+ return group;
4514
+ }
4515
+ /** Shared overlay furniture is not parented to section lift groups, so keep
4516
+ * the small transient set aligned explicitly. Selection is capped in buyer
4517
+ * mode, making this constant-sized work during the iso tween. */
4518
+ syncSeatOverlayPositions() {
4519
+ if (this.hoveredId) {
4520
+ const seat = this.seatById.get(this.hoveredId);
4521
+ if (seat) {
4522
+ this.hoverRing.position(this.renderedSeatPoint(seat));
4523
+ const scale = this.viewMode === "perspective" ? this.perspectiveSeatScale.get(seat.id) ?? 1 : 1;
4524
+ this.hoverRing.scale({ x: scale, y: scale });
4525
+ }
4526
+ }
4527
+ if (this.focusedId) {
4528
+ const seat = this.seatById.get(this.focusedId);
4529
+ if (seat) {
4530
+ this.focusRing.position(this.renderedSeatPoint(seat));
4531
+ const scale = this.viewMode === "perspective" ? this.perspectiveSeatScale.get(seat.id) ?? 1 : 1;
4532
+ this.focusRing.scale({ x: scale, y: scale });
4533
+ }
4534
+ }
4535
+ for (const [id, marker] of this.selectionMarkers) {
4536
+ const seat = this.seatById.get(id);
4537
+ if (seat) {
4538
+ marker.position(this.renderedSeatPoint(seat));
4539
+ const scale = this.viewMode === "perspective" ? this.perspectiveSeatScale.get(seat.id) ?? 1 : 1;
4540
+ marker.scale({ x: scale, y: scale });
4541
+ }
4542
+ }
4543
+ }
4544
+ renderSeats() {
4545
+ const paintOrder = [...this.seats].sort((left, right) => (this.perspectiveSeatDepth.get(right.id) ?? 0) - (this.perspectiveSeatDepth.get(left.id) ?? 0));
4546
+ for (const seat of paintOrder) {
3141
4547
  if (seat.kind === "booth") {
3142
4548
  this.renderBoothUnit(seat);
3143
4549
  continue;
3144
4550
  }
3145
4551
  const target = this.seatContainer(seat.id);
3146
- const c = new import_Circle.Circle({
4552
+ const c = seat.wheelchairSpaceType === "no-seat" ? new import_Rect.Rect({
4553
+ x: seat.x,
4554
+ y: seat.y,
4555
+ width: this.seatR * 2,
4556
+ height: this.seatR * 2,
4557
+ offsetX: this.seatR,
4558
+ offsetY: this.seatR,
4559
+ cornerRadius: 2,
4560
+ perfectDrawEnabled: false,
4561
+ shadowForStrokeEnabled: false,
4562
+ hitStrokeWidth: 0
4563
+ }) : new import_Circle.Circle({
3147
4564
  x: seat.x,
3148
4565
  y: seat.y,
3149
4566
  radius: this.seatR,
@@ -3156,21 +4573,25 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3156
4573
  this.paintSeat(c, seat.id);
3157
4574
  target.add(c);
3158
4575
  if (seat.accessible) {
3159
- const ring = new import_Circle.Circle({
3160
- x: seat.x,
3161
- y: seat.y,
3162
- radius: this.seatR + 1.5,
3163
- stroke: accessibilityRingColor(seat.accessibility),
3164
- strokeWidth: 2.5,
3165
- listening: false,
3166
- perfectDrawEnabled: false,
3167
- shadowForStrokeEnabled: false
3168
- });
3169
- this.accessRingById.set(seat.id, ring);
3170
- target.add(ring);
3171
- const glyph = this.buildAccessGlyph(seat, typeof c.fill() === "string" ? c.fill() : "");
3172
- this.accessGlyphById.set(seat.id, glyph);
3173
- target.add(glyph);
4576
+ if (seat.wheelchairSpaceType !== "no-seat") {
4577
+ const ring = new import_Circle.Circle({
4578
+ x: seat.x,
4579
+ y: seat.y,
4580
+ radius: this.seatR + 1.5,
4581
+ stroke: accessibilityRingColor(seat.accessibility),
4582
+ strokeWidth: 2.5,
4583
+ listening: false,
4584
+ perfectDrawEnabled: false,
4585
+ shadowForStrokeEnabled: false
4586
+ });
4587
+ this.accessRingById.set(seat.id, ring);
4588
+ target.add(ring);
4589
+ }
4590
+ if (seat.accessibility?.includes("wheelchair")) {
4591
+ const glyph = this.buildAccessGlyph(seat, typeof c.fill() === "string" ? c.fill() : "");
4592
+ this.accessGlyphById.set(seat.id, glyph);
4593
+ target.add(glyph);
4594
+ }
3174
4595
  }
3175
4596
  }
3176
4597
  }
@@ -3178,25 +4599,42 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3178
4599
  renderBoothUnit(seat) {
3179
4600
  const target = this.seatContainer(seat.id);
3180
4601
  const dims = this.boothDims.get(seat.rowId) ?? { width: 40, height: 30, rotation: 0 };
3181
- const rect = new import_Rect.Rect({
3182
- x: seat.x,
3183
- y: seat.y,
3184
- width: dims.width,
3185
- height: dims.height,
3186
- offsetX: dims.width / 2,
3187
- offsetY: dims.height / 2,
3188
- rotation: dims.rotation,
3189
- cornerRadius: 4,
3190
- perfectDrawEnabled: false,
3191
- shadowForStrokeEnabled: false,
3192
- hitStrokeWidth: 0
3193
- });
3194
- rect.setAttr("seatId", seat.id);
3195
- this.circleById.set(seat.id, rect);
3196
- target.add(rect);
4602
+ let labelX = seat.x;
4603
+ let labelY = seat.y;
4604
+ let node;
4605
+ if (dims.points && dims.points.length >= 3) {
4606
+ node = new import_Line.Line({
4607
+ x: seat.x,
4608
+ y: seat.y,
4609
+ points: dims.points.flatMap((p) => [p.x, p.y]),
4610
+ closed: true,
4611
+ perfectDrawEnabled: false,
4612
+ shadowForStrokeEnabled: false,
4613
+ hitStrokeWidth: 0
4614
+ });
4615
+ labelX = seat.x + dims.points.reduce((a, p) => a + p.x, 0) / dims.points.length;
4616
+ labelY = seat.y + dims.points.reduce((a, p) => a + p.y, 0) / dims.points.length;
4617
+ } else {
4618
+ node = new import_Rect.Rect({
4619
+ x: seat.x,
4620
+ y: seat.y,
4621
+ width: dims.width,
4622
+ height: dims.height,
4623
+ offsetX: dims.width / 2,
4624
+ offsetY: dims.height / 2,
4625
+ rotation: dims.rotation,
4626
+ cornerRadius: 4,
4627
+ perfectDrawEnabled: false,
4628
+ shadowForStrokeEnabled: false,
4629
+ hitStrokeWidth: 0
4630
+ });
4631
+ }
4632
+ node.setAttr("seatId", seat.id);
4633
+ this.circleById.set(seat.id, node);
4634
+ target.add(node);
3197
4635
  const t2 = new import_Text.Text({
3198
- x: seat.x,
3199
- y: seat.y,
4636
+ x: labelX,
4637
+ y: labelY,
3200
4638
  text: seat.displayLabel ?? seat.label,
3201
4639
  fontSize: BOOTH_LABEL_FONT_SIZE,
3202
4640
  fontStyle: "600",
@@ -3208,11 +4646,10 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3208
4646
  t2.offsetX(t2.width() / 2);
3209
4647
  t2.offsetY(t2.height() / 2);
3210
4648
  t2.visible(false);
3211
- this.boothLabelById.set(seat.id, t2);
3212
4649
  this.hasBoothText = true;
3213
4650
  this.boothLabelById.set(seat.id, t2);
3214
4651
  target.add(t2);
3215
- this.paintSeat(rect, seat.id);
4652
+ this.paintSeat(node, seat.id);
3216
4653
  }
3217
4654
  /**
3218
4655
  * Build the centred accessibility glyph for a seat. Sized relative to the seat
@@ -3231,24 +4668,40 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3231
4668
  scaleY: k,
3232
4669
  fill: stateAwareBookableLabelInk(seatFill, "#ffffff"),
3233
4670
  listening: false,
3234
- visible: this.accessGlyphVisible,
4671
+ visible: false,
3235
4672
  perfectDrawEnabled: false,
3236
4673
  shadowForStrokeEnabled: false
3237
4674
  });
3238
4675
  }
3239
4676
  /**
3240
- * Toggle the accessibility glyphs for the current camera scale: shown once the
3241
- * effective on-screen seat radius clears {@link SEAT_GLYPH_MIN_PX}, otherwise
3242
- * hidden so only the ring remains. Iterates the (small) accessible-seat set,
3243
- * never the full node graph, so it is cheap to call on every view change.
4677
+ * Keep physical wheelchair provision readable in screen space at every map
4678
+ * LOD. The active Wheelchair filter promotes it further. Iterates the small
4679
+ * wheelchair-seat set, never the full node graph.
3244
4680
  */
3245
4681
  updateAccessGlyphs(scale) {
3246
4682
  if (!this.accessGlyphById.size) return;
3247
- this.accessGlyphVisible = this.seatR * scale >= SEAT_GLYPH_MIN_PX;
3248
4683
  for (const [id, glyph] of this.accessGlyphById) {
3249
- glyph.visible(this.accessGlyphVisible && this.accessGlyphEligible(id));
4684
+ const perspectiveScale = this.viewMode === "perspective" ? this.perspectiveSeatScale.get(id) ?? 1 : 1;
4685
+ const baseScale = this.seatR * 1.5 / ACCESS_GLYPH_VIEWBOX * perspectiveScale;
4686
+ const emphasized = this.accessGlyphFilterEmphasized(id);
4687
+ const minimumScreenPx = emphasized ? FILTERED_WHEELCHAIR_GLYPH_MIN_PX : WHEELCHAIR_GLYPH_MIN_PX;
4688
+ const glyphScale = Math.max(
4689
+ baseScale,
4690
+ minimumScreenPx / (ACCESS_GLYPH_VIEWBOX * Math.max(scale, 1e-3))
4691
+ );
4692
+ glyph.scale({ x: glyphScale, y: glyphScale });
4693
+ glyph.visible(this.accessGlyphShouldShow(id));
3250
4694
  }
3251
4695
  }
4696
+ /** True only for a physical wheelchair provision matching an active buyer filter. */
4697
+ accessGlyphFilterEmphasized(id) {
4698
+ const seat = this.seatById.get(id);
4699
+ const filter = this.accessFilter;
4700
+ return !!seat && filter !== null && !!seat.accessibility?.includes("wheelchair") && (filter.length === 0 || filter.includes("wheelchair")) && seatMatchesAccess(seat, filter);
4701
+ }
4702
+ accessGlyphShouldShow(id) {
4703
+ return this.accessGlyphById.has(id) && this.accessGlyphEligible(id);
4704
+ }
3252
4705
  /**
3253
4706
  * Whether an accessible seat's glyph should show for its current status. It is
3254
4707
  * hidden on seats a buyer cannot take (sold, or another buyer's hold) where the
@@ -3264,8 +4717,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3264
4717
  /** The category's display color — Okabe-Ito hue when colorblind-safe is on. */
3265
4718
  seatBaseColor(categoryKey) {
3266
4719
  if (!this.colorblind) return this.catColor.get(categoryKey) ?? "#6e7bff";
3267
- const idx = this.catOrder.indexOf(categoryKey);
3268
- return CB_PALETTE[(idx >= 0 ? idx : 0) % CB_PALETTE.length];
4720
+ return this.cbHueByKey.get(categoryKey) ?? CB_PALETTE[0];
3269
4721
  }
3270
4722
  /** Per-seat authored label size relative to the neutral default (1 = default),
3271
4723
  * mirroring the section convention (labelStyle.size ?? 18) / 18. Only real
@@ -3328,6 +4780,11 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3328
4780
  c.dash([2, 2]);
3329
4781
  break;
3330
4782
  }
4783
+ if (seat.wheelchairSpaceType === "no-seat" && status === "free") {
4784
+ c.stroke(accessibilityRingColor(seat.accessibility));
4785
+ c.strokeWidth(selected ? 3 : 2);
4786
+ c.dash([3, 2]);
4787
+ }
3331
4788
  if (boothLabel) {
3332
4789
  boothLabel.text(status === "booked" ? "SOLD" : status === "held" ? "HELD" : seat.label);
3333
4790
  boothLabel.fontSize(status === "free" ? 10 : Math.min(10, Math.max(6, this.boothDims.get(seat.rowId)?.width ?? 40) / 6));
@@ -3361,11 +4818,6 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3361
4818
  c.dash([]);
3362
4819
  c.opacity(CLOSED_SEAT_OPACITY);
3363
4820
  }
3364
- if (this.focusedSectionId) {
3365
- const sec = this.seatSection.get(id);
3366
- const inFocus = !!sec && (sec.id === this.focusedSectionId || sec.logicalId === this.focusedSectionId || sec.zone === this.focusedSectionId);
3367
- if (!inFocus) c.opacity(FOCUS_DIM_OPACITY);
3368
- }
3369
4821
  if (this.selectionFocusId && id !== this.selectionFocusId) c.opacity(Math.min(c.opacity(), 0.16));
3370
4822
  const bookableLabel = this.boothLabelById.get(id) ?? this.seatLabelById.get(id);
3371
4823
  if (bookableLabel) {
@@ -3379,7 +4831,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3379
4831
  const fill = c.fill();
3380
4832
  accessGlyph.fill(stateAwareBookableLabelInk(typeof fill === "string" ? fill : "", "#ffffff"));
3381
4833
  accessGlyph.opacity(c.opacity());
3382
- accessGlyph.visible(this.accessGlyphVisible && this.accessGlyphEligible(id));
4834
+ accessGlyph.visible(this.accessGlyphShouldShow(id));
3383
4835
  }
3384
4836
  this.accessRingById.get(id)?.opacity(c.opacity());
3385
4837
  }
@@ -3448,7 +4900,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3448
4900
  if (!this.sections.some((section) => section.id === id || section.logicalId === id)) return;
3449
4901
  this.focusedSectionId = id;
3450
4902
  this.drawFocusBackdrop(id);
3451
- this.repaintSectionsAndSeats();
4903
+ this.bgLayer.batchDraw();
4904
+ this.overlayLayer.batchDraw();
3452
4905
  this.updateLOD();
3453
4906
  this.focusRegion(id, { minScale: SEAT_FOCUS_SCALE });
3454
4907
  }
@@ -3460,7 +4913,10 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3460
4913
  this.focusBackdrop.destroy();
3461
4914
  this.focusBackdrop = null;
3462
4915
  }
3463
- this.repaintSectionsAndSeats();
4916
+ this.focusDimOverlay?.destroy();
4917
+ this.focusDimOverlay = null;
4918
+ this.bgLayer.batchDraw();
4919
+ this.overlayLayer.batchDraw();
3464
4920
  this.updateLOD();
3465
4921
  }
3466
4922
  /** The currently AXS-focused section id, or null. */
@@ -3477,16 +4933,69 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3477
4933
  if (!sections.length) return;
3478
4934
  const backdrop = new import_Group.Group({ listening: false });
3479
4935
  for (const section of sections) {
3480
- backdrop.add(polygonWithHolesShape(section.outline, section.holes, {
4936
+ const perspective = this.viewMode === "perspective" && section.perspectiveAffine;
4937
+ const outline = perspective ? section.outline.map((point) => this.perspectiveLocalPoint(this.projectedSectionPoint(section, point))) : section.outline;
4938
+ const holes = perspective ? section.holes.map((hole) => hole.map((point) => this.perspectiveLocalPoint(this.projectedSectionPoint(section, point)))) : section.holes;
4939
+ backdrop.add(polygonWithHolesShape(outline, holes, {
3481
4940
  fill: FOCUS_BACKDROP_FILL,
3482
4941
  stroke: rgba("#ffffff", 0.1),
3483
4942
  strokeWidth: 1,
3484
4943
  listening: false
3485
- }, section.outlinePath));
4944
+ }, perspective ? void 0 : section.outlinePath));
3486
4945
  }
3487
4946
  this.bgLayer.add(backdrop);
3488
4947
  backdrop.moveToTop();
3489
4948
  this.focusBackdrop = backdrop;
4949
+ this.drawFocusDimOverlay(sections);
4950
+ }
4951
+ /** Dim everything outside the active section with one paint-only shape. Group
4952
+ * opacity would make Konva recursively invalidate absolute-opacity caches on
4953
+ * all 14k descendants. The cut-out follows the live elevation offset and is
4954
+ * non-listening, so direct seat hit-testing is unchanged. */
4955
+ drawFocusDimOverlay(sections) {
4956
+ this.focusDimOverlay?.destroy();
4957
+ const span = Math.max(this.bounds.width, this.bounds.height, 1);
4958
+ const padding = span * 4 + 1e3;
4959
+ const outer = [
4960
+ { x: this.bounds.x - padding, y: this.bounds.y - padding },
4961
+ { x: this.bounds.x + this.bounds.width + padding, y: this.bounds.y - padding },
4962
+ { x: this.bounds.x + this.bounds.width + padding, y: this.bounds.y + this.bounds.height + padding },
4963
+ { x: this.bounds.x - padding, y: this.bounds.y + this.bounds.height + padding }
4964
+ ];
4965
+ const signedArea = (points) => points.reduce((sum, point, index) => {
4966
+ const next = points[(index + 1) % points.length];
4967
+ return sum + point.x * next.y - next.x * point.y;
4968
+ }, 0);
4969
+ const overlay = new import_Shape.Shape({
4970
+ fill: this.canvasBackground,
4971
+ opacity: 1 - FOCUS_DIM_OPACITY,
4972
+ listening: false,
4973
+ perfectDrawEnabled: false,
4974
+ sceneFunc: (context, shape) => {
4975
+ const polygonPath = (points) => {
4976
+ if (!points.length) return;
4977
+ context.moveTo(points[0].x, points[0].y);
4978
+ for (let index = 1; index < points.length; index++) context.lineTo(points[index].x, points[index].y);
4979
+ context.closePath();
4980
+ };
4981
+ context.beginPath();
4982
+ polygonPath(outer);
4983
+ for (const section of sections) {
4984
+ const hole = this.viewMode === "perspective" && section.perspectiveAffine ? section.outline.map((point) => this.perspectiveLocalPoint(this.projectedSectionPoint(section, point))) : section.outline.map((point) => {
4985
+ const offset = section.liftWorld > 0 ? this.isoLiftLocal(section.liftWorld) : { x: 0, y: 0 };
4986
+ return { x: point.x + offset.x, y: point.y + offset.y };
4987
+ });
4988
+ polygonPath(signedArea(hole) > 0 ? [...hole].reverse() : hole);
4989
+ }
4990
+ context.fillStrokeShape(shape);
4991
+ }
4992
+ });
4993
+ this.overlayLayer.add(overlay);
4994
+ overlay.moveToTop();
4995
+ for (const marker of this.selectionMarkers.values()) marker.moveToTop();
4996
+ this.hoverRing.moveToTop();
4997
+ this.focusRing.moveToTop();
4998
+ this.focusDimOverlay = overlay;
3490
4999
  }
3491
5000
  /** Repaint every seat + section block to reflect closed/focus state, then redraw. */
3492
5001
  repaintSectionsAndSeats() {
@@ -3550,7 +5059,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3550
5059
  this.applyGAFilterState();
3551
5060
  }
3552
5061
  renderBackground(doc) {
3553
- if (doc.backgroundImage) this.renderBackgroundImage(doc.backgroundImage);
5062
+ const buyerBackground = buyerBackgroundImage(doc);
5063
+ if (buyerBackground) this.renderBackgroundImage(buyerBackground);
3554
5064
  for (const obj of doc.objects) if (obj.type === "decorImage") this.renderDecorImage(obj);
3555
5065
  for (const obj of doc.objects) if (obj.type === "section") this.renderSection(obj);
3556
5066
  this.renderZones(doc);
@@ -3630,10 +5140,12 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3630
5140
  listening: false,
3631
5141
  perfectDrawEnabled: false
3632
5142
  });
3633
- this.bgLayer.add(node);
5143
+ if (obj.layer === "foreground") this.fgDecorGroup.add(node);
5144
+ else this.bgLayer.add(node);
3634
5145
  img.onload = () => {
3635
- if (!node.getLayer()) return;
3636
- this.bgLayer.batchDraw();
5146
+ const layer = node.getLayer();
5147
+ if (!layer) return;
5148
+ layer.batchDraw();
3637
5149
  };
3638
5150
  img.src = obj.href;
3639
5151
  }
@@ -3670,23 +5182,46 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3670
5182
  })
3671
5183
  );
3672
5184
  }
3673
- const label = this.addCentredLabel(this.bgLayer, obj.label, obj.center.x, obj.center.y, "#cbd5e1", 12, true);
5185
+ const label = this.addCentredLabel(this.bgLayer, obj.displayLabel ?? obj.label, obj.center.x, obj.center.y, "#cbd5e1", 12, true);
3674
5186
  this.freeTextById.set(obj.id, { node: label, background: "#232c40", kind: "table" });
3675
5187
  }
3676
5188
  renderText(obj) {
3677
5189
  const background = this.canvasBackground;
3678
5190
  const preferredInk = obj.color ?? this.theme.textColor ?? DEF_TEXT;
5191
+ const iconPath = obj.semanticKind === "icon" ? venueIconPath(obj.iconKey) : void 0;
5192
+ if (iconPath) {
5193
+ const scale = obj.fontSize / VENUE_ICON_VIEWBOX;
5194
+ const iconNode = new import_Path.Path({
5195
+ x: obj.position.x,
5196
+ y: obj.position.y,
5197
+ data: iconPath,
5198
+ rotation: obj.rotation,
5199
+ scaleX: scale,
5200
+ scaleY: scale,
5201
+ stroke: stateAwareBookableLabelInk(background, preferredInk),
5202
+ strokeWidth: VENUE_ICON_STROKE,
5203
+ lineCap: "round",
5204
+ lineJoin: "round",
5205
+ fillEnabled: false,
5206
+ listening: false,
5207
+ perfectDrawEnabled: false
5208
+ });
5209
+ this.iconNodeById.set(obj.id, { node: iconNode, fontSize: obj.fontSize });
5210
+ this.bgLayer.add(iconNode);
5211
+ return;
5212
+ }
3679
5213
  const node = new import_Text.Text({
3680
5214
  x: obj.position.x,
3681
5215
  y: obj.position.y,
3682
5216
  text: obj.text,
3683
5217
  fontSize: obj.fontSize,
3684
5218
  rotation: obj.rotation,
5219
+ fontStyle: konvaFontStyle(obj.bold, obj.italic, "normal"),
3685
5220
  // Authored ink remains preferred, but an embed/theme surface can change
3686
5221
  // the actual canvas. Fail over to readable black/white instead of
3687
5222
  // painting an otherwise valid caption invisibly on that active surface.
3688
5223
  fill: stateAwareBookableLabelInk(background, preferredInk),
3689
- fontFamily: this.labelFont(),
5224
+ fontFamily: obj.fontFamily ?? this.labelFont(),
3690
5225
  listening: false,
3691
5226
  perfectDrawEnabled: false
3692
5227
  });
@@ -3704,11 +5239,40 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3704
5239
  const isDecor = !!obj.role && !isStage;
3705
5240
  const palette = overviewPalette(this.canvasBackground);
3706
5241
  const fill = referenceFocal ? palette.focalFill : authoredFill;
3707
- const stroke = isStage ? lighten(fill, 0.28) : referenceFocal ? palette.focalStroke : void 0;
3708
- const strokeWidth = isStage ? 1 : referenceFocal ? 2 : 0;
5242
+ const isOpenPath = obj.kind === "line" || obj.kind === "polyline";
5243
+ const stroke = obj.stroke?.color ?? (isStage ? lighten(fill, 0.28) : referenceFocal ? palette.focalStroke : isOpenPath ? "#9aa3b5" : void 0);
5244
+ const strokeWidth = obj.stroke?.width ?? (isStage ? 1 : referenceFocal ? 2 : isOpenPath ? 2 : 0);
5245
+ const opacity = obj.opacity != null ? clamp(obj.opacity, 0.1, 1) : 1;
3709
5246
  let cx = 0;
3710
5247
  let cy = 0;
3711
- if (obj.kind === "rect" && obj.x != null && obj.y != null && obj.width != null && obj.height != null) {
5248
+ if (isOpenPath && obj.points && obj.points.length >= 2) {
5249
+ cx = obj.points.reduce((a, p) => a + p.x, 0) / obj.points.length;
5250
+ cy = obj.points.reduce((a, p) => a + p.y, 0) / obj.points.length;
5251
+ const lineStyle = resolvedShapeLineStyle(obj);
5252
+ const arrow = shapeArrowMetrics(strokeWidth);
5253
+ const path = new import_Arrow.Arrow({
5254
+ points: obj.points.flatMap((p) => [p.x, p.y]),
5255
+ closed: false,
5256
+ x: cx,
5257
+ y: cy,
5258
+ offsetX: cx,
5259
+ offsetY: cy,
5260
+ rotation: obj.rotation ?? 0,
5261
+ stroke,
5262
+ fill: stroke,
5263
+ strokeWidth,
5264
+ opacity,
5265
+ lineCap: lineStyle.lineCap,
5266
+ lineJoin: lineStyle.lineJoin,
5267
+ pointerAtBeginning: lineStyle.startEnding === "arrow",
5268
+ pointerAtEnding: lineStyle.endEnding === "arrow",
5269
+ ...arrow,
5270
+ listening: false,
5271
+ name: "buyer-shape-open-path"
5272
+ });
5273
+ path.setAttr("shapeObjectId", obj.id);
5274
+ this.bgLayer.add(path);
5275
+ } else if (obj.kind === "rect" && obj.x != null && obj.y != null && obj.width != null && obj.height != null) {
3712
5276
  const grad = isStage ? {
3713
5277
  fillLinearGradientStartPoint: { x: 0, y: 0 },
3714
5278
  fillLinearGradientEndPoint: { x: 0, y: obj.height },
@@ -3728,7 +5292,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3728
5292
  ...grad,
3729
5293
  stroke,
3730
5294
  strokeWidth,
3731
- cornerRadius: 4,
5295
+ cornerRadius: clamp(obj.cornerRadius ?? 4, 0, Math.min(obj.width, obj.height) / 2),
5296
+ opacity,
3732
5297
  listening: false
3733
5298
  })
3734
5299
  );
@@ -3741,7 +5306,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3741
5306
  fillLinearGradientColorStops: [0, darken(fill, 0.3), 1, lighten(fill, 0.12)]
3742
5307
  } : { fill };
3743
5308
  this.bgLayer.add(
3744
- new import_Ellipse.Ellipse({ x: cx, y: cy, rotation: obj.rotation ?? 0, radiusX: obj.width / 2, radiusY: obj.height / 2, ...grad, stroke, strokeWidth, listening: false })
5309
+ new import_Ellipse.Ellipse({ x: cx, y: cy, rotation: obj.rotation ?? 0, radiusX: obj.width / 2, radiusY: obj.height / 2, ...grad, stroke, strokeWidth, opacity, listening: false })
3745
5310
  );
3746
5311
  } else if (obj.kind === "polygon" && obj.points && obj.points.length) {
3747
5312
  const pts = obj.points.flatMap((p) => [p.x, p.y]);
@@ -3756,7 +5321,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3756
5321
  fillLinearGradientColorStops: [0, darken(fill, 0.3), 1, lighten(fill, 0.12)]
3757
5322
  } : { fill };
3758
5323
  this.bgLayer.add(
3759
- new import_Line.Line({ points: pts, closed: true, x: cx, y: cy, offsetX: cx, offsetY: cy, rotation: obj.rotation ?? 0, ...grad, stroke, strokeWidth, listening: false })
5324
+ new import_Line.Line({ points: pts, closed: true, x: cx, y: cy, offsetX: cx, offsetY: cy, rotation: obj.rotation ?? 0, ...grad, stroke, strokeWidth, opacity, listening: false })
3760
5325
  );
3761
5326
  }
3762
5327
  if (obj.label) {
@@ -3812,10 +5377,14 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3812
5377
  fill: color,
3813
5378
  opacity: GA_FILL_OPACITY,
3814
5379
  stroke: color,
3815
- strokeWidth: 1.5
5380
+ strokeWidth: 1.5,
5381
+ cornerRadius: obj.cornerRadius
3816
5382
  });
3817
5383
  poly.setAttr("gaId", obj.id);
3818
- poly.on("click tap", () => this.opts.onGAClick?.(obj.id));
5384
+ poly.on("click tap", (e) => {
5385
+ if (this.isGhostClick(e)) return;
5386
+ this.opts.onGAClick?.(obj.id);
5387
+ });
3819
5388
  poly.on("mouseenter", () => {
3820
5389
  this.container.style.cursor = "pointer";
3821
5390
  });
@@ -3825,7 +5394,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3825
5394
  this.bgLayer.add(poly);
3826
5395
  const labelPoint = polygonLabelPoint(obj.points, obj.holes);
3827
5396
  const containingSection = this.sections.find((section) => pointInPolygonWithHoles(labelPoint, section.outline, section.holes));
3828
- const label = this.addCentredLabel(this.bgLayer, obj.label, labelPoint.x, labelPoint.y - 8, ink, GA_LABEL_FONT_SIZE, false);
5397
+ const label = this.addCentredLabel(this.bgLayer, obj.displayLabel ?? obj.label, labelPoint.x, labelPoint.y - 8, ink, GA_LABEL_FONT_SIZE, false);
3829
5398
  const capacity = this.addCentredLabel(this.bgLayer, `cap ${obj.capacity}`, labelPoint.x, labelPoint.y + 10, ink, GA_CAPACITY_LABEL_FONT_SIZE, false);
3830
5399
  this.freeTextById.set(`${obj.id}:label`, {
3831
5400
  objectId: obj.id,
@@ -3842,7 +5411,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3842
5411
  categoryKey: obj.categoryKey
3843
5412
  });
3844
5413
  this.gaById.set(obj.id, {
3845
- label: obj.label,
5414
+ label: obj.displayLabel ?? obj.label,
3846
5415
  capacity: obj.capacity,
3847
5416
  categoryKey: obj.categoryKey,
3848
5417
  points: obj.points,
@@ -3865,7 +5434,9 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3865
5434
  const memberIds = [];
3866
5435
  const catCounts = /* @__PURE__ */ new Map();
3867
5436
  let free = 0;
3868
- for (const seat of this.seats) {
5437
+ const bounds2 = polyBounds(obj.outline);
5438
+ const candidates = this.seatIndex ? queryRect(this.seatIndex, bounds2).map((id) => this.seatById.get(id)).filter((seat) => !!seat) : this.seats;
5439
+ for (const seat of candidates) {
3869
5440
  if (this.seatSection.has(seat.id)) continue;
3870
5441
  if (!pointInPolygonWithHoles(seat, obj.outline, obj.holes)) continue;
3871
5442
  memberIds.push(seat.id);
@@ -3876,11 +5447,19 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3876
5447
  [...catCounts].map(([key, w]) => ({ hex: this.catColor.get(key) ?? "#6e7bff", w })),
3877
5448
  "#3a4358"
3878
5449
  );
3879
- const elevation = Math.max(0, Math.round(obj.elevation ?? 0));
3880
- let liftGroupBg = null;
3881
- let liftGroupSeat = null;
5450
+ const elevation = sectionElevationTier(obj.elevation);
5451
+ const geometry = sectionGeometry(obj, {
5452
+ floorBaseHeightM: this.floorBaseHeightMFor(obj.id)
5453
+ });
5454
+ const liftWorld = geometry.height * CHART_UNITS_PER_METRE;
5455
+ const rootGroupBg = new import_Group.Group({ listening: false });
5456
+ this.bgLayer.add(rootGroupBg);
5457
+ const liftGroupBg = new import_Group.Group({ listening: false });
5458
+ rootGroupBg.add(liftGroupBg);
5459
+ const liftGroupSeat = new ViewportGroup({ listening: true });
5460
+ this.seatLayer.add(liftGroupSeat);
3882
5461
  const sideFaces = [];
3883
- if (elevation > 0) {
5462
+ if (liftWorld > 0) {
3884
5463
  const faceFill = darken(this.zoneColor.get(obj.zone ?? "") ?? baseFill, 0.42);
3885
5464
  for (let i = 0; i < obj.outline.length; i++) {
3886
5465
  const face = new import_Line.Line({
@@ -3894,14 +5473,11 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3894
5473
  perfectDrawEnabled: false
3895
5474
  });
3896
5475
  sideFaces.push(face);
3897
- this.bgLayer.add(face);
5476
+ rootGroupBg.add(face);
3898
5477
  }
3899
- liftGroupBg = new import_Group.Group({ listening: false });
3900
- this.bgLayer.add(liftGroupBg);
3901
- liftGroupSeat = new import_Group.Group({ listening: false });
3902
- this.seatLayer.add(liftGroupSeat);
3903
5478
  }
3904
- const bgTarget = liftGroupBg ?? this.bgLayer;
5479
+ liftGroupBg.moveToTop();
5480
+ const bgTarget = liftGroupBg;
3905
5481
  const outlineTint = obj.color ?? this.zoneColor.get(obj.zone ?? "") ?? "#3a4358";
3906
5482
  const outlinePoly = polygonWithHolesShape(obj.outline, obj.holes, {
3907
5483
  stroke: rgba(outlineTint, 0.5),
@@ -3975,10 +5551,23 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3975
5551
  nameLabelFits: true,
3976
5552
  subLabelFits: true,
3977
5553
  elevation,
5554
+ liftWorld,
5555
+ rakeDeg: geometry.rake,
5556
+ surfaceFocal: memberIds.length ? { ...this.seatById.get(memberIds[0])?.focalPoint ?? this.isoCentre } : { ...this.chartDoc?.focalPoint ?? this.isoCentre },
5557
+ frontDistanceWorld: 0,
5558
+ rootGroupBg,
5559
+ bgZIndex: rootGroupBg.zIndex(),
5560
+ seatZIndex: liftGroupSeat.zIndex(),
3978
5561
  liftGroupBg,
3979
5562
  liftGroupSeat,
3980
5563
  sideFaces
3981
5564
  };
5565
+ const surfaceCandidates = memberIds.map((id) => this.seatById.get(id)).filter((seat) => !!seat);
5566
+ const distanceCandidates = surfaceCandidates.length ? surfaceCandidates : obj.outline;
5567
+ sec.frontDistanceWorld = Math.min(...distanceCandidates.map((point) => Math.hypot(
5568
+ point.x - sec.surfaceFocal.x,
5569
+ point.y - sec.surfaceFocal.y
5570
+ )));
3982
5571
  for (const id of memberIds) this.seatSection.set(id, sec);
3983
5572
  this.refreshSectionFill(sec);
3984
5573
  this.refreshSectionHeat(sec);
@@ -4001,20 +5590,33 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4001
5590
  sec.outlinePoly.shadowBlur(4 + raw * 12);
4002
5591
  sec.outlinePoly.shadowOpacity(0.25 + raw * 0.45);
4003
5592
  }
4004
- /** Recompute a section's neutral overview state and retained detail count. */
5593
+ /** Recompute a section's availability shading + retained availability text. */
4005
5594
  refreshSectionFill(sec) {
4006
5595
  sec.blockPoly.fill(this.sectionBlockFill(sec));
4007
- sec.subLabel.text(t("map.seatsLeft", { count: sec.free }));
5596
+ sec.subLabel.text(
5597
+ this.sectionAvailabilityState(sec) === "sold-out" ? t("map.soldOut") : t("map.seatsLeft", { count: sec.free })
5598
+ );
4008
5599
  sec.subLabel.offsetX(sec.subLabel.width() / 2);
4009
5600
  }
4010
5601
  /** True when a section/zone is currently in the `closed` event-state. */
4011
5602
  isSectionClosed(sec) {
4012
5603
  return this.closedSections.has(sec.id) || this.closedSections.has(sec.logicalId) || sec.zone != null && this.closedSections.has(sec.zone);
4013
5604
  }
4014
- /** Clean overview shells never leak category, price, or live availability paint. */
5605
+ /** Resolve the four-bucket overview availability state for a section (OV-69).
5606
+ * `closed` (operator) outranks live availability; the availability buckets
5607
+ * only apply to seated sections (GA-only shells have no seat capacity here). */
5608
+ sectionAvailabilityState(sec) {
5609
+ if (this.isSectionClosed(sec)) return "closed";
5610
+ if (sec.total <= 0) return "normal";
5611
+ if (sec.free <= 0) return "sold-out";
5612
+ if (sec.free <= sec.total * SECTION_NEARLY_GONE_RATIO) return "nearly-gone";
5613
+ return "normal";
5614
+ }
5615
+ /** Overview shells stay neutral except for the bounded availability shading
5616
+ * (nearly-gone / sold-out) and the operator `closed` slab — never category
5617
+ * or price paint, which is deferred to section focus/seat detail. */
4015
5618
  sectionBlockFill(sec) {
4016
- const fill = overviewPalette(this.canvasBackground).sectionFill;
4017
- return this.isSectionClosed(sec) ? darken(fill, 0.12) : fill;
5619
+ return overviewStateFill(overviewPalette(this.canvasBackground), this.sectionAvailabilityState(sec));
4018
5620
  }
4019
5621
  /**
4020
5622
  * Zone rung: one giant screen-constant label per zone (+ optional "FROM $n"),
@@ -4124,15 +5726,16 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4124
5726
  const sectionOverview = scale < CACHE_THRESHOLD;
4125
5727
  if (sectionOverview) blockT = 1;
4126
5728
  if (!this.zones.length) zoneT = 0;
4127
- this.seatLayer.opacity(sectionOverview ? 0 : 1 - blockT);
5729
+ const deferPerspectiveSeatReveal = this.viewMode === "perspective" && this.glideInProgress && this.seats.length > 2500;
5730
+ this.seatLayer.opacity(sectionOverview || deferPerspectiveSeatReveal ? 0 : 1 - blockT);
4128
5731
  const sx = this.stage.scaleX();
4129
- const rescale = this.lodScale === 0 || Math.abs(scale - this.lodScale) / (this.lodScale || 1) > 0.02;
5732
+ const rescale = !this.glideInProgress && (this.lodScale === 0 || Math.abs(scale - this.lodScale) / (this.lodScale || 1) > 0.08);
4130
5733
  if (rescale) this.lodScale = scale;
4131
5734
  const focus = this.focusedSectionId;
4132
5735
  const palette = overviewPalette(this.canvasBackground);
4133
5736
  const sectionLabelT = clamp((blockT - 0.2) / 0.8, 0, 1);
4134
5737
  for (const sec of this.sections) {
4135
- const dim = focus && sec.id !== focus && sec.logicalId !== focus && sec.zone !== focus ? FOCUS_DIM_OPACITY : 1;
5738
+ const dim = this.focusDimOverlay ? 1 : focus && sec.id !== focus && sec.logicalId !== focus && sec.zone !== focus ? FOCUS_DIM_OPACITY : 1;
4136
5739
  sec.outlinePoly.opacity(sectionOverview ? 0 : (1 - blockT) * dim);
4137
5740
  sec.blockPoly.opacity(BLOCK_FILL_ALPHA * blockT * dim);
4138
5741
  sec.blockPoly.stroke(palette.sectionStroke);
@@ -4144,7 +5747,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4144
5747
  );
4145
5748
  sec.nameLabel.fill(sectionInk);
4146
5749
  sec.subLabel.fill(sectionInk);
4147
- if (rescale) this.fitSectionRungLabels(sec, sx);
5750
+ if (rescale && sectionLabelT > 0.01) this.fitSectionRungLabels(sec, sx);
4148
5751
  const labelOpacity = sectionLabelT * (1 - zoneT) * dim;
4149
5752
  sec.nameLabel.opacity(sec.nameLabelFits ? labelOpacity : 0);
4150
5753
  sec.subLabel.opacity(0);
@@ -4156,12 +5759,14 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4156
5759
  if (zone.sub) zone.sub.opacity(zoneOpacity);
4157
5760
  if (rescale) this.sizeZonePill(zone, sx);
4158
5761
  }
4159
- this.decollideRungLabels(sx);
4160
- this.dedupeLogicalSectionLabels();
4161
- for (const zone of this.zones) {
4162
- const opacity = zone.label.opacity();
4163
- zone.back.opacity(opacity);
4164
- if (zone.sub) zone.sub.opacity(opacity);
5762
+ if (!this.glideInProgress && (sectionLabelT > 0.01 || zoneOpacity > 0.01)) {
5763
+ this.decollideRungLabels(sx);
5764
+ this.dedupeLogicalSectionLabels();
5765
+ for (const zone of this.zones) {
5766
+ const opacity = zone.label.opacity();
5767
+ zone.back.opacity(opacity);
5768
+ if (zone.sub) zone.sub.opacity(opacity);
5769
+ }
4165
5770
  }
4166
5771
  this.bgLayer.batchDraw();
4167
5772
  }
@@ -4288,13 +5893,30 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4288
5893
  screenToWorld(clientPoint) {
4289
5894
  const s = this.stage.scaleX();
4290
5895
  const iso = { x: (clientPoint.x - this.stage.x()) / s, y: (clientPoint.y - this.stage.y()) / s };
5896
+ if (this.viewMode === "perspective" && this.perspectiveBaseInverse) {
5897
+ return applyAffine(this.perspectiveBaseInverse, iso);
5898
+ }
4291
5899
  return this.isoT === 0 ? iso : this.isoInverse(iso);
4292
5900
  }
4293
5901
  /** Section id under a container-relative screen point, or null (Slice 5 tap-to-zoom). */
4294
5902
  sectionAt(clientPoint) {
4295
5903
  if (!this.sections.length) return null;
4296
5904
  const world = this.screenToWorld(clientPoint);
4297
- const hit = this.sections.find((sec) => pointInPolygonWithHoles(world, sec.outline, sec.holes));
5905
+ const hit = this.sections.find((sec) => {
5906
+ if (this.viewMode === "perspective" && sec.perspectiveAffine) {
5907
+ return pointInPolygonWithHoles(
5908
+ world,
5909
+ sec.outline.map((point) => this.perspectiveLocalPoint(this.projectedSectionPoint(sec, point))),
5910
+ sec.holes.map((hole) => hole.map((point) => this.perspectiveLocalPoint(this.projectedSectionPoint(sec, point))))
5911
+ );
5912
+ }
5913
+ const offset = sec.liftWorld > 0 ? this.isoLiftLocal(sec.liftWorld) : { x: 0, y: 0 };
5914
+ return pointInPolygonWithHoles(
5915
+ { x: world.x - offset.x, y: world.y - offset.y },
5916
+ sec.outline,
5917
+ sec.holes
5918
+ );
5919
+ });
4298
5920
  return hit ? hit.logicalId : null;
4299
5921
  }
4300
5922
  /** Seat ids belonging to a section (Slice 5 section-summary card). */
@@ -4390,15 +6012,20 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4390
6012
  if (!seat) return;
4391
6013
  const candidate = this.selectionFocusId === id;
4392
6014
  const dims = this.boothDims.get(seat.rowId);
6015
+ const rendered = this.renderedSeatPoint(seat);
4393
6016
  const marker = new import_Group.Group({
4394
6017
  name: "selection-ring",
4395
- x: seat.x,
4396
- y: seat.y,
6018
+ x: rendered.x,
6019
+ y: rendered.y,
4397
6020
  rotation: dims?.rotation ?? 0,
4398
6021
  listening: false,
4399
6022
  perfectDrawEnabled: false,
4400
6023
  opacity: this.selectionFocusId && !candidate ? 0.2 : 1
4401
6024
  });
6025
+ if (this.viewMode === "perspective") {
6026
+ const projectionScale = this.perspectiveSeatScale.get(id) ?? 1;
6027
+ marker.scale({ x: projectionScale, y: projectionScale });
6028
+ }
4402
6029
  marker.setAttr("seatId", id);
4403
6030
  const common = {
4404
6031
  stroke: this.effSelection,
@@ -4477,12 +6104,22 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4477
6104
  * must fall through and pick.
4478
6105
  */
4479
6106
  sectionBounds(id) {
4480
- const bounds = this.sections.filter((section) => section.id === id || section.logicalId === id).map((section) => polyBounds(section.outline));
4481
- if (!bounds.length) return null;
4482
- const left = Math.min(...bounds.map((box) => box.x));
4483
- const top = Math.min(...bounds.map((box) => box.y));
4484
- const right = Math.max(...bounds.map((box) => box.x + box.width));
4485
- const bottom = Math.max(...bounds.map((box) => box.y + box.height));
6107
+ const bounds2 = this.sections.filter((section) => section.id === id || section.logicalId === id).map((section) => {
6108
+ if (this.viewMode === "perspective" && section.perspectiveAffine) {
6109
+ return polyBounds(section.outline.map((point) => this.projectedSectionPoint(section, point)));
6110
+ }
6111
+ const offset = section.liftWorld > 0 ? this.isoLiftLocal(section.liftWorld) : { x: 0, y: 0 };
6112
+ const points = section.outline.map((point) => {
6113
+ const local = { x: point.x + offset.x, y: point.y + offset.y };
6114
+ return this.isoT === 0 ? local : this.isoForward(local);
6115
+ });
6116
+ return polyBounds(points);
6117
+ });
6118
+ if (!bounds2.length) return null;
6119
+ const left = Math.min(...bounds2.map((box) => box.x));
6120
+ const top = Math.min(...bounds2.map((box) => box.y));
6121
+ const right = Math.max(...bounds2.map((box) => box.x + box.width));
6122
+ const bottom = Math.max(...bounds2.map((box) => box.y + box.height));
4486
6123
  return { x: left, y: top, width: right - left, height: bottom - top };
4487
6124
  }
4488
6125
  sectionFrameScale(id) {
@@ -4535,15 +6172,16 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4535
6172
  * hijacking a clean tap on empty space beyond the slop.
4536
6173
  */
4537
6174
  nearestSeatToScreen(screen, slopPx) {
4538
- const s = this.stage.scaleX() || 1;
4539
- const reachWorld = this.seatR + slopPx / s;
4540
- const world = this.screenToWorld(screen);
4541
6175
  let best = null;
4542
- let bestD = reachWorld;
6176
+ let bestD = Infinity;
6177
+ const stageScale = this.stage.scaleX() || 1;
4543
6178
  for (const seat of this.seats) {
4544
6179
  if (!this.selection.has(seat.id) && !this.isSelectable(seat.id)) continue;
4545
- const d = Math.hypot(seat.x - world.x, seat.y - world.y);
4546
- if (d < bestD) {
6180
+ const centre = this.worldToScreen(seat);
6181
+ const perspectiveScale = this.viewMode === "perspective" ? this.perspectiveSeatScale.get(seat.id) ?? 1 : 1;
6182
+ const reachPx = this.seatR * stageScale * perspectiveScale + slopPx;
6183
+ const d = Math.hypot(centre.x - screen.x, centre.y - screen.y);
6184
+ if (d <= reachPx && d < bestD) {
4547
6185
  bestD = d;
4548
6186
  best = seat.id;
4549
6187
  }
@@ -4553,6 +6191,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4553
6191
  wireInteraction() {
4554
6192
  this.seatLayer.on("click tap", (e) => {
4555
6193
  if (this.moved > PAN_START_SLOP_PX) return;
6194
+ if (this.isGhostClick(e)) return;
4556
6195
  const id = seatIdOf(e.target);
4557
6196
  if (!id) return;
4558
6197
  this.handleSeatTap(id);
@@ -4562,13 +6201,17 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4562
6201
  if (!id) return;
4563
6202
  const seat = this.seatById.get(id);
4564
6203
  if (!seat) return;
4565
- this.hoverRing.position({ x: seat.x, y: seat.y });
6204
+ this.hoveredId = id;
6205
+ this.hoverRing.position(this.renderedSeatPoint(seat));
6206
+ const projectionScale = this.viewMode === "perspective" ? this.perspectiveSeatScale.get(id) ?? 1 : 1;
6207
+ this.hoverRing.scale({ x: projectionScale, y: projectionScale });
4566
6208
  this.hoverRing.visible(true);
4567
6209
  this.overlayLayer.batchDraw();
4568
6210
  this.container.style.cursor = "pointer";
4569
6211
  this.opts.onHover?.(seat);
4570
6212
  });
4571
6213
  this.seatLayer.on("mouseout", () => {
6214
+ this.hoveredId = null;
4572
6215
  this.hoverRing.visible(false);
4573
6216
  this.overlayLayer.batchDraw();
4574
6217
  this.container.style.cursor = "default";
@@ -4583,6 +6226,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4583
6226
  });
4584
6227
  this.stage.on("click tap", (e) => {
4585
6228
  if (this.moved > PAN_START_SLOP_PX) return;
6229
+ if (this.isGhostClick(e)) return;
4586
6230
  const pointer = this.stage.getPointerPosition();
4587
6231
  if (!pointer) return;
4588
6232
  if (!this.cached) {
@@ -4599,11 +6243,10 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4599
6243
  }
4600
6244
  }
4601
6245
  if (this.sections.length) {
4602
- const world = this.screenToWorld(pointer);
4603
- const hit = this.sections.find((sn) => pointInPolygonWithHoles(world, sn.outline, sn.holes));
4604
- if (hit) {
4605
- if (this.opts.onSectionTap) this.opts.onSectionTap(hit.logicalId);
4606
- else this.focusRegion(hit.logicalId);
6246
+ const sectionId = this.sectionAt(pointer);
6247
+ if (sectionId) {
6248
+ if (this.opts.onSectionTap) this.opts.onSectionTap(sectionId);
6249
+ else this.focusRegion(sectionId);
4607
6250
  return;
4608
6251
  }
4609
6252
  }
@@ -4620,13 +6263,15 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4620
6263
  */
4621
6264
  deckFloorAt() {
4622
6265
  if (!this.objectFloor.size) return null;
4623
- const p = this.seatLayer.getRelativePointerPosition();
4624
- if (!p) return null;
6266
+ const pointer = this.stage.getPointerPosition();
6267
+ if (!pointer) return null;
6268
+ const p = this.screenToWorld(pointer);
4625
6269
  let best = null;
4626
6270
  let bestD = Infinity;
4627
6271
  for (const s of this.seats) {
4628
- const dx = s.x - p.x;
4629
- const dy = s.y - p.y;
6272
+ const rendered = this.renderedSeatPoint(s);
6273
+ const dx = rendered.x - p.x;
6274
+ const dy = rendered.y - p.y;
4630
6275
  const d = dx * dx + dy * dy;
4631
6276
  if (d < bestD) {
4632
6277
  bestD = d;
@@ -4713,9 +6358,11 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4713
6358
  }
4714
6359
  const start = performance.now();
4715
6360
  const duration = Math.max(180, Math.min(1200, opts?.durationMs ?? CAMERA_GLIDE_MS));
6361
+ this.glideInProgress = true;
4716
6362
  const step = (now) => {
4717
6363
  if (this.destroyed) {
4718
6364
  this.glideRaf = 0;
6365
+ this.glideInProgress = false;
4719
6366
  return;
4720
6367
  }
4721
6368
  const raw = Math.min(1, (now - start) / duration);
@@ -4723,6 +6370,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4723
6370
  const sc = fromScale + (toScale - fromScale) * e;
4724
6371
  this.stage.scale({ x: sc, y: sc });
4725
6372
  this.stage.position({ x: fromX + (toX - fromX) * e, y: fromY + (toY - fromY) * e });
6373
+ this.updateSeatGroupVisibility();
4726
6374
  this.updateLOD();
4727
6375
  this.scheduleViewChange();
4728
6376
  this.stage.batchDraw();
@@ -4730,6 +6378,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4730
6378
  this.glideRaf = requestAnimationFrame(step);
4731
6379
  } else {
4732
6380
  this.glideRaf = 0;
6381
+ this.glideInProgress = false;
4733
6382
  this.afterViewChange();
4734
6383
  }
4735
6384
  };
@@ -4741,6 +6390,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4741
6390
  cancelAnimationFrame(this.glideRaf);
4742
6391
  this.glideRaf = 0;
4743
6392
  }
6393
+ this.glideInProgress = false;
4744
6394
  }
4745
6395
  /** Current LOD rung derived from effective zoom — drives the ZONES/SECTIONS/SEATS pill. */
4746
6396
  getRung() {
@@ -4757,11 +6407,14 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4757
6407
  const labels = this.seats.map((seat) => {
4758
6408
  const shape = this.circleById.get(seat.id);
4759
6409
  const label = this.boothLabelById.get(seat.id) ?? this.seatLabelById.get(seat.id);
6410
+ const localPerspectiveScale = this.viewMode === "perspective" ? this.perspectiveSeatScale.get(seat.id) ?? 1 : 1;
6411
+ const seatEffectiveScale = (this.viewMode === "perspective" ? stageScale : effectiveScale) * localPerspectiveScale;
4760
6412
  const authoredFontSize = seat.kind === "booth" ? BOOTH_LABEL_FONT_SIZE : SEAT_LABEL_FONT_SIZE * this.seatLabelScale(seat);
4761
- const renderedFontPx = rounded((label?.fontSize() ?? authoredFontSize) * effectiveScale);
6413
+ const labelEffectiveScale = this.viewMode === "perspective" && seat.kind !== "booth" ? effectiveScale * localPerspectiveScale : seatEffectiveScale;
6414
+ const renderedFontPx = rounded((label?.fontSize() ?? authoredFontSize) * labelEffectiveScale);
4762
6415
  const screen = this.worldToScreen(seat);
4763
6416
  const outside = screen.x < 0 || screen.x > viewport.width || screen.y < 0 || screen.y > viewport.height;
4764
- const opacity = shape?.opacity() ?? 0;
6417
+ const opacity = this.focusedSeatOpacity(seat.id, shape?.getAbsoluteOpacity() ?? 0);
4765
6418
  const section = this.seatSection.get(seat.id);
4766
6419
  const visible = Boolean(label?.isVisible()) && opacity >= 0.5 && !outside;
4767
6420
  let hiddenReason;
@@ -4772,17 +6425,21 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4772
6425
  else if (!label) hiddenReason = "clutter-or-fit";
4773
6426
  else hiddenReason = "renderer-hidden";
4774
6427
  }
4775
- const labelWidth = label ? label.width() * stageScale : 0;
4776
- const labelHeight = label ? label.height() * effectiveScale : 0;
4777
- const directWidthPx = shape instanceof import_Rect.Rect ? shape.width() * stageScale : this.seatR * 2 * effectiveScale;
4778
- const directHeightPx = shape instanceof import_Rect.Rect ? shape.height() * stageScale : this.seatR * 2 * effectiveScale;
4779
- const assistedDiameterPx = 2 * (this.seatR * effectiveScale + SEAT_TAP_SLOP_PX);
6428
+ const labelWidth = label ? label.width() * stageScale * localPerspectiveScale : 0;
6429
+ const labelHeight = label ? label.height() * (this.viewMode === "perspective" && seat.kind === "booth" ? stageScale : effectiveScale) * localPerspectiveScale : 0;
6430
+ const directWidthPx = shape instanceof import_Rect.Rect ? shape.width() * stageScale * localPerspectiveScale : this.seatR * 2 * seatEffectiveScale;
6431
+ const directHeightPx = shape instanceof import_Rect.Rect ? shape.height() * (this.viewMode === "perspective" ? stageScale : effectiveScale) * localPerspectiveScale : this.seatR * 2 * seatEffectiveScale;
6432
+ const assistedDiameterPx = 2 * (this.seatR * seatEffectiveScale + SEAT_TAP_SLOP_PX);
4780
6433
  const fill = shape?.fill();
4781
6434
  const ink = label?.fill();
6435
+ const accessGlyph = this.accessGlyphById.get(seat.id);
6436
+ const accessGlyphScale = accessGlyph?.getAbsoluteScale();
4782
6437
  return {
4783
6438
  seatId: seat.id,
4784
6439
  label: seat.label,
4785
6440
  kind: seat.kind === "booth" ? "booth" : "seat",
6441
+ markerShape: seat.kind === "booth" ? "booth" : seat.wheelchairSpaceType === "no-seat" ? "square" : "circle",
6442
+ ...seat.wheelchairSpaceType ? { wheelchairSpaceType: seat.wheelchairSpaceType } : {},
4786
6443
  categoryKey: seat.categoryKey,
4787
6444
  ...section ? { sectionId: section.id } : {},
4788
6445
  ...section?.zone ? { zoneId: section.zone } : {},
@@ -4793,8 +6450,15 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4793
6450
  fill: typeof fill === "string" ? fill : "",
4794
6451
  ink: typeof ink === "string" ? ink : this.theme.seatLabelColor ?? DEF_SEAT_LABEL,
4795
6452
  opacity: rounded(opacity),
6453
+ ...accessGlyph ? {
6454
+ accessibilityMarker: {
6455
+ glyphVisible: accessGlyph.isVisible(),
6456
+ glyphWidthPx: rounded(ACCESS_GLYPH_VIEWBOX * Math.abs(accessGlyphScale?.x ?? 0)),
6457
+ emphasizedByFilter: this.accessGlyphFilterEmphasized(seat.id)
6458
+ }
6459
+ } : {},
4796
6460
  pointerTarget: {
4797
- active: !this.cached && this.isSelectable(seat.id),
6461
+ active: !this.cached && !this.seatViewportCulled(seat.id) && Boolean(shape?.isVisible()) && Boolean(shape?.isListening()) && this.isSelectable(seat.id),
4798
6462
  directWidthPx: rounded(directWidthPx),
4799
6463
  directHeightPx: rounded(directHeightPx),
4800
6464
  effectiveMinimumPx: rounded(Math.max(
@@ -4822,7 +6486,11 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4822
6486
  node.height(),
4823
6487
  node.rotation()
4824
6488
  );
4825
- const screenBounds = pointsBounds(worldCorners.map((corner) => this.worldToScreen(corner)));
6489
+ const lift = section && section.liftWorld > 0 ? this.isoLiftLocal(section.liftWorld) : { x: 0, y: 0 };
6490
+ const screenBounds = pointsBounds(worldCorners.map((corner) => this.worldToScreen({
6491
+ x: corner.x + lift.x,
6492
+ y: corner.y + lift.y
6493
+ })));
4826
6494
  const opacity = rounded(node.opacity());
4827
6495
  const ink = node.fill();
4828
6496
  const outside = screenBounds.x + screenBounds.width < 0 || screenBounds.x > viewport.width || screenBounds.y + screenBounds.height < 0 || screenBounds.y > viewport.height;
@@ -4954,13 +6622,21 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4954
6622
  };
4955
6623
  });
4956
6624
  const palette = overviewPalette(this.canvasBackground);
4957
- const neutralSectionFills = /* @__PURE__ */ new Set([
4958
- palette.sectionFill.toLowerCase(),
4959
- darken(palette.sectionFill, 0.12).toLowerCase()
4960
- ]);
6625
+ const neutralSectionFills = new Set(
6626
+ ["normal", "nearly-gone", "sold-out", "closed"].map((state) => overviewStateFill(palette, state).toLowerCase())
6627
+ );
4961
6628
  const visibleSectionShells = this.sections.filter((section) => section.blockPoly.opacity() > 0.05);
4962
6629
  return {
4963
6630
  viewport,
6631
+ projection: this.viewMode,
6632
+ ...this.viewMode === "perspective" ? {
6633
+ perspective: {
6634
+ model: "pinhole-exact-seat-anchors",
6635
+ sectionSurfaceModel: "tangent-plane",
6636
+ exactSeatAnchorCount: this.perspectiveSeatProjected.size,
6637
+ depthSorted: true
6638
+ }
6639
+ } : {},
4964
6640
  canvasBackground: this.canvasBackground,
4965
6641
  effectiveScale: rounded(effectiveScale),
4966
6642
  rung: this.getRung(),
@@ -5015,10 +6691,10 @@ var _SeatmapRenderer = class _SeatmapRenderer {
5015
6691
  let cy = viewCentre.y;
5016
6692
  if (rung === "sections" && this.sections.length > 0) {
5017
6693
  const sectionCentres = this.sections.map((section) => {
5018
- const bounds = polyBounds(section.outline);
6694
+ const bounds2 = polyBounds(section.outline);
5019
6695
  return {
5020
- x: bounds.x + bounds.width / 2,
5021
- y: bounds.y + bounds.height / 2
6696
+ x: bounds2.x + bounds2.width / 2,
6697
+ y: bounds2.y + bounds2.height / 2
5022
6698
  };
5023
6699
  });
5024
6700
  const halfWidth = w / (target * 2);
@@ -5085,6 +6761,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
5085
6761
  }
5086
6762
  /** Recompute LOD (cache/labels) after any pan/zoom settles. */
5087
6763
  afterViewChange() {
6764
+ this.updateSeatGroupVisibility();
5088
6765
  this.updateLOD();
5089
6766
  this.updateFreeTextVisibility();
5090
6767
  this.updateLabels();
@@ -5109,10 +6786,15 @@ var _SeatmapRenderer = class _SeatmapRenderer {
5109
6786
  this.paintGAStateForView();
5110
6787
  this.updateAccessGlyphs(scale);
5111
6788
  const shouldCache = scale < CACHE_THRESHOLD;
6789
+ const suppressPerspectiveSeatCache = this.viewMode === "perspective" && this.hasSections && this.seats.length > 2500 && shouldCache;
6790
+ if (suppressPerspectiveSeatCache) {
6791
+ this.seatLayer.listening(false);
6792
+ return;
6793
+ }
5112
6794
  if (shouldCache && !this.cached) {
5113
6795
  this.cacheSeatLayer();
5114
- } else if (!shouldCache && this.cached) {
5115
- this.seatLayer.clearCache();
6796
+ } else if (!shouldCache && (this.cached || !this.seatLayer.listening())) {
6797
+ if (this.cached) this.seatLayer.clearCache();
5116
6798
  this.seatLayer.listening(true);
5117
6799
  this.cached = false;
5118
6800
  this.seatLayer.batchDraw();
@@ -5121,6 +6803,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
5121
6803
  /** Rebuild the seat-layer bitmap synchronously (no paint). Shared by the
5122
6804
  * debounced cacheSeatLayer() and the synchronous forceDraw() catch-up. */
5123
6805
  rebuildSeatCache() {
6806
+ this.updateSeatGroupVisibility();
5124
6807
  const pr = clamp(this.stage.scaleX() * this.dpr, 0.15, 2);
5125
6808
  this.seatLayer.clearCache();
5126
6809
  this.seatLayer.cache({ pixelRatio: pr });
@@ -5165,6 +6848,63 @@ var _SeatmapRenderer = class _SeatmapRenderer {
5165
6848
  const gaOverviewHidden = objectId != null && (kind === "ga-label" || kind === "ga-capacity") && this.gaById.get(objectId)?.sectionId != null && effectiveScale < CACHE_THRESHOLD;
5166
6849
  node.visible(!gaDimmed && !gaOverviewHidden && isBookableLabelLegibleAtScale(node.fontSize(), effectiveScale));
5167
6850
  }
6851
+ for (const { node, fontSize } of this.iconNodeById.values()) {
6852
+ node.visible(isBookableLabelLegibleAtScale(fontSize, effectiveScale));
6853
+ }
6854
+ }
6855
+ /** OV-45(a): resolve authored row-letter labels for the buyer/preview map, in
6856
+ * world space, matching the Designer's placement contract (start/end/both/none
6857
+ * preset, a free-drag `position` that wins over the preset, rotation, and
6858
+ * labelStyle colour/size fed through the same auto-contrast guard). Segmented
6859
+ * rows keep their own logical-label handling and are resolved by their owner. */
6860
+ buildRowLabelPlan(view) {
6861
+ this.rowLabelPlan = [];
6862
+ const background = this.theme.background ?? "#0e1117";
6863
+ const seatsByRow = /* @__PURE__ */ new Map();
6864
+ for (const seat of this.seats) {
6865
+ if (seat.kind === "booth") continue;
6866
+ const key = seat.logicalRowId ?? seat.rowId;
6867
+ (seatsByRow.get(key) ?? seatsByRow.set(key, []).get(key)).push(seat);
6868
+ }
6869
+ const seatOrder = (seat) => seat.logicalSeatIndex ?? Number(seat.id.split(":").pop() ?? 0);
6870
+ for (const obj of view.objects) {
6871
+ if (obj.type !== "row") continue;
6872
+ if (obj.segmentedRow && obj.segmentedRow.componentIndex !== 0) continue;
6873
+ const presentation = obj.segmentedRow?.labelPresentation ?? obj.labelPresentation;
6874
+ if (presentation?.visible === false || presentation?.positionPreset === "none") continue;
6875
+ const key = obj.segmentedRow ? obj.segmentedRow.groupId : obj.id;
6876
+ const seats = (seatsByRow.get(key) ?? []).slice().sort((a, b) => seatOrder(a) - seatOrder(b));
6877
+ if (!seats.length) continue;
6878
+ const first = seats[0];
6879
+ const last = seats[seats.length - 1];
6880
+ const labelStyle = presentation?.labelStyle;
6881
+ const ink = stateAwareBookableLabelInk(
6882
+ background,
6883
+ labelStyle?.color ?? this.theme.rowLabelColor ?? this.theme.textColor ?? "#e6e9f0"
6884
+ );
6885
+ const fontSize = labelStyle?.size ?? 12;
6886
+ const rotation = presentation?.rotation ?? 0;
6887
+ const text = obj.segmentedRow?.displayLabel ?? obj.displayLabel ?? obj.label;
6888
+ const opacity = this.objectFilteredOut(obj) ? 0.15 : 1;
6889
+ const push = (x, y) => this.rowLabelPlan.push({ text, x, y, rotation, fontSize, ink, opacity });
6890
+ if (presentation?.position) {
6891
+ push(presentation.position.x, presentation.position.y);
6892
+ continue;
6893
+ }
6894
+ const radians = obj.rotation * Math.PI / 180;
6895
+ const along = { x: Math.cos(radians), y: Math.sin(radians) };
6896
+ const gap = this.seatR + 12;
6897
+ const preset = presentation?.positionPreset ?? "start";
6898
+ if (preset === "start" || preset === "both") push(first.x - along.x * gap, first.y - along.y * gap);
6899
+ if (preset === "end" || preset === "both") push(last.x + along.x * gap, last.y + along.y * gap);
6900
+ }
6901
+ }
6902
+ /** Row labels never carry status; the buyer just dims them under the same
6903
+ * category/price filters that dim their seats. */
6904
+ objectFilteredOut(obj) {
6905
+ const key = obj.categoryKey;
6906
+ if (!key) return false;
6907
+ return Boolean(this.categoryHighlight && key !== this.categoryHighlight || this.categoryFilter && !this.categoryFilter.has(key));
5168
6908
  }
5169
6909
  updateLabels() {
5170
6910
  const effectiveScale = this.effScale();
@@ -5172,31 +6912,31 @@ var _SeatmapRenderer = class _SeatmapRenderer {
5172
6912
  for (const [id, label] of this.boothLabelById) {
5173
6913
  const shape = this.circleById.get(id);
5174
6914
  label.visible(
5175
- isBookableLabelLegibleAtScale(label.fontSize(), effectiveScale) && (shape?.opacity() ?? 1) >= 0.5
6915
+ isBookableLabelLegibleAtScale(label.fontSize(), effectiveScale) && Boolean(shape?.isVisible()) && this.focusedSeatOpacity(id, shape?.getAbsoluteOpacity() ?? 1) >= 0.5
5176
6916
  );
5177
6917
  }
5178
6918
  this.labelGroup.destroyChildren();
6919
+ this.labelLiftGroups.clear();
5179
6920
  this.seatLabelById.clear();
5180
6921
  if (!show) {
5181
6922
  this.overlayLayer.batchDraw();
5182
6923
  return;
5183
6924
  }
5184
- const s = this.stage.scaleX();
5185
- const x0 = -this.stage.x() / s;
5186
- const y0 = -this.stage.y() / s;
5187
- const x1 = (this.stage.width() - this.stage.x()) / s;
5188
- const y1 = (this.stage.height() - this.stage.y()) / s;
5189
6925
  let count = 0;
5190
- for (const seat of this.seats) {
6926
+ for (const seat of this.visibleSeatCandidates()) {
5191
6927
  if (seat.kind === "booth") continue;
5192
- if (seat.x < x0 || seat.x > x1 || seat.y < y0 || seat.y > y1) continue;
5193
- if (seat.accessible && this.accessGlyphVisible) continue;
6928
+ const screen = this.worldToScreen(seat);
6929
+ if (screen.x < -20 || screen.x > this.stage.width() + 20 || screen.y < -20 || screen.y > this.stage.height() + 20) continue;
6930
+ if (this.accessGlyphById.has(seat.id) && this.accessGlyphShouldShow(seat.id)) continue;
5194
6931
  const shape = this.circleById.get(seat.id);
5195
- if ((shape?.opacity() ?? 1) < 0.5) continue;
6932
+ if (!shape?.isVisible() || this.focusedSeatOpacity(seat.id, shape.getAbsoluteOpacity()) < 0.5) continue;
5196
6933
  const status = this.statusById.get(seat.id) ?? "free";
6934
+ const labelPoint = this.renderedSeatPoint(seat);
6935
+ const perspectiveScale = this.viewMode === "perspective" ? this.perspectiveSeatScale.get(seat.id) ?? 1 : 1;
5197
6936
  const unavailable = status === "booked" || status === "held" && !this.ownedHold.has(seat.id) && !this.opts.manageMode;
5198
6937
  if (unavailable) {
5199
- const cue = new import_Group.Group({ x: seat.x, y: seat.y, listening: false });
6938
+ const cue = new import_Group.Group({ x: labelPoint.x, y: labelPoint.y, listening: false });
6939
+ cue.scale({ x: perspectiveScale, y: perspectiveScale });
5200
6940
  if (status === "held") {
5201
6941
  cue.add(new import_Rect.Rect({
5202
6942
  x: -4.2,
@@ -5225,14 +6965,14 @@ var _SeatmapRenderer = class _SeatmapRenderer {
5225
6965
  listening: false
5226
6966
  }));
5227
6967
  }
5228
- this.labelGroup.add(cue);
6968
+ this.seatLabelContainer(seat.id).add(cue);
5229
6969
  if (++count >= MAX_LABELS) break;
5230
6970
  continue;
5231
6971
  }
5232
6972
  const authoredFontSize = SEAT_LABEL_FONT_SIZE * this.seatLabelScale(seat);
5233
6973
  const t2 = new import_Text.Text({
5234
- x: seat.x,
5235
- y: seat.y,
6974
+ x: labelPoint.x,
6975
+ y: labelPoint.y,
5236
6976
  text: bookableMarkerLabel(seat.displayLabel ?? seat.label),
5237
6977
  fontSize: authoredFontSize,
5238
6978
  fontStyle: "600",
@@ -5253,11 +6993,36 @@ var _SeatmapRenderer = class _SeatmapRenderer {
5253
6993
  }
5254
6994
  t2.offsetX(t2.width() / 2);
5255
6995
  t2.offsetY(t2.height() / 2);
5256
- this.labelGroup.add(t2);
6996
+ t2.scale({ x: perspectiveScale, y: perspectiveScale });
6997
+ this.seatLabelContainer(seat.id).add(t2);
5257
6998
  this.seatLabelById.set(seat.id, t2);
5258
6999
  if (++count >= MAX_LABELS) break;
5259
7000
  }
5260
- if (this.isoT > 0) this.applyUprightLabels();
7001
+ for (const rl of this.rowLabelPlan) {
7002
+ const screen = this.worldToScreen({ x: rl.x, y: rl.y });
7003
+ if (screen.x < -40 || screen.x > this.stage.width() + 40 || screen.y < -40 || screen.y > this.stage.height() + 40) continue;
7004
+ const t2 = new import_Text.Text({
7005
+ x: rl.x,
7006
+ y: rl.y,
7007
+ text: rl.text,
7008
+ fontSize: rl.fontSize,
7009
+ fontStyle: "700",
7010
+ fontFamily: this.labelFont(),
7011
+ fill: rl.ink,
7012
+ rotation: rl.rotation,
7013
+ opacity: rl.opacity,
7014
+ shadowColor: "#05070c",
7015
+ shadowBlur: rl.ink.toLowerCase() === "#ffffff" ? 5 : 0,
7016
+ shadowOpacity: rl.ink.toLowerCase() === "#ffffff" ? 0.8 : 0,
7017
+ shadowForStrokeEnabled: false,
7018
+ listening: false,
7019
+ perfectDrawEnabled: false
7020
+ });
7021
+ t2.offsetX(t2.width() / 2);
7022
+ t2.offsetY(t2.height() / 2);
7023
+ this.labelGroup.add(t2);
7024
+ }
7025
+ if (this.isoT > 0 && this.viewMode !== "perspective") this.applyUprightLabels();
5261
7026
  this.overlayLayer.batchDraw();
5262
7027
  }
5263
7028
  handleResize() {
@@ -5323,6 +7088,7 @@ function strandedSingles(seats, statusOf, selectedIds) {
5323
7088
 
5324
7089
  // src/core/bestAvailable.ts
5325
7090
  function seatIndex2(seat, fallback) {
7091
+ if (Number.isInteger(seat.logicalSeatIndex)) return seat.logicalSeatIndex;
5326
7092
  const i = seat.id.lastIndexOf(":");
5327
7093
  if (i < 0) return fallback;
5328
7094
  const n = Number(seat.id.slice(i + 1));
@@ -5342,21 +7108,25 @@ function centroid(seats) {
5342
7108
  }
5343
7109
  return { x: x / seats.length, y: y / seats.length };
5344
7110
  }
7111
+ function candidateFocal(seats, fallback) {
7112
+ return seats.find((seat) => seat.focalPoint)?.focalPoint ?? fallback;
7113
+ }
5345
7114
  function isPremium(seat) {
5346
7115
  return seat.commercial?.premium === true;
5347
7116
  }
5348
7117
  function pickBestAvailable(seats, available, opts) {
5349
7118
  const qty = Math.floor(opts.qty);
5350
7119
  if (!Number.isFinite(qty) || qty <= 0) return { labels: [], reason: "sold_out" };
5351
- const { categoryKey, focal, preferPremium } = opts;
7120
+ const { categoryKey, zoneId, focal, preferPremium } = opts;
5352
7121
  const rows = /* @__PURE__ */ new Map();
5353
7122
  const eligibleAll = [];
5354
7123
  seats.forEach((seat, i) => {
5355
- const elig = available.has(seat.label) && (!categoryKey || seat.categoryKey === categoryKey);
5356
- let arr = rows.get(seat.rowId);
7124
+ const elig = available.has(seat.label) && (!categoryKey || seat.categoryKey === categoryKey) && (!zoneId || seat.zoneId === zoneId);
7125
+ const rowId = seat.logicalRowId ?? seat.rowId;
7126
+ let arr = rows.get(rowId);
5357
7127
  if (!arr) {
5358
7128
  arr = [];
5359
- rows.set(seat.rowId, arr);
7129
+ rows.set(rowId, arr);
5360
7130
  }
5361
7131
  arr.push({ seat, index: seatIndex2(seat, i), elig });
5362
7132
  if (elig) eligibleAll.push(seat);
@@ -5381,7 +7151,7 @@ function pickBestAvailable(seats, available, opts) {
5381
7151
  continue;
5382
7152
  }
5383
7153
  let j = i;
5384
- while (j < slots.length && slots[j].elig) j++;
7154
+ while (j < slots.length && slots[j].elig && (j === i || slots[j].index === slots[j - 1].index + 1)) j++;
5385
7155
  const segLen = j - i;
5386
7156
  for (let p = 0; p + qty <= segLen; p++) {
5387
7157
  const leftRem = p;
@@ -5395,7 +7165,7 @@ function pickBestAvailable(seats, available, opts) {
5395
7165
  startIndex: slots[i + p].index,
5396
7166
  orphan,
5397
7167
  nonPremium: preferPremium ? runSeats.reduce((n, s) => n + (isPremium(s) ? 0 : 1), 0) : 0,
5398
- d2: dist2(centroid(runSeats), focal)
7168
+ d2: dist2(centroid(runSeats), candidateFocal(runSeats, focal))
5399
7169
  };
5400
7170
  if (better(c)) best = c;
5401
7171
  }
@@ -5404,20 +7174,71 @@ function pickBestAvailable(seats, available, opts) {
5404
7174
  }
5405
7175
  if (best) return { labels: best.labels };
5406
7176
  if (eligibleAll.length < qty) return { labels: [], reason: "not_enough_together" };
5407
- const ranked = eligibleAll.map((seat, i) => ({ seat, i, d2: dist2(seat, focal) })).sort((a, b) => {
7177
+ const ranked = eligibleAll.map((seat, i) => ({ seat, i, d2: dist2(seat, seat.focalPoint ?? focal) })).sort((a, b) => {
5408
7178
  if (preferPremium) {
5409
7179
  const pa = isPremium(a.seat) ? 0 : 1;
5410
7180
  const pb = isPremium(b.seat) ? 0 : 1;
5411
7181
  if (pa !== pb) return pa - pb;
5412
7182
  }
5413
7183
  if (a.d2 !== b.d2) return a.d2 - b.d2;
5414
- const r = a.seat.rowId.localeCompare(b.seat.rowId);
7184
+ const r = (a.seat.logicalRowId ?? a.seat.rowId).localeCompare(b.seat.logicalRowId ?? b.seat.rowId);
5415
7185
  if (r !== 0) return r;
5416
7186
  return seatIndex2(a.seat, a.i) - seatIndex2(b.seat, b.i);
5417
7187
  });
5418
7188
  return { labels: ranked.slice(0, qty).map((r) => r.seat.label) };
5419
7189
  }
5420
7190
 
7191
+ // src/core/tableInventory.ts
7192
+ var GroupedTableProjectionError = class extends Error {
7193
+ constructor(objectId, message) {
7194
+ super(message);
7195
+ this.objectId = objectId;
7196
+ this.name = "GroupedTableProjectionError";
7197
+ }
7198
+ };
7199
+ function bounds(table) {
7200
+ const min = table.minOccupancy;
7201
+ const max = table.maxOccupancy;
7202
+ if (!Number.isInteger(min) || !Number.isInteger(max) || min < 1 || max < min || max > table.seatCount) {
7203
+ throw new GroupedTableProjectionError(
7204
+ table.id,
7205
+ `Table "${table.label}" has invalid variable-occupancy bounds`
7206
+ );
7207
+ }
7208
+ return { min, max };
7209
+ }
7210
+ function groupedTableInventory(doc, modelVersion) {
7211
+ if (modelVersion !== 2) return [];
7212
+ const units = [];
7213
+ for (const object of allObjects(doc)) {
7214
+ if (object.type !== "table" || !object.bookAsWhole && !object.variableOccupancy) continue;
7215
+ if (object.bookAsWhole && object.variableOccupancy) {
7216
+ throw new GroupedTableProjectionError(
7217
+ object.id,
7218
+ `Table "${object.label}" cannot be whole-table and variable-occupancy inventory`
7219
+ );
7220
+ }
7221
+ if (!Number.isInteger(object.seatCount) || object.seatCount < 1 || !object.label.trim()) {
7222
+ throw new GroupedTableProjectionError(object.id, "Grouped tables require a label and at least one chair");
7223
+ }
7224
+ const mode = object.variableOccupancy ? "variable" : "whole";
7225
+ const occupancy = mode === "variable" ? bounds(object) : { min: object.seatCount, max: object.seatCount };
7226
+ units.push({
7227
+ objectId: object.id,
7228
+ label: object.label,
7229
+ ...object.displayLabel ? { displayLabel: object.displayLabel } : {},
7230
+ ...object.displayType ? { displayType: object.displayType } : {},
7231
+ categoryKey: object.categoryKey,
7232
+ mode,
7233
+ capacity: object.seatCount,
7234
+ minOccupancy: occupancy.min,
7235
+ maxOccupancy: occupancy.max,
7236
+ chairs: expandTable(object)
7237
+ });
7238
+ }
7239
+ return units;
7240
+ }
7241
+
5421
7242
  // src/picker/PickerController.ts
5422
7243
  var DEFAULT_MAX_SELECTION = 10;
5423
7244
  var MAX_BACKOFF_MS = 15e3;
@@ -5449,6 +7270,14 @@ var PickerController = class {
5449
7270
  /** id → buyer-facing spatial metadata used by every tooltip/confirm surface. */
5450
7271
  this.seatContext = /* @__PURE__ */ new Map();
5451
7272
  this.allIds = [];
7273
+ /** Model-2 tables are one booking unit whose chairs remain renderer geometry. */
7274
+ this.groupedTablesByObject = /* @__PURE__ */ new Map();
7275
+ this.groupedTablesByLabel = /* @__PURE__ */ new Map();
7276
+ this.groupedTableBySeatId = /* @__PURE__ */ new Map();
7277
+ this.tableQuantities = /* @__PURE__ */ new Map();
7278
+ /** Variable quantity is a buyer contract, never an inferred default. */
7279
+ this.confirmedVariableTables = /* @__PURE__ */ new Set();
7280
+ this.groupedSelectionOverhead = 0;
5452
7281
  // realtime socket
5453
7282
  this.ws = null;
5454
7283
  this.reconnectTimer = null;
@@ -5510,6 +7339,20 @@ var PickerController = class {
5510
7339
  idForLabel(label) {
5511
7340
  return this.labelToId.get(label);
5512
7341
  }
7342
+ idsForLabel(label) {
7343
+ const table = this.groupedTablesByLabel.get(label);
7344
+ if (table) return table.chairs.map((chair) => chair.id);
7345
+ const id = this.labelToId.get(label);
7346
+ return id ? [id] : [];
7347
+ }
7348
+ idsForLabels(labels) {
7349
+ return [...new Set([...labels].flatMap((label) => this.idsForLabel(label)))];
7350
+ }
7351
+ /** Resolve a physical chair id (or table inventory label) to its table unit. */
7352
+ tableSelection(seatIdOrLabel) {
7353
+ const group = this.groupedTableBySeatId.get(seatIdOrLabel) ?? this.groupedTablesByLabel.get(seatIdOrLabel);
7354
+ return group ? this.toTableSeat(group) : null;
7355
+ }
5513
7356
  /** Fetch chart, build label maps, mount the renderer, seed statuses, go live. */
5514
7357
  async render(host) {
5515
7358
  if (this.renderer) return null;
@@ -5523,6 +7366,29 @@ var PickerController = class {
5523
7366
  }
5524
7367
  if (this.closed) return null;
5525
7368
  this._doc = res.doc;
7369
+ try {
7370
+ const groups = groupedTableInventory(
7371
+ res.doc,
7372
+ res.event.inventoryModelVersion === 2 ? 2 : 1
7373
+ );
7374
+ this.groupedTablesByObject = new Map(groups.map((group) => [group.objectId, group]));
7375
+ this.groupedTablesByLabel = new Map(groups.map((group) => [group.label, group]));
7376
+ this.groupedTableBySeatId = new Map(
7377
+ groups.flatMap((group) => group.chairs.map((chair) => [chair.id, group]))
7378
+ );
7379
+ this.tableQuantities = new Map(groups.map((group) => [
7380
+ group.label,
7381
+ group.mode === "whole" ? group.capacity : group.minOccupancy
7382
+ ]));
7383
+ this.confirmedVariableTables = /* @__PURE__ */ new Set();
7384
+ this.groupedSelectionOverhead = groups.reduce(
7385
+ (sum, group) => sum + Math.max(0, group.chairs.length - 1),
7386
+ 0
7387
+ );
7388
+ } catch (err) {
7389
+ this.emitError(err);
7390
+ return null;
7391
+ }
5526
7392
  this.labelToId = /* @__PURE__ */ new Map();
5527
7393
  this.labelToSeat = /* @__PURE__ */ new Map();
5528
7394
  this.seatById = /* @__PURE__ */ new Map();
@@ -5540,32 +7406,43 @@ var PickerController = class {
5540
7406
  this.allIds.push(s.id);
5541
7407
  const source = chartObjects.get(s.rowId);
5542
7408
  const sourceLabel = source && "label" in source && typeof source.label === "string" ? source.label : void 0;
5543
- const sourceDisplayLabel = source && "displayLabel" in source && typeof source.displayLabel === "string" && source.displayLabel ? source.displayLabel : sourceLabel;
5544
- const rowLabel = s.kind === "booth" ? void 0 : sourceDisplayLabel;
5545
- const rowType = source && "displayType" in source && typeof source.displayType === "string" && source.displayType.trim() ? source.displayType.trim() : void 0;
7409
+ const logicalRow = source?.type === "row" ? source.segmentedRow : void 0;
7410
+ const sourceDisplayLabel = logicalRow?.displayLabel ?? (source && "displayLabel" in source && typeof source.displayLabel === "string" && source.displayLabel ? source.displayLabel : sourceLabel);
7411
+ const groupedTable = this.groupedTablesByObject.get(s.rowId);
7412
+ const objectType = source?.type === "table" ? "table" : source?.type === "booth" ? "booth" : "seat";
7413
+ const rowLabel = sourceDisplayLabel;
7414
+ const rowType = logicalRow?.displayType?.trim() || (source && "displayType" in source && typeof source.displayType === "string" && source.displayType.trim() ? source.displayType.trim() : void 0);
5546
7415
  const visibleSeatLabel = s.displayLabel ?? s.label;
5547
7416
  const labelParts = visibleSeatLabel.split("-");
5548
- const seatNumber = sourceDisplayLabel && visibleSeatLabel.startsWith(`${sourceDisplayLabel}-`) ? visibleSeatLabel.slice(sourceDisplayLabel.length + 1) : s.kind === "booth" ? visibleSeatLabel : labelParts[labelParts.length - 1] ?? visibleSeatLabel;
7417
+ const seatNumber = groupedTable ? void 0 : s.kind === "booth" ? void 0 : sourceDisplayLabel && visibleSeatLabel.startsWith(`${sourceDisplayLabel}-`) ? visibleSeatLabel.slice(sourceDisplayLabel.length + 1) : labelParts[labelParts.length - 1] ?? visibleSeatLabel;
5549
7418
  this.seatContext.set(s.id, {
7419
+ objectId: s.rowId,
7420
+ objectType,
5550
7421
  sectionLabel: sectionLabels.get(membership.objectToSection.get(s.rowId) ?? ""),
5551
7422
  rowLabel,
5552
7423
  seatNumber,
5553
- rowType
7424
+ ...rowType ? { displayType: rowType, rowType } : {}
5554
7425
  });
5555
7426
  }
7427
+ for (const group of this.groupedTablesByLabel.values()) {
7428
+ const representative = group.chairs[0];
7429
+ if (!representative) continue;
7430
+ this.labelToId.set(group.label, representative.id);
7431
+ this.labelToSeat.set(group.label, representative);
7432
+ }
5556
7433
  const currency = res.event.currency ?? this.opts.currency;
5557
7434
  this.currency = currency ?? "USD";
5558
7435
  const renderer = createRenderer(host, {
5559
- maxSelection: this.maxSelection,
7436
+ // Group chairs are selected together for paint, but count as one logical
7437
+ // inventory line. The controller enforces the guest-weighted cap.
7438
+ maxSelection: this.rendererSelectionCap(),
5560
7439
  confirmSelection: this.opts.confirmSelection,
5561
7440
  currency,
5562
7441
  onSelect: (seat) => {
5563
- this.opts.onSelect?.(seat);
5564
- this.emitSelectionChange();
7442
+ this.handleRendererSelect(seat);
5565
7443
  },
5566
7444
  onDeselect: (seat) => {
5567
- this.opts.onDeselect?.(seat);
5568
- this.emitSelectionChange();
7445
+ this.handleRendererDeselect(seat);
5569
7446
  },
5570
7447
  onSelectionLimit: this.opts.onSelectionLimit,
5571
7448
  onHover: (seat) => {
@@ -5620,7 +7497,20 @@ var PickerController = class {
5620
7497
  // ---- selection ------------------------------------------------------------
5621
7498
  getSelection() {
5622
7499
  if (!this.renderer) return [];
5623
- return this.renderer.getSelection().map((s) => this.toSeat(s));
7500
+ const out = [];
7501
+ const grouped = /* @__PURE__ */ new Set();
7502
+ for (const seat of this.renderer.getSelection()) {
7503
+ const table = this.groupedTableBySeatId.get(seat.id);
7504
+ if (table) {
7505
+ if (!grouped.has(table.label)) {
7506
+ grouped.add(table.label);
7507
+ out.push(this.toTableSeat(table));
7508
+ }
7509
+ } else {
7510
+ out.push(this.toSeat(seat));
7511
+ }
7512
+ }
7513
+ return out;
5624
7514
  }
5625
7515
  /** Enriched metadata for a seat confirmation card or tooltip. */
5626
7516
  seatDetails(seatId) {
@@ -5629,20 +7519,168 @@ var PickerController = class {
5629
7519
  }
5630
7520
  clearSelection() {
5631
7521
  this.renderer?.clearSelection();
7522
+ this.resetUnheldTableQuantities();
5632
7523
  this.emitSelectionChange();
5633
7524
  }
5634
7525
  deselect(ids) {
5635
- this.renderer?.deselect(ids);
7526
+ const expanded = /* @__PURE__ */ new Set();
7527
+ for (const id of ids) {
7528
+ const table = this.groupedTableBySeatId.get(id) ?? this.groupedTablesByLabel.get(id);
7529
+ if (table) {
7530
+ table.chairs.forEach((chair) => expanded.add(chair.id));
7531
+ if (!this.hold_?.labels.includes(table.label)) {
7532
+ this.tableQuantities.set(
7533
+ table.label,
7534
+ table.mode === "whole" ? table.capacity : table.minOccupancy
7535
+ );
7536
+ if (table.mode === "variable") this.confirmedVariableTables.delete(table.label);
7537
+ }
7538
+ } else expanded.add(id);
7539
+ }
7540
+ this.renderer?.deselect([...expanded]);
5636
7541
  this.emitSelectionChange();
5637
7542
  }
5638
7543
  setMaxSelection(maxSelection) {
5639
7544
  this.maxSelection = Math.max(0, Math.floor(maxSelection));
5640
- this.renderer?.setMaxSelection?.(this.maxSelection);
7545
+ this.renderer?.setMaxSelection?.(this.rendererSelectionCap());
5641
7546
  }
5642
7547
  select(ids) {
5643
- const added = this.renderer?.select?.(ids) ?? [];
7548
+ const before = new Set(this.getSelection().map((seat) => seat.label));
7549
+ const expanded = /* @__PURE__ */ new Set();
7550
+ for (const id of ids) {
7551
+ const table = this.groupedTableBySeatId.get(id) ?? this.groupedTablesByLabel.get(id);
7552
+ if (table) table.chairs.forEach((chair) => expanded.add(chair.id));
7553
+ else expanded.add(id);
7554
+ }
7555
+ this.renderer?.select?.([...expanded]);
7556
+ if (this.selectionGuestCount() > this.maxSelection) {
7557
+ this.renderer?.deselect([...expanded]);
7558
+ this.opts.onSelectionLimit?.(this.maxSelection);
7559
+ return [];
7560
+ }
7561
+ const added = this.getSelection().filter((seat) => !before.has(seat.label));
5644
7562
  if (added.length) this.emitSelectionChange();
5645
- return added.map((seat) => this.toSeat(seat));
7563
+ return added;
7564
+ }
7565
+ /** Confirm/update the guest quantity for a selected variable table. */
7566
+ setTableQuantity(label, quantity) {
7567
+ const table = this.groupedTablesByLabel.get(label);
7568
+ if (!table) return false;
7569
+ const q = table.mode === "whole" ? table.capacity : Math.floor(quantity);
7570
+ if (!Number.isInteger(q) || q < table.minOccupancy || q > table.maxOccupancy) return false;
7571
+ const previous = this.tableQuantities.get(label) ?? table.minOccupancy;
7572
+ this.tableQuantities.set(label, q);
7573
+ if (this.selectionGuestCount() > this.maxSelection) {
7574
+ this.tableQuantities.set(label, previous);
7575
+ this.opts.onSelectionLimit?.(this.maxSelection);
7576
+ return false;
7577
+ }
7578
+ if (table.mode === "variable") this.confirmedVariableTables.add(label);
7579
+ this.emitSelectionChange();
7580
+ return true;
7581
+ }
7582
+ /** Atomically replace an active variable-table hold with a new guest count. */
7583
+ async replaceTableQuantity(label, quantity) {
7584
+ const table = this.groupedTablesByLabel.get(label);
7585
+ const hold = this.hold_;
7586
+ if (!table || table.mode !== "variable" || !hold?.labels.includes(label)) return null;
7587
+ const q = Math.floor(quantity);
7588
+ if (!Number.isInteger(q) || q < table.minOccupancy || q > table.maxOccupancy) return null;
7589
+ const otherGuests = (hold.items ?? []).filter((item) => item.label !== label).reduce((sum, item) => sum + (item.quantity ?? 1), 0);
7590
+ if (otherGuests + q > this.maxSelection) {
7591
+ this.opts.onSelectionLimit?.(this.maxSelection);
7592
+ return null;
7593
+ }
7594
+ const selections = (hold.items ?? []).map((item) => ({
7595
+ label: item.label,
7596
+ tierId: item.tierId,
7597
+ quantity: item.label === label ? q : item.quantity
7598
+ }));
7599
+ if (!selections.some((item) => item.label === label)) return null;
7600
+ const result = await this.api.hold(this.key, selections, void 0, hold.holdId);
7601
+ this.tableQuantities.set(label, q);
7602
+ this.setHold({
7603
+ holdId: result.holdId,
7604
+ labels: selections.map((item) => item.label),
7605
+ expiresAt: result.expiresAt,
7606
+ items: result.items
7607
+ });
7608
+ return this.hold_;
7609
+ }
7610
+ rendererSelectionCap() {
7611
+ return this.maxSelection + this.groupedSelectionOverhead;
7612
+ }
7613
+ selectionGuestCount() {
7614
+ return this.getSelection().reduce((sum, seat) => sum + (seat.quantity ?? 1), 0);
7615
+ }
7616
+ handleRendererSelect(seat) {
7617
+ const table = this.groupedTableBySeatId.get(seat.id);
7618
+ if (table) {
7619
+ if (this.selectionGuestCount() > this.maxSelection) {
7620
+ this.renderer?.deselect(table.chairs.map((chair) => chair.id));
7621
+ this.opts.onSelectionLimit?.(this.maxSelection);
7622
+ this.emitSelectionChange();
7623
+ return;
7624
+ }
7625
+ this.renderer?.select?.(table.chairs.map((chair) => chair.id));
7626
+ this.opts.onSelect?.(table.chairs[0] ?? seat);
7627
+ this.opts.onTableSelectionRequest?.(this.toTableSeat(table));
7628
+ this.emitSelectionChange();
7629
+ return;
7630
+ }
7631
+ if (this.selectionGuestCount() > this.maxSelection) {
7632
+ this.renderer?.deselect([seat.id]);
7633
+ this.opts.onSelectionLimit?.(this.maxSelection);
7634
+ this.emitSelectionChange();
7635
+ return;
7636
+ }
7637
+ this.opts.onSelect?.(seat);
7638
+ this.emitSelectionChange();
7639
+ }
7640
+ handleRendererDeselect(seat) {
7641
+ const table = this.groupedTableBySeatId.get(seat.id);
7642
+ if (table) {
7643
+ this.renderer?.deselect(table.chairs.map((chair) => chair.id));
7644
+ if (!this.hold_?.labels.includes(table.label)) {
7645
+ this.tableQuantities.set(
7646
+ table.label,
7647
+ table.mode === "whole" ? table.capacity : table.minOccupancy
7648
+ );
7649
+ if (table.mode === "variable") this.confirmedVariableTables.delete(table.label);
7650
+ }
7651
+ this.opts.onDeselect?.(table.chairs[0] ?? seat);
7652
+ } else {
7653
+ this.opts.onDeselect?.(seat);
7654
+ }
7655
+ this.emitSelectionChange();
7656
+ }
7657
+ resetUnheldTableQuantities() {
7658
+ for (const table of this.groupedTablesByLabel.values()) {
7659
+ if (this.hold_?.labels.includes(table.label)) continue;
7660
+ this.tableQuantities.set(
7661
+ table.label,
7662
+ table.mode === "whole" ? table.capacity : table.minOccupancy
7663
+ );
7664
+ if (table.mode === "variable") this.confirmedVariableTables.delete(table.label);
7665
+ }
7666
+ }
7667
+ resetTableQuantitiesForLabels(labels) {
7668
+ for (const label of labels) {
7669
+ const table = this.groupedTablesByLabel.get(label);
7670
+ if (!table) continue;
7671
+ this.tableQuantities.set(
7672
+ label,
7673
+ table.mode === "whole" ? table.capacity : table.minOccupancy
7674
+ );
7675
+ if (table.mode === "variable") this.confirmedVariableTables.delete(label);
7676
+ }
7677
+ }
7678
+ tableQuantityRequest(seat) {
7679
+ if (seat?.objectType !== "table") return {};
7680
+ if (seat.bookingMode === "variable" && !this.confirmedVariableTables.has(seat.label)) {
7681
+ throw new Error("picker: confirm a variable table guest quantity before holding");
7682
+ }
7683
+ return { quantity: seat.quantity };
5646
7684
  }
5647
7685
  // ---- booking machine ------------------------------------------------------
5648
7686
  /**
@@ -5654,7 +7692,7 @@ var PickerController = class {
5654
7692
  async hold(labelsArg, ttlMs) {
5655
7693
  const r = this.renderer;
5656
7694
  if (!r) return null;
5657
- const labels = labelsArg ?? r.getSelection().map((s) => s.label);
7695
+ const labels = labelsArg ?? this.getSelection().map((seat) => seat.label);
5658
7696
  const existingGA = (this.hold_?.items ?? []).filter((item) => item.objectType === "ga");
5659
7697
  const combinedLabels = [.../* @__PURE__ */ new Set([...existingGA.map((item) => item.label), ...labels])];
5660
7698
  if (!combinedLabels.length) return null;
@@ -5662,10 +7700,14 @@ var PickerController = class {
5662
7700
  try {
5663
7701
  const selections = combinedLabels.map((label) => {
5664
7702
  const ga = existingGA.find((item) => item.label === label);
5665
- if (ga) return { label, tierId: ga.tierId };
7703
+ if (ga) return { label, tierId: ga.tierId, quantity: ga.quantity };
5666
7704
  const seat = this.labelToSeat.get(label);
5667
7705
  const resolved = seat ? this.toSeat(seat) : null;
5668
- return { label, ...resolved?.tierId ? { tierId: resolved.tierId } : {} };
7706
+ return {
7707
+ label,
7708
+ ...resolved?.tierId ? { tierId: resolved.tierId } : {},
7709
+ ...this.tableQuantityRequest(resolved)
7710
+ };
5669
7711
  });
5670
7712
  const result = await this.api.hold(this.key, selections, ttlMs, this.hold_?.holdId);
5671
7713
  this.setHold({ holdId: result.holdId, labels: combinedLabels, expiresAt: result.expiresAt, items: result.items });
@@ -5717,11 +7759,19 @@ var PickerController = class {
5717
7759
  const out = {};
5718
7760
  const closedMembers = this.closedMemberIds();
5719
7761
  for (const [id, s] of this.seatById) {
7762
+ if (this.groupedTableBySeatId.has(id)) continue;
5720
7763
  if (closedMembers.has(id)) continue;
5721
7764
  if ((this.getStatus(id) ?? "free") === "free") {
5722
7765
  out[s.categoryKey] = (out[s.categoryKey] ?? 0) + 1;
5723
7766
  }
5724
7767
  }
7768
+ for (const table of this.groupedTablesByLabel.values()) {
7769
+ if (table.chairs.some((chair) => closedMembers.has(chair.id))) continue;
7770
+ const representative = table.chairs[0];
7771
+ if (representative && (this.getStatus(representative.id) ?? "free") === "free") {
7772
+ out[table.categoryKey] = (out[table.categoryKey] ?? 0) + table.maxOccupancy;
7773
+ }
7774
+ }
5725
7775
  return out;
5726
7776
  }
5727
7777
  /** Seat ids belonging to a currently-closed section (excluded from counts). */
@@ -5740,6 +7790,8 @@ var PickerController = class {
5740
7790
  return {
5741
7791
  id: area.id,
5742
7792
  label: area.label,
7793
+ ...area.displayLabel ? { displayLabel: area.displayLabel } : {},
7794
+ ...area.displayType ? { displayType: area.displayType } : {},
5743
7795
  capacity: Math.max(0, Math.floor(area.capacity)),
5744
7796
  available: gaUnitLabels(area).filter((label) => (this.liveStatuses.get(label) ?? "free") === "free").length,
5745
7797
  categoryKey: area.categoryKey,
@@ -5758,14 +7810,20 @@ var PickerController = class {
5758
7810
  const labels = gaUnitLabels(area).filter((label) => !this.hold_?.labels.includes(label) && (this.liveStatuses.get(label) ?? "free") === "free").slice(0, Math.floor(qty));
5759
7811
  if (labels.length !== Math.floor(qty)) return null;
5760
7812
  const selections = /* @__PURE__ */ new Map();
5761
- for (const item of this.hold_?.items ?? []) selections.set(item.label, { label: item.label, tierId: item.tierId });
7813
+ for (const item of this.hold_?.items ?? []) selections.set(item.label, {
7814
+ label: item.label,
7815
+ tierId: item.tierId,
7816
+ quantity: item.quantity
7817
+ });
5762
7818
  if (this.hold_ && !this.hold_?.items?.length) {
5763
7819
  for (const seat of this.hold_.seats) selections.set(seat.label, { label: seat.label, ...seat.tierId ? { tierId: seat.tierId } : {} });
5764
7820
  }
5765
- for (const seat of this.renderer?.getSelection() ?? []) {
5766
- const resolved = this.labelToSeat.get(seat.label);
5767
- const chosen = resolved ? this.toSeat(resolved) : null;
5768
- selections.set(seat.label, { label: seat.label, ...chosen?.tierId ? { tierId: chosen.tierId } : {} });
7821
+ for (const seat of this.getSelection()) {
7822
+ selections.set(seat.label, {
7823
+ label: seat.label,
7824
+ ...seat.tierId ? { tierId: seat.tierId } : {},
7825
+ ...this.tableQuantityRequest(seat)
7826
+ });
5769
7827
  }
5770
7828
  for (const label of labels) selections.set(label, { label, ...options.tierId ? { tierId: options.tierId } : {} });
5771
7829
  const combined = [...selections.values()];
@@ -5782,6 +7840,15 @@ var PickerController = class {
5782
7840
  }
5783
7841
  return false;
5784
7842
  }
7843
+ /** Zones which still own visible buyer inventory, in authored order. */
7844
+ getBestAvailableZones() {
7845
+ const doc = this.visibleDoc();
7846
+ if (!doc?.zones?.length) return [];
7847
+ const used = new Set(
7848
+ computeSections(doc).sections.filter((section) => section.seatCount > 0 && !!section.zone).map((section) => section.zone)
7849
+ );
7850
+ return doc.zones.filter((zone) => used.has(zone.id)).map((zone) => ({ id: zone.id, label: zone.label }));
7851
+ }
5785
7852
  /**
5786
7853
  * Client-side premium pre-pass: find the best contiguous block of `qty`
5787
7854
  * PREMIUM-flagged free seats (orphan-avoiding, closest to the focal point)
@@ -5790,11 +7857,12 @@ var PickerController = class {
5790
7857
  * seat status. Returns a FULL premium block of `qty`, or null when none
5791
7858
  * exists (the caller then falls back to the normal server pick).
5792
7859
  */
5793
- pickPremiumBlock(qty, categoryKey) {
7860
+ pickPremiumBlock(qty, categoryKey, zoneId) {
5794
7861
  const doc = this.visibleDoc();
5795
7862
  if (!doc?.objects?.length) return null;
5796
7863
  const focal = doc.focalPoint ?? { x: 0, y: 0 };
5797
- const seats = expandChart(doc);
7864
+ const groupedObjectIds = new Set(this.groupedTablesByObject.keys());
7865
+ const seats = expandChart(doc).filter((seat) => !groupedObjectIds.has(seat.rowId));
5798
7866
  const held = new Set(this.hold_?.labels ?? []);
5799
7867
  const available = /* @__PURE__ */ new Set();
5800
7868
  for (const seat of seats) {
@@ -5803,7 +7871,7 @@ var PickerController = class {
5803
7871
  const status = id ? this.getStatus(id) : void 0;
5804
7872
  if ((status ?? "free") === "free") available.add(seat.label);
5805
7873
  }
5806
- const pick = pickBestAvailable(seats, available, { qty, categoryKey, focal, preferPremium: true });
7874
+ const pick = pickBestAvailable(seats, available, { qty, categoryKey, zoneId, focal, preferPremium: true });
5807
7875
  if (pick.labels.length !== qty) return null;
5808
7876
  const allPremium = pick.labels.every((l) => this.labelToSeat.get(l)?.commercial?.premium);
5809
7877
  return allPremium ? [...pick.labels] : null;
@@ -5818,7 +7886,7 @@ var PickerController = class {
5818
7886
  const r = this.renderer;
5819
7887
  if (!r) return null;
5820
7888
  if (opts.preferPremium) {
5821
- const block = this.pickPremiumBlock(qty, categoryKey);
7889
+ const block = this.pickPremiumBlock(qty, categoryKey, opts.zoneId);
5822
7890
  if (block) {
5823
7891
  if (this.hold_ && !await this.release()) return null;
5824
7892
  try {
@@ -5829,7 +7897,7 @@ var PickerController = class {
5829
7897
  });
5830
7898
  const result = await this.api.hold(this.key, selection);
5831
7899
  r.clearSelection();
5832
- const ids = block.map((l) => this.labelToId.get(l)).filter((v) => !!v);
7900
+ const ids = this.idsForLabels(block);
5833
7901
  if (ids.length) r.setStatus(ids, "held");
5834
7902
  this.setHold({ holdId: result.holdId, labels: [...block], expiresAt: result.expiresAt, items: result.items });
5835
7903
  const seats = block.map((l) => this.labelToSeat.get(l)).filter((s) => !!s).map((s) => this.toSeat(s));
@@ -5846,9 +7914,9 @@ var PickerController = class {
5846
7914
  }
5847
7915
  if (this.hold_ && !await this.release()) return null;
5848
7916
  try {
5849
- const result = await this.api.bestAvailable(this.key, qty, categoryKey);
7917
+ const result = await this.api.bestAvailable(this.key, qty, categoryKey, opts.zoneId);
5850
7918
  r.clearSelection();
5851
- const ids = result.labels.map((l) => this.labelToId.get(l)).filter((v) => !!v);
7919
+ const ids = this.idsForLabels(result.labels);
5852
7920
  if (ids.length) r.setStatus(ids, "held");
5853
7921
  this.setHold({ holdId: result.holdId, labels: [...result.labels], expiresAt: result.expiresAt, items: result.items });
5854
7922
  const seats = result.labels.map((l) => this.labelToSeat.get(l)).filter((s) => !!s).map((s) => this.toSeat(s));
@@ -5872,7 +7940,7 @@ var PickerController = class {
5872
7940
  const r = this.renderer;
5873
7941
  if (!r) return null;
5874
7942
  if (!this.api.book) throw new Error("picker: transport has no book() \u2014 hold-only mode");
5875
- const labels = labelsArg ?? r.getSelection().map((s) => s.label);
7943
+ const labels = labelsArg ?? this.getSelection().map((seat) => seat.label);
5876
7944
  if (!labels.length) return null;
5877
7945
  let holdId;
5878
7946
  try {
@@ -5885,7 +7953,11 @@ var PickerController = class {
5885
7953
  labels.map((label) => {
5886
7954
  const seat = this.labelToSeat.get(label);
5887
7955
  const resolved = seat ? this.toSeat(seat) : null;
5888
- return { label, ...resolved?.tierId ? { tierId: resolved.tierId } : {} };
7956
+ return {
7957
+ label,
7958
+ ...resolved?.tierId ? { tierId: resolved.tierId } : {},
7959
+ ...this.tableQuantityRequest(resolved)
7960
+ };
5889
7961
  }),
5890
7962
  void 0,
5891
7963
  replaceHoldId
@@ -5901,10 +7973,11 @@ var PickerController = class {
5901
7973
  this.opts.onSalesClosed?.();
5902
7974
  } else if (status === 409 && conflicts?.length) {
5903
7975
  const takenLabels = new Set(conflicts.map((c) => c.label));
5904
- const takenIds = [...takenLabels].map((l) => this.labelToId.get(l)).filter((v) => !!v);
7976
+ const takenIds = this.idsForLabels(takenLabels);
5905
7977
  if (takenIds.length) {
5906
7978
  r.setStatus(takenIds, "booked");
5907
7979
  r.deselect(takenIds);
7980
+ this.resetTableQuantitiesForLabels(takenLabels);
5908
7981
  }
5909
7982
  const stillFree = labels.filter((l) => !takenLabels.has(l));
5910
7983
  if (stillFree.length && holdId) void this.api.release(this.key, stillFree, holdId).catch(() => {
@@ -5916,12 +7989,13 @@ var PickerController = class {
5916
7989
  }
5917
7990
  throw err;
5918
7991
  }
5919
- const ids = labels.map((l) => this.labelToId.get(l)).filter((v) => !!v);
7992
+ const ids = this.idsForLabels(labels);
5920
7993
  if (ids.length) {
5921
7994
  r.setStatus(ids, "booked");
5922
7995
  r.deselect(ids);
5923
7996
  }
5924
7997
  this.clearHold();
7998
+ this.resetTableQuantitiesForLabels(labels);
5925
7999
  this.emitSelectionChange();
5926
8000
  this.opts.onBook?.(bookingRef);
5927
8001
  return labels;
@@ -5942,7 +8016,8 @@ var PickerController = class {
5942
8016
  }
5943
8017
  if (this.hold_?.holdId !== hold.holdId) return true;
5944
8018
  this.clearHold();
5945
- const ids = hold.labels.map((l) => this.labelToId.get(l)).filter((v) => !!v);
8019
+ this.resetTableQuantitiesForLabels(hold.labels);
8020
+ const ids = this.idsForLabels(hold.labels);
5946
8021
  if (ids.length) {
5947
8022
  this.renderer?.deselect(ids);
5948
8023
  this.renderer?.setStatus(ids, "free");
@@ -5973,7 +8048,8 @@ var PickerController = class {
5973
8048
  const remainingItems = hold.items?.filter((item) => !drop.includes(item.label));
5974
8049
  if (remaining.length) this.setHold({ ...hold, labels: remaining, items: remainingItems });
5975
8050
  else this.clearHold();
5976
- const ids = drop.map((l) => this.labelToId.get(l)).filter((v) => !!v);
8051
+ this.resetTableQuantitiesForLabels(drop);
8052
+ const ids = this.idsForLabels(drop);
5977
8053
  if (ids.length) {
5978
8054
  this.renderer?.deselect(ids);
5979
8055
  this.renderer?.setStatus(ids, "free");
@@ -6062,7 +8138,7 @@ var PickerController = class {
6062
8138
  this.opts.onDeckTap?.(floorId);
6063
8139
  }
6064
8140
  // ---- big-venue: sections / rungs / projection (Slice 5) -------------------
6065
- /** Switch the map projection (2D flat ⇄ 3D isometric). No-op on a flat renderer. */
8141
+ /** Switch the map projection. No-op on a flat-only renderer. */
6066
8142
  setViewMode(mode) {
6067
8143
  this.renderer?.setViewMode?.(mode);
6068
8144
  }
@@ -6161,12 +8237,21 @@ var PickerController = class {
6161
8237
  if (!sec) return null;
6162
8238
  const memberIds = r.sectionMembers?.(id) ?? [];
6163
8239
  const byCat = /* @__PURE__ */ new Map();
8240
+ const seenTables = /* @__PURE__ */ new Set();
6164
8241
  let seatsLeft = 0;
6165
8242
  for (const sid of memberIds) {
6166
8243
  const seat = this.seatById.get(sid);
6167
8244
  if (!seat) continue;
6168
- byCat.set(seat.categoryKey, (byCat.get(seat.categoryKey) ?? 0) + 1);
6169
- if (r.getStatus(sid) === "free") seatsLeft++;
8245
+ const table = this.groupedTableBySeatId.get(sid);
8246
+ if (table) {
8247
+ if (seenTables.has(table.label)) continue;
8248
+ seenTables.add(table.label);
8249
+ byCat.set(table.categoryKey, (byCat.get(table.categoryKey) ?? 0) + table.maxOccupancy);
8250
+ if (r.getStatus(table.chairs[0]?.id ?? sid) === "free") seatsLeft += table.maxOccupancy;
8251
+ } else {
8252
+ byCat.set(seat.categoryKey, (byCat.get(seat.categoryKey) ?? 0) + 1);
8253
+ if (r.getStatus(sid) === "free") seatsLeft++;
8254
+ }
6170
8255
  }
6171
8256
  const categories = [...byCat.entries()].map(([key, count]) => {
6172
8257
  const cat = doc.categories.find((c) => c.key === key);
@@ -6183,7 +8268,7 @@ var PickerController = class {
6183
8268
  const color = sec.color ?? zone?.color ?? categories[0]?.color ?? "#6e7bff";
6184
8269
  return {
6185
8270
  id,
6186
- label: sec.label,
8271
+ label: sec.displayLabel ?? sec.label,
6187
8272
  zoneLabel: zone?.label ?? "",
6188
8273
  ...sec.entrance && sec.entrance.trim() ? { entrance: sec.entrance.trim() } : {},
6189
8274
  color,
@@ -6216,22 +8301,54 @@ var PickerController = class {
6216
8301
  }
6217
8302
  // ---- internals ------------------------------------------------------------
6218
8303
  toSeat(s) {
8304
+ const table = this.groupedTableBySeatId.get(s.id);
8305
+ if (table) return this.toTableSeat(table);
6219
8306
  const commercial = s.commercial ? { commercial: s.commercial } : void 0;
6220
8307
  const display = s.displayLabel ? { displayLabel: s.displayLabel } : void 0;
8308
+ const accessibility = s.accessibility?.length ? { accessibility: s.accessibility } : void 0;
8309
+ const wheelchair = s.wheelchairSpaceType ? { wheelchairSpaceType: s.wheelchairSpaceType } : void 0;
8310
+ const context = this.seatContext.get(s.id);
6221
8311
  const tiers = this.tiersFor(s.categoryKey);
6222
8312
  if (!tiers) {
6223
- return { id: s.id, label: s.label, ...display, categoryKey: s.categoryKey, price: this.priceFor(s.categoryKey), ...commercial };
8313
+ return { id: s.id, label: s.label, ...display, ...context, categoryKey: s.categoryKey, price: this.priceFor(s.categoryKey), ...commercial, ...accessibility, ...wheelchair };
6224
8314
  }
6225
8315
  const chosen = tiers.find((t2) => t2.id === this.seatTiers.get(s.id)) ?? tiers[0];
6226
8316
  return {
6227
8317
  id: s.id,
6228
8318
  label: s.label,
6229
8319
  ...display,
8320
+ ...context,
6230
8321
  categoryKey: s.categoryKey,
6231
8322
  price: chosen.price,
6232
8323
  tiers,
6233
8324
  tierId: chosen.id,
6234
- ...commercial
8325
+ ...commercial,
8326
+ ...accessibility,
8327
+ ...wheelchair
8328
+ };
8329
+ }
8330
+ toTableSeat(table) {
8331
+ const representative = table.chairs[0];
8332
+ const tiers = this.tiersFor(table.categoryKey);
8333
+ const chosen = tiers?.find((tier) => tier.id === this.seatTiers.get(representative?.id ?? "")) ?? tiers?.[0];
8334
+ return {
8335
+ id: representative?.id ?? table.objectId,
8336
+ label: table.label,
8337
+ displayLabel: table.displayLabel ?? table.label,
8338
+ ...table.displayType ? { displayType: table.displayType, rowType: table.displayType } : {},
8339
+ objectId: table.objectId,
8340
+ sectionLabel: representative ? this.seatContext.get(representative.id)?.sectionLabel : void 0,
8341
+ rowLabel: table.displayLabel ?? table.label,
8342
+ categoryKey: table.categoryKey,
8343
+ price: chosen?.price ?? this.priceFor(table.categoryKey),
8344
+ ...tiers ? { tiers, tierId: chosen?.id } : {},
8345
+ objectType: "table",
8346
+ bookingMode: table.mode,
8347
+ quantity: this.tableQuantities.get(table.label) ?? (table.mode === "whole" ? table.capacity : table.minOccupancy),
8348
+ capacity: table.capacity,
8349
+ minOccupancy: table.minOccupancy,
8350
+ maxOccupancy: table.maxOccupancy,
8351
+ physicalSeatIds: table.chairs.map((chair) => chair.id)
6235
8352
  };
6236
8353
  }
6237
8354
  /** Tooltip payload for a hovered seat — see PickerCallbacks.onSeatHover. */
@@ -6315,10 +8432,13 @@ var PickerController = class {
6315
8432
  const h = this.hold_;
6316
8433
  if (!h || h.labels.length !== labels.length || !labels.every((l) => h.labels.includes(l))) return false;
6317
8434
  const tiers = new Map((h.items ?? []).map((item) => [item.label, item.tierId]));
8435
+ const quantities = new Map((h.items ?? []).map((item) => [item.label, item.quantity ?? 1]));
6318
8436
  return labels.every((label) => {
6319
8437
  const seat = this.labelToSeat.get(label);
6320
- const currentTier = seat ? this.toSeat(seat).tierId ?? null : null;
6321
- return !tiers.has(label) || tiers.get(label) === currentTier;
8438
+ const resolved = seat ? this.toSeat(seat) : null;
8439
+ const currentTier = resolved?.tierId ?? null;
8440
+ const quantityMatches = resolved?.objectType !== "table" || (resolved.bookingMode !== "variable" || this.confirmedVariableTables.has(label)) && (!quantities.has(label) || quantities.get(label) === resolved.quantity);
8441
+ return quantityMatches && (!tiers.has(label) || tiers.get(label) === currentTier);
6322
8442
  });
6323
8443
  }
6324
8444
  handle409Conflicts(err) {
@@ -6326,19 +8446,28 @@ var PickerController = class {
6326
8446
  if (status !== 409 || !conflicts?.length) return;
6327
8447
  const r = this.renderer;
6328
8448
  if (!r) return;
6329
- const takenIds = conflicts.map((c) => this.labelToId.get(c.label)).filter((v) => !!v);
8449
+ const takenIds = this.idsForLabels(conflicts.map((conflict) => conflict.label));
6330
8450
  if (takenIds.length) {
6331
8451
  r.deselect(takenIds);
6332
8452
  r.setStatus(takenIds, "held");
8453
+ this.resetTableQuantitiesForLabels(conflicts.map((conflict) => conflict.label));
6333
8454
  }
6334
8455
  this.emitSelectionChange();
6335
8456
  }
6336
8457
  /** Set the open hold + (re)arm the server-authoritative expiry timer. */
6337
8458
  setHold(hold, source = "created") {
8459
+ for (const item of hold.items ?? []) {
8460
+ if (this.groupedTablesByLabel.has(item.label) && item.quantity != null) {
8461
+ this.tableQuantities.set(item.label, item.quantity);
8462
+ if (this.groupedTablesByLabel.get(item.label)?.mode === "variable") {
8463
+ this.confirmedVariableTables.add(item.label);
8464
+ }
8465
+ }
8466
+ }
6338
8467
  const full = { ...hold, seats: this.seatsForLabels(hold.labels) };
6339
8468
  this.hold_ = full;
6340
8469
  this.renderer?.setOwnedHold?.(
6341
- full.labels.map((label) => this.labelToId.get(label)).filter((id) => !!id)
8470
+ this.idsForLabels(full.labels)
6342
8471
  );
6343
8472
  if (this.expiryTimer) clearTimeout(this.expiryTimer);
6344
8473
  const ms = Math.max(0, full.expiresAt - Date.now());
@@ -6381,12 +8510,11 @@ var PickerController = class {
6381
8510
  this.liveStatuses = new Map(Object.entries(seats));
6382
8511
  if (this.allIds.length) r.setStatus(this.allIds, "free");
6383
8512
  r.setOwnedHold?.(
6384
- (this.hold_?.labels ?? []).map((label) => this.labelToId.get(label)).filter((id) => !!id)
8513
+ this.idsForLabels(this.hold_?.labels ?? [])
6385
8514
  );
6386
8515
  const byStatus = { free: [], held: [], booked: [], not_for_sale: [] };
6387
8516
  for (const [label, st] of Object.entries(seats)) {
6388
- const id = this.labelToId.get(label);
6389
- if (id) byStatus[mapStatus(st)].push(id);
8517
+ byStatus[mapStatus(st)].push(...this.idsForLabel(label));
6390
8518
  }
6391
8519
  ["held", "booked", "not_for_sale"].forEach((st) => {
6392
8520
  if (byStatus[st].length) r.setStatus(byStatus[st], st);
@@ -6395,7 +8523,11 @@ var PickerController = class {
6395
8523
  this.clearBookedHoldIfSettled();
6396
8524
  }
6397
8525
  clearBookedHoldIfSettled() {
6398
- if (this.hold_?.labels.every((label) => this.liveStatuses.get(label) === "booked")) this.clearHold();
8526
+ const hold = this.hold_;
8527
+ if (hold?.labels.every((label) => this.liveStatuses.get(label) === "booked")) {
8528
+ this.clearHold();
8529
+ this.resetTableQuantitiesForLabels(hold.labels);
8530
+ }
6399
8531
  }
6400
8532
  async resnapshot() {
6401
8533
  try {
@@ -6484,13 +8616,13 @@ var PickerController = class {
6484
8616
  } else if (Array.isArray(m.changes)) {
6485
8617
  for (const ch of m.changes) {
6486
8618
  this.liveStatuses.set(ch.label, ch.status);
6487
- const id = this.labelToId.get(ch.label);
6488
- if (!id) continue;
8619
+ const ids = this.idsForLabel(ch.label);
8620
+ if (!ids.length) continue;
6489
8621
  const next = mapStatus(ch.status);
6490
- if (this.opts.flashOnLiveChange && next !== "free" && r.getStatus(id) === "free" && !this.hold_?.labels.includes(ch.label)) {
6491
- r.flashSeat(id, next === "held" ? "#f4b740" : "#f43f5e");
8622
+ if (this.opts.flashOnLiveChange && next !== "free" && ids.some((id) => r.getStatus(id) === "free") && !this.hold_?.labels.includes(ch.label)) {
8623
+ ids.forEach((id) => r.flashSeat(id, next === "held" ? "#f4b740" : "#f43f5e"));
6492
8624
  }
6493
- r.setStatus([id], next);
8625
+ r.setStatus(ids, next);
6494
8626
  }
6495
8627
  if (this.opts.keepLiveWhileHidden && typeof document !== "undefined" && document.visibilityState === "hidden") {
6496
8628
  r.forceDraw();
@@ -6521,10 +8653,25 @@ var PickerController = class {
6521
8653
  }
6522
8654
  };
6523
8655
 
8656
+ // src/view/sightline.ts
8657
+ var STAGE_TOP_M = 5;
8658
+ var STAGE_BASE_M = -1.1;
8659
+ var MAX_LOOKDOWN_DEG = 35;
8660
+ function stageSightlinePitch(eyeHeightM, distM) {
8661
+ const extraEyeM = eyeHeightM - SEATED_EYE_HEIGHT_M;
8662
+ let topPitch = Math.atan2(STAGE_TOP_M - extraEyeM, distM) * 180 / Math.PI;
8663
+ let basePitch = Math.atan2(STAGE_BASE_M - extraEyeM, distM) * 180 / Math.PI;
8664
+ if (basePitch < -MAX_LOOKDOWN_DEG) {
8665
+ topPitch += -MAX_LOOKDOWN_DEG - basePitch;
8666
+ basePitch = -MAX_LOOKDOWN_DEG;
8667
+ }
8668
+ return { topPitch, basePitch };
8669
+ }
8670
+
6524
8671
  // src/view/generatePanorama.ts
6525
8672
  var W = 2048;
6526
8673
  var H = 1024;
6527
- var UNIT = 0.55 / 24;
8674
+ var UNIT = METRES_PER_CHART_UNIT;
6528
8675
  var yawToX = (yawDeg) => (yawDeg + 180) / 360 * W;
6529
8676
  var pitchToY = (pitchDeg) => (90 - pitchDeg) / 180 * H;
6530
8677
  function generateSeatPanorama(seat, focalPoint, neighborSeats) {
@@ -6544,9 +8691,11 @@ function generateSeatPanorama(seat, focalPoint, neighborSeats) {
6544
8691
  sky.addColorStop(1, "#07090f");
6545
8692
  ctx.fillStyle = sky;
6546
8693
  ctx.fillRect(0, 0, W, H);
8694
+ const eyeM = seat.eyeHeightM ?? SEATED_EYE_HEIGHT_M;
6547
8695
  const stageHalfYaw = Math.min(80, Math.atan2(4, distM) * 180 / Math.PI);
6548
- const stageTopPitch = Math.min(45, Math.atan2(5, distM) * 180 / Math.PI);
6549
- const stageBasePitch = Math.max(-30, -Math.atan2(1.1, distM) * 180 / Math.PI);
8696
+ const sightline = stageSightlinePitch(eyeM, distM);
8697
+ const stageTopPitch = Math.min(45, sightline.topPitch);
8698
+ const stageBasePitch = sightline.basePitch;
6550
8699
  const sx0 = yawToX(-stageHalfYaw);
6551
8700
  const sx1 = yawToX(stageHalfYaw);
6552
8701
  const sy0 = pitchToY(stageTopPitch);
@@ -6653,9 +8802,11 @@ function generateSeatThumb(seat, focalPoint, _neighborSeats) {
6653
8802
  sky.addColorStop(1, "#070910");
6654
8803
  ctx.fillStyle = sky;
6655
8804
  ctx.fillRect(0, 0, TW, TH);
8805
+ const eyeM = seat.eyeHeightM ?? SEATED_EYE_HEIGHT_M;
8806
+ const sightline = stageSightlinePitch(eyeM, distM);
6656
8807
  const stageHalfYaw = Math.min(HALF_HFOV - 2, Math.atan2(4, distM) * 180 / Math.PI);
6657
- const stageTopPitch = Math.min(HALF_VFOV - 2, Math.atan2(5, distM) * 180 / Math.PI);
6658
- const stageBasePitch = Math.max(-HALF_VFOV + 2, -Math.atan2(1.1, distM) * 180 / Math.PI);
8808
+ const stageTopPitch = Math.min(HALF_VFOV - 2, sightline.topPitch);
8809
+ const stageBasePitch = Math.max(-HALF_VFOV + 2, sightline.basePitch);
6659
8810
  const sx0 = yawToTX(-stageHalfYaw);
6660
8811
  const sx1 = yawToTX(stageHalfYaw);
6661
8812
  const sy0 = pitchToTY(stageTopPitch);
@@ -6753,7 +8904,9 @@ async function loadLocale(code) {
6753
8904
  PickerController,
6754
8905
  RENDERED_QUALITY_REPORT_VERSION,
6755
8906
  SUPPORTED_LOCALES,
8907
+ SURROUNDINGS_SHAPE_ROLES,
6756
8908
  SeatmapRenderer,
8909
+ TIER_HEIGHT_M,
6757
8910
  UNGROUPED_ID,
6758
8911
  accessibilityMeta,
6759
8912
  accessibilityRingColor,
@@ -6768,16 +8921,19 @@ async function loadLocale(code) {
6768
8921
  expandRow,
6769
8922
  expandRowSlots,
6770
8923
  expandTable,
8924
+ expandTableSlots,
6771
8925
  floorObjects,
6772
8926
  floorsOf,
6773
8927
  formatDate,
6774
8928
  formatMoney,
6775
8929
  gaAreasOf,
8930
+ gaInventorySegments,
6776
8931
  gaUnitLabel,
6777
8932
  gaUnitLabels,
6778
8933
  generateSeatPanorama,
6779
8934
  generateSeatThumb,
6780
8935
  getLocale,
8936
+ growJoinedGAInventory,
6781
8937
  hiddenObjectIds,
6782
8938
  inspectRenderedQualityEvidence,
6783
8939
  isGaUnitLabel,
@@ -6785,17 +8941,23 @@ async function loadLocale(code) {
6785
8941
  layerOf,
6786
8942
  loadLocale,
6787
8943
  objectCenter,
8944
+ owningSectionForObject,
6788
8945
  pointInPolygon,
6789
8946
  pointInPolygonWithHoles,
6790
8947
  polygonLabelPoint,
6791
8948
  resolveLocale,
8949
+ rowInventoryCount,
6792
8950
  rowSeatPositions,
6793
8951
  seatLabelPart,
8952
+ sectionGeometry,
6794
8953
  setLocale,
6795
8954
  setMoneyLocale,
6796
8955
  setStringOverrides,
6797
8956
  stackFloors,
6798
8957
  t,
6799
- tCount
8958
+ tCount,
8959
+ tableInventoryCount,
8960
+ tableSeatCountsBySide,
8961
+ validGAInventorySegments
6800
8962
  });
6801
8963
  //# sourceMappingURL=index.cjs.map