@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.js CHANGED
@@ -4,6 +4,7 @@ var ACCESSIBILITY_TYPES = [
4
4
  { key: "companion", label: "Companion seat", short: "Companion", icon: "\u{1F9D1}\u200D\u{1F91D}\u200D\u{1F9D1}" },
5
5
  { key: "semi-ambulatory", label: "Semi-ambulatory (limited mobility)", short: "Limited mobility", icon: "\u{1F9AF}" },
6
6
  { key: "hearing", label: "Assistive listening", short: "Hearing", icon: "\u{1F9BB}" },
7
+ { key: "cart", label: "CART live-caption view", short: "CART captions", icon: "CC" },
7
8
  { key: "sign-language", label: "Sign-language view", short: "Sign language", icon: "\u{1F91F}" },
8
9
  { key: "plus-size", label: "Plus-size seat", short: "Plus-size", icon: "\u{1F4BA}" },
9
10
  { key: "lift-armrest", label: "Lift-up armrest", short: "Lift armrest", icon: "\u2195\uFE0F" }
@@ -17,6 +18,7 @@ var ACCESSIBILITY_RING_COLOR = {
17
18
  companion: "#8b5cf6",
18
19
  "semi-ambulatory": "#0ea5e9",
19
20
  hearing: "#14b8a6",
21
+ cart: "#7c3aed",
20
22
  "sign-language": "#f59e0b",
21
23
  "plus-size": "#ec4899",
22
24
  "lift-armrest": "#22c55e"
@@ -27,6 +29,19 @@ function accessibilityRingColor(types) {
27
29
  }
28
30
  var LABEL_STYLE_MIN_SIZE = 8;
29
31
  var LABEL_STYLE_MAX_SIZE = 24;
32
+ var SURROUNDINGS_SHAPE_ROLES = [
33
+ "reference-focal",
34
+ "bar",
35
+ "entrance",
36
+ "exit",
37
+ "restroom",
38
+ "screen",
39
+ "sound",
40
+ "concession",
41
+ "coat",
42
+ "wall"
43
+ ];
44
+ var SURROUNDINGS_SHAPE_ROLE_SET = new Set(SURROUNDINGS_SHAPE_ROLES);
30
45
  function layerOf(obj) {
31
46
  switch (obj.type) {
32
47
  case "row":
@@ -35,10 +50,17 @@ function layerOf(obj) {
35
50
  case "booth":
36
51
  case "section":
37
52
  return "interactive";
38
- // stage / décor live on 'shape'; free text + decor images are background furniture.
53
+ // Raster décor follows its explicit authored z-layer. Absence retains the
54
+ // legacy Background default; bitmap content is never guessed semantically.
55
+ case "decorImage":
56
+ return obj.layer === "foreground" ? "foreground" : "background";
57
+ // Source-backed focal geometry and curated venue landmarks help an author
58
+ // orient around the sellable plan. A stage and an ordinary authored shape
59
+ // remain Background even when they carry an arbitrary/custom role.
39
60
  case "shape":
61
+ return obj.role && SURROUNDINGS_SHAPE_ROLE_SET.has(obj.role) ? "surroundings" : "background";
62
+ // Free text — including semantic icon text — is background furniture.
40
63
  case "text":
41
- case "decorImage":
42
64
  return "background";
43
65
  default:
44
66
  return "interactive";
@@ -158,10 +180,57 @@ function toRoman(value) {
158
180
  return out;
159
181
  }
160
182
 
183
+ // src/core/units.ts
184
+ var METRES_PER_CHART_UNIT = 0.55 / 24;
185
+ var CHART_UNITS_PER_METRE = 1 / METRES_PER_CHART_UNIT;
186
+ var LIFT_PER_STEP_WORLD = 58;
187
+ var TIER_HEIGHT_M = LIFT_PER_STEP_WORLD * METRES_PER_CHART_UNIT;
188
+ var SECTION_ELEVATION_TIER_MIN = 0;
189
+ var SECTION_ELEVATION_TIER_MAX = 3;
190
+ var SECTION_HEIGHT_MIN_M = 0;
191
+ var SECTION_HEIGHT_MAX_M = 120;
192
+ var SECTION_RAKE_MIN_DEG = 0;
193
+ var SECTION_RAKE_MAX_DEG = 45;
194
+ var LEGACY_SECTION_ELEVATION_TIER_MAX = 7;
195
+ var SEATED_EYE_HEIGHT_M = 1.2;
196
+ function finiteClamped(value, min, max, fallback) {
197
+ return Number.isFinite(value) ? Math.max(min, Math.min(max, value)) : fallback;
198
+ }
199
+ function sectionElevationTier(value) {
200
+ if (!Number.isFinite(value)) return SECTION_ELEVATION_TIER_MIN;
201
+ return Math.max(
202
+ SECTION_ELEVATION_TIER_MIN,
203
+ Math.min(SECTION_ELEVATION_TIER_MAX, Math.round(value))
204
+ );
205
+ }
206
+ function compatibleAutomaticTier(value) {
207
+ if (!Number.isFinite(value) || !Number.isInteger(value) || value < 0) return 0;
208
+ if (value <= LEGACY_SECTION_ELEVATION_TIER_MAX) return value;
209
+ return SECTION_ELEVATION_TIER_MAX;
210
+ }
211
+ function sectionGeometry(section, context = {}) {
212
+ const floorBaseHeightM = finiteClamped(
213
+ context.floorBaseHeightM,
214
+ SECTION_HEIGHT_MIN_M,
215
+ SECTION_HEIGHT_MAX_M,
216
+ 0
217
+ );
218
+ const automaticHeight = Math.min(
219
+ SECTION_HEIGHT_MAX_M,
220
+ floorBaseHeightM + compatibleAutomaticTier(section.elevation) * TIER_HEIGHT_M
221
+ );
222
+ const height = section.height === void 0 ? automaticHeight : finiteClamped(section.height, SECTION_HEIGHT_MIN_M, SECTION_HEIGHT_MAX_M, automaticHeight);
223
+ const rake = finiteClamped(section.rake, SECTION_RAKE_MIN_DEG, SECTION_RAKE_MAX_DEG, 0);
224
+ return { height, rake };
225
+ }
226
+
161
227
  // src/core/layout.ts
162
228
  function overrideAccessibility(o) {
163
229
  if (!o) return [];
164
- if (o.accessibility && o.accessibility.length) return o.accessibility;
230
+ if (o.accessibility && o.accessibility.length) {
231
+ return o.wheelchairSpaceType && !o.accessibility.includes("wheelchair") ? ["wheelchair", ...o.accessibility] : o.accessibility;
232
+ }
233
+ if (o.wheelchairSpaceType) return ["wheelchair"];
165
234
  return o.accessible ? ["wheelchair"] : [];
166
235
  }
167
236
  var DEG = Math.PI / 180;
@@ -203,6 +272,10 @@ function overrideMap(row) {
203
272
  if (row.overrides) for (const o of row.overrides) m.set(o.index, o);
204
273
  return m;
205
274
  }
275
+ function rowInventoryCount(row) {
276
+ const skipped = new Set((row.overrides ?? []).filter((override) => override.skip && Number.isInteger(override.index) && override.index >= 0 && override.index < row.seatCount).map((override) => override.index));
277
+ return Math.max(0, row.seatCount - skipped.size);
278
+ }
206
279
  function centerRank(n) {
207
280
  const rank = new Array(n);
208
281
  Array.from({ length: n }, (_, i) => i).sort((a, b) => Math.abs(2 * a - (n - 1)) - Math.abs(2 * b - (n - 1)) || a - b).forEach((idx, k) => rank[idx] = k);
@@ -216,9 +289,9 @@ function seatLabelPart(row, i) {
216
289
  const prefix = row.seatNumbering?.prefix ?? "";
217
290
  const endAt = row.seatNumbering?.endAt;
218
291
  const n = row.seatCount;
219
- if (scheme === "updown") {
292
+ if (scheme === "updown" || scheme === "updown-descending") {
220
293
  const half = Math.ceil(n / 2);
221
- const core2 = i < half ? rawStart + 2 * i : rawStart - 1 + 2 * (n - i);
294
+ const core2 = scheme === "updown" ? i < half ? rawStart + 2 * i : rawStart - 1 + 2 * (n - i) : i < half ? rawStart + 2 * (half - 1 - i) : rawStart + 1 + 2 * (i - half);
222
295
  return `${prefix}${core2}`;
223
296
  }
224
297
  const effStep = scheme === "odd" || scheme === "even" ? 2 : step;
@@ -271,6 +344,7 @@ function expandRowSlots(row) {
271
344
  skipped: !!o?.skip,
272
345
  accessible: accessibility.length > 0,
273
346
  accessibility,
347
+ wheelchairSpaceType: o?.wheelchairSpaceType,
274
348
  commercial: Object.values(commercial).some((value) => value !== void 0 && value !== false && value !== "") ? commercial : void 0,
275
349
  viewUrl: o?.viewFromSeatUrl ?? row.viewFromSeatUrl,
276
350
  labelStyle: o?.labelStyle
@@ -291,6 +365,7 @@ function expandRow(row) {
291
365
  categoryKey: slot.categoryKey,
292
366
  accessible: slot.accessible || void 0,
293
367
  accessibility: slot.accessibility.length ? slot.accessibility : void 0,
368
+ wheelchairSpaceType: slot.wheelchairSpaceType,
294
369
  commercial: slot.commercial,
295
370
  viewUrl: slot.viewUrl,
296
371
  labelStyle: slot.labelStyle
@@ -298,18 +373,43 @@ function expandRow(row) {
298
373
  }
299
374
  return seats;
300
375
  }
301
- function expandTable(t2) {
376
+ function tableSeatCountsBySide(t2) {
377
+ if (t2.seatCountsBySide) return { ...t2.seatCountsBySide };
378
+ const enabled = t2.sides && t2.sides.length ? t2.sides : ["top", "bottom"];
379
+ const order = ["top", "bottom", "left", "right"].filter((side) => enabled.includes(side));
380
+ const counts = { top: 0, bottom: 0, left: 0, right: 0 };
381
+ if (!order.length) return counts;
382
+ const n = Math.max(0, Math.round(t2.seatCount));
383
+ for (let index = 0; index < n; index++) counts[order[index % order.length]] += 1;
384
+ return counts;
385
+ }
386
+ function expandTableSlots(t2) {
302
387
  const seats = [];
303
388
  const n = Math.max(0, Math.round(t2.seatCount));
304
389
  if (n === 0) return seats;
305
- const mk = (i, x, y) => ({
306
- id: `${t2.id}:${i}`,
307
- label: `${t2.label}-${i + 1}`,
308
- x,
309
- y,
310
- rowId: t2.id,
311
- categoryKey: t2.categoryKey
312
- });
390
+ const overrides2 = new Map((t2.overrides ?? []).map((override) => [override.index, override]));
391
+ const mk = (index, x, y, side) => {
392
+ const override = overrides2.get(index);
393
+ const accessibility = overrideAccessibility(override);
394
+ const label = override?.label ?? `${t2.label}-${index + 1}`;
395
+ const displayPrefix = t2.displayLabel ?? t2.label;
396
+ return {
397
+ index,
398
+ label,
399
+ displayLabel: override?.displayLabel ?? `${displayPrefix}-${index + 1}`,
400
+ x: x + (override?.dx ?? 0),
401
+ y: y + (override?.dy ?? 0),
402
+ categoryKey: override?.categoryKey ?? t2.categoryKey,
403
+ skipped: !!override?.skip,
404
+ accessible: accessibility.length > 0,
405
+ accessibility,
406
+ wheelchairSpaceType: override?.wheelchairSpaceType,
407
+ commercial: override?.commercial,
408
+ viewUrl: override?.viewFromSeatUrl,
409
+ labelStyle: override?.labelStyle,
410
+ ...side ? { side } : {}
411
+ };
412
+ };
313
413
  if (t2.shape === "round") {
314
414
  const R = (t2.radius ?? 40) + TABLE_SEAT_OFFSET;
315
415
  const base = t2.rotation * DEG;
@@ -332,17 +432,11 @@ function expandTable(t2) {
332
432
  }
333
433
  const w = t2.width ?? 80;
334
434
  const h = t2.height ?? 50;
335
- const enabled = t2.sides && t2.sides.length ? t2.sides : ["top", "bottom"];
336
- const order = ["top", "bottom", "left", "right"].filter((s) => enabled.includes(s));
337
- if (!order.length) return seats;
338
- const counts = new Map(order.map((s) => [s, 0]));
339
- for (let i = 0; i < n; i++) {
340
- const s = order[i % order.length];
341
- counts.set(s, counts.get(s) + 1);
342
- }
435
+ const counts = tableSeatCountsBySide(t2);
436
+ const order = ["top", "bottom", "left", "right"];
343
437
  let idx = 0;
344
438
  for (const side of order) {
345
- const count = counts.get(side);
439
+ const count = counts[side];
346
440
  for (let j = 0; j < count; j++) {
347
441
  let localX;
348
442
  let localY;
@@ -359,17 +453,39 @@ function expandTable(t2) {
359
453
  localX = w / 2 + TABLE_SEAT_OFFSET;
360
454
  localY = -h / 2 + (j + 0.5) * h / count;
361
455
  }
362
- const p = place(localX, localY, t2.rotation, t2.center);
363
- seats.push(mk(idx++, p.x, p.y));
456
+ const point = place(localX, localY, t2.rotation, t2.center);
457
+ seats.push(mk(idx++, point.x, point.y, side));
364
458
  }
365
459
  }
366
460
  return seats;
367
461
  }
462
+ function tableInventoryCount(t2) {
463
+ if (t2.bookAsWhole || t2.variableOccupancy) return Math.max(0, Math.round(t2.seatCount));
464
+ return expandTableSlots(t2).filter((slot) => !slot.skipped).length;
465
+ }
466
+ function expandTable(t2) {
467
+ return expandTableSlots(t2).filter((slot) => !slot.skipped).map((slot) => ({
468
+ id: `${t2.id}:${slot.index}`,
469
+ label: slot.label,
470
+ ...slot.displayLabel !== slot.label ? { displayLabel: slot.displayLabel } : {},
471
+ x: slot.x,
472
+ y: slot.y,
473
+ rowId: t2.id,
474
+ categoryKey: slot.categoryKey,
475
+ ...slot.accessible ? { accessible: true } : {},
476
+ ...slot.accessibility.length ? { accessibility: slot.accessibility } : {},
477
+ ...slot.wheelchairSpaceType ? { wheelchairSpaceType: slot.wheelchairSpaceType } : {},
478
+ ...slot.commercial ? { commercial: slot.commercial } : {},
479
+ ...slot.viewUrl ? { viewUrl: slot.viewUrl } : {},
480
+ ...slot.labelStyle ? { labelStyle: slot.labelStyle } : {}
481
+ }));
482
+ }
368
483
  function expandBooth(b) {
369
484
  return [
370
485
  {
371
486
  id: `${b.id}:0`,
372
487
  label: b.label,
488
+ ...b.displayLabel && b.displayLabel !== b.label ? { displayLabel: b.displayLabel } : {},
373
489
  x: b.center.x,
374
490
  y: b.center.y,
375
491
  rowId: b.id,
@@ -407,7 +523,7 @@ function polygonLabelPoint(outer, holes) {
407
523
  if (!outer.length) return { x: 0, y: 0 };
408
524
  const xs = outer.map((point) => point.x);
409
525
  const ys = outer.map((point) => point.y);
410
- const bounds = { minX: Math.min(...xs), maxX: Math.max(...xs), minY: Math.min(...ys), maxY: Math.max(...ys) };
526
+ const bounds2 = { minX: Math.min(...xs), maxX: Math.max(...xs), minY: Math.min(...ys), maxY: Math.max(...ys) };
411
527
  const centroid2 = polygonCentroid(outer);
412
528
  if (pointInPolygonWithHoles(centroid2, outer, holes)) return centroid2;
413
529
  let best = outer[0];
@@ -416,8 +532,8 @@ function polygonLabelPoint(outer, holes) {
416
532
  for (let row = 1; row < 24; row += 1) {
417
533
  for (let column = 1; column < 24; column += 1) {
418
534
  const point = {
419
- x: bounds.minX + (bounds.maxX - bounds.minX) * column / 24,
420
- y: bounds.minY + (bounds.maxY - bounds.minY) * row / 24
535
+ x: bounds2.minX + (bounds2.maxX - bounds2.minX) * column / 24,
536
+ y: bounds2.minY + (bounds2.maxY - bounds2.minY) * row / 24
421
537
  };
422
538
  if (!pointInPolygonWithHoles(point, outer, holes)) continue;
423
539
  const score = Math.min(...rings.flatMap((ring) => ring.map((start, index) => {
@@ -479,9 +595,32 @@ function objectCenter(o) {
479
595
  return { x: o.x + o.width / 2, y: o.y + o.height / 2 };
480
596
  }
481
597
  }
598
+ function samePoints(left, right) {
599
+ return left.length === right.length && left.every((point, index) => point.x === right[index].x && point.y === right[index].y);
600
+ }
601
+ function sameGASurfaceAsSection(object, section) {
602
+ if (object.type !== "gaArea" || !samePoints(object.points, section.outline)) return false;
603
+ const objectHoles = object.holes ?? [];
604
+ const sectionHoles = section.holes ?? [];
605
+ return objectHoles.length === sectionHoles.length && objectHoles.every((hole, index) => samePoints(hole, sectionHoles[index]));
606
+ }
607
+ function owningSectionForObject(objects, object) {
608
+ const sections = objects.filter((candidate) => candidate.type === "section");
609
+ const referencedLogicalId = "referenceInventorySource" in object ? object.referenceInventorySource?.logicalSectionId : void 0;
610
+ const center = objectCenter(object);
611
+ const referencedOwner = referencedLogicalId ? sections.find((section) => (section.logicalSectionId ?? section.id) === referencedLogicalId && (sameGASurfaceAsSection(object, section) || pointInPolygonWithHoles(center, section.outline, section.holes))) : void 0;
612
+ return referencedOwner ?? sections.find((section) => pointInPolygonWithHoles(center, section.outline, section.holes));
613
+ }
482
614
  function floorsOf(doc) {
483
615
  if (doc.floors && doc.floors.length) return doc.floors;
484
- return [{ id: "floor-0", name: "Main", objects: doc.objects, focalPoint: doc.focalPoint, backgroundImage: doc.backgroundImage }];
616
+ return [{
617
+ id: "floor-0",
618
+ name: "Main",
619
+ objects: doc.objects,
620
+ focalPoint: doc.focalPoint,
621
+ referenceImage: doc.referenceImage,
622
+ backgroundImage: doc.backgroundImage
623
+ }];
485
624
  }
486
625
  function floorObjects(doc, floorId) {
487
626
  const floors = floorsOf(doc);
@@ -530,15 +669,138 @@ function stackFloors(doc, spread = 900) {
530
669
  });
531
670
  return { ...doc, objects, floors: void 0 };
532
671
  }
533
- function expandChart(doc) {
672
+ function expandFloorObjects(objects, zones, fallbackFocal) {
534
673
  const out = [];
535
- for (const obj of allObjects(doc)) {
536
- if (obj.type === "row") out.push(...expandRow(obj));
537
- else if (obj.type === "table") out.push(...expandTable(obj));
538
- else if (obj.type === "booth") out.push(...expandBooth(obj));
674
+ const segmented = /* @__PURE__ */ new Map();
675
+ const grouped = /* @__PURE__ */ new Map();
676
+ for (const object of objects) {
677
+ if (object.type !== "row" || !object.segmentedRow) continue;
678
+ const list = grouped.get(object.segmentedRow.groupId) ?? [];
679
+ list.push(object);
680
+ grouped.set(object.segmentedRow.groupId, list);
681
+ }
682
+ for (const [groupId, members] of grouped) {
683
+ const ordered = members.slice().sort((left, right) => left.segmentedRow.componentIndex - right.segmentedRow.componentIndex);
684
+ const expectedCount = ordered[0]?.segmentedRow?.componentCount ?? 0;
685
+ const first = ordered[0]?.segmentedRow;
686
+ if (!first) continue;
687
+ const valid = expectedCount >= 2 && ordered.length === expectedCount && first?.boundaryBefore === "start" && ordered.every((row, index) => row.segmentedRow?.kind === "segmented-row-v1" && row.segmentedRow.groupId === groupId && row.segmentedRow.componentCount === expectedCount && row.segmentedRow.componentIndex === index && (index === 0 ? row.segmentedRow.boundaryBefore === "start" : row.segmentedRow.boundaryBefore !== "start") && row.segmentedRow.displayLabel === first.displayLabel);
688
+ if (!valid) continue;
689
+ const totalSeats = ordered.reduce((sum, row) => sum + row.seatCount, 0);
690
+ let adjacencyOffset = 0;
691
+ let displayOffset = 0;
692
+ for (const row of ordered) {
693
+ if (row.segmentedRow.boundaryBefore === "break") adjacencyOffset += 1;
694
+ segmented.set(row.id, {
695
+ groupId,
696
+ adjacencyOffset,
697
+ displayOffset,
698
+ displayLabel: first.displayLabel,
699
+ totalSeats,
700
+ canonical: ordered[0],
701
+ viewFromSeatUrl: first.viewFromSeatUrl
702
+ });
703
+ adjacencyOffset += row.seatCount;
704
+ displayOffset += row.seatCount;
705
+ }
706
+ }
707
+ for (const obj of objects) {
708
+ let seats = [];
709
+ if (obj.type === "row") seats = expandRow(obj);
710
+ else if (obj.type === "table") seats = expandTable(obj);
711
+ else if (obj.type === "booth") seats = expandBooth(obj);
712
+ if (!seats.length) continue;
713
+ if (obj.type === "row") {
714
+ const logical = segmented.get(obj.id);
715
+ if (logical) {
716
+ const overrides2 = new Map((obj.overrides ?? []).map((override) => [override.index, override]));
717
+ for (const seat of seats) {
718
+ const physicalIndex = Number(seat.id.slice(seat.id.lastIndexOf(":") + 1));
719
+ if (!Number.isInteger(physicalIndex)) continue;
720
+ const displayOrdinal = logical.displayOffset + physicalIndex;
721
+ seat.logicalRowId = logical.groupId;
722
+ seat.logicalSeatIndex = logical.adjacencyOffset + physicalIndex;
723
+ if (!overrides2.get(physicalIndex)?.displayLabel) {
724
+ const numberingRow = {
725
+ ...logical.canonical,
726
+ seatCount: logical.totalSeats,
727
+ label: logical.displayLabel,
728
+ displayLabel: logical.displayLabel
729
+ };
730
+ seat.displayLabel = `${logical.displayLabel}-${seatLabelPart(numberingRow, displayOrdinal)}`;
731
+ }
732
+ seat.viewUrl ??= logical.viewFromSeatUrl;
733
+ }
734
+ }
735
+ }
736
+ const owner = owningSectionForObject(objects, obj);
737
+ const inheritedView = owner?.viewFromSeatUrl;
738
+ const zone = owner?.zone ? zones?.find((candidate) => candidate.id === owner.zone) : void 0;
739
+ const resolvedFocal = zone?.focalPoint ?? fallbackFocal;
740
+ for (const seat of seats) {
741
+ if (inheritedView) seat.viewUrl ??= inheritedView;
742
+ if (owner) seat.sectionId = owner.logicalSectionId ?? owner.id;
743
+ if (owner?.zone) seat.zoneId = owner.zone;
744
+ if (resolvedFocal) seat.focalPoint = { ...resolvedFocal };
745
+ }
746
+ out.push(...seats);
539
747
  }
540
748
  return out;
541
749
  }
750
+ function expandChart(doc, options = {}) {
751
+ if (doc.floors?.length) {
752
+ const out2 = [];
753
+ for (const floor of doc.floors) {
754
+ const floorFocal = floor.focalPoint ?? doc.focalPoint;
755
+ const seats = expandFloorObjects(floor.objects, doc.zones, floorFocal);
756
+ assignEyeHeights(floor.objects, floor.focalPoint ?? doc.focalPoint, floor.baseHeightM ?? 0, seats);
757
+ out2.push(...seats);
758
+ }
759
+ return out2;
760
+ }
761
+ const out = expandFloorObjects(doc.objects, doc.zones, doc.focalPoint);
762
+ assignEyeHeights(doc.objects, doc.focalPoint, options.floorBaseHeightM ?? 0, out);
763
+ return out;
764
+ }
765
+ function assignEyeHeights(objects, focal, floorBaseHeightM, seats) {
766
+ const sections = objects.filter((o) => o.type === "section");
767
+ const hasGeometry = floorBaseHeightM > 0 || sections.some((s) => s.height !== void 0 || s.rake !== void 0 || (s.elevation ?? 0) > 0);
768
+ if (!sections.length || !hasGeometry || !focal) {
769
+ for (const seat of seats) seat.eyeHeightM = floorBaseHeightM + SEATED_EYE_HEIGHT_M;
770
+ return;
771
+ }
772
+ const owner = new Array(seats.length);
773
+ const geo = /* @__PURE__ */ new Map();
774
+ const frontDistU = /* @__PURE__ */ new Map();
775
+ for (let i = 0; i < seats.length; i++) {
776
+ const seat = seats[i];
777
+ const sec = sections.find((s) => pointInPolygonWithHoles({ x: seat.x, y: seat.y }, s.outline, s.holes)) ?? null;
778
+ owner[i] = sec;
779
+ if (!sec) continue;
780
+ if (!geo.has(sec.id)) geo.set(sec.id, sectionGeometry(sec, { floorBaseHeightM }));
781
+ const seatFocal = seat.focalPoint ?? focal;
782
+ const d = Math.hypot(seat.x - seatFocal.x, seat.y - seatFocal.y);
783
+ const cur = frontDistU.get(sec.id);
784
+ if (cur === void 0 || d < cur) frontDistU.set(sec.id, d);
785
+ }
786
+ for (let i = 0; i < seats.length; i++) {
787
+ const seat = seats[i];
788
+ const sec = owner[i];
789
+ if (!sec) {
790
+ seat.eyeHeightM = floorBaseHeightM + SEATED_EYE_HEIGHT_M;
791
+ continue;
792
+ }
793
+ const g = geo.get(sec.id);
794
+ let riseM = 0;
795
+ if (g.rake > 0) {
796
+ const seatFocal = seat.focalPoint ?? focal;
797
+ const d = Math.hypot(seat.x - seatFocal.x, seat.y - seatFocal.y);
798
+ const depthU = Math.max(0, d - (frontDistU.get(sec.id) ?? d));
799
+ riseM = depthU * METRES_PER_CHART_UNIT * Math.tan(g.rake * Math.PI / 180);
800
+ }
801
+ seat.eyeHeightM = g.height + riseM + SEATED_EYE_HEIGHT_M;
802
+ }
803
+ }
542
804
  var PAD = 40;
543
805
  function chartBounds(doc) {
544
806
  let minX = Infinity;
@@ -582,8 +844,9 @@ function chartBounds(doc) {
582
844
  acc(obj.position.x + w, obj.position.y + obj.fontSize);
583
845
  }
584
846
  }
585
- if (doc.backgroundImage) {
586
- const { center, width } = doc.backgroundImage;
847
+ for (const image of [doc.referenceImage, doc.backgroundImage]) {
848
+ if (!image) continue;
849
+ const { center, width } = image;
587
850
  const bh = width * 3 / 4;
588
851
  acc(center.x - width / 2, center.y - bh / 2);
589
852
  acc(center.x + width / 2, center.y + bh / 2);
@@ -607,9 +870,55 @@ var MAX_EVENT_INVENTORY = 1e5;
607
870
  function gaUnitLabel(areaId, index) {
608
871
  return `${PREFIX}${encodeURIComponent(areaId)}__${index + 1}`;
609
872
  }
610
- function gaUnitLabels(area) {
873
+ function validGAInventorySegments(area) {
874
+ const segments = area.inventorySegments;
875
+ if (segments === void 0) return true;
876
+ if (!Array.isArray(segments) || segments.length < 1 || segments.length > 256) return false;
877
+ let total = 0;
878
+ const ranges = /* @__PURE__ */ new Map();
879
+ for (const segment of segments) {
880
+ if (!segment || typeof segment.sourceAreaId !== "string" || !segment.sourceAreaId.trim() || segment.sourceAreaId.length > 160 || !Number.isInteger(segment.startIndex) || segment.startIndex < 0 || !Number.isInteger(segment.count) || segment.count < 1 || segment.startIndex + segment.count > MAX_GA_CAPACITY) return false;
881
+ total += segment.count;
882
+ if (total > MAX_GA_CAPACITY) return false;
883
+ const sourceRanges = ranges.get(segment.sourceAreaId) ?? [];
884
+ const end = segment.startIndex + segment.count;
885
+ if (sourceRanges.some((range) => segment.startIndex < range.end && end > range.start)) return false;
886
+ sourceRanges.push({ start: segment.startIndex, end });
887
+ ranges.set(segment.sourceAreaId, sourceRanges);
888
+ }
889
+ return total === area.capacity;
890
+ }
891
+ function gaInventorySegments(area) {
611
892
  const capacity = Math.min(MAX_GA_CAPACITY, Math.max(0, Math.floor(area.capacity)));
612
- return Array.from({ length: capacity }, (_, i) => gaUnitLabel(area.id, i));
893
+ if (area.inventorySegments === void 0) {
894
+ return capacity ? [{ sourceAreaId: area.id, startIndex: 0, count: capacity }] : [];
895
+ }
896
+ if (!validGAInventorySegments(area)) return [];
897
+ return area.inventorySegments.map((segment) => ({ ...segment }));
898
+ }
899
+ function gaUnitLabels(area) {
900
+ return gaInventorySegments(area).flatMap((segment) => Array.from(
901
+ { length: segment.count },
902
+ (_, offset) => gaUnitLabel(segment.sourceAreaId, segment.startIndex + offset)
903
+ ));
904
+ }
905
+ function growJoinedGAInventory(area, nextCapacity) {
906
+ if (area.inventorySegments === void 0 || nextCapacity === area.capacity) return area.inventorySegments;
907
+ if (nextCapacity < area.capacity) return void 0;
908
+ const segments = gaInventorySegments(area);
909
+ if (!segments.length) return void 0;
910
+ const extra = nextCapacity - area.capacity;
911
+ if (!extra) return segments;
912
+ const ownRanges = segments.filter((segment) => segment.sourceAreaId === area.id);
913
+ const startIndex = ownRanges.reduce((max, segment) => Math.max(max, segment.startIndex + segment.count), 0);
914
+ const last = segments[segments.length - 1];
915
+ if (last?.sourceAreaId === area.id && last.startIndex + last.count === startIndex) {
916
+ last.count += extra;
917
+ } else {
918
+ if (segments.length >= 256) return void 0;
919
+ segments.push({ sourceAreaId: area.id, startIndex, count: extra });
920
+ }
921
+ return segments;
613
922
  }
614
923
  function gaAreasOf(doc) {
615
924
  return allObjects(doc).filter((o) => o.type === "gaArea");
@@ -630,15 +939,6 @@ function objectSeatLabels(o) {
630
939
  function isSeatObject(o) {
631
940
  return o.type === "row" || o.type === "table" || o.type === "booth" || o.type === "gaArea";
632
941
  }
633
- function samePoints(left, right) {
634
- return left.length === right.length && left.every((point, index) => point.x === right[index].x && point.y === right[index].y);
635
- }
636
- function sameGASurfaceAsSection(object, section) {
637
- if (object.type !== "gaArea" || !samePoints(object.points, section.outline)) return false;
638
- const objectHoles = object.holes ?? [];
639
- const sectionHoles = section.holes ?? [];
640
- return objectHoles.length === sectionHoles.length && objectHoles.every((hole, index) => samePoints(hole, sectionHoles[index]));
641
- }
642
942
  function computeSections(doc) {
643
943
  const objs = allObjects(doc);
644
944
  const sectionObjs = objs.filter((o) => o.type === "section");
@@ -664,10 +964,7 @@ function computeSections(doc) {
664
964
  if (!isSeatObject(obj)) continue;
665
965
  const labels = objectSeatLabels(obj);
666
966
  if (labels.length === 0) continue;
667
- const referencedLogicalId = obj.referenceInventorySource?.logicalSectionId;
668
- const c = objectCenter(obj);
669
- const referencedOwner = referencedLogicalId ? sectionObjs.find((section) => (section.logicalSectionId ?? section.id) === referencedLogicalId && (sameGASurfaceAsSection(obj, section) || pointInPolygonWithHoles(c, section.outline, section.holes))) : void 0;
670
- const owner = referencedOwner ?? sectionObjs.find((s) => pointInPolygonWithHoles(c, s.outline, s.holes));
967
+ const owner = owningSectionForObject(objs, obj);
671
968
  const node = owner ? nodes.get(owner.logicalSectionId ?? owner.id) : ungrouped;
672
969
  node.seatCount += labels.length;
673
970
  node.objectIds.push(obj.id);
@@ -1238,11 +1535,473 @@ import { Circle } from "konva/lib/shapes/Circle";
1238
1535
  import { Rect } from "konva/lib/shapes/Rect";
1239
1536
  import { Ellipse } from "konva/lib/shapes/Ellipse";
1240
1537
  import { Line } from "konva/lib/shapes/Line";
1538
+ import { Arrow } from "konva/lib/shapes/Arrow";
1241
1539
  import { Text } from "konva/lib/shapes/Text";
1242
1540
  import { Path } from "konva/lib/shapes/Path";
1243
1541
  import { Image as KImage } from "konva/lib/shapes/Image";
1244
1542
  import { Shape } from "konva/lib/Shape";
1245
1543
 
1544
+ // src/core/chartBackgrounds.ts
1545
+ function buyerBackgroundImage(owner) {
1546
+ const background = owner.backgroundImage;
1547
+ return background?.url && !background.assetId ? background : void 0;
1548
+ }
1549
+
1550
+ // src/core/icons.ts
1551
+ var VENUE_ICON_VIEWBOX = 24;
1552
+ var VENUE_ICON_STROKE = 2;
1553
+ 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";
1554
+ var VENUE_ICONS = [
1555
+ // ---- Facilities --------------------------------------------------------
1556
+ {
1557
+ key: "restroom-men",
1558
+ label: "Men's restroom",
1559
+ group: "facilities",
1560
+ 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"
1561
+ },
1562
+ {
1563
+ key: "restroom-women",
1564
+ label: "Women's restroom",
1565
+ group: "facilities",
1566
+ 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"
1567
+ },
1568
+ {
1569
+ key: "restroom-accessible",
1570
+ label: "Accessible restroom",
1571
+ group: "facilities",
1572
+ path: WHEELCHAIR
1573
+ },
1574
+ {
1575
+ key: "first-aid",
1576
+ label: "First aid",
1577
+ group: "facilities",
1578
+ 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"
1579
+ },
1580
+ {
1581
+ key: "coat-check",
1582
+ label: "Coat check",
1583
+ group: "facilities",
1584
+ path: "M12 6a2 2 0 0 1 0-4 2 2 0 0 1 1.6 3.2L21 13H3l8.4-7.8 M3 13h18"
1585
+ },
1586
+ {
1587
+ key: "atm",
1588
+ label: "ATM / cash",
1589
+ group: "facilities",
1590
+ path: "M2 6h20v12H2V6Z M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0 M6 9h.01 M18 15h.01"
1591
+ },
1592
+ {
1593
+ key: "info",
1594
+ label: "Info point",
1595
+ group: "facilities",
1596
+ path: "M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18 M12 11v5 M12 8h.01"
1597
+ },
1598
+ {
1599
+ key: "lost-found",
1600
+ label: "Lost & found",
1601
+ group: "facilities",
1602
+ 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"
1603
+ },
1604
+ {
1605
+ key: "charging",
1606
+ label: "Charging point",
1607
+ group: "facilities",
1608
+ path: "M3 8h13v8H3V8Z M16 10h3v4h-3 M9.6 9l-2 3.5h2.5l-1.5 3"
1609
+ },
1610
+ {
1611
+ key: "smoking",
1612
+ label: "Smoking area",
1613
+ group: "facilities",
1614
+ path: "M2 15h13v3H2v-3Z M18 16h2 M19 8c0 1 1 1.5 1 2.5S19 12 19 13"
1615
+ },
1616
+ {
1617
+ key: "no-smoking",
1618
+ label: "No smoking",
1619
+ group: "facilities",
1620
+ 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"
1621
+ },
1622
+ // ---- Food & drink ------------------------------------------------------
1623
+ {
1624
+ key: "food",
1625
+ label: "Food",
1626
+ group: "food-drink",
1627
+ 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"
1628
+ },
1629
+ {
1630
+ key: "bar",
1631
+ label: "Bar",
1632
+ group: "food-drink",
1633
+ path: "M4 5h16l-8 8-8-8Z M12 13v6 M8 20h8"
1634
+ },
1635
+ {
1636
+ key: "coffee",
1637
+ label: "Caf\xE9 / coffee",
1638
+ group: "food-drink",
1639
+ 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"
1640
+ },
1641
+ {
1642
+ key: "water",
1643
+ label: "Water / drinks",
1644
+ group: "food-drink",
1645
+ path: "M12 22a7 7 0 0 1-7-7c0-5 7-12 7-12s7 7 7 12a7 7 0 0 1-7 7Z"
1646
+ },
1647
+ {
1648
+ key: "merch",
1649
+ label: "Merch / shop",
1650
+ group: "food-drink",
1651
+ path: "M6 8h12l-1 12H7L6 8Z M9 8V6a3 3 0 0 1 6 0v2"
1652
+ },
1653
+ // ---- Getting around ----------------------------------------------------
1654
+ {
1655
+ key: "entrance",
1656
+ label: "Entrance",
1657
+ group: "navigation",
1658
+ path: "M14 3h5a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1h-5 M3 12h11 M10 8l4 4-4 4"
1659
+ },
1660
+ {
1661
+ key: "exit",
1662
+ label: "Exit",
1663
+ group: "navigation",
1664
+ path: "M10 3H5a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h5 M14 12h7 M17 8l4 4-4 4"
1665
+ },
1666
+ {
1667
+ key: "emergency-exit",
1668
+ label: "Emergency exit",
1669
+ group: "navigation",
1670
+ 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"
1671
+ },
1672
+ {
1673
+ key: "stairs",
1674
+ label: "Stairs",
1675
+ group: "navigation",
1676
+ path: "M3 20v-4h4v-4h4v-4h4V4h5"
1677
+ },
1678
+ {
1679
+ key: "elevator",
1680
+ label: "Elevator",
1681
+ group: "navigation",
1682
+ 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"
1683
+ },
1684
+ {
1685
+ key: "parking",
1686
+ label: "Parking",
1687
+ group: "navigation",
1688
+ 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"
1689
+ },
1690
+ // ---- Accessibility -----------------------------------------------------
1691
+ {
1692
+ key: "wheelchair",
1693
+ label: "Wheelchair access",
1694
+ group: "accessibility",
1695
+ path: WHEELCHAIR
1696
+ },
1697
+ {
1698
+ key: "hearing",
1699
+ label: "Hearing assistance",
1700
+ group: "accessibility",
1701
+ 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"
1702
+ }
1703
+ ];
1704
+ var BY_KEY = new Map(VENUE_ICONS.map((icon) => [icon.key, icon]));
1705
+ function venueIcon(key) {
1706
+ return key ? BY_KEY.get(key) : void 0;
1707
+ }
1708
+ function venueIconPath(key) {
1709
+ return venueIcon(key)?.path;
1710
+ }
1711
+ var VENUE_ICON_KEYS = VENUE_ICONS.map((icon) => icon.key);
1712
+
1713
+ // src/core/spatialIndex.ts
1714
+ var DEFAULT_SEAT_RADIUS = 9;
1715
+ function halfExtents(shape) {
1716
+ if (shape.kind === "circle") return { hx: shape.r, hy: shape.r };
1717
+ const hw = shape.width / 2;
1718
+ const hh = shape.height / 2;
1719
+ const rot = shape.rotation ?? 0;
1720
+ if (!rot) return { hx: hw, hy: hh };
1721
+ const rad = rot * Math.PI / 180;
1722
+ const c = Math.abs(Math.cos(rad));
1723
+ const s = Math.abs(Math.sin(rad));
1724
+ return { hx: hw * c + hh * s, hy: hw * s + hh * c };
1725
+ }
1726
+ function shapeIntersectsRect(e, x0, y0, x1, y1) {
1727
+ if (e.shape.kind === "circle") {
1728
+ const cx = Math.min(Math.max(e.seat.x, x0), x1);
1729
+ const cy = Math.min(Math.max(e.seat.y, y0), y1);
1730
+ const dx = e.seat.x - cx;
1731
+ const dy = e.seat.y - cy;
1732
+ return dx * dx + dy * dy <= e.shape.r * e.shape.r;
1733
+ }
1734
+ const rot = e.shape.rotation ?? 0;
1735
+ if (!rot) {
1736
+ 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;
1737
+ }
1738
+ const rad = rot * Math.PI / 180;
1739
+ const c = Math.cos(rad);
1740
+ const s = Math.sin(rad);
1741
+ const hw = e.shape.width / 2;
1742
+ const hh = e.shape.height / 2;
1743
+ const corners = [
1744
+ [-hw, -hh],
1745
+ [hw, -hh],
1746
+ [hw, hh],
1747
+ [-hw, hh]
1748
+ ].map(([lx, ly]) => [e.seat.x + lx * c - ly * s, e.seat.y + lx * s + ly * c]);
1749
+ const query = [
1750
+ [x0, y0],
1751
+ [x1, y0],
1752
+ [x1, y1],
1753
+ [x0, y1]
1754
+ ];
1755
+ const axes = [
1756
+ [1, 0],
1757
+ [0, 1],
1758
+ [c, s],
1759
+ [-s, c]
1760
+ ];
1761
+ for (const [ax, ay] of axes) {
1762
+ let minA = Infinity;
1763
+ let maxA = -Infinity;
1764
+ for (const [px, py] of corners) {
1765
+ const d = px * ax + py * ay;
1766
+ if (d < minA) minA = d;
1767
+ if (d > maxA) maxA = d;
1768
+ }
1769
+ let minB = Infinity;
1770
+ let maxB = -Infinity;
1771
+ for (const [px, py] of query) {
1772
+ const d = px * ax + py * ay;
1773
+ if (d < minB) minB = d;
1774
+ if (d > maxB) maxB = d;
1775
+ }
1776
+ if (maxA < minB || maxB < minA) return false;
1777
+ }
1778
+ return true;
1779
+ }
1780
+ function buildSeatIndex(seats, opts = {}) {
1781
+ const seatRadius = opts.seatRadius ?? DEFAULT_SEAT_RADIUS;
1782
+ const n = seats.length;
1783
+ const entries = new Array(n);
1784
+ let minX = Infinity;
1785
+ let minY = Infinity;
1786
+ let maxX = -Infinity;
1787
+ let maxY = -Infinity;
1788
+ let maxHalf = 0;
1789
+ for (let i = 0; i < n; i++) {
1790
+ const seat = seats[i];
1791
+ const shape = opts.shapeOf?.(seat) ?? { kind: "circle", r: seatRadius };
1792
+ const { hx, hy } = halfExtents(shape);
1793
+ entries[i] = { seat, shape, hx, hy };
1794
+ if (seat.x - hx < minX) minX = seat.x - hx;
1795
+ if (seat.y - hy < minY) minY = seat.y - hy;
1796
+ if (seat.x + hx > maxX) maxX = seat.x + hx;
1797
+ if (seat.y + hy > maxY) maxY = seat.y + hy;
1798
+ if (hx > maxHalf) maxHalf = hx;
1799
+ if (hy > maxHalf) maxHalf = hy;
1800
+ }
1801
+ if (n === 0) {
1802
+ minX = 0;
1803
+ minY = 0;
1804
+ maxX = 0;
1805
+ maxY = 0;
1806
+ }
1807
+ const nominal = maxHalf > 0 ? maxHalf * 4 : 1;
1808
+ const spanX = Math.max(maxX - minX, 0);
1809
+ const spanY = Math.max(maxY - minY, 0);
1810
+ const maxCells = Math.max(64, opts.maxCells ?? n * 4);
1811
+ let cell = nominal;
1812
+ let cols = Math.max(1, Math.ceil(spanX / cell) || 1);
1813
+ let rows = Math.max(1, Math.ceil(spanY / cell) || 1);
1814
+ while (cols * rows > maxCells && (cols > 1 || rows > 1)) {
1815
+ cell *= 2;
1816
+ cols = Math.max(1, Math.ceil(spanX / cell) || 1);
1817
+ rows = Math.max(1, Math.ceil(spanY / cell) || 1);
1818
+ }
1819
+ const cellCount = cols * rows;
1820
+ const cellStart = new Int32Array(cellCount + 1);
1821
+ let total = 0;
1822
+ for (let i = 0; i < n; i++) {
1823
+ const e = entries[i];
1824
+ const cx0 = clampInt(Math.floor((e.seat.x - e.hx - minX) / cell), 0, cols - 1);
1825
+ const cx1 = clampInt(Math.floor((e.seat.x + e.hx - minX) / cell), 0, cols - 1);
1826
+ const cy0 = clampInt(Math.floor((e.seat.y - e.hy - minY) / cell), 0, rows - 1);
1827
+ const cy1 = clampInt(Math.floor((e.seat.y + e.hy - minY) / cell), 0, rows - 1);
1828
+ for (let cy = cy0; cy <= cy1; cy++) {
1829
+ for (let cx = cx0; cx <= cx1; cx++) {
1830
+ cellStart[cy * cols + cx + 1]++;
1831
+ total++;
1832
+ }
1833
+ }
1834
+ }
1835
+ for (let c = 0; c < cellCount; c++) cellStart[c + 1] += cellStart[c];
1836
+ const items = new Int32Array(total);
1837
+ const cursor = cellStart.slice(0, cellCount);
1838
+ for (let i = 0; i < n; i++) {
1839
+ const e = entries[i];
1840
+ const cx0 = clampInt(Math.floor((e.seat.x - e.hx - minX) / cell), 0, cols - 1);
1841
+ const cx1 = clampInt(Math.floor((e.seat.x + e.hx - minX) / cell), 0, cols - 1);
1842
+ const cy0 = clampInt(Math.floor((e.seat.y - e.hy - minY) / cell), 0, rows - 1);
1843
+ const cy1 = clampInt(Math.floor((e.seat.y + e.hy - minY) / cell), 0, rows - 1);
1844
+ for (let cy = cy0; cy <= cy1; cy++) {
1845
+ for (let cx = cx0; cx <= cx1; cx++) {
1846
+ const c = cy * cols + cx;
1847
+ items[cursor[c]++] = i;
1848
+ }
1849
+ }
1850
+ }
1851
+ return {
1852
+ seats,
1853
+ entries,
1854
+ minX,
1855
+ minY,
1856
+ cell,
1857
+ cols,
1858
+ rows,
1859
+ cellStart,
1860
+ items,
1861
+ stamp: new Int32Array(n),
1862
+ epoch: 0
1863
+ };
1864
+ }
1865
+ function clampInt(v, lo, hi) {
1866
+ return v < lo ? lo : v > hi ? hi : v;
1867
+ }
1868
+ function queryRect(index, rect, opts = {}) {
1869
+ const x0 = Math.min(rect.x, rect.x + rect.width);
1870
+ const x1 = Math.max(rect.x, rect.x + rect.width);
1871
+ const y0 = Math.min(rect.y, rect.y + rect.height);
1872
+ const y1 = Math.max(rect.y, rect.y + rect.height);
1873
+ const mode = opts.mode ?? "center";
1874
+ const { cols, rows, cell, minX, minY, cellStart, items, entries } = index;
1875
+ const cx0 = Math.max(0, Math.floor((x0 - minX) / cell));
1876
+ const cx1 = Math.min(cols - 1, Math.floor((x1 - minX) / cell));
1877
+ const cy0 = Math.max(0, Math.floor((y0 - minY) / cell));
1878
+ const cy1 = Math.min(rows - 1, Math.floor((y1 - minY) / cell));
1879
+ if (cx0 > cx1 || cy0 > cy1) return [];
1880
+ const found = [];
1881
+ const stamp = index.stamp;
1882
+ const ep = ++index.epoch;
1883
+ for (let cy = cy0; cy <= cy1; cy++) {
1884
+ const rowBase = cy * cols;
1885
+ for (let cx = cx0; cx <= cx1; cx++) {
1886
+ const c = rowBase + cx;
1887
+ const end = cellStart[c + 1];
1888
+ for (let k = cellStart[c]; k < end; k++) {
1889
+ const i = items[k];
1890
+ if (stamp[i] === ep) continue;
1891
+ stamp[i] = ep;
1892
+ const e = entries[i];
1893
+ if (opts.filter && !opts.filter(e.seat.id, e.seat)) continue;
1894
+ 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);
1895
+ if (ok) found.push(i);
1896
+ }
1897
+ }
1898
+ }
1899
+ found.sort((a, b) => a - b);
1900
+ return found.map((i) => entries[i].seat.id);
1901
+ }
1902
+
1903
+ // src/core/perspectiveProjection.ts
1904
+ function createPerspectiveCamera(bounds2, maxSurfaceHeightWorld = 0) {
1905
+ const span = Math.max(1, bounds2.width, bounds2.height);
1906
+ const target = {
1907
+ x: bounds2.x + bounds2.width / 2,
1908
+ y: bounds2.y + bounds2.height / 2
1909
+ };
1910
+ const distance = span * 1.55;
1911
+ const height = Math.max(span * 0.82, Math.max(0, maxSurfaceHeightWorld) + span * 0.45);
1912
+ return {
1913
+ target,
1914
+ distance,
1915
+ height,
1916
+ focalLength: Math.hypot(distance, height),
1917
+ yawRad: -8 * Math.PI / 180
1918
+ };
1919
+ }
1920
+ function projectPerspectivePoint(camera, point, heightWorld = 0) {
1921
+ const dx = point.x - camera.target.x;
1922
+ const dy = point.y - camera.target.y;
1923
+ const cos = Math.cos(camera.yawRad);
1924
+ const sin = Math.sin(camera.yawRad);
1925
+ const x = dx * cos - dy * sin;
1926
+ const y = dx * sin + dy * cos;
1927
+ const translatedY = y - camera.distance;
1928
+ const translatedZ = heightWorld - camera.height;
1929
+ const cameraLength = Math.hypot(camera.distance, camera.height);
1930
+ const depth = (-camera.distance * translatedY - camera.height * translatedZ) / cameraLength;
1931
+ const up = (-camera.height * translatedY + camera.distance * translatedZ) / cameraLength;
1932
+ const safeDepth = Math.max(cameraLength * 0.08, depth);
1933
+ const scale = camera.focalLength / safeDepth;
1934
+ return {
1935
+ x: camera.target.x + x * scale,
1936
+ y: camera.target.y - up * scale,
1937
+ depth: safeDepth,
1938
+ scale
1939
+ };
1940
+ }
1941
+ function applyAffine(affine, point) {
1942
+ return {
1943
+ x: affine.a * point.x + affine.c * point.y + affine.e,
1944
+ y: affine.b * point.x + affine.d * point.y + affine.f
1945
+ };
1946
+ }
1947
+ function invertAffine(affine) {
1948
+ const determinant = affine.a * affine.d - affine.b * affine.c;
1949
+ if (!Number.isFinite(determinant) || Math.abs(determinant) < 1e-9) {
1950
+ throw new Error("perspective projection produced a singular affine");
1951
+ }
1952
+ const a = affine.d / determinant;
1953
+ const b = -affine.b / determinant;
1954
+ const c = -affine.c / determinant;
1955
+ const d = affine.a / determinant;
1956
+ return {
1957
+ a,
1958
+ b,
1959
+ c,
1960
+ d,
1961
+ e: -(a * affine.e + c * affine.f),
1962
+ f: -(b * affine.e + d * affine.f)
1963
+ };
1964
+ }
1965
+ function composeAffine(left, right) {
1966
+ return {
1967
+ a: left.a * right.a + left.c * right.b,
1968
+ b: left.b * right.a + left.d * right.b,
1969
+ c: left.a * right.c + left.c * right.d,
1970
+ d: left.b * right.c + left.d * right.d,
1971
+ e: left.a * right.e + left.c * right.f + left.e,
1972
+ f: left.b * right.e + left.d * right.f + left.f
1973
+ };
1974
+ }
1975
+ function perspectiveTangentAffine(camera, anchor, heightAt = () => 0) {
1976
+ const origin = projectPerspectivePoint(camera, anchor, heightAt(anchor));
1977
+ const alongXPoint = { x: anchor.x + 1, y: anchor.y };
1978
+ const alongYPoint = { x: anchor.x, y: anchor.y + 1 };
1979
+ const alongX = projectPerspectivePoint(camera, alongXPoint, heightAt(alongXPoint));
1980
+ const alongY = projectPerspectivePoint(camera, alongYPoint, heightAt(alongYPoint));
1981
+ const a = alongX.x - origin.x;
1982
+ const b = alongX.y - origin.y;
1983
+ const c = alongY.x - origin.x;
1984
+ const d = alongY.y - origin.y;
1985
+ return {
1986
+ a,
1987
+ b,
1988
+ c,
1989
+ d,
1990
+ e: origin.x - a * anchor.x - c * anchor.y,
1991
+ f: origin.y - b * anchor.x - d * anchor.y
1992
+ };
1993
+ }
1994
+ function decomposeAffineLinear(affine) {
1995
+ const scaleX = Math.hypot(affine.a, affine.b);
1996
+ const determinant = affine.a * affine.d - affine.b * affine.c;
1997
+ return {
1998
+ rotationDeg: Math.atan2(affine.b, affine.a) * 180 / Math.PI,
1999
+ scaleX,
2000
+ scaleY: determinant / Math.max(scaleX, 1e-9),
2001
+ skewX: (affine.a * affine.c + affine.b * affine.d) / Math.max(determinant, 1e-9)
2002
+ };
2003
+ }
2004
+
1246
2005
  // src/lib/money.ts
1247
2006
  var DEFAULT_CURRENCY = "USD";
1248
2007
  var displayLocale;
@@ -1288,6 +2047,7 @@ var en = {
1288
2047
  // renderer (drawn on the Konva map — shared by the embed SDK)
1289
2048
  "map.aria": "Seating map. Use arrow keys to move between seats, Enter to select.",
1290
2049
  "map.seatsLeft": "{count} LEFT",
2050
+ "map.soldOut": "SOLD OUT",
1291
2051
  "map.fromPrice": "FROM {price}",
1292
2052
  "map.statusHeld": "On hold",
1293
2053
  "map.statusTaken": "Taken",
@@ -1295,7 +2055,7 @@ var en = {
1295
2055
  "picker.floor": "Floor",
1296
2056
  "picker.zoomLevel": "Zoom level",
1297
2057
  "picker.rungTip.zones": "Venue overview \u2014 groups of sections such as North Stand or VIP",
1298
- "picker.rungTip.sections": "Section blocks \u2014 category-mix fill + availability tint",
2058
+ "picker.rungTip.sections": "Section blocks \u2014 sold-out and nearly-gone sections shade to show availability at a glance",
1299
2059
  "picker.rungTip.seats": "Individual seats \u2014 blocks melt into dots",
1300
2060
  "picker.rungLabel.zones": "ZONES",
1301
2061
  "picker.rungLabel.sections": "SECTIONS",
@@ -1363,6 +2123,25 @@ function formatDate(value, opts) {
1363
2123
  return new Intl.DateTimeFormat(active, opts ?? { dateStyle: "medium", timeStyle: "short" }).format(value);
1364
2124
  }
1365
2125
 
2126
+ // src/core/shapeLineStyle.ts
2127
+ var DEFAULT_SHAPE_LINE_CAP = "round";
2128
+ var DEFAULT_SHAPE_LINE_JOIN = "round";
2129
+ var DEFAULT_SHAPE_LINE_ENDING = "none";
2130
+ function resolvedShapeLineStyle(shape) {
2131
+ return {
2132
+ lineCap: shape.lineCap ?? DEFAULT_SHAPE_LINE_CAP,
2133
+ lineJoin: shape.lineJoin ?? DEFAULT_SHAPE_LINE_JOIN,
2134
+ startEnding: shape.startEnding ?? DEFAULT_SHAPE_LINE_ENDING,
2135
+ endEnding: shape.endEnding ?? DEFAULT_SHAPE_LINE_ENDING
2136
+ };
2137
+ }
2138
+ function shapeArrowMetrics(strokeWidth) {
2139
+ return {
2140
+ pointerLength: Math.min(48, Math.max(8, strokeWidth * 3)),
2141
+ pointerWidth: Math.min(40, Math.max(8, strokeWidth * 2.25))
2142
+ };
2143
+ }
2144
+
1366
2145
  // src/engine/SeatmapRenderer.ts
1367
2146
  var SEAT_RADIUS = 9;
1368
2147
  var SEAT_LEGIBLE_SCALE = 0.9;
@@ -1370,17 +2149,18 @@ var CACHE_THRESHOLD = 0.55 * SEAT_LEGIBLE_SCALE;
1370
2149
  var LABEL_SCALE = MIN_VISIBLE_BOOKABLE_LABEL_PX / SEAT_LABEL_FONT_SIZE;
1371
2150
  var MIN_FITTED_SEAT_LABEL_FONT_SIZE = 4;
1372
2151
  var SEAT_TAP_SLOP_PX = 14;
1373
- var SEAT_GLYPH_MIN_PX = 6.5;
2152
+ var WHEELCHAIR_GLYPH_MIN_PX = 10;
2153
+ var FILTERED_WHEELCHAIR_GLYPH_MIN_PX = 14;
1374
2154
  var SECTION_PROMINENT_SCALE = 0.45 * SEAT_LEGIBLE_SCALE;
1375
2155
  var BLOCK_MELT_TOP = 0.9 * SEAT_LEGIBLE_SCALE;
1376
2156
  var SEAT_FOCUS_SCALE = Math.max(SEAT_LEGIBLE_SCALE * 1.1, BLOCK_MELT_TOP);
1377
2157
  var PAN_START_SLOP_PX = 8;
2158
+ var GHOST_CLICK_MS = 700;
1378
2159
  var ZONE_PROMINENT_SCALE = 0.55 * SECTION_PROMINENT_SCALE;
1379
2160
  var MAX_LABELS = 700;
1380
2161
  var MARQUEE_RING_CAP = 2500;
1381
2162
  var ISO_ANGLE_DEG = -11.5;
1382
2163
  var ISO_SQUASH = 0.58;
1383
- var LIFT_PER_STEP = 58;
1384
2164
  var ISO_TWEEN_MS = 320;
1385
2165
  var CAMERA_GLIDE_MS = 650;
1386
2166
  var BLOCK_FILL_ALPHA = 1;
@@ -1417,6 +2197,10 @@ var DEF_SELECTION = "#ffffff";
1417
2197
  var DEF_SELECTION_ON_LIGHT = "#0b1220";
1418
2198
  var DEF_DECOR_FILL = "#232c40";
1419
2199
  var DEF_TEXT = "#8b93a7";
2200
+ function konvaFontStyle(bold, italic, defaultWeight = "normal") {
2201
+ const weight = bold ? "bold" : defaultWeight;
2202
+ return italic ? `italic ${weight}`.trim() : weight;
2203
+ }
1420
2204
  var DEF_CANVAS_BACKGROUND = "#0e1117";
1421
2205
  function colorLuminance(color) {
1422
2206
  const s = color.trim();
@@ -1472,6 +2256,22 @@ function overviewPalette(canvasBackground) {
1472
2256
  focalStroke: DARK_OVERVIEW_FOCAL_STROKE
1473
2257
  };
1474
2258
  }
2259
+ var SECTION_NEARLY_GONE_RATIO = 0.1;
2260
+ function overviewStateFill(palette, state) {
2261
+ const base = palette.sectionFill;
2262
+ const soldOut = desaturate(darken(base, 0.2), 0.85);
2263
+ switch (state) {
2264
+ case "closed":
2265
+ return darken(base, 0.12);
2266
+ case "sold-out":
2267
+ return soldOut;
2268
+ case "nearly-gone":
2269
+ return lerpColor(base, soldOut, 0.4);
2270
+ case "normal":
2271
+ default:
2272
+ return base;
2273
+ }
2274
+ }
1475
2275
  var clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
1476
2276
  function seatIdOf(target) {
1477
2277
  const n = target;
@@ -1494,6 +2294,13 @@ function darken(hex, amt) {
1494
2294
  const mix = (c) => Math.round(c * (1 - amt));
1495
2295
  return `#${(1 << 24 | mix(n >> 16 & 255) << 16 | mix(n >> 8 & 255) << 8 | mix(n & 255)).toString(16).slice(1)}`;
1496
2296
  }
2297
+ function desaturate(hex, amt) {
2298
+ const rgb2 = hexToRgb(hex);
2299
+ if (!rgb2) return hex;
2300
+ const [r, g, b] = rgb2;
2301
+ const grey = 0.299 * r + 0.587 * g + 0.114 * b;
2302
+ return toHex(r + (grey - r) * amt, g + (grey - g) * amt, b + (grey - b) * amt);
2303
+ }
1497
2304
  function polyBounds(pts) {
1498
2305
  let minX = Infinity;
1499
2306
  let minY = Infinity;
@@ -1522,8 +2329,8 @@ function rotatedRectPoints(center, width, height, rotation) {
1522
2329
  }));
1523
2330
  }
1524
2331
  function pointsBounds(points) {
1525
- const bounds = polyBounds(points);
1526
- return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height };
2332
+ const bounds2 = polyBounds(points);
2333
+ return { x: bounds2.x, y: bounds2.y, width: bounds2.width, height: bounds2.height };
1527
2334
  }
1528
2335
  function rotatedRectFitsPolygon(center, width, height, rotation, outer, holes) {
1529
2336
  const radians = rotation * Math.PI / 180;
@@ -1543,32 +2350,50 @@ function rotatedRectFitsPolygon(center, width, height, rotation, outer, holes) {
1543
2350
  return true;
1544
2351
  }
1545
2352
  function polygonLabelCandidates(outer, holes, preferred) {
1546
- const bounds = polyBounds(outer);
1547
- const centre = { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 };
2353
+ const bounds2 = polyBounds(outer);
2354
+ const centre = { x: bounds2.x + bounds2.width / 2, y: bounds2.y + bounds2.height / 2 };
1548
2355
  const points = [preferred];
1549
2356
  for (let row = 1; row < 12; row += 1) {
1550
2357
  for (let column = 1; column < 12; column += 1) {
1551
2358
  const point = {
1552
- x: bounds.x + bounds.width * column / 12,
1553
- y: bounds.y + bounds.height * row / 12
2359
+ x: bounds2.x + bounds2.width * column / 12,
2360
+ y: bounds2.y + bounds2.height * row / 12
1554
2361
  };
1555
2362
  if (pointInPolygonWithHoles(point, outer, holes)) points.push(point);
1556
2363
  }
1557
2364
  }
1558
2365
  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));
1559
2366
  }
2367
+ function roundedPolygonSubpath(context, points, radius) {
2368
+ const n = points.length;
2369
+ const start = { x: (points[n - 1].x + points[0].x) / 2, y: (points[n - 1].y + points[0].y) / 2 };
2370
+ context.moveTo(start.x, start.y);
2371
+ for (let i = 0; i < n; i += 1) {
2372
+ const curr = points[i];
2373
+ const next = points[(i + 1) % n];
2374
+ const prev = points[(i - 1 + n) % n];
2375
+ const rMax = Math.min(
2376
+ Math.hypot(curr.x - prev.x, curr.y - prev.y) / 2,
2377
+ Math.hypot(next.x - curr.x, next.y - curr.y) / 2
2378
+ );
2379
+ context.arcTo(curr.x, curr.y, next.x, next.y, Math.max(0, Math.min(radius, rMax)));
2380
+ }
2381
+ context.closePath();
2382
+ }
1560
2383
  function polygonWithHolesShape(outer, holes, attrs, outerPath) {
1561
2384
  const signedArea = (points) => points.reduce((sum, point, index) => {
1562
2385
  const next = points[(index + 1) % points.length];
1563
2386
  return sum + point.x * next.y - next.x * point.y;
1564
2387
  }, 0);
1565
2388
  const outerClockwise = signedArea(outer) > 0;
2389
+ const cornerRadius = attrs.cornerRadius && attrs.cornerRadius > 0 ? attrs.cornerRadius : 0;
1566
2390
  return new Shape({
1567
2391
  ...attrs,
1568
2392
  sceneFunc(context, shape) {
1569
2393
  context.beginPath();
1570
2394
  const polygonPath = (points) => {
1571
2395
  if (!points.length) return;
2396
+ if (cornerRadius > 0 && points.length >= 3) return roundedPolygonSubpath(context, points, cornerRadius);
1572
2397
  context.moveTo(points[0].x, points[0].y);
1573
2398
  for (let index = 1; index < points.length; index += 1) context.lineTo(points[index].x, points[index].y);
1574
2399
  context.closePath();
@@ -1615,6 +2440,33 @@ function rgba(hex, a) {
1615
2440
  const n = parseInt(m[1], 16);
1616
2441
  return `rgba(${n >> 16 & 255},${n >> 8 & 255},${n & 255},${a})`;
1617
2442
  }
2443
+ var ViewportGroup = class extends Group {
2444
+ constructor() {
2445
+ super(...arguments);
2446
+ this.viewportCulled = false;
2447
+ }
2448
+ setViewportCulled(culled) {
2449
+ if (culled === this.viewportCulled) return;
2450
+ this.viewportCulled = culled;
2451
+ if (!culled) super._clearSelfAndDescendantCache();
2452
+ }
2453
+ isViewportCulled() {
2454
+ return this.viewportCulled;
2455
+ }
2456
+ drawScene(...args) {
2457
+ return this.viewportCulled ? this : super.drawScene(...args);
2458
+ }
2459
+ drawHit(...args) {
2460
+ return this.viewportCulled ? this : super.drawHit(...args);
2461
+ }
2462
+ _clearSelfAndDescendantCache(attr) {
2463
+ if (!this.viewportCulled) {
2464
+ super._clearSelfAndDescendantCache(attr);
2465
+ return;
2466
+ }
2467
+ this._clearCache(attr);
2468
+ }
2469
+ };
1618
2470
  function hexToRgb(hex) {
1619
2471
  const m = /^#?([\da-f]{6})$/i.exec(hex.trim());
1620
2472
  if (!m) return null;
@@ -1645,8 +2497,15 @@ function lerpColor(a, b, t2) {
1645
2497
  }
1646
2498
  var _SeatmapRenderer = class _SeatmapRenderer {
1647
2499
  constructor(container, options = {}) {
2500
+ /** Per-elevated-section label containers. Keeping the labels grouped lets the
2501
+ * Phase-B lift move thousands of seat labels with one transform per section
2502
+ * instead of rewriting every label on every tween frame. */
2503
+ this.labelLiftGroups = /* @__PURE__ */ new Map();
1648
2504
  this.seats = [];
1649
2505
  this.seatById = /* @__PURE__ */ new Map();
2506
+ /** One build per chart/floor; section membership queries only inspect seats in
2507
+ * the section bounds instead of rescanning the full 13k venue per section. */
2508
+ this.seatIndex = null;
1650
2509
  /** Multi-floor (Batch 5): the last-set chart + which floor we're rendering. */
1651
2510
  this.chartDoc = null;
1652
2511
  this.activeFloorId = "";
@@ -1662,15 +2521,15 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1662
2521
  this.seatLabelById = /* @__PURE__ */ new Map();
1663
2522
  /** Coloured accommodation ring per accessible seat (few per chart). */
1664
2523
  this.accessRingById = /* @__PURE__ */ new Map();
1665
- /** Centred accessibility glyph per accessible seat shown once the seat is
1666
- * big enough on-screen (see {@link SEAT_GLYPH_MIN_PX}); the ring is the
1667
- * smaller-zoom fallback. Kept in a map so zoom toggles touch only the handful
1668
- * of accessible seats, never all 13k nodes. */
2524
+ /** Centred glyph per physical wheelchair provision. It keeps a small
2525
+ * screen-space floor at every LOD and grows when the Wheelchair filter is
2526
+ * active. Kept in a map so zoom updates touch only the handful of matching
2527
+ * seats, never all 13k nodes. */
1669
2528
  this.accessGlyphById = /* @__PURE__ */ new Map();
1670
- /** Whether the accessibility glyph is legible at the current camera scale. */
1671
- this.accessGlyphVisible = false;
1672
2529
  /** Authored free-text nodes obey the same rendered-size visibility floor. */
1673
2530
  this.freeTextById = /* @__PURE__ */ new Map();
2531
+ /** Vector venue-icon Path nodes + authored size; obey the free-text size floor. */
2532
+ this.iconNodeById = /* @__PURE__ */ new Map();
1674
2533
  /** Stage/rink landmarks retain a readable screen-space caption at overview. */
1675
2534
  this.primaryFocalLabels = /* @__PURE__ */ new Map();
1676
2535
  /** GA paint and text share price/highlight filter state. */
@@ -1684,6 +2543,12 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1684
2543
  this.effSelection = DEF_SELECTION;
1685
2544
  /** Colorblind-safe mode (Okabe-Ito hues + hollow booked seats). */
1686
2545
  this.colorblind = false;
2546
+ /** Stable per-category-KEY colorblind hue (OV-46) — order-independent. */
2547
+ this.cbHueByKey = /* @__PURE__ */ new Map();
2548
+ /** OV-45(a): authored row-letter labels for the buyer/preview map, resolved in
2549
+ * world space (matching seat labels) and honouring each row's labelPresentation
2550
+ * (position/rotation/visibility/style). Painted at the seats LOD rung. */
2551
+ this.rowLabelPlan = [];
1687
2552
  /** Category order from the doc — the stable index into the CB palette. */
1688
2553
  this.catOrder = [];
1689
2554
  this.selection = /* @__PURE__ */ new Set();
@@ -1693,6 +2558,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1693
2558
  this.ownedHold = /* @__PURE__ */ new Set();
1694
2559
  /** One selected seat being inspected before it is committed to the cart. */
1695
2560
  this.selectionFocusId = null;
2561
+ /** Seat currently owning the shared hover ring (null while not hovering). */
2562
+ this.hoveredId = null;
1696
2563
  this.focusedId = null;
1697
2564
  /**
1698
2565
  * Accessibility filter: `null` = off; `[]` = dim all non-accessible free seats;
@@ -1711,6 +2578,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1711
2578
  this.sections = [];
1712
2579
  this.zones = [];
1713
2580
  this.seatSection = /* @__PURE__ */ new Map();
2581
+ /** Seats outside every authored section retain the legacy path in one group. */
2582
+ this.unsectionedSeatGroup = null;
1714
2583
  this.catPrice = /* @__PURE__ */ new Map();
1715
2584
  /** Section/zone ids to render dimmed (organizer manager: held-back inventory). */
1716
2585
  this.dimmedSections = /* @__PURE__ */ new Set();
@@ -1723,6 +2592,9 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1723
2592
  this.focusedSectionId = null;
1724
2593
  /** Light backdrop panel drawn behind the focused section (removed on clear). */
1725
2594
  this.focusBackdrop = null;
2595
+ /** Non-listening visual dim outside the focused section. One overlay replaces
2596
+ * descendant opacity mutations across every seat in the venue. */
2597
+ this.focusDimOverlay = null;
1726
2598
  /** Object id → floor id (multi-floor only) — resolves a deck tap in the 3D stack. */
1727
2599
  this.objectFloor = /* @__PURE__ */ new Map();
1728
2600
  /** Zone id → colour (drives extruded side faces in iso view). */
@@ -1735,10 +2607,33 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1735
2607
  this.isoT = 0;
1736
2608
  this.isoTarget = 0;
1737
2609
  this.isoRaf = 0;
2610
+ /** Public view target; perspective is a separate non-affine Phase-C lane. */
2611
+ this.viewMode = "flat";
2612
+ this.perspectiveCamera = null;
2613
+ /** Ground-plane tangent applied once to every renderer layer. */
2614
+ this.perspectiveBaseAffine = null;
2615
+ this.perspectiveBaseInverse = null;
2616
+ /** Exact pinhole anchor expressed in the overlay tangent-layer coordinates. */
2617
+ this.perspectiveSeatLocal = /* @__PURE__ */ new Map();
2618
+ /** Exact projected point (before Stage pan/zoom), keyed by seat. */
2619
+ this.perspectiveSeatProjected = /* @__PURE__ */ new Map();
2620
+ /** Positive camera depth, used for deterministic far→near paint order. */
2621
+ this.perspectiveSeatDepth = /* @__PURE__ */ new Map();
2622
+ /** Exact pinhole size ratio at each seat anchor. */
2623
+ this.perspectiveSeatScale = /* @__PURE__ */ new Map();
2624
+ /** Seats whose native Konva nodes currently carry the exact projected pose.
2625
+ * Large sectioned venues apply these lazily as a section enters the live-seat
2626
+ * viewport; the overview rung hides the seat layer completely. */
2627
+ this.perspectiveAppliedSeats = /* @__PURE__ */ new Set();
2628
+ this.perspectiveBounds = null;
1738
2629
  /** Chart centre the iso projection pivots about (bounds centre). */
1739
2630
  this.isoCentre = { x: 0, y: 0 };
1740
2631
  /** rAF for an in-flight camera glide (focusRegion / setRung); 0 = none. */
1741
2632
  this.glideRaf = 0;
2633
+ /** While the camera tween is active, defer polygon label fitting/collision to
2634
+ * the settled frame. Geometry remains smoothly stage-scaled, while expensive
2635
+ * point-in-polygon text work no longer repeats at 120 Hz. */
2636
+ this.glideInProgress = false;
1742
2637
  /** Set in destroy() so an in-flight iso tween bails. */
1743
2638
  this.destroyed = false;
1744
2639
  /** Cached scale the section/zone labels were last sized for (scale-compensation). */
@@ -1768,6 +2663,19 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1768
2663
  this.panStarted = false;
1769
2664
  /** Maximum displacement from gesture start — suppress taps only after a real pan/pinch. */
1770
2665
  this.moved = 0;
2666
+ /**
2667
+ * Ghost-click guard. Konva binds `_pointerup` to mouseup, touchend AND
2668
+ * pointerup, so `on('click tap')` is two handlers for one finger tap unless
2669
+ * the browser's synthesized compatibility click is suppressed. Konva only
2670
+ * suppresses it when the touch lands on a LISTENING SHAPE (Stage.js returns
2671
+ * early before its preventDefault() when `!shape || !shape.isListening()`),
2672
+ * so a near-miss rescued by nearestSeatToScreen used to fire `tap` (select)
2673
+ * then `click` (deselect) 1-3ms apart and net to nothing. With
2674
+ * SEAT_TAP_SLOP_PX=14 against a ~12px seat radius, that broken annulus is
2675
+ * ~3.7x the area of the seat itself — i.e. most successful mobile taps.
2676
+ * `touch-action: none` does NOT suppress compatibility mouse events.
2677
+ */
2678
+ this.lastTapAt = 0;
1771
2679
  /**
1772
2680
  * Manage-mode rubber-band marquee (option-gated). `start`/`cur` are WORLD-space
1773
2681
  * points (overlayLayer rides the stage transform); `rect` is the on-canvas
@@ -1864,6 +2772,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1864
2772
  x: mid.x - this.pinch.worldMid.x * scale,
1865
2773
  y: mid.y - this.pinch.worldMid.y * scale
1866
2774
  });
2775
+ this.updateSeatGroupVisibility();
1867
2776
  this.stage.batchDraw();
1868
2777
  this.scheduleViewChange();
1869
2778
  } else if (this.panLast && this.pointers.size === 1) {
@@ -1876,6 +2785,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1876
2785
  y: this.stage.y() + (p.y - this.panLast.y)
1877
2786
  });
1878
2787
  this.panLast = p;
2788
+ this.updateSeatGroupVisibility();
1879
2789
  this.stage.batchDraw();
1880
2790
  this.scheduleViewChange();
1881
2791
  }
@@ -1928,6 +2838,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1928
2838
  this.overlayLayer = new Layer({ listening: false });
1929
2839
  this.labelGroup = new Group({ listening: false });
1930
2840
  this.overlayLayer.add(this.labelGroup);
2841
+ this.fgDecorGroup = new Group({ listening: false });
2842
+ this.overlayLayer.add(this.fgDecorGroup);
1931
2843
  this.hoverRing = new Circle({
1932
2844
  radius: SEAT_RADIUS + 2,
1933
2845
  stroke: "#ffffff",
@@ -1962,6 +2874,21 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1962
2874
  this.resizeObs.observe(container);
1963
2875
  }
1964
2876
  }
2877
+ /**
2878
+ * True when this is the browser's synthesized compatibility click following a
2879
+ * finger tap we already handled. Discriminates on `e.type` deliberately —
2880
+ * `PointerEvent extends MouseEvent`, so an `instanceof MouseEvent` test
2881
+ * matches genuine pointer taps too. One shared timestamp across the layer and
2882
+ * stage handlers: a tap consumed at the layer must also suppress the ghost at
2883
+ * the stage, and only `click` is ever rejected, so bubbling stays intact.
2884
+ */
2885
+ isGhostClick(e) {
2886
+ if (e.type === "tap") {
2887
+ this.lastTapAt = performance.now();
2888
+ return false;
2889
+ }
2890
+ return this.lastTapAt > 0 && performance.now() - this.lastTapAt < GHOST_CLICK_MS;
2891
+ }
1965
2892
  // ---- ISeatmapRenderer -----------------------------------------------------
1966
2893
  setChart(doc, opts) {
1967
2894
  if (doc !== this.chartDoc) this.stacked = false;
@@ -1975,8 +2902,15 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1975
2902
  this.focusedId = null;
1976
2903
  this.focusRing.visible(false);
1977
2904
  this.bgLayer.destroyChildren();
2905
+ this.focusDimOverlay?.destroy();
2906
+ this.focusDimOverlay = null;
2907
+ this.seatLayer.clearCache();
2908
+ this.seatLayer.listening(true);
1978
2909
  this.seatLayer.destroyChildren();
2910
+ this.unsectionedSeatGroup = null;
1979
2911
  this.labelGroup.destroyChildren();
2912
+ this.labelLiftGroups.clear();
2913
+ this.fgDecorGroup.destroyChildren();
1980
2914
  this.circleById.clear();
1981
2915
  this.boothDims.clear();
1982
2916
  this.boothLabelById.clear();
@@ -1984,6 +2918,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1984
2918
  this.accessRingById.clear();
1985
2919
  this.accessGlyphById.clear();
1986
2920
  this.freeTextById.clear();
2921
+ this.iconNodeById.clear();
1987
2922
  this.primaryFocalLabels.clear();
1988
2923
  this.gaById.clear();
1989
2924
  for (const marker of this.selectionMarkers.values()) marker.destroy();
@@ -1993,6 +2928,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1993
2928
  this.statusById.clear();
1994
2929
  this.selection.clear();
1995
2930
  this.seatById.clear();
2931
+ this.seatIndex = null;
1996
2932
  this.cached = false;
1997
2933
  this.accessFilter = null;
1998
2934
  this.sections = [];
@@ -2010,11 +2946,22 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2010
2946
  }
2011
2947
  this.isoT = 0;
2012
2948
  this.isoTarget = 0;
2949
+ this.viewMode = "flat";
2950
+ this.perspectiveCamera = null;
2951
+ this.perspectiveBaseAffine = null;
2952
+ this.perspectiveBaseInverse = null;
2953
+ this.perspectiveSeatLocal.clear();
2954
+ this.perspectiveSeatProjected.clear();
2955
+ this.perspectiveSeatDepth.clear();
2956
+ this.perspectiveSeatScale.clear();
2957
+ this.perspectiveAppliedSeats.clear();
2958
+ this.perspectiveBounds = null;
2013
2959
  this.resetLayerTransforms();
2014
2960
  this.hasSections = view.objects.some((o) => o.type === "section");
2015
2961
  for (const z of doc.zones ?? []) if (z.color) this.zoneColor.set(z.id, z.color);
2016
2962
  this.seatLayer.opacity(1);
2017
2963
  this.hoverRing.visible(false);
2964
+ this.hoveredId = null;
2018
2965
  this.theme = doc.theme ?? {};
2019
2966
  this.seatR = clamp(this.theme.seatScale ?? 1, 0.7, 1.6) * SEAT_RADIUS;
2020
2967
  this.container.style.background = "";
@@ -2026,35 +2973,80 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2026
2973
  this.catColor.clear();
2027
2974
  this.catPrice.clear();
2028
2975
  this.catOrder = doc.categories.map((c) => c.key);
2976
+ this.cbHueByKey.clear();
2977
+ [...this.catOrder].sort().forEach((key, i) => {
2978
+ this.cbHueByKey.set(key, CB_PALETTE[i % CB_PALETTE.length]);
2979
+ });
2029
2980
  for (const c of doc.categories) {
2030
2981
  this.catColor.set(c.key, c.color);
2031
2982
  if (typeof c.price === "number") this.catPrice.set(c.key, c.price);
2032
2983
  }
2033
2984
  for (const obj of view.objects) {
2034
2985
  if (obj.type === "booth") {
2035
- this.boothDims.set(obj.id, { width: obj.width, height: obj.height, rotation: obj.rotation });
2986
+ this.boothDims.set(obj.id, {
2987
+ width: obj.width,
2988
+ height: obj.height,
2989
+ rotation: obj.rotation,
2990
+ ...obj.points && obj.points.length >= 3 ? { points: obj.points.map((p) => ({ x: p.x, y: p.y })) } : {}
2991
+ });
2036
2992
  }
2037
2993
  }
2038
- this.seats = expandChart(view);
2994
+ this.seats = expandChart(view, {
2995
+ // A stacked overview is presentation-only and never opens a seat POV. The
2996
+ // per-section renderer below still resolves each object's real floor base.
2997
+ floorBaseHeightM: this.stacked ? 0 : this.floorBaseHeightMFor()
2998
+ });
2039
2999
  for (const s of this.seats) {
2040
3000
  this.seatById.set(s.id, s);
2041
3001
  this.statusById.set(s.id, "free");
2042
3002
  }
3003
+ this.seatIndex = buildSeatIndex(this.seats, { seatRadius: this.seatR });
3004
+ this.bounds = chartBounds(view);
3005
+ this.isoCentre = { x: this.bounds.x + this.bounds.width / 2, y: this.bounds.y + this.bounds.height / 2 };
3006
+ this.buildPerspectiveProjection(view);
2043
3007
  this.renderBackground(view);
3008
+ this.unsectionedSeatGroup = new Group({ listening: true });
3009
+ this.seatLayer.add(this.unsectionedSeatGroup);
2044
3010
  this.renderSeats();
3011
+ this.buildRowLabelPlan(view);
2045
3012
  this.overlayLayer.add(this.labelGroup);
3013
+ this.overlayLayer.add(this.fgDecorGroup);
2046
3014
  this.overlayLayer.add(this.hoverRing);
2047
- this.bounds = chartBounds(view);
2048
- this.isoCentre = { x: this.bounds.x + this.bounds.width / 2, y: this.bounds.y + this.bounds.height / 2 };
3015
+ this.overlayLayer.add(this.focusRing);
2049
3016
  this.zoomToFit();
2050
3017
  }
2051
3018
  /** The chart to render: all floors stacked (3D overview), the active floor, or
2052
3019
  * the whole chart for single-floor charts. */
2053
3020
  floorView(doc) {
2054
- if (!doc.floors || !doc.floors.length) return doc;
2055
- if (this.stacked && doc.floors.length >= 2) return stackFloors(doc);
3021
+ if (!doc.floors || !doc.floors.length) return {
3022
+ ...doc,
3023
+ referenceImage: void 0,
3024
+ backgroundImage: buyerBackgroundImage(doc)
3025
+ };
3026
+ if (this.stacked && doc.floors.length >= 2) {
3027
+ return {
3028
+ ...stackFloors(doc),
3029
+ referenceImage: void 0,
3030
+ backgroundImage: buyerBackgroundImage(doc)
3031
+ };
3032
+ }
2056
3033
  const floor = doc.floors.find((f) => f.id === this.activeFloorId) ?? doc.floors[0];
2057
- return { ...doc, objects: floor.objects, focalPoint: floor.focalPoint, backgroundImage: floor.backgroundImage, floors: void 0 };
3034
+ return {
3035
+ ...doc,
3036
+ objects: floor.objects,
3037
+ focalPoint: floor.focalPoint,
3038
+ referenceImage: void 0,
3039
+ backgroundImage: buyerBackgroundImage(floor),
3040
+ floors: void 0
3041
+ };
3042
+ }
3043
+ /** Physical floor height is authored data; the 900-unit stacked overview
3044
+ * spread is presentation-only and must never leak into real 3D geometry. */
3045
+ floorBaseHeightMFor(objectId) {
3046
+ const floors = this.chartDoc?.floors;
3047
+ if (!floors?.length) return 0;
3048
+ const floorId = this.stacked && objectId ? this.objectFloor.get(objectId) ?? this.activeFloorId : this.activeFloorId;
3049
+ return floors.find((floor) => floor.id === floorId)?.baseHeightM ?? 0;
2058
3050
  }
2059
3051
  /** Toggle the 3D all-floors stacked overview (Batch 5). Re-renders; no-op on
2060
3052
  * single-floor charts. The caller re-applies statuses + animates the iso view. */
@@ -2367,7 +3359,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2367
3359
  }
2368
3360
  const ids = [];
2369
3361
  for (const seat of this.seats) {
2370
- if (seat.x < x0 || seat.x > x1 || seat.y < y0 || seat.y > y1) continue;
3362
+ const rendered = this.renderedSeatPoint(seat);
3363
+ if (rendered.x < x0 || rendered.x > x1 || rendered.y < y0 || rendered.y > y1) continue;
2371
3364
  if (!this.isSelectable(seat.id)) continue;
2372
3365
  ids.push(seat.id);
2373
3366
  }
@@ -2377,9 +3370,10 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2377
3370
  flashSeat(seatId, color = "#f43f5e") {
2378
3371
  const seat = this.seatById.get(seatId);
2379
3372
  if (!seat) return;
3373
+ const rendered = this.renderedSeatPoint(seat);
2380
3374
  const ring = new Circle({
2381
- x: seat.x,
2382
- y: seat.y,
3375
+ x: rendered.x,
3376
+ y: rendered.y,
2383
3377
  radius: this.seatR,
2384
3378
  stroke: color,
2385
3379
  strokeWidth: 3,
@@ -2388,6 +3382,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2388
3382
  perfectDrawEnabled: false,
2389
3383
  shadowForStrokeEnabled: false
2390
3384
  });
3385
+ const projectionScale = this.viewMode === "perspective" ? this.perspectiveSeatScale.get(seatId) ?? 1 : 1;
3386
+ ring.scale({ x: projectionScale, y: projectionScale });
2391
3387
  this.overlayLayer.add(ring);
2392
3388
  const start = performance.now();
2393
3389
  const dur = 620;
@@ -2414,17 +3410,20 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2414
3410
  const matches = this.sections.filter((section) => section.id === sectionId || section.zone === sectionId);
2415
3411
  if (!matches.length) return;
2416
3412
  for (const section of matches) {
2417
- const centre = section.outline.reduce(
3413
+ const outline = this.viewMode === "perspective" && section.perspectiveAffine ? section.outline.map((point) => this.perspectiveLocalPoint(this.projectedSectionPoint(section, point))) : section.outline.map((point) => {
3414
+ const lift = section.liftWorld > 0 ? this.isoLiftLocal(section.liftWorld) : { x: 0, y: 0 };
3415
+ return { x: point.x + lift.x, y: point.y + lift.y };
3416
+ });
3417
+ const centre = outline.reduce(
2418
3418
  (sum, point) => ({ x: sum.x + point.x, y: sum.y + point.y }),
2419
3419
  { x: 0, y: 0 }
2420
3420
  );
2421
- centre.x /= section.outline.length;
2422
- centre.y /= section.outline.length;
2423
- const lift = section.elevation > 0 ? this.isoLiftLocal(section.elevation) : { x: 0, y: 0 };
3421
+ centre.x /= outline.length;
3422
+ centre.y /= outline.length;
2424
3423
  const halo = new Line({
2425
- x: centre.x + lift.x,
2426
- y: centre.y + lift.y,
2427
- points: section.outline.flatMap((point) => [point.x - centre.x, point.y - centre.y]),
3424
+ x: centre.x,
3425
+ y: centre.y,
3426
+ points: outline.flatMap((point) => [point.x - centre.x, point.y - centre.y]),
2428
3427
  closed: true,
2429
3428
  stroke: color,
2430
3429
  strokeWidth: 3,
@@ -2468,12 +3467,14 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2468
3467
  nearestSeat(fromId, dir) {
2469
3468
  const from = this.seatById.get(fromId);
2470
3469
  if (!from) return null;
3470
+ const fromPoint = this.viewMode === "perspective" ? this.perspectiveSeatProjected.get(from.id) ?? from : from;
2471
3471
  let best = null;
2472
3472
  let bestScore = Infinity;
2473
3473
  for (const s of this.seats) {
2474
3474
  if (s.id === fromId) continue;
2475
- const dx = s.x - from.x;
2476
- const dy = s.y - from.y;
3475
+ const candidatePoint = this.viewMode === "perspective" ? this.perspectiveSeatProjected.get(s.id) ?? s : s;
3476
+ const dx = candidatePoint.x - fromPoint.x;
3477
+ const dy = candidatePoint.y - fromPoint.y;
2477
3478
  const proj = dx * dir.x + dy * dir.y;
2478
3479
  if (proj <= 0.5) continue;
2479
3480
  const perp = Math.abs(dx * dir.y - dy * dir.x);
@@ -2490,7 +3491,9 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2490
3491
  if (!seat) return;
2491
3492
  this.focusedId = id;
2492
3493
  this.focusRing.radius(this.seatR + 3);
2493
- this.focusRing.position({ x: seat.x, y: seat.y });
3494
+ this.focusRing.position(this.renderedSeatPoint(seat));
3495
+ const projectionScale = this.viewMode === "perspective" ? this.perspectiveSeatScale.get(id) ?? 1 : 1;
3496
+ this.focusRing.scale({ x: projectionScale, y: projectionScale });
2494
3497
  this.focusRing.visible(true);
2495
3498
  this.ensureVisible(seat);
2496
3499
  this.overlayLayer.batchDraw();
@@ -2505,7 +3508,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2505
3508
  const margin = 70;
2506
3509
  const offscreen = p.x < margin || p.x > w - margin || p.y < margin || p.y > h - margin;
2507
3510
  if (this.stage.scaleX() < target || offscreen) {
2508
- const ip = this.isoT === 0 ? seat : this.isoForward(seat);
3511
+ const rendered = this.renderedSeatPoint(seat);
3512
+ const ip = this.viewMode === "perspective" && this.perspectiveBaseAffine ? applyAffine(this.perspectiveBaseAffine, rendered) : this.isoT === 0 ? rendered : this.isoForward(rendered);
2509
3513
  this.stage.scale({ x: target, y: target });
2510
3514
  this.stage.position({ x: w / 2 - ip.x * target, y: h / 2 - ip.y * target });
2511
3515
  this.afterViewChange();
@@ -2515,7 +3519,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2515
3519
  zoomToFit() {
2516
3520
  const w = this.stage.width();
2517
3521
  const h = this.stage.height();
2518
- const b = this.bounds;
3522
+ const b = this.viewMode === "perspective" && this.perspectiveBounds ? this.perspectiveBounds : this.bounds;
2519
3523
  this.fitScale = Math.min(w / b.width, h / b.height) || 1;
2520
3524
  const s = this.fitScale;
2521
3525
  this.stage.scale({ x: s, y: s });
@@ -2544,7 +3548,14 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2544
3548
  }
2545
3549
  worldToScreen(point) {
2546
3550
  const s = this.stage.scaleX();
2547
- const p = this.isoT === 0 ? point : this.isoForward(point);
3551
+ const candidateId = point.id;
3552
+ const seat = typeof candidateId === "string" ? this.seatById.get(candidateId) : void 0;
3553
+ if (this.viewMode === "perspective" && this.perspectiveBaseAffine) {
3554
+ const projected = seat ? this.perspectiveSeatProjected.get(seat.id) ?? applyAffine(this.perspectiveBaseAffine, point) : applyAffine(this.perspectiveBaseAffine, point);
3555
+ return { x: projected.x * s + this.stage.x(), y: projected.y * s + this.stage.y() };
3556
+ }
3557
+ const localPoint = seat ? this.renderedSeatPoint(seat) : point;
3558
+ const p = this.isoT === 0 ? localPoint : this.isoForward(localPoint);
2548
3559
  return { x: p.x * s + this.stage.x(), y: p.y * s + this.stage.y() };
2549
3560
  }
2550
3561
  setAccessibleFilter(on) {
@@ -2554,6 +3565,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2554
3565
  const next = types === null ? null : [...types];
2555
3566
  if (this.sameAccessFilter(next)) return;
2556
3567
  this.accessFilter = next;
3568
+ this.updateAccessGlyphs(this.effScale());
2557
3569
  for (const seat of this.seats) {
2558
3570
  const c = this.circleById.get(seat.id);
2559
3571
  if (c) this.paintSeat(c, seat.id);
@@ -2652,10 +3664,11 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2652
3664
  let minY = Infinity;
2653
3665
  let maxY = -Infinity;
2654
3666
  for (const seat of matching) {
2655
- minX = Math.min(minX, seat.x);
2656
- maxX = Math.max(maxX, seat.x);
2657
- minY = Math.min(minY, seat.y);
2658
- maxY = Math.max(maxY, seat.y);
3667
+ const point = this.renderedSeatPoint(seat);
3668
+ minX = Math.min(minX, point.x);
3669
+ maxX = Math.max(maxX, point.x);
3670
+ minY = Math.min(minY, point.y);
3671
+ maxY = Math.max(maxY, point.y);
2659
3672
  }
2660
3673
  const pad = Math.max(24, this.seatR * 3);
2661
3674
  minX -= pad;
@@ -2676,12 +3689,37 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2676
3689
  * inverse) keeps landing on the projected seats/sections.
2677
3690
  */
2678
3691
  setViewMode(mode) {
3692
+ if (mode === "perspective") {
3693
+ if (this.viewMode === mode) {
3694
+ this.afterViewChange();
3695
+ return;
3696
+ }
3697
+ const resetElevatedSeatGroups = this.viewMode === "isometric" && this.isoT > 0;
3698
+ this.viewMode = mode;
3699
+ this.isoTarget = 1;
3700
+ this.isoT = 1;
3701
+ if (this.isoRaf) {
3702
+ cancelAnimationFrame(this.isoRaf);
3703
+ this.isoRaf = 0;
3704
+ }
3705
+ this.applyPerspective(resetElevatedSeatGroups);
3706
+ this.zoomToFit();
3707
+ return;
3708
+ }
2679
3709
  const target = mode === "isometric" ? 1 : 0;
3710
+ const leavingPerspective = this.viewMode === "perspective";
3711
+ this.viewMode = mode;
2680
3712
  this.isoTarget = target;
2681
3713
  if (this.isoRaf) {
2682
3714
  cancelAnimationFrame(this.isoRaf);
2683
3715
  this.isoRaf = 0;
2684
3716
  }
3717
+ if (leavingPerspective) {
3718
+ this.isoT = target;
3719
+ this.applyIso();
3720
+ this.afterViewChange();
3721
+ return;
3722
+ }
2685
3723
  if (this.reducedMotion) {
2686
3724
  this.isoT = target;
2687
3725
  this.applyIso();
@@ -2715,7 +3753,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2715
3753
  }
2716
3754
  /** Current projection — reflects the tween target, not the mid-tween isoT. */
2717
3755
  getViewMode() {
2718
- return this.isoTarget === 1 ? "isometric" : "flat";
3756
+ return this.viewMode;
2719
3757
  }
2720
3758
  /** Iso angle (rad) + y-squash for the current isoT. */
2721
3759
  isoParams() {
@@ -2723,6 +3761,11 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2723
3761
  }
2724
3762
  /** Effective vertical scale = stage scale × iso squash — legibility math uses this. */
2725
3763
  effScale() {
3764
+ if (this.viewMode === "perspective" && this.perspectiveBaseAffine) {
3765
+ const xScale = Math.hypot(this.perspectiveBaseAffine.a, this.perspectiveBaseAffine.b);
3766
+ const yScale = Math.hypot(this.perspectiveBaseAffine.c, this.perspectiveBaseAffine.d);
3767
+ return this.stage.scaleX() * Math.min(xScale, yScale);
3768
+ }
2726
3769
  return this.stage.scaleX() * (1 - (1 - ISO_SQUASH) * this.isoT);
2727
3770
  }
2728
3771
  /** Project a world point through the iso affine about the chart centre (→ iso-world). */
@@ -2746,19 +3789,113 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2746
3789
  const wy = -dx * Math.sin(th) + uy * Math.cos(th);
2747
3790
  return { x: c.x + wx, y: c.y + wy };
2748
3791
  }
3792
+ /** Precompute the Phase-C pinhole camera and every exact seat anchor. */
3793
+ buildPerspectiveProjection(view) {
3794
+ let maxSurfaceHeightWorld = 0;
3795
+ for (const seat of this.seats) {
3796
+ const surfaceM = Math.max(0, (seat.eyeHeightM ?? SEATED_EYE_HEIGHT_M) - SEATED_EYE_HEIGHT_M);
3797
+ maxSurfaceHeightWorld = Math.max(maxSurfaceHeightWorld, surfaceM * CHART_UNITS_PER_METRE);
3798
+ }
3799
+ for (const object of view.objects) {
3800
+ if (object.type !== "section") continue;
3801
+ maxSurfaceHeightWorld = Math.max(
3802
+ maxSurfaceHeightWorld,
3803
+ sectionGeometry(object, { floorBaseHeightM: this.floorBaseHeightMFor(object.id) }).height * CHART_UNITS_PER_METRE
3804
+ );
3805
+ }
3806
+ const camera = createPerspectiveCamera(this.bounds, maxSurfaceHeightWorld);
3807
+ const base = perspectiveTangentAffine(camera, camera.target);
3808
+ const inverse = invertAffine(base);
3809
+ this.perspectiveCamera = camera;
3810
+ this.perspectiveBaseAffine = base;
3811
+ this.perspectiveBaseInverse = inverse;
3812
+ this.perspectiveSeatLocal.clear();
3813
+ this.perspectiveSeatProjected.clear();
3814
+ this.perspectiveSeatDepth.clear();
3815
+ this.perspectiveSeatScale.clear();
3816
+ this.perspectiveBounds = null;
3817
+ let minX = Infinity;
3818
+ let minY = Infinity;
3819
+ let maxX = -Infinity;
3820
+ let maxY = -Infinity;
3821
+ for (const seat of this.seats) {
3822
+ const surfaceM = Math.max(0, (seat.eyeHeightM ?? SEATED_EYE_HEIGHT_M) - SEATED_EYE_HEIGHT_M);
3823
+ const projected = projectPerspectivePoint(camera, seat, surfaceM * CHART_UNITS_PER_METRE);
3824
+ const point = { x: projected.x, y: projected.y };
3825
+ this.perspectiveSeatProjected.set(seat.id, point);
3826
+ this.perspectiveSeatLocal.set(seat.id, applyAffine(inverse, point));
3827
+ this.perspectiveSeatDepth.set(seat.id, projected.depth);
3828
+ this.perspectiveSeatScale.set(seat.id, projected.scale);
3829
+ minX = Math.min(minX, projected.x);
3830
+ minY = Math.min(minY, projected.y);
3831
+ maxX = Math.max(maxX, projected.x);
3832
+ maxY = Math.max(maxY, projected.y);
3833
+ }
3834
+ for (const point of [
3835
+ { x: this.bounds.x, y: this.bounds.y },
3836
+ { x: this.bounds.x + this.bounds.width, y: this.bounds.y },
3837
+ { x: this.bounds.x + this.bounds.width, y: this.bounds.y + this.bounds.height },
3838
+ { x: this.bounds.x, y: this.bounds.y + this.bounds.height }
3839
+ ]) {
3840
+ const projected = projectPerspectivePoint(camera, point, 0);
3841
+ minX = Math.min(minX, projected.x);
3842
+ minY = Math.min(minY, projected.y);
3843
+ maxX = Math.max(maxX, projected.x);
3844
+ maxY = Math.max(maxY, projected.y);
3845
+ }
3846
+ const pad = Math.max(24, this.seatR * 3);
3847
+ 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;
3848
+ }
3849
+ /** Apply an affine to a Konva container while keeping `pivot` fixed logically. */
3850
+ setContainerAffine(node, affine, pivot) {
3851
+ const decomposed = decomposeAffineLinear(affine);
3852
+ node.offset(pivot);
3853
+ node.position(applyAffine(affine, pivot));
3854
+ node.rotation(decomposed.rotationDeg);
3855
+ node.scale({ x: decomposed.scaleX, y: decomposed.scaleY });
3856
+ node.skewX(decomposed.skewX);
3857
+ node.skewY(0);
3858
+ }
3859
+ resetContainerTransform(node) {
3860
+ node.position({ x: 0, y: 0 });
3861
+ node.offset({ x: 0, y: 0 });
3862
+ node.scale({ x: 1, y: 1 });
3863
+ node.rotation(0);
3864
+ node.skewX(0);
3865
+ node.skewY(0);
3866
+ }
3867
+ /** Exact raked surface height used for the bounded section tangent plane. */
3868
+ sectionSurfaceHeight(section, point) {
3869
+ const radial = Math.hypot(point.x - section.surfaceFocal.x, point.y - section.surfaceFocal.y);
3870
+ const depth = Math.max(0, radial - section.frontDistanceWorld);
3871
+ return section.liftWorld + depth * Math.tan(section.rakeDeg * Math.PI / 180);
3872
+ }
3873
+ /** Desired perspective-space point for a section surface, before Stage pan/zoom. */
3874
+ projectedSectionPoint(section, point) {
3875
+ return section.perspectiveAffine ? applyAffine(section.perspectiveAffine, point) : applyAffine(this.perspectiveBaseAffine, point);
3876
+ }
3877
+ /** Shared-layer local point which the ground affine paints at `projected`. */
3878
+ perspectiveLocalPoint(projected) {
3879
+ return this.perspectiveBaseInverse ? applyAffine(this.perspectiveBaseInverse, projected) : projected;
3880
+ }
2749
3881
  /**
2750
3882
  * Local-space offset that, once the layer applies the iso affine, lifts an
2751
- * object straight UP in iso-world by `elevation × LIFT_PER_STEP × isoT`
2752
- * (= inverse-linear of the pure vertical lift). Zero at isoT=0.
3883
+ * object straight UP in iso-world by `liftWorld × isoT` world units
3884
+ * (= inverse-linear of the pure vertical lift). Zero at isoT=0. `liftWorld` is
3885
+ * the section's resolved real height in world units (Phase B1).
2753
3886
  */
2754
- isoLiftLocal(elevation) {
3887
+ isoLiftLocal(liftWorld) {
2755
3888
  const { th, sg } = this.isoParams();
2756
- const delta = elevation * LIFT_PER_STEP * this.isoT;
3889
+ const delta = liftWorld * this.isoT;
2757
3890
  return { x: -(delta / sg) * Math.sin(th), y: -(delta / sg) * Math.cos(th) };
2758
3891
  }
2759
- /** Restore the three layers to identity (flat) — byte-for-byte the original. */
2760
- resetLayerTransforms() {
2761
- for (const layer of [this.bgLayer, this.seatLayer, this.overlayLayer]) {
3892
+ /** Restore projection layers to identity (flat) — byte-for-byte the original.
3893
+ * `seatLayer` can be skipped when entering perspective from flat: assigning a
3894
+ * top-level transform invalidates all 14k descendant absolute transforms even
3895
+ * while the semantic overview keeps that layer fully transparent. */
3896
+ resetLayerTransforms(includeSeatLayer = true) {
3897
+ const layers = includeSeatLayer ? [this.bgLayer, this.seatLayer, this.overlayLayer] : [this.bgLayer, this.overlayLayer];
3898
+ for (const layer of layers) {
2762
3899
  layer.position({ x: 0, y: 0 });
2763
3900
  layer.offset({ x: 0, y: 0 });
2764
3901
  layer.scale({ x: 1, y: 1 });
@@ -2767,6 +3904,117 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2767
3904
  layer.skewY(0);
2768
3905
  }
2769
3906
  }
3907
+ /** Restore section-owned containers before applying another projection. */
3908
+ resetSectionProjection(resetSeatGroups = true) {
3909
+ for (const section of this.sections) {
3910
+ this.resetContainerTransform(section.rootGroupBg);
3911
+ this.resetContainerTransform(section.liftGroupBg);
3912
+ if (resetSeatGroups) this.resetContainerTransform(section.liftGroupSeat);
3913
+ section.rootGroupBg.zIndex(section.bgZIndex);
3914
+ if (resetSeatGroups) section.liftGroupSeat.zIndex(section.seatZIndex);
3915
+ const labelGroup = this.labelLiftGroups.get(section.id);
3916
+ if (labelGroup && resetSeatGroups) this.resetContainerTransform(labelGroup);
3917
+ section.perspectiveAffine = void 0;
3918
+ section.perspectiveCorrection = void 0;
3919
+ section.perspectiveDepth = void 0;
3920
+ }
3921
+ }
3922
+ /** Move native seat/booth hit shapes to or from their exact pinhole anchors. */
3923
+ applyExactSeatAnchors(perspective, seatIds) {
3924
+ 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);
3925
+ for (const seat of seats) {
3926
+ if (perspective && this.perspectiveAppliedSeats.has(seat.id)) continue;
3927
+ const point = perspective ? this.perspectiveSeatProjected.get(seat.id) ?? seat : seat;
3928
+ const scale = perspective ? this.perspectiveSeatScale.get(seat.id) ?? 1 : 1;
3929
+ const shape = this.circleById.get(seat.id);
3930
+ shape?.position(point);
3931
+ shape?.scale({ x: scale, y: scale });
3932
+ const ring = this.accessRingById.get(seat.id);
3933
+ ring?.position(point);
3934
+ ring?.scale({ x: scale, y: scale });
3935
+ const glyph = this.accessGlyphById.get(seat.id);
3936
+ if (glyph) {
3937
+ const baseGlyphScale = this.seatR * 1.5 / ACCESS_GLYPH_VIEWBOX;
3938
+ glyph.position(point);
3939
+ glyph.scale({ x: baseGlyphScale * scale, y: baseGlyphScale * scale });
3940
+ }
3941
+ const boothLabel = this.boothLabelById.get(seat.id);
3942
+ boothLabel?.position(point);
3943
+ boothLabel?.scale({ x: scale, y: scale });
3944
+ if (perspective) this.perspectiveAppliedSeats.add(seat.id);
3945
+ }
3946
+ if (!perspective) this.perspectiveAppliedSeats.clear();
3947
+ }
3948
+ /**
3949
+ * Phase C projected 2.5D. Seat/booth anchors are exact pinhole projections;
3950
+ * section top surfaces use one explicitly bounded tangent plane per authored
3951
+ * section. Native Konva shapes move with the anchors, so direct hit testing is
3952
+ * the same graph that paints the buyer-visible unit.
3953
+ */
3954
+ applyPerspective(resetElevatedSeatGroups = true) {
3955
+ const camera = this.perspectiveCamera;
3956
+ const base = this.perspectiveBaseAffine;
3957
+ const inverse = this.perspectiveBaseInverse;
3958
+ if (!camera || !base || !inverse) return;
3959
+ const lazySectionSeats = this.seats.length > 2500 && this.sections.length > 0;
3960
+ if (this.cached && !lazySectionSeats) {
3961
+ this.seatLayer.clearCache();
3962
+ this.seatLayer.listening(true);
3963
+ this.cached = false;
3964
+ }
3965
+ this.resetLayerTransforms(resetElevatedSeatGroups);
3966
+ this.resetSectionProjection(resetElevatedSeatGroups);
3967
+ for (const layer of [this.bgLayer, this.overlayLayer]) {
3968
+ this.setContainerAffine(layer, base, camera.target);
3969
+ }
3970
+ this.perspectiveAppliedSeats.clear();
3971
+ if (!lazySectionSeats) this.applyExactSeatAnchors(true);
3972
+ for (const section of this.sections) {
3973
+ const heightAt = (point) => this.sectionSurfaceHeight(section, point);
3974
+ const desired = perspectiveTangentAffine(camera, section.centroid, heightAt);
3975
+ const correction = composeAffine(inverse, desired);
3976
+ section.perspectiveAffine = desired;
3977
+ section.perspectiveCorrection = correction;
3978
+ section.perspectiveDepth = projectPerspectivePoint(
3979
+ camera,
3980
+ section.centroid,
3981
+ heightAt(section.centroid)
3982
+ ).depth;
3983
+ this.setContainerAffine(section.liftGroupBg, correction, section.centroid);
3984
+ for (let index = 0; index < section.sideFaces.length; index++) {
3985
+ const p0 = section.outline[index];
3986
+ const p1 = section.outline[(index + 1) % section.outline.length];
3987
+ const base0 = this.perspectiveLocalPoint(projectPerspectivePoint(camera, p0, 0));
3988
+ const base1 = this.perspectiveLocalPoint(projectPerspectivePoint(camera, p1, 0));
3989
+ const top1 = this.perspectiveLocalPoint(projectPerspectivePoint(camera, p1, heightAt(p1)));
3990
+ const top0 = this.perspectiveLocalPoint(projectPerspectivePoint(camera, p0, heightAt(p0)));
3991
+ section.sideFaces[index].points([
3992
+ base0.x,
3993
+ base0.y,
3994
+ base1.x,
3995
+ base1.y,
3996
+ top1.x,
3997
+ top1.y,
3998
+ top0.x,
3999
+ top0.y
4000
+ ]);
4001
+ section.sideFaces[index].opacity(0.9);
4002
+ }
4003
+ }
4004
+ const farToNear = [...this.sections].sort(
4005
+ (left, right) => (right.perspectiveDepth ?? 0) - (left.perspectiveDepth ?? 0)
4006
+ );
4007
+ for (const section of farToNear) {
4008
+ section.rootGroupBg.moveToTop();
4009
+ section.liftGroupSeat.moveToTop();
4010
+ }
4011
+ this.unsectionedSeatGroup?.moveToTop();
4012
+ this.syncSeatOverlayPositions();
4013
+ if (!lazySectionSeats) this.updateSeatGroupVisibility();
4014
+ this.bgLayer.batchDraw();
4015
+ this.seatLayer.batchDraw();
4016
+ this.overlayLayer.batchDraw();
4017
+ }
2770
4018
  /**
2771
4019
  * Apply the current isoT to the scene: the base rotate+squash as a decomposed
2772
4020
  * layer transform (so seats/décor/rings project together and Konva's own
@@ -2774,6 +4022,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2774
4022
  * lift + extruded side faces on elevated sections.
2775
4023
  */
2776
4024
  applyIso() {
4025
+ this.resetSectionProjection();
4026
+ this.applyExactSeatAnchors(false);
2777
4027
  const t2 = this.isoT;
2778
4028
  if (t2 === 0) {
2779
4029
  this.resetLayerTransforms();
@@ -2802,6 +4052,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2802
4052
  }
2803
4053
  this.applyUprightLabels();
2804
4054
  this.applyElevation();
4055
+ this.updateSeatGroupVisibility();
2805
4056
  this.bgLayer.batchDraw();
2806
4057
  this.seatLayer.batchDraw();
2807
4058
  this.overlayLayer.batchDraw();
@@ -2830,10 +4081,11 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2830
4081
  applyElevation() {
2831
4082
  const t2 = this.isoT;
2832
4083
  for (const sec of this.sections) {
2833
- if (sec.elevation <= 0) continue;
2834
- const off = this.isoLiftLocal(sec.elevation);
4084
+ if (sec.liftWorld <= 0) continue;
4085
+ const off = this.isoLiftLocal(sec.liftWorld);
2835
4086
  sec.liftGroupBg?.position(off);
2836
- sec.liftGroupSeat?.position(off);
4087
+ sec.liftGroupSeat.position(off);
4088
+ this.labelLiftGroups.get(sec.id)?.position(off);
2837
4089
  const alpha = 0.9 * t2;
2838
4090
  for (let i = 0; i < sec.sideFaces.length; i++) {
2839
4091
  const face = sec.sideFaces[i];
@@ -2843,6 +4095,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2843
4095
  face.opacity(alpha);
2844
4096
  }
2845
4097
  }
4098
+ this.syncSeatOverlayPositions();
2846
4099
  }
2847
4100
  destroy() {
2848
4101
  this.destroyed = true;
@@ -2869,16 +4122,166 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2869
4122
  * tier shifts with one offset in iso view) or the seat layer directly.
2870
4123
  */
2871
4124
  seatContainer(id) {
2872
- return this.seatSection.get(id)?.liftGroupSeat ?? this.seatLayer;
4125
+ return this.seatSection.get(id)?.liftGroupSeat ?? this.unsectionedSeatGroup ?? this.seatLayer;
2873
4126
  }
2874
- renderSeats() {
4127
+ /** True when a section belongs to the active logical section/zone focus. */
4128
+ sectionMatchesFocus(section) {
4129
+ const focus = this.focusedSectionId;
4130
+ return focus == null || section.id === focus || section.logicalId === focus || section.zone === focus;
4131
+ }
4132
+ /** Visual opacity after the section-focus overlay is composed. */
4133
+ focusedSeatOpacity(id, localOpacity) {
4134
+ if (!this.focusedSectionId) return localOpacity;
4135
+ const section = this.seatSection.get(id);
4136
+ return localOpacity * (section && this.sectionMatchesFocus(section) ? 1 : FOCUS_DIM_OPACITY);
4137
+ }
4138
+ /** Project a section outline through elevation + the active affine view and
4139
+ * then into viewport pixels. The result is conservative (AABB), so a visible
4140
+ * section is never clipped even for rotated or isometric outlines. */
4141
+ sectionScreenBounds(section) {
4142
+ if (this.viewMode === "perspective" && section.perspectiveAffine) {
4143
+ const scale2 = this.stage.scaleX();
4144
+ const stageX2 = this.stage.x();
4145
+ const stageY2 = this.stage.y();
4146
+ return polyBounds(section.outline.map((point) => {
4147
+ const projected = this.projectedSectionPoint(section, point);
4148
+ return { x: projected.x * scale2 + stageX2, y: projected.y * scale2 + stageY2 };
4149
+ }));
4150
+ }
4151
+ const offset = section.liftWorld > 0 ? this.isoLiftLocal(section.liftWorld) : { x: 0, y: 0 };
4152
+ const scale = this.stage.scaleX();
4153
+ const stageX = this.stage.x();
4154
+ const stageY = this.stage.y();
4155
+ return polyBounds(section.outline.map((point) => {
4156
+ const local = { x: point.x + offset.x, y: point.y + offset.y };
4157
+ const projected = this.isoT === 0 ? local : this.isoForward(local);
4158
+ return { x: projected.x * scale + stageX, y: projected.y * scale + stageY };
4159
+ }));
4160
+ }
4161
+ /** Hide only section groups wholly outside a padded viewport at the live-seat
4162
+ * rung. Overview caching always restores all groups first, so panning a
4163
+ * cached whole-venue bitmap can never reveal missing inventory. */
4164
+ updateSeatGroupVisibility() {
4165
+ const liveSeats = this.effScale() >= CACHE_THRESHOLD;
4166
+ const deferPerspectiveSeatReveal = this.viewMode === "perspective" && this.glideInProgress && this.seats.length > 2500;
4167
+ const padding = 96;
4168
+ const width = this.stage.width();
4169
+ const height = this.stage.height();
4170
+ for (const section of this.sections) {
4171
+ let visible = true;
4172
+ if (liveSeats) {
4173
+ const bounds2 = this.sectionScreenBounds(section);
4174
+ visible = bounds2.x + bounds2.width >= -padding && bounds2.y + bounds2.height >= -padding && bounds2.x <= width + padding && bounds2.y <= height + padding;
4175
+ if (visible && this.viewMode === "perspective" && !deferPerspectiveSeatReveal) {
4176
+ this.applyExactSeatAnchors(true, section.memberIds);
4177
+ }
4178
+ }
4179
+ section.liftGroupSeat.setViewportCulled(!visible);
4180
+ const labelGroup = this.labelLiftGroups.get(section.id);
4181
+ labelGroup?.setViewportCulled(!visible);
4182
+ }
4183
+ if (this.unsectionedSeatGroup && !this.unsectionedSeatGroup.visible()) {
4184
+ this.unsectionedSeatGroup.visible(true);
4185
+ }
4186
+ if (liveSeats && this.viewMode === "perspective" && !deferPerspectiveSeatReveal) {
4187
+ this.applyExactSeatAnchors(true, this.seats.filter((seat) => !this.seatSection.has(seat.id)).map((seat) => seat.id));
4188
+ }
4189
+ }
4190
+ /** Seats worth considering for viewport labels. Section culling is a safe
4191
+ * coarse index; the later screen test remains the exact filter. */
4192
+ visibleSeatCandidates() {
4193
+ if (!this.sections.length) return this.seats;
4194
+ const candidates = [];
4195
+ for (const section of this.sections) {
4196
+ if (section.liftGroupSeat.isViewportCulled()) continue;
4197
+ for (const id of section.memberIds) {
4198
+ const seat = this.seatById.get(id);
4199
+ if (seat) candidates.push(seat);
4200
+ }
4201
+ }
2875
4202
  for (const seat of this.seats) {
4203
+ if (!this.seatSection.has(seat.id)) candidates.push(seat);
4204
+ }
4205
+ return candidates;
4206
+ }
4207
+ seatViewportCulled(id) {
4208
+ return this.seatSection.get(id)?.liftGroupSeat.isViewportCulled() ?? false;
4209
+ }
4210
+ /** Local world coordinate at which an elevated seat is actually painted for
4211
+ * the current iso tween. Flat seats and the 2D endpoint are unchanged. */
4212
+ renderedSeatPoint(seat) {
4213
+ if (this.viewMode === "perspective") return this.perspectiveSeatLocal.get(seat.id) ?? seat;
4214
+ const section = this.seatSection.get(seat.id);
4215
+ if (!section || section.liftWorld <= 0 || this.isoT === 0) return seat;
4216
+ const offset = this.isoLiftLocal(section.liftWorld);
4217
+ return { x: seat.x + offset.x, y: seat.y + offset.y };
4218
+ }
4219
+ /** Overlay labels mirror the seat-layer lift without an O(seats) per-frame
4220
+ * rewrite. The groups are rebuilt with the zoom-dependent label set. */
4221
+ seatLabelContainer(id) {
4222
+ const section = this.seatSection.get(id);
4223
+ if (!section) return this.labelGroup;
4224
+ let group = this.labelLiftGroups.get(section.id);
4225
+ if (!group) {
4226
+ group = new ViewportGroup({
4227
+ listening: false
4228
+ });
4229
+ group.setViewportCulled(section.liftGroupSeat.isViewportCulled());
4230
+ group.position(this.viewMode === "perspective" ? { x: 0, y: 0 } : this.isoLiftLocal(section.liftWorld));
4231
+ this.labelGroup.add(group);
4232
+ this.labelLiftGroups.set(section.id, group);
4233
+ }
4234
+ return group;
4235
+ }
4236
+ /** Shared overlay furniture is not parented to section lift groups, so keep
4237
+ * the small transient set aligned explicitly. Selection is capped in buyer
4238
+ * mode, making this constant-sized work during the iso tween. */
4239
+ syncSeatOverlayPositions() {
4240
+ if (this.hoveredId) {
4241
+ const seat = this.seatById.get(this.hoveredId);
4242
+ if (seat) {
4243
+ this.hoverRing.position(this.renderedSeatPoint(seat));
4244
+ const scale = this.viewMode === "perspective" ? this.perspectiveSeatScale.get(seat.id) ?? 1 : 1;
4245
+ this.hoverRing.scale({ x: scale, y: scale });
4246
+ }
4247
+ }
4248
+ if (this.focusedId) {
4249
+ const seat = this.seatById.get(this.focusedId);
4250
+ if (seat) {
4251
+ this.focusRing.position(this.renderedSeatPoint(seat));
4252
+ const scale = this.viewMode === "perspective" ? this.perspectiveSeatScale.get(seat.id) ?? 1 : 1;
4253
+ this.focusRing.scale({ x: scale, y: scale });
4254
+ }
4255
+ }
4256
+ for (const [id, marker] of this.selectionMarkers) {
4257
+ const seat = this.seatById.get(id);
4258
+ if (seat) {
4259
+ marker.position(this.renderedSeatPoint(seat));
4260
+ const scale = this.viewMode === "perspective" ? this.perspectiveSeatScale.get(seat.id) ?? 1 : 1;
4261
+ marker.scale({ x: scale, y: scale });
4262
+ }
4263
+ }
4264
+ }
4265
+ renderSeats() {
4266
+ const paintOrder = [...this.seats].sort((left, right) => (this.perspectiveSeatDepth.get(right.id) ?? 0) - (this.perspectiveSeatDepth.get(left.id) ?? 0));
4267
+ for (const seat of paintOrder) {
2876
4268
  if (seat.kind === "booth") {
2877
4269
  this.renderBoothUnit(seat);
2878
4270
  continue;
2879
4271
  }
2880
4272
  const target = this.seatContainer(seat.id);
2881
- const c = new Circle({
4273
+ const c = seat.wheelchairSpaceType === "no-seat" ? new Rect({
4274
+ x: seat.x,
4275
+ y: seat.y,
4276
+ width: this.seatR * 2,
4277
+ height: this.seatR * 2,
4278
+ offsetX: this.seatR,
4279
+ offsetY: this.seatR,
4280
+ cornerRadius: 2,
4281
+ perfectDrawEnabled: false,
4282
+ shadowForStrokeEnabled: false,
4283
+ hitStrokeWidth: 0
4284
+ }) : new Circle({
2882
4285
  x: seat.x,
2883
4286
  y: seat.y,
2884
4287
  radius: this.seatR,
@@ -2891,21 +4294,25 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2891
4294
  this.paintSeat(c, seat.id);
2892
4295
  target.add(c);
2893
4296
  if (seat.accessible) {
2894
- const ring = new Circle({
2895
- x: seat.x,
2896
- y: seat.y,
2897
- radius: this.seatR + 1.5,
2898
- stroke: accessibilityRingColor(seat.accessibility),
2899
- strokeWidth: 2.5,
2900
- listening: false,
2901
- perfectDrawEnabled: false,
2902
- shadowForStrokeEnabled: false
2903
- });
2904
- this.accessRingById.set(seat.id, ring);
2905
- target.add(ring);
2906
- const glyph = this.buildAccessGlyph(seat, typeof c.fill() === "string" ? c.fill() : "");
2907
- this.accessGlyphById.set(seat.id, glyph);
2908
- target.add(glyph);
4297
+ if (seat.wheelchairSpaceType !== "no-seat") {
4298
+ const ring = new Circle({
4299
+ x: seat.x,
4300
+ y: seat.y,
4301
+ radius: this.seatR + 1.5,
4302
+ stroke: accessibilityRingColor(seat.accessibility),
4303
+ strokeWidth: 2.5,
4304
+ listening: false,
4305
+ perfectDrawEnabled: false,
4306
+ shadowForStrokeEnabled: false
4307
+ });
4308
+ this.accessRingById.set(seat.id, ring);
4309
+ target.add(ring);
4310
+ }
4311
+ if (seat.accessibility?.includes("wheelchair")) {
4312
+ const glyph = this.buildAccessGlyph(seat, typeof c.fill() === "string" ? c.fill() : "");
4313
+ this.accessGlyphById.set(seat.id, glyph);
4314
+ target.add(glyph);
4315
+ }
2909
4316
  }
2910
4317
  }
2911
4318
  }
@@ -2913,25 +4320,42 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2913
4320
  renderBoothUnit(seat) {
2914
4321
  const target = this.seatContainer(seat.id);
2915
4322
  const dims = this.boothDims.get(seat.rowId) ?? { width: 40, height: 30, rotation: 0 };
2916
- const rect = new Rect({
2917
- x: seat.x,
2918
- y: seat.y,
2919
- width: dims.width,
2920
- height: dims.height,
2921
- offsetX: dims.width / 2,
2922
- offsetY: dims.height / 2,
2923
- rotation: dims.rotation,
2924
- cornerRadius: 4,
2925
- perfectDrawEnabled: false,
2926
- shadowForStrokeEnabled: false,
2927
- hitStrokeWidth: 0
2928
- });
2929
- rect.setAttr("seatId", seat.id);
2930
- this.circleById.set(seat.id, rect);
2931
- target.add(rect);
4323
+ let labelX = seat.x;
4324
+ let labelY = seat.y;
4325
+ let node;
4326
+ if (dims.points && dims.points.length >= 3) {
4327
+ node = new Line({
4328
+ x: seat.x,
4329
+ y: seat.y,
4330
+ points: dims.points.flatMap((p) => [p.x, p.y]),
4331
+ closed: true,
4332
+ perfectDrawEnabled: false,
4333
+ shadowForStrokeEnabled: false,
4334
+ hitStrokeWidth: 0
4335
+ });
4336
+ labelX = seat.x + dims.points.reduce((a, p) => a + p.x, 0) / dims.points.length;
4337
+ labelY = seat.y + dims.points.reduce((a, p) => a + p.y, 0) / dims.points.length;
4338
+ } else {
4339
+ node = new Rect({
4340
+ x: seat.x,
4341
+ y: seat.y,
4342
+ width: dims.width,
4343
+ height: dims.height,
4344
+ offsetX: dims.width / 2,
4345
+ offsetY: dims.height / 2,
4346
+ rotation: dims.rotation,
4347
+ cornerRadius: 4,
4348
+ perfectDrawEnabled: false,
4349
+ shadowForStrokeEnabled: false,
4350
+ hitStrokeWidth: 0
4351
+ });
4352
+ }
4353
+ node.setAttr("seatId", seat.id);
4354
+ this.circleById.set(seat.id, node);
4355
+ target.add(node);
2932
4356
  const t2 = new Text({
2933
- x: seat.x,
2934
- y: seat.y,
4357
+ x: labelX,
4358
+ y: labelY,
2935
4359
  text: seat.displayLabel ?? seat.label,
2936
4360
  fontSize: BOOTH_LABEL_FONT_SIZE,
2937
4361
  fontStyle: "600",
@@ -2943,11 +4367,10 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2943
4367
  t2.offsetX(t2.width() / 2);
2944
4368
  t2.offsetY(t2.height() / 2);
2945
4369
  t2.visible(false);
2946
- this.boothLabelById.set(seat.id, t2);
2947
4370
  this.hasBoothText = true;
2948
4371
  this.boothLabelById.set(seat.id, t2);
2949
4372
  target.add(t2);
2950
- this.paintSeat(rect, seat.id);
4373
+ this.paintSeat(node, seat.id);
2951
4374
  }
2952
4375
  /**
2953
4376
  * Build the centred accessibility glyph for a seat. Sized relative to the seat
@@ -2966,24 +4389,40 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2966
4389
  scaleY: k,
2967
4390
  fill: stateAwareBookableLabelInk(seatFill, "#ffffff"),
2968
4391
  listening: false,
2969
- visible: this.accessGlyphVisible,
4392
+ visible: false,
2970
4393
  perfectDrawEnabled: false,
2971
4394
  shadowForStrokeEnabled: false
2972
4395
  });
2973
4396
  }
2974
4397
  /**
2975
- * Toggle the accessibility glyphs for the current camera scale: shown once the
2976
- * effective on-screen seat radius clears {@link SEAT_GLYPH_MIN_PX}, otherwise
2977
- * hidden so only the ring remains. Iterates the (small) accessible-seat set,
2978
- * never the full node graph, so it is cheap to call on every view change.
4398
+ * Keep physical wheelchair provision readable in screen space at every map
4399
+ * LOD. The active Wheelchair filter promotes it further. Iterates the small
4400
+ * wheelchair-seat set, never the full node graph.
2979
4401
  */
2980
4402
  updateAccessGlyphs(scale) {
2981
4403
  if (!this.accessGlyphById.size) return;
2982
- this.accessGlyphVisible = this.seatR * scale >= SEAT_GLYPH_MIN_PX;
2983
4404
  for (const [id, glyph] of this.accessGlyphById) {
2984
- glyph.visible(this.accessGlyphVisible && this.accessGlyphEligible(id));
4405
+ const perspectiveScale = this.viewMode === "perspective" ? this.perspectiveSeatScale.get(id) ?? 1 : 1;
4406
+ const baseScale = this.seatR * 1.5 / ACCESS_GLYPH_VIEWBOX * perspectiveScale;
4407
+ const emphasized = this.accessGlyphFilterEmphasized(id);
4408
+ const minimumScreenPx = emphasized ? FILTERED_WHEELCHAIR_GLYPH_MIN_PX : WHEELCHAIR_GLYPH_MIN_PX;
4409
+ const glyphScale = Math.max(
4410
+ baseScale,
4411
+ minimumScreenPx / (ACCESS_GLYPH_VIEWBOX * Math.max(scale, 1e-3))
4412
+ );
4413
+ glyph.scale({ x: glyphScale, y: glyphScale });
4414
+ glyph.visible(this.accessGlyphShouldShow(id));
2985
4415
  }
2986
4416
  }
4417
+ /** True only for a physical wheelchair provision matching an active buyer filter. */
4418
+ accessGlyphFilterEmphasized(id) {
4419
+ const seat = this.seatById.get(id);
4420
+ const filter = this.accessFilter;
4421
+ return !!seat && filter !== null && !!seat.accessibility?.includes("wheelchair") && (filter.length === 0 || filter.includes("wheelchair")) && seatMatchesAccess(seat, filter);
4422
+ }
4423
+ accessGlyphShouldShow(id) {
4424
+ return this.accessGlyphById.has(id) && this.accessGlyphEligible(id);
4425
+ }
2987
4426
  /**
2988
4427
  * Whether an accessible seat's glyph should show for its current status. It is
2989
4428
  * hidden on seats a buyer cannot take (sold, or another buyer's hold) where the
@@ -2999,8 +4438,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2999
4438
  /** The category's display color — Okabe-Ito hue when colorblind-safe is on. */
3000
4439
  seatBaseColor(categoryKey) {
3001
4440
  if (!this.colorblind) return this.catColor.get(categoryKey) ?? "#6e7bff";
3002
- const idx = this.catOrder.indexOf(categoryKey);
3003
- return CB_PALETTE[(idx >= 0 ? idx : 0) % CB_PALETTE.length];
4441
+ return this.cbHueByKey.get(categoryKey) ?? CB_PALETTE[0];
3004
4442
  }
3005
4443
  /** Per-seat authored label size relative to the neutral default (1 = default),
3006
4444
  * mirroring the section convention (labelStyle.size ?? 18) / 18. Only real
@@ -3063,6 +4501,11 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3063
4501
  c.dash([2, 2]);
3064
4502
  break;
3065
4503
  }
4504
+ if (seat.wheelchairSpaceType === "no-seat" && status === "free") {
4505
+ c.stroke(accessibilityRingColor(seat.accessibility));
4506
+ c.strokeWidth(selected ? 3 : 2);
4507
+ c.dash([3, 2]);
4508
+ }
3066
4509
  if (boothLabel) {
3067
4510
  boothLabel.text(status === "booked" ? "SOLD" : status === "held" ? "HELD" : seat.label);
3068
4511
  boothLabel.fontSize(status === "free" ? 10 : Math.min(10, Math.max(6, this.boothDims.get(seat.rowId)?.width ?? 40) / 6));
@@ -3096,11 +4539,6 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3096
4539
  c.dash([]);
3097
4540
  c.opacity(CLOSED_SEAT_OPACITY);
3098
4541
  }
3099
- if (this.focusedSectionId) {
3100
- const sec = this.seatSection.get(id);
3101
- const inFocus = !!sec && (sec.id === this.focusedSectionId || sec.logicalId === this.focusedSectionId || sec.zone === this.focusedSectionId);
3102
- if (!inFocus) c.opacity(FOCUS_DIM_OPACITY);
3103
- }
3104
4542
  if (this.selectionFocusId && id !== this.selectionFocusId) c.opacity(Math.min(c.opacity(), 0.16));
3105
4543
  const bookableLabel = this.boothLabelById.get(id) ?? this.seatLabelById.get(id);
3106
4544
  if (bookableLabel) {
@@ -3114,7 +4552,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3114
4552
  const fill = c.fill();
3115
4553
  accessGlyph.fill(stateAwareBookableLabelInk(typeof fill === "string" ? fill : "", "#ffffff"));
3116
4554
  accessGlyph.opacity(c.opacity());
3117
- accessGlyph.visible(this.accessGlyphVisible && this.accessGlyphEligible(id));
4555
+ accessGlyph.visible(this.accessGlyphShouldShow(id));
3118
4556
  }
3119
4557
  this.accessRingById.get(id)?.opacity(c.opacity());
3120
4558
  }
@@ -3183,7 +4621,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3183
4621
  if (!this.sections.some((section) => section.id === id || section.logicalId === id)) return;
3184
4622
  this.focusedSectionId = id;
3185
4623
  this.drawFocusBackdrop(id);
3186
- this.repaintSectionsAndSeats();
4624
+ this.bgLayer.batchDraw();
4625
+ this.overlayLayer.batchDraw();
3187
4626
  this.updateLOD();
3188
4627
  this.focusRegion(id, { minScale: SEAT_FOCUS_SCALE });
3189
4628
  }
@@ -3195,7 +4634,10 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3195
4634
  this.focusBackdrop.destroy();
3196
4635
  this.focusBackdrop = null;
3197
4636
  }
3198
- this.repaintSectionsAndSeats();
4637
+ this.focusDimOverlay?.destroy();
4638
+ this.focusDimOverlay = null;
4639
+ this.bgLayer.batchDraw();
4640
+ this.overlayLayer.batchDraw();
3199
4641
  this.updateLOD();
3200
4642
  }
3201
4643
  /** The currently AXS-focused section id, or null. */
@@ -3212,16 +4654,69 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3212
4654
  if (!sections.length) return;
3213
4655
  const backdrop = new Group({ listening: false });
3214
4656
  for (const section of sections) {
3215
- backdrop.add(polygonWithHolesShape(section.outline, section.holes, {
4657
+ const perspective = this.viewMode === "perspective" && section.perspectiveAffine;
4658
+ const outline = perspective ? section.outline.map((point) => this.perspectiveLocalPoint(this.projectedSectionPoint(section, point))) : section.outline;
4659
+ const holes = perspective ? section.holes.map((hole) => hole.map((point) => this.perspectiveLocalPoint(this.projectedSectionPoint(section, point)))) : section.holes;
4660
+ backdrop.add(polygonWithHolesShape(outline, holes, {
3216
4661
  fill: FOCUS_BACKDROP_FILL,
3217
4662
  stroke: rgba("#ffffff", 0.1),
3218
4663
  strokeWidth: 1,
3219
4664
  listening: false
3220
- }, section.outlinePath));
4665
+ }, perspective ? void 0 : section.outlinePath));
3221
4666
  }
3222
4667
  this.bgLayer.add(backdrop);
3223
4668
  backdrop.moveToTop();
3224
4669
  this.focusBackdrop = backdrop;
4670
+ this.drawFocusDimOverlay(sections);
4671
+ }
4672
+ /** Dim everything outside the active section with one paint-only shape. Group
4673
+ * opacity would make Konva recursively invalidate absolute-opacity caches on
4674
+ * all 14k descendants. The cut-out follows the live elevation offset and is
4675
+ * non-listening, so direct seat hit-testing is unchanged. */
4676
+ drawFocusDimOverlay(sections) {
4677
+ this.focusDimOverlay?.destroy();
4678
+ const span = Math.max(this.bounds.width, this.bounds.height, 1);
4679
+ const padding = span * 4 + 1e3;
4680
+ const outer = [
4681
+ { x: this.bounds.x - padding, y: this.bounds.y - padding },
4682
+ { x: this.bounds.x + this.bounds.width + padding, y: this.bounds.y - padding },
4683
+ { x: this.bounds.x + this.bounds.width + padding, y: this.bounds.y + this.bounds.height + padding },
4684
+ { x: this.bounds.x - padding, y: this.bounds.y + this.bounds.height + padding }
4685
+ ];
4686
+ const signedArea = (points) => points.reduce((sum, point, index) => {
4687
+ const next = points[(index + 1) % points.length];
4688
+ return sum + point.x * next.y - next.x * point.y;
4689
+ }, 0);
4690
+ const overlay = new Shape({
4691
+ fill: this.canvasBackground,
4692
+ opacity: 1 - FOCUS_DIM_OPACITY,
4693
+ listening: false,
4694
+ perfectDrawEnabled: false,
4695
+ sceneFunc: (context, shape) => {
4696
+ const polygonPath = (points) => {
4697
+ if (!points.length) return;
4698
+ context.moveTo(points[0].x, points[0].y);
4699
+ for (let index = 1; index < points.length; index++) context.lineTo(points[index].x, points[index].y);
4700
+ context.closePath();
4701
+ };
4702
+ context.beginPath();
4703
+ polygonPath(outer);
4704
+ for (const section of sections) {
4705
+ const hole = this.viewMode === "perspective" && section.perspectiveAffine ? section.outline.map((point) => this.perspectiveLocalPoint(this.projectedSectionPoint(section, point))) : section.outline.map((point) => {
4706
+ const offset = section.liftWorld > 0 ? this.isoLiftLocal(section.liftWorld) : { x: 0, y: 0 };
4707
+ return { x: point.x + offset.x, y: point.y + offset.y };
4708
+ });
4709
+ polygonPath(signedArea(hole) > 0 ? [...hole].reverse() : hole);
4710
+ }
4711
+ context.fillStrokeShape(shape);
4712
+ }
4713
+ });
4714
+ this.overlayLayer.add(overlay);
4715
+ overlay.moveToTop();
4716
+ for (const marker of this.selectionMarkers.values()) marker.moveToTop();
4717
+ this.hoverRing.moveToTop();
4718
+ this.focusRing.moveToTop();
4719
+ this.focusDimOverlay = overlay;
3225
4720
  }
3226
4721
  /** Repaint every seat + section block to reflect closed/focus state, then redraw. */
3227
4722
  repaintSectionsAndSeats() {
@@ -3285,7 +4780,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3285
4780
  this.applyGAFilterState();
3286
4781
  }
3287
4782
  renderBackground(doc) {
3288
- if (doc.backgroundImage) this.renderBackgroundImage(doc.backgroundImage);
4783
+ const buyerBackground = buyerBackgroundImage(doc);
4784
+ if (buyerBackground) this.renderBackgroundImage(buyerBackground);
3289
4785
  for (const obj of doc.objects) if (obj.type === "decorImage") this.renderDecorImage(obj);
3290
4786
  for (const obj of doc.objects) if (obj.type === "section") this.renderSection(obj);
3291
4787
  this.renderZones(doc);
@@ -3365,10 +4861,12 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3365
4861
  listening: false,
3366
4862
  perfectDrawEnabled: false
3367
4863
  });
3368
- this.bgLayer.add(node);
4864
+ if (obj.layer === "foreground") this.fgDecorGroup.add(node);
4865
+ else this.bgLayer.add(node);
3369
4866
  img.onload = () => {
3370
- if (!node.getLayer()) return;
3371
- this.bgLayer.batchDraw();
4867
+ const layer = node.getLayer();
4868
+ if (!layer) return;
4869
+ layer.batchDraw();
3372
4870
  };
3373
4871
  img.src = obj.href;
3374
4872
  }
@@ -3405,23 +4903,46 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3405
4903
  })
3406
4904
  );
3407
4905
  }
3408
- const label = this.addCentredLabel(this.bgLayer, obj.label, obj.center.x, obj.center.y, "#cbd5e1", 12, true);
4906
+ const label = this.addCentredLabel(this.bgLayer, obj.displayLabel ?? obj.label, obj.center.x, obj.center.y, "#cbd5e1", 12, true);
3409
4907
  this.freeTextById.set(obj.id, { node: label, background: "#232c40", kind: "table" });
3410
4908
  }
3411
4909
  renderText(obj) {
3412
4910
  const background = this.canvasBackground;
3413
4911
  const preferredInk = obj.color ?? this.theme.textColor ?? DEF_TEXT;
4912
+ const iconPath = obj.semanticKind === "icon" ? venueIconPath(obj.iconKey) : void 0;
4913
+ if (iconPath) {
4914
+ const scale = obj.fontSize / VENUE_ICON_VIEWBOX;
4915
+ const iconNode = new Path({
4916
+ x: obj.position.x,
4917
+ y: obj.position.y,
4918
+ data: iconPath,
4919
+ rotation: obj.rotation,
4920
+ scaleX: scale,
4921
+ scaleY: scale,
4922
+ stroke: stateAwareBookableLabelInk(background, preferredInk),
4923
+ strokeWidth: VENUE_ICON_STROKE,
4924
+ lineCap: "round",
4925
+ lineJoin: "round",
4926
+ fillEnabled: false,
4927
+ listening: false,
4928
+ perfectDrawEnabled: false
4929
+ });
4930
+ this.iconNodeById.set(obj.id, { node: iconNode, fontSize: obj.fontSize });
4931
+ this.bgLayer.add(iconNode);
4932
+ return;
4933
+ }
3414
4934
  const node = new Text({
3415
4935
  x: obj.position.x,
3416
4936
  y: obj.position.y,
3417
4937
  text: obj.text,
3418
4938
  fontSize: obj.fontSize,
3419
4939
  rotation: obj.rotation,
4940
+ fontStyle: konvaFontStyle(obj.bold, obj.italic, "normal"),
3420
4941
  // Authored ink remains preferred, but an embed/theme surface can change
3421
4942
  // the actual canvas. Fail over to readable black/white instead of
3422
4943
  // painting an otherwise valid caption invisibly on that active surface.
3423
4944
  fill: stateAwareBookableLabelInk(background, preferredInk),
3424
- fontFamily: this.labelFont(),
4945
+ fontFamily: obj.fontFamily ?? this.labelFont(),
3425
4946
  listening: false,
3426
4947
  perfectDrawEnabled: false
3427
4948
  });
@@ -3439,11 +4960,40 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3439
4960
  const isDecor = !!obj.role && !isStage;
3440
4961
  const palette = overviewPalette(this.canvasBackground);
3441
4962
  const fill = referenceFocal ? palette.focalFill : authoredFill;
3442
- const stroke = isStage ? lighten(fill, 0.28) : referenceFocal ? palette.focalStroke : void 0;
3443
- const strokeWidth = isStage ? 1 : referenceFocal ? 2 : 0;
4963
+ const isOpenPath = obj.kind === "line" || obj.kind === "polyline";
4964
+ const stroke = obj.stroke?.color ?? (isStage ? lighten(fill, 0.28) : referenceFocal ? palette.focalStroke : isOpenPath ? "#9aa3b5" : void 0);
4965
+ const strokeWidth = obj.stroke?.width ?? (isStage ? 1 : referenceFocal ? 2 : isOpenPath ? 2 : 0);
4966
+ const opacity = obj.opacity != null ? clamp(obj.opacity, 0.1, 1) : 1;
3444
4967
  let cx = 0;
3445
4968
  let cy = 0;
3446
- if (obj.kind === "rect" && obj.x != null && obj.y != null && obj.width != null && obj.height != null) {
4969
+ if (isOpenPath && obj.points && obj.points.length >= 2) {
4970
+ cx = obj.points.reduce((a, p) => a + p.x, 0) / obj.points.length;
4971
+ cy = obj.points.reduce((a, p) => a + p.y, 0) / obj.points.length;
4972
+ const lineStyle = resolvedShapeLineStyle(obj);
4973
+ const arrow = shapeArrowMetrics(strokeWidth);
4974
+ const path = new Arrow({
4975
+ points: obj.points.flatMap((p) => [p.x, p.y]),
4976
+ closed: false,
4977
+ x: cx,
4978
+ y: cy,
4979
+ offsetX: cx,
4980
+ offsetY: cy,
4981
+ rotation: obj.rotation ?? 0,
4982
+ stroke,
4983
+ fill: stroke,
4984
+ strokeWidth,
4985
+ opacity,
4986
+ lineCap: lineStyle.lineCap,
4987
+ lineJoin: lineStyle.lineJoin,
4988
+ pointerAtBeginning: lineStyle.startEnding === "arrow",
4989
+ pointerAtEnding: lineStyle.endEnding === "arrow",
4990
+ ...arrow,
4991
+ listening: false,
4992
+ name: "buyer-shape-open-path"
4993
+ });
4994
+ path.setAttr("shapeObjectId", obj.id);
4995
+ this.bgLayer.add(path);
4996
+ } else if (obj.kind === "rect" && obj.x != null && obj.y != null && obj.width != null && obj.height != null) {
3447
4997
  const grad = isStage ? {
3448
4998
  fillLinearGradientStartPoint: { x: 0, y: 0 },
3449
4999
  fillLinearGradientEndPoint: { x: 0, y: obj.height },
@@ -3463,7 +5013,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3463
5013
  ...grad,
3464
5014
  stroke,
3465
5015
  strokeWidth,
3466
- cornerRadius: 4,
5016
+ cornerRadius: clamp(obj.cornerRadius ?? 4, 0, Math.min(obj.width, obj.height) / 2),
5017
+ opacity,
3467
5018
  listening: false
3468
5019
  })
3469
5020
  );
@@ -3476,7 +5027,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3476
5027
  fillLinearGradientColorStops: [0, darken(fill, 0.3), 1, lighten(fill, 0.12)]
3477
5028
  } : { fill };
3478
5029
  this.bgLayer.add(
3479
- new Ellipse({ x: cx, y: cy, rotation: obj.rotation ?? 0, radiusX: obj.width / 2, radiusY: obj.height / 2, ...grad, stroke, strokeWidth, listening: false })
5030
+ new Ellipse({ x: cx, y: cy, rotation: obj.rotation ?? 0, radiusX: obj.width / 2, radiusY: obj.height / 2, ...grad, stroke, strokeWidth, opacity, listening: false })
3480
5031
  );
3481
5032
  } else if (obj.kind === "polygon" && obj.points && obj.points.length) {
3482
5033
  const pts = obj.points.flatMap((p) => [p.x, p.y]);
@@ -3491,7 +5042,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3491
5042
  fillLinearGradientColorStops: [0, darken(fill, 0.3), 1, lighten(fill, 0.12)]
3492
5043
  } : { fill };
3493
5044
  this.bgLayer.add(
3494
- new Line({ points: pts, closed: true, x: cx, y: cy, offsetX: cx, offsetY: cy, rotation: obj.rotation ?? 0, ...grad, stroke, strokeWidth, listening: false })
5045
+ new Line({ points: pts, closed: true, x: cx, y: cy, offsetX: cx, offsetY: cy, rotation: obj.rotation ?? 0, ...grad, stroke, strokeWidth, opacity, listening: false })
3495
5046
  );
3496
5047
  }
3497
5048
  if (obj.label) {
@@ -3547,10 +5098,14 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3547
5098
  fill: color,
3548
5099
  opacity: GA_FILL_OPACITY,
3549
5100
  stroke: color,
3550
- strokeWidth: 1.5
5101
+ strokeWidth: 1.5,
5102
+ cornerRadius: obj.cornerRadius
3551
5103
  });
3552
5104
  poly.setAttr("gaId", obj.id);
3553
- poly.on("click tap", () => this.opts.onGAClick?.(obj.id));
5105
+ poly.on("click tap", (e) => {
5106
+ if (this.isGhostClick(e)) return;
5107
+ this.opts.onGAClick?.(obj.id);
5108
+ });
3554
5109
  poly.on("mouseenter", () => {
3555
5110
  this.container.style.cursor = "pointer";
3556
5111
  });
@@ -3560,7 +5115,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3560
5115
  this.bgLayer.add(poly);
3561
5116
  const labelPoint = polygonLabelPoint(obj.points, obj.holes);
3562
5117
  const containingSection = this.sections.find((section) => pointInPolygonWithHoles(labelPoint, section.outline, section.holes));
3563
- const label = this.addCentredLabel(this.bgLayer, obj.label, labelPoint.x, labelPoint.y - 8, ink, GA_LABEL_FONT_SIZE, false);
5118
+ const label = this.addCentredLabel(this.bgLayer, obj.displayLabel ?? obj.label, labelPoint.x, labelPoint.y - 8, ink, GA_LABEL_FONT_SIZE, false);
3564
5119
  const capacity = this.addCentredLabel(this.bgLayer, `cap ${obj.capacity}`, labelPoint.x, labelPoint.y + 10, ink, GA_CAPACITY_LABEL_FONT_SIZE, false);
3565
5120
  this.freeTextById.set(`${obj.id}:label`, {
3566
5121
  objectId: obj.id,
@@ -3577,7 +5132,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3577
5132
  categoryKey: obj.categoryKey
3578
5133
  });
3579
5134
  this.gaById.set(obj.id, {
3580
- label: obj.label,
5135
+ label: obj.displayLabel ?? obj.label,
3581
5136
  capacity: obj.capacity,
3582
5137
  categoryKey: obj.categoryKey,
3583
5138
  points: obj.points,
@@ -3600,7 +5155,9 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3600
5155
  const memberIds = [];
3601
5156
  const catCounts = /* @__PURE__ */ new Map();
3602
5157
  let free = 0;
3603
- for (const seat of this.seats) {
5158
+ const bounds2 = polyBounds(obj.outline);
5159
+ const candidates = this.seatIndex ? queryRect(this.seatIndex, bounds2).map((id) => this.seatById.get(id)).filter((seat) => !!seat) : this.seats;
5160
+ for (const seat of candidates) {
3604
5161
  if (this.seatSection.has(seat.id)) continue;
3605
5162
  if (!pointInPolygonWithHoles(seat, obj.outline, obj.holes)) continue;
3606
5163
  memberIds.push(seat.id);
@@ -3611,11 +5168,19 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3611
5168
  [...catCounts].map(([key, w]) => ({ hex: this.catColor.get(key) ?? "#6e7bff", w })),
3612
5169
  "#3a4358"
3613
5170
  );
3614
- const elevation = Math.max(0, Math.round(obj.elevation ?? 0));
3615
- let liftGroupBg = null;
3616
- let liftGroupSeat = null;
5171
+ const elevation = sectionElevationTier(obj.elevation);
5172
+ const geometry = sectionGeometry(obj, {
5173
+ floorBaseHeightM: this.floorBaseHeightMFor(obj.id)
5174
+ });
5175
+ const liftWorld = geometry.height * CHART_UNITS_PER_METRE;
5176
+ const rootGroupBg = new Group({ listening: false });
5177
+ this.bgLayer.add(rootGroupBg);
5178
+ const liftGroupBg = new Group({ listening: false });
5179
+ rootGroupBg.add(liftGroupBg);
5180
+ const liftGroupSeat = new ViewportGroup({ listening: true });
5181
+ this.seatLayer.add(liftGroupSeat);
3617
5182
  const sideFaces = [];
3618
- if (elevation > 0) {
5183
+ if (liftWorld > 0) {
3619
5184
  const faceFill = darken(this.zoneColor.get(obj.zone ?? "") ?? baseFill, 0.42);
3620
5185
  for (let i = 0; i < obj.outline.length; i++) {
3621
5186
  const face = new Line({
@@ -3629,14 +5194,11 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3629
5194
  perfectDrawEnabled: false
3630
5195
  });
3631
5196
  sideFaces.push(face);
3632
- this.bgLayer.add(face);
5197
+ rootGroupBg.add(face);
3633
5198
  }
3634
- liftGroupBg = new Group({ listening: false });
3635
- this.bgLayer.add(liftGroupBg);
3636
- liftGroupSeat = new Group({ listening: false });
3637
- this.seatLayer.add(liftGroupSeat);
3638
5199
  }
3639
- const bgTarget = liftGroupBg ?? this.bgLayer;
5200
+ liftGroupBg.moveToTop();
5201
+ const bgTarget = liftGroupBg;
3640
5202
  const outlineTint = obj.color ?? this.zoneColor.get(obj.zone ?? "") ?? "#3a4358";
3641
5203
  const outlinePoly = polygonWithHolesShape(obj.outline, obj.holes, {
3642
5204
  stroke: rgba(outlineTint, 0.5),
@@ -3710,10 +5272,23 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3710
5272
  nameLabelFits: true,
3711
5273
  subLabelFits: true,
3712
5274
  elevation,
5275
+ liftWorld,
5276
+ rakeDeg: geometry.rake,
5277
+ surfaceFocal: memberIds.length ? { ...this.seatById.get(memberIds[0])?.focalPoint ?? this.isoCentre } : { ...this.chartDoc?.focalPoint ?? this.isoCentre },
5278
+ frontDistanceWorld: 0,
5279
+ rootGroupBg,
5280
+ bgZIndex: rootGroupBg.zIndex(),
5281
+ seatZIndex: liftGroupSeat.zIndex(),
3713
5282
  liftGroupBg,
3714
5283
  liftGroupSeat,
3715
5284
  sideFaces
3716
5285
  };
5286
+ const surfaceCandidates = memberIds.map((id) => this.seatById.get(id)).filter((seat) => !!seat);
5287
+ const distanceCandidates = surfaceCandidates.length ? surfaceCandidates : obj.outline;
5288
+ sec.frontDistanceWorld = Math.min(...distanceCandidates.map((point) => Math.hypot(
5289
+ point.x - sec.surfaceFocal.x,
5290
+ point.y - sec.surfaceFocal.y
5291
+ )));
3717
5292
  for (const id of memberIds) this.seatSection.set(id, sec);
3718
5293
  this.refreshSectionFill(sec);
3719
5294
  this.refreshSectionHeat(sec);
@@ -3736,20 +5311,33 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3736
5311
  sec.outlinePoly.shadowBlur(4 + raw * 12);
3737
5312
  sec.outlinePoly.shadowOpacity(0.25 + raw * 0.45);
3738
5313
  }
3739
- /** Recompute a section's neutral overview state and retained detail count. */
5314
+ /** Recompute a section's availability shading + retained availability text. */
3740
5315
  refreshSectionFill(sec) {
3741
5316
  sec.blockPoly.fill(this.sectionBlockFill(sec));
3742
- sec.subLabel.text(t("map.seatsLeft", { count: sec.free }));
5317
+ sec.subLabel.text(
5318
+ this.sectionAvailabilityState(sec) === "sold-out" ? t("map.soldOut") : t("map.seatsLeft", { count: sec.free })
5319
+ );
3743
5320
  sec.subLabel.offsetX(sec.subLabel.width() / 2);
3744
5321
  }
3745
5322
  /** True when a section/zone is currently in the `closed` event-state. */
3746
5323
  isSectionClosed(sec) {
3747
5324
  return this.closedSections.has(sec.id) || this.closedSections.has(sec.logicalId) || sec.zone != null && this.closedSections.has(sec.zone);
3748
5325
  }
3749
- /** Clean overview shells never leak category, price, or live availability paint. */
5326
+ /** Resolve the four-bucket overview availability state for a section (OV-69).
5327
+ * `closed` (operator) outranks live availability; the availability buckets
5328
+ * only apply to seated sections (GA-only shells have no seat capacity here). */
5329
+ sectionAvailabilityState(sec) {
5330
+ if (this.isSectionClosed(sec)) return "closed";
5331
+ if (sec.total <= 0) return "normal";
5332
+ if (sec.free <= 0) return "sold-out";
5333
+ if (sec.free <= sec.total * SECTION_NEARLY_GONE_RATIO) return "nearly-gone";
5334
+ return "normal";
5335
+ }
5336
+ /** Overview shells stay neutral except for the bounded availability shading
5337
+ * (nearly-gone / sold-out) and the operator `closed` slab — never category
5338
+ * or price paint, which is deferred to section focus/seat detail. */
3750
5339
  sectionBlockFill(sec) {
3751
- const fill = overviewPalette(this.canvasBackground).sectionFill;
3752
- return this.isSectionClosed(sec) ? darken(fill, 0.12) : fill;
5340
+ return overviewStateFill(overviewPalette(this.canvasBackground), this.sectionAvailabilityState(sec));
3753
5341
  }
3754
5342
  /**
3755
5343
  * Zone rung: one giant screen-constant label per zone (+ optional "FROM $n"),
@@ -3859,15 +5447,16 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3859
5447
  const sectionOverview = scale < CACHE_THRESHOLD;
3860
5448
  if (sectionOverview) blockT = 1;
3861
5449
  if (!this.zones.length) zoneT = 0;
3862
- this.seatLayer.opacity(sectionOverview ? 0 : 1 - blockT);
5450
+ const deferPerspectiveSeatReveal = this.viewMode === "perspective" && this.glideInProgress && this.seats.length > 2500;
5451
+ this.seatLayer.opacity(sectionOverview || deferPerspectiveSeatReveal ? 0 : 1 - blockT);
3863
5452
  const sx = this.stage.scaleX();
3864
- const rescale = this.lodScale === 0 || Math.abs(scale - this.lodScale) / (this.lodScale || 1) > 0.02;
5453
+ const rescale = !this.glideInProgress && (this.lodScale === 0 || Math.abs(scale - this.lodScale) / (this.lodScale || 1) > 0.08);
3865
5454
  if (rescale) this.lodScale = scale;
3866
5455
  const focus = this.focusedSectionId;
3867
5456
  const palette = overviewPalette(this.canvasBackground);
3868
5457
  const sectionLabelT = clamp((blockT - 0.2) / 0.8, 0, 1);
3869
5458
  for (const sec of this.sections) {
3870
- const dim = focus && sec.id !== focus && sec.logicalId !== focus && sec.zone !== focus ? FOCUS_DIM_OPACITY : 1;
5459
+ const dim = this.focusDimOverlay ? 1 : focus && sec.id !== focus && sec.logicalId !== focus && sec.zone !== focus ? FOCUS_DIM_OPACITY : 1;
3871
5460
  sec.outlinePoly.opacity(sectionOverview ? 0 : (1 - blockT) * dim);
3872
5461
  sec.blockPoly.opacity(BLOCK_FILL_ALPHA * blockT * dim);
3873
5462
  sec.blockPoly.stroke(palette.sectionStroke);
@@ -3879,7 +5468,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3879
5468
  );
3880
5469
  sec.nameLabel.fill(sectionInk);
3881
5470
  sec.subLabel.fill(sectionInk);
3882
- if (rescale) this.fitSectionRungLabels(sec, sx);
5471
+ if (rescale && sectionLabelT > 0.01) this.fitSectionRungLabels(sec, sx);
3883
5472
  const labelOpacity = sectionLabelT * (1 - zoneT) * dim;
3884
5473
  sec.nameLabel.opacity(sec.nameLabelFits ? labelOpacity : 0);
3885
5474
  sec.subLabel.opacity(0);
@@ -3891,12 +5480,14 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3891
5480
  if (zone.sub) zone.sub.opacity(zoneOpacity);
3892
5481
  if (rescale) this.sizeZonePill(zone, sx);
3893
5482
  }
3894
- this.decollideRungLabels(sx);
3895
- this.dedupeLogicalSectionLabels();
3896
- for (const zone of this.zones) {
3897
- const opacity = zone.label.opacity();
3898
- zone.back.opacity(opacity);
3899
- if (zone.sub) zone.sub.opacity(opacity);
5483
+ if (!this.glideInProgress && (sectionLabelT > 0.01 || zoneOpacity > 0.01)) {
5484
+ this.decollideRungLabels(sx);
5485
+ this.dedupeLogicalSectionLabels();
5486
+ for (const zone of this.zones) {
5487
+ const opacity = zone.label.opacity();
5488
+ zone.back.opacity(opacity);
5489
+ if (zone.sub) zone.sub.opacity(opacity);
5490
+ }
3900
5491
  }
3901
5492
  this.bgLayer.batchDraw();
3902
5493
  }
@@ -4023,13 +5614,30 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4023
5614
  screenToWorld(clientPoint) {
4024
5615
  const s = this.stage.scaleX();
4025
5616
  const iso = { x: (clientPoint.x - this.stage.x()) / s, y: (clientPoint.y - this.stage.y()) / s };
5617
+ if (this.viewMode === "perspective" && this.perspectiveBaseInverse) {
5618
+ return applyAffine(this.perspectiveBaseInverse, iso);
5619
+ }
4026
5620
  return this.isoT === 0 ? iso : this.isoInverse(iso);
4027
5621
  }
4028
5622
  /** Section id under a container-relative screen point, or null (Slice 5 tap-to-zoom). */
4029
5623
  sectionAt(clientPoint) {
4030
5624
  if (!this.sections.length) return null;
4031
5625
  const world = this.screenToWorld(clientPoint);
4032
- const hit = this.sections.find((sec) => pointInPolygonWithHoles(world, sec.outline, sec.holes));
5626
+ const hit = this.sections.find((sec) => {
5627
+ if (this.viewMode === "perspective" && sec.perspectiveAffine) {
5628
+ return pointInPolygonWithHoles(
5629
+ world,
5630
+ sec.outline.map((point) => this.perspectiveLocalPoint(this.projectedSectionPoint(sec, point))),
5631
+ sec.holes.map((hole) => hole.map((point) => this.perspectiveLocalPoint(this.projectedSectionPoint(sec, point))))
5632
+ );
5633
+ }
5634
+ const offset = sec.liftWorld > 0 ? this.isoLiftLocal(sec.liftWorld) : { x: 0, y: 0 };
5635
+ return pointInPolygonWithHoles(
5636
+ { x: world.x - offset.x, y: world.y - offset.y },
5637
+ sec.outline,
5638
+ sec.holes
5639
+ );
5640
+ });
4033
5641
  return hit ? hit.logicalId : null;
4034
5642
  }
4035
5643
  /** Seat ids belonging to a section (Slice 5 section-summary card). */
@@ -4125,15 +5733,20 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4125
5733
  if (!seat) return;
4126
5734
  const candidate = this.selectionFocusId === id;
4127
5735
  const dims = this.boothDims.get(seat.rowId);
5736
+ const rendered = this.renderedSeatPoint(seat);
4128
5737
  const marker = new Group({
4129
5738
  name: "selection-ring",
4130
- x: seat.x,
4131
- y: seat.y,
5739
+ x: rendered.x,
5740
+ y: rendered.y,
4132
5741
  rotation: dims?.rotation ?? 0,
4133
5742
  listening: false,
4134
5743
  perfectDrawEnabled: false,
4135
5744
  opacity: this.selectionFocusId && !candidate ? 0.2 : 1
4136
5745
  });
5746
+ if (this.viewMode === "perspective") {
5747
+ const projectionScale = this.perspectiveSeatScale.get(id) ?? 1;
5748
+ marker.scale({ x: projectionScale, y: projectionScale });
5749
+ }
4137
5750
  marker.setAttr("seatId", id);
4138
5751
  const common = {
4139
5752
  stroke: this.effSelection,
@@ -4212,12 +5825,22 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4212
5825
  * must fall through and pick.
4213
5826
  */
4214
5827
  sectionBounds(id) {
4215
- const bounds = this.sections.filter((section) => section.id === id || section.logicalId === id).map((section) => polyBounds(section.outline));
4216
- if (!bounds.length) return null;
4217
- const left = Math.min(...bounds.map((box) => box.x));
4218
- const top = Math.min(...bounds.map((box) => box.y));
4219
- const right = Math.max(...bounds.map((box) => box.x + box.width));
4220
- const bottom = Math.max(...bounds.map((box) => box.y + box.height));
5828
+ const bounds2 = this.sections.filter((section) => section.id === id || section.logicalId === id).map((section) => {
5829
+ if (this.viewMode === "perspective" && section.perspectiveAffine) {
5830
+ return polyBounds(section.outline.map((point) => this.projectedSectionPoint(section, point)));
5831
+ }
5832
+ const offset = section.liftWorld > 0 ? this.isoLiftLocal(section.liftWorld) : { x: 0, y: 0 };
5833
+ const points = section.outline.map((point) => {
5834
+ const local = { x: point.x + offset.x, y: point.y + offset.y };
5835
+ return this.isoT === 0 ? local : this.isoForward(local);
5836
+ });
5837
+ return polyBounds(points);
5838
+ });
5839
+ if (!bounds2.length) return null;
5840
+ const left = Math.min(...bounds2.map((box) => box.x));
5841
+ const top = Math.min(...bounds2.map((box) => box.y));
5842
+ const right = Math.max(...bounds2.map((box) => box.x + box.width));
5843
+ const bottom = Math.max(...bounds2.map((box) => box.y + box.height));
4221
5844
  return { x: left, y: top, width: right - left, height: bottom - top };
4222
5845
  }
4223
5846
  sectionFrameScale(id) {
@@ -4270,15 +5893,16 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4270
5893
  * hijacking a clean tap on empty space beyond the slop.
4271
5894
  */
4272
5895
  nearestSeatToScreen(screen, slopPx) {
4273
- const s = this.stage.scaleX() || 1;
4274
- const reachWorld = this.seatR + slopPx / s;
4275
- const world = this.screenToWorld(screen);
4276
5896
  let best = null;
4277
- let bestD = reachWorld;
5897
+ let bestD = Infinity;
5898
+ const stageScale = this.stage.scaleX() || 1;
4278
5899
  for (const seat of this.seats) {
4279
5900
  if (!this.selection.has(seat.id) && !this.isSelectable(seat.id)) continue;
4280
- const d = Math.hypot(seat.x - world.x, seat.y - world.y);
4281
- if (d < bestD) {
5901
+ const centre = this.worldToScreen(seat);
5902
+ const perspectiveScale = this.viewMode === "perspective" ? this.perspectiveSeatScale.get(seat.id) ?? 1 : 1;
5903
+ const reachPx = this.seatR * stageScale * perspectiveScale + slopPx;
5904
+ const d = Math.hypot(centre.x - screen.x, centre.y - screen.y);
5905
+ if (d <= reachPx && d < bestD) {
4282
5906
  bestD = d;
4283
5907
  best = seat.id;
4284
5908
  }
@@ -4288,6 +5912,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4288
5912
  wireInteraction() {
4289
5913
  this.seatLayer.on("click tap", (e) => {
4290
5914
  if (this.moved > PAN_START_SLOP_PX) return;
5915
+ if (this.isGhostClick(e)) return;
4291
5916
  const id = seatIdOf(e.target);
4292
5917
  if (!id) return;
4293
5918
  this.handleSeatTap(id);
@@ -4297,13 +5922,17 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4297
5922
  if (!id) return;
4298
5923
  const seat = this.seatById.get(id);
4299
5924
  if (!seat) return;
4300
- this.hoverRing.position({ x: seat.x, y: seat.y });
5925
+ this.hoveredId = id;
5926
+ this.hoverRing.position(this.renderedSeatPoint(seat));
5927
+ const projectionScale = this.viewMode === "perspective" ? this.perspectiveSeatScale.get(id) ?? 1 : 1;
5928
+ this.hoverRing.scale({ x: projectionScale, y: projectionScale });
4301
5929
  this.hoverRing.visible(true);
4302
5930
  this.overlayLayer.batchDraw();
4303
5931
  this.container.style.cursor = "pointer";
4304
5932
  this.opts.onHover?.(seat);
4305
5933
  });
4306
5934
  this.seatLayer.on("mouseout", () => {
5935
+ this.hoveredId = null;
4307
5936
  this.hoverRing.visible(false);
4308
5937
  this.overlayLayer.batchDraw();
4309
5938
  this.container.style.cursor = "default";
@@ -4318,6 +5947,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4318
5947
  });
4319
5948
  this.stage.on("click tap", (e) => {
4320
5949
  if (this.moved > PAN_START_SLOP_PX) return;
5950
+ if (this.isGhostClick(e)) return;
4321
5951
  const pointer = this.stage.getPointerPosition();
4322
5952
  if (!pointer) return;
4323
5953
  if (!this.cached) {
@@ -4334,11 +5964,10 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4334
5964
  }
4335
5965
  }
4336
5966
  if (this.sections.length) {
4337
- const world = this.screenToWorld(pointer);
4338
- const hit = this.sections.find((sn) => pointInPolygonWithHoles(world, sn.outline, sn.holes));
4339
- if (hit) {
4340
- if (this.opts.onSectionTap) this.opts.onSectionTap(hit.logicalId);
4341
- else this.focusRegion(hit.logicalId);
5967
+ const sectionId = this.sectionAt(pointer);
5968
+ if (sectionId) {
5969
+ if (this.opts.onSectionTap) this.opts.onSectionTap(sectionId);
5970
+ else this.focusRegion(sectionId);
4342
5971
  return;
4343
5972
  }
4344
5973
  }
@@ -4355,13 +5984,15 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4355
5984
  */
4356
5985
  deckFloorAt() {
4357
5986
  if (!this.objectFloor.size) return null;
4358
- const p = this.seatLayer.getRelativePointerPosition();
4359
- if (!p) return null;
5987
+ const pointer = this.stage.getPointerPosition();
5988
+ if (!pointer) return null;
5989
+ const p = this.screenToWorld(pointer);
4360
5990
  let best = null;
4361
5991
  let bestD = Infinity;
4362
5992
  for (const s of this.seats) {
4363
- const dx = s.x - p.x;
4364
- const dy = s.y - p.y;
5993
+ const rendered = this.renderedSeatPoint(s);
5994
+ const dx = rendered.x - p.x;
5995
+ const dy = rendered.y - p.y;
4365
5996
  const d = dx * dx + dy * dy;
4366
5997
  if (d < bestD) {
4367
5998
  bestD = d;
@@ -4448,9 +6079,11 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4448
6079
  }
4449
6080
  const start = performance.now();
4450
6081
  const duration = Math.max(180, Math.min(1200, opts?.durationMs ?? CAMERA_GLIDE_MS));
6082
+ this.glideInProgress = true;
4451
6083
  const step = (now) => {
4452
6084
  if (this.destroyed) {
4453
6085
  this.glideRaf = 0;
6086
+ this.glideInProgress = false;
4454
6087
  return;
4455
6088
  }
4456
6089
  const raw = Math.min(1, (now - start) / duration);
@@ -4458,6 +6091,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4458
6091
  const sc = fromScale + (toScale - fromScale) * e;
4459
6092
  this.stage.scale({ x: sc, y: sc });
4460
6093
  this.stage.position({ x: fromX + (toX - fromX) * e, y: fromY + (toY - fromY) * e });
6094
+ this.updateSeatGroupVisibility();
4461
6095
  this.updateLOD();
4462
6096
  this.scheduleViewChange();
4463
6097
  this.stage.batchDraw();
@@ -4465,6 +6099,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4465
6099
  this.glideRaf = requestAnimationFrame(step);
4466
6100
  } else {
4467
6101
  this.glideRaf = 0;
6102
+ this.glideInProgress = false;
4468
6103
  this.afterViewChange();
4469
6104
  }
4470
6105
  };
@@ -4476,6 +6111,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4476
6111
  cancelAnimationFrame(this.glideRaf);
4477
6112
  this.glideRaf = 0;
4478
6113
  }
6114
+ this.glideInProgress = false;
4479
6115
  }
4480
6116
  /** Current LOD rung derived from effective zoom — drives the ZONES/SECTIONS/SEATS pill. */
4481
6117
  getRung() {
@@ -4492,11 +6128,14 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4492
6128
  const labels = this.seats.map((seat) => {
4493
6129
  const shape = this.circleById.get(seat.id);
4494
6130
  const label = this.boothLabelById.get(seat.id) ?? this.seatLabelById.get(seat.id);
6131
+ const localPerspectiveScale = this.viewMode === "perspective" ? this.perspectiveSeatScale.get(seat.id) ?? 1 : 1;
6132
+ const seatEffectiveScale = (this.viewMode === "perspective" ? stageScale : effectiveScale) * localPerspectiveScale;
4495
6133
  const authoredFontSize = seat.kind === "booth" ? BOOTH_LABEL_FONT_SIZE : SEAT_LABEL_FONT_SIZE * this.seatLabelScale(seat);
4496
- const renderedFontPx = rounded((label?.fontSize() ?? authoredFontSize) * effectiveScale);
6134
+ const labelEffectiveScale = this.viewMode === "perspective" && seat.kind !== "booth" ? effectiveScale * localPerspectiveScale : seatEffectiveScale;
6135
+ const renderedFontPx = rounded((label?.fontSize() ?? authoredFontSize) * labelEffectiveScale);
4497
6136
  const screen = this.worldToScreen(seat);
4498
6137
  const outside = screen.x < 0 || screen.x > viewport.width || screen.y < 0 || screen.y > viewport.height;
4499
- const opacity = shape?.opacity() ?? 0;
6138
+ const opacity = this.focusedSeatOpacity(seat.id, shape?.getAbsoluteOpacity() ?? 0);
4500
6139
  const section = this.seatSection.get(seat.id);
4501
6140
  const visible = Boolean(label?.isVisible()) && opacity >= 0.5 && !outside;
4502
6141
  let hiddenReason;
@@ -4507,17 +6146,21 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4507
6146
  else if (!label) hiddenReason = "clutter-or-fit";
4508
6147
  else hiddenReason = "renderer-hidden";
4509
6148
  }
4510
- const labelWidth = label ? label.width() * stageScale : 0;
4511
- const labelHeight = label ? label.height() * effectiveScale : 0;
4512
- const directWidthPx = shape instanceof Rect ? shape.width() * stageScale : this.seatR * 2 * effectiveScale;
4513
- const directHeightPx = shape instanceof Rect ? shape.height() * stageScale : this.seatR * 2 * effectiveScale;
4514
- const assistedDiameterPx = 2 * (this.seatR * effectiveScale + SEAT_TAP_SLOP_PX);
6149
+ const labelWidth = label ? label.width() * stageScale * localPerspectiveScale : 0;
6150
+ const labelHeight = label ? label.height() * (this.viewMode === "perspective" && seat.kind === "booth" ? stageScale : effectiveScale) * localPerspectiveScale : 0;
6151
+ const directWidthPx = shape instanceof Rect ? shape.width() * stageScale * localPerspectiveScale : this.seatR * 2 * seatEffectiveScale;
6152
+ const directHeightPx = shape instanceof Rect ? shape.height() * (this.viewMode === "perspective" ? stageScale : effectiveScale) * localPerspectiveScale : this.seatR * 2 * seatEffectiveScale;
6153
+ const assistedDiameterPx = 2 * (this.seatR * seatEffectiveScale + SEAT_TAP_SLOP_PX);
4515
6154
  const fill = shape?.fill();
4516
6155
  const ink = label?.fill();
6156
+ const accessGlyph = this.accessGlyphById.get(seat.id);
6157
+ const accessGlyphScale = accessGlyph?.getAbsoluteScale();
4517
6158
  return {
4518
6159
  seatId: seat.id,
4519
6160
  label: seat.label,
4520
6161
  kind: seat.kind === "booth" ? "booth" : "seat",
6162
+ markerShape: seat.kind === "booth" ? "booth" : seat.wheelchairSpaceType === "no-seat" ? "square" : "circle",
6163
+ ...seat.wheelchairSpaceType ? { wheelchairSpaceType: seat.wheelchairSpaceType } : {},
4521
6164
  categoryKey: seat.categoryKey,
4522
6165
  ...section ? { sectionId: section.id } : {},
4523
6166
  ...section?.zone ? { zoneId: section.zone } : {},
@@ -4528,8 +6171,15 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4528
6171
  fill: typeof fill === "string" ? fill : "",
4529
6172
  ink: typeof ink === "string" ? ink : this.theme.seatLabelColor ?? DEF_SEAT_LABEL,
4530
6173
  opacity: rounded(opacity),
6174
+ ...accessGlyph ? {
6175
+ accessibilityMarker: {
6176
+ glyphVisible: accessGlyph.isVisible(),
6177
+ glyphWidthPx: rounded(ACCESS_GLYPH_VIEWBOX * Math.abs(accessGlyphScale?.x ?? 0)),
6178
+ emphasizedByFilter: this.accessGlyphFilterEmphasized(seat.id)
6179
+ }
6180
+ } : {},
4531
6181
  pointerTarget: {
4532
- active: !this.cached && this.isSelectable(seat.id),
6182
+ active: !this.cached && !this.seatViewportCulled(seat.id) && Boolean(shape?.isVisible()) && Boolean(shape?.isListening()) && this.isSelectable(seat.id),
4533
6183
  directWidthPx: rounded(directWidthPx),
4534
6184
  directHeightPx: rounded(directHeightPx),
4535
6185
  effectiveMinimumPx: rounded(Math.max(
@@ -4557,7 +6207,11 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4557
6207
  node.height(),
4558
6208
  node.rotation()
4559
6209
  );
4560
- const screenBounds = pointsBounds(worldCorners.map((corner) => this.worldToScreen(corner)));
6210
+ const lift = section && section.liftWorld > 0 ? this.isoLiftLocal(section.liftWorld) : { x: 0, y: 0 };
6211
+ const screenBounds = pointsBounds(worldCorners.map((corner) => this.worldToScreen({
6212
+ x: corner.x + lift.x,
6213
+ y: corner.y + lift.y
6214
+ })));
4561
6215
  const opacity = rounded(node.opacity());
4562
6216
  const ink = node.fill();
4563
6217
  const outside = screenBounds.x + screenBounds.width < 0 || screenBounds.x > viewport.width || screenBounds.y + screenBounds.height < 0 || screenBounds.y > viewport.height;
@@ -4689,13 +6343,21 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4689
6343
  };
4690
6344
  });
4691
6345
  const palette = overviewPalette(this.canvasBackground);
4692
- const neutralSectionFills = /* @__PURE__ */ new Set([
4693
- palette.sectionFill.toLowerCase(),
4694
- darken(palette.sectionFill, 0.12).toLowerCase()
4695
- ]);
6346
+ const neutralSectionFills = new Set(
6347
+ ["normal", "nearly-gone", "sold-out", "closed"].map((state) => overviewStateFill(palette, state).toLowerCase())
6348
+ );
4696
6349
  const visibleSectionShells = this.sections.filter((section) => section.blockPoly.opacity() > 0.05);
4697
6350
  return {
4698
6351
  viewport,
6352
+ projection: this.viewMode,
6353
+ ...this.viewMode === "perspective" ? {
6354
+ perspective: {
6355
+ model: "pinhole-exact-seat-anchors",
6356
+ sectionSurfaceModel: "tangent-plane",
6357
+ exactSeatAnchorCount: this.perspectiveSeatProjected.size,
6358
+ depthSorted: true
6359
+ }
6360
+ } : {},
4699
6361
  canvasBackground: this.canvasBackground,
4700
6362
  effectiveScale: rounded(effectiveScale),
4701
6363
  rung: this.getRung(),
@@ -4750,10 +6412,10 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4750
6412
  let cy = viewCentre.y;
4751
6413
  if (rung === "sections" && this.sections.length > 0) {
4752
6414
  const sectionCentres = this.sections.map((section) => {
4753
- const bounds = polyBounds(section.outline);
6415
+ const bounds2 = polyBounds(section.outline);
4754
6416
  return {
4755
- x: bounds.x + bounds.width / 2,
4756
- y: bounds.y + bounds.height / 2
6417
+ x: bounds2.x + bounds2.width / 2,
6418
+ y: bounds2.y + bounds2.height / 2
4757
6419
  };
4758
6420
  });
4759
6421
  const halfWidth = w / (target * 2);
@@ -4820,6 +6482,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4820
6482
  }
4821
6483
  /** Recompute LOD (cache/labels) after any pan/zoom settles. */
4822
6484
  afterViewChange() {
6485
+ this.updateSeatGroupVisibility();
4823
6486
  this.updateLOD();
4824
6487
  this.updateFreeTextVisibility();
4825
6488
  this.updateLabels();
@@ -4844,10 +6507,15 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4844
6507
  this.paintGAStateForView();
4845
6508
  this.updateAccessGlyphs(scale);
4846
6509
  const shouldCache = scale < CACHE_THRESHOLD;
6510
+ const suppressPerspectiveSeatCache = this.viewMode === "perspective" && this.hasSections && this.seats.length > 2500 && shouldCache;
6511
+ if (suppressPerspectiveSeatCache) {
6512
+ this.seatLayer.listening(false);
6513
+ return;
6514
+ }
4847
6515
  if (shouldCache && !this.cached) {
4848
6516
  this.cacheSeatLayer();
4849
- } else if (!shouldCache && this.cached) {
4850
- this.seatLayer.clearCache();
6517
+ } else if (!shouldCache && (this.cached || !this.seatLayer.listening())) {
6518
+ if (this.cached) this.seatLayer.clearCache();
4851
6519
  this.seatLayer.listening(true);
4852
6520
  this.cached = false;
4853
6521
  this.seatLayer.batchDraw();
@@ -4856,6 +6524,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4856
6524
  /** Rebuild the seat-layer bitmap synchronously (no paint). Shared by the
4857
6525
  * debounced cacheSeatLayer() and the synchronous forceDraw() catch-up. */
4858
6526
  rebuildSeatCache() {
6527
+ this.updateSeatGroupVisibility();
4859
6528
  const pr = clamp(this.stage.scaleX() * this.dpr, 0.15, 2);
4860
6529
  this.seatLayer.clearCache();
4861
6530
  this.seatLayer.cache({ pixelRatio: pr });
@@ -4900,6 +6569,63 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4900
6569
  const gaOverviewHidden = objectId != null && (kind === "ga-label" || kind === "ga-capacity") && this.gaById.get(objectId)?.sectionId != null && effectiveScale < CACHE_THRESHOLD;
4901
6570
  node.visible(!gaDimmed && !gaOverviewHidden && isBookableLabelLegibleAtScale(node.fontSize(), effectiveScale));
4902
6571
  }
6572
+ for (const { node, fontSize } of this.iconNodeById.values()) {
6573
+ node.visible(isBookableLabelLegibleAtScale(fontSize, effectiveScale));
6574
+ }
6575
+ }
6576
+ /** OV-45(a): resolve authored row-letter labels for the buyer/preview map, in
6577
+ * world space, matching the Designer's placement contract (start/end/both/none
6578
+ * preset, a free-drag `position` that wins over the preset, rotation, and
6579
+ * labelStyle colour/size fed through the same auto-contrast guard). Segmented
6580
+ * rows keep their own logical-label handling and are resolved by their owner. */
6581
+ buildRowLabelPlan(view) {
6582
+ this.rowLabelPlan = [];
6583
+ const background = this.theme.background ?? "#0e1117";
6584
+ const seatsByRow = /* @__PURE__ */ new Map();
6585
+ for (const seat of this.seats) {
6586
+ if (seat.kind === "booth") continue;
6587
+ const key = seat.logicalRowId ?? seat.rowId;
6588
+ (seatsByRow.get(key) ?? seatsByRow.set(key, []).get(key)).push(seat);
6589
+ }
6590
+ const seatOrder = (seat) => seat.logicalSeatIndex ?? Number(seat.id.split(":").pop() ?? 0);
6591
+ for (const obj of view.objects) {
6592
+ if (obj.type !== "row") continue;
6593
+ if (obj.segmentedRow && obj.segmentedRow.componentIndex !== 0) continue;
6594
+ const presentation = obj.segmentedRow?.labelPresentation ?? obj.labelPresentation;
6595
+ if (presentation?.visible === false || presentation?.positionPreset === "none") continue;
6596
+ const key = obj.segmentedRow ? obj.segmentedRow.groupId : obj.id;
6597
+ const seats = (seatsByRow.get(key) ?? []).slice().sort((a, b) => seatOrder(a) - seatOrder(b));
6598
+ if (!seats.length) continue;
6599
+ const first = seats[0];
6600
+ const last = seats[seats.length - 1];
6601
+ const labelStyle = presentation?.labelStyle;
6602
+ const ink = stateAwareBookableLabelInk(
6603
+ background,
6604
+ labelStyle?.color ?? this.theme.rowLabelColor ?? this.theme.textColor ?? "#e6e9f0"
6605
+ );
6606
+ const fontSize = labelStyle?.size ?? 12;
6607
+ const rotation = presentation?.rotation ?? 0;
6608
+ const text = obj.segmentedRow?.displayLabel ?? obj.displayLabel ?? obj.label;
6609
+ const opacity = this.objectFilteredOut(obj) ? 0.15 : 1;
6610
+ const push = (x, y) => this.rowLabelPlan.push({ text, x, y, rotation, fontSize, ink, opacity });
6611
+ if (presentation?.position) {
6612
+ push(presentation.position.x, presentation.position.y);
6613
+ continue;
6614
+ }
6615
+ const radians = obj.rotation * Math.PI / 180;
6616
+ const along = { x: Math.cos(radians), y: Math.sin(radians) };
6617
+ const gap = this.seatR + 12;
6618
+ const preset = presentation?.positionPreset ?? "start";
6619
+ if (preset === "start" || preset === "both") push(first.x - along.x * gap, first.y - along.y * gap);
6620
+ if (preset === "end" || preset === "both") push(last.x + along.x * gap, last.y + along.y * gap);
6621
+ }
6622
+ }
6623
+ /** Row labels never carry status; the buyer just dims them under the same
6624
+ * category/price filters that dim their seats. */
6625
+ objectFilteredOut(obj) {
6626
+ const key = obj.categoryKey;
6627
+ if (!key) return false;
6628
+ return Boolean(this.categoryHighlight && key !== this.categoryHighlight || this.categoryFilter && !this.categoryFilter.has(key));
4903
6629
  }
4904
6630
  updateLabels() {
4905
6631
  const effectiveScale = this.effScale();
@@ -4907,31 +6633,31 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4907
6633
  for (const [id, label] of this.boothLabelById) {
4908
6634
  const shape = this.circleById.get(id);
4909
6635
  label.visible(
4910
- isBookableLabelLegibleAtScale(label.fontSize(), effectiveScale) && (shape?.opacity() ?? 1) >= 0.5
6636
+ isBookableLabelLegibleAtScale(label.fontSize(), effectiveScale) && Boolean(shape?.isVisible()) && this.focusedSeatOpacity(id, shape?.getAbsoluteOpacity() ?? 1) >= 0.5
4911
6637
  );
4912
6638
  }
4913
6639
  this.labelGroup.destroyChildren();
6640
+ this.labelLiftGroups.clear();
4914
6641
  this.seatLabelById.clear();
4915
6642
  if (!show) {
4916
6643
  this.overlayLayer.batchDraw();
4917
6644
  return;
4918
6645
  }
4919
- const s = this.stage.scaleX();
4920
- const x0 = -this.stage.x() / s;
4921
- const y0 = -this.stage.y() / s;
4922
- const x1 = (this.stage.width() - this.stage.x()) / s;
4923
- const y1 = (this.stage.height() - this.stage.y()) / s;
4924
6646
  let count = 0;
4925
- for (const seat of this.seats) {
6647
+ for (const seat of this.visibleSeatCandidates()) {
4926
6648
  if (seat.kind === "booth") continue;
4927
- if (seat.x < x0 || seat.x > x1 || seat.y < y0 || seat.y > y1) continue;
4928
- if (seat.accessible && this.accessGlyphVisible) continue;
6649
+ const screen = this.worldToScreen(seat);
6650
+ if (screen.x < -20 || screen.x > this.stage.width() + 20 || screen.y < -20 || screen.y > this.stage.height() + 20) continue;
6651
+ if (this.accessGlyphById.has(seat.id) && this.accessGlyphShouldShow(seat.id)) continue;
4929
6652
  const shape = this.circleById.get(seat.id);
4930
- if ((shape?.opacity() ?? 1) < 0.5) continue;
6653
+ if (!shape?.isVisible() || this.focusedSeatOpacity(seat.id, shape.getAbsoluteOpacity()) < 0.5) continue;
4931
6654
  const status = this.statusById.get(seat.id) ?? "free";
6655
+ const labelPoint = this.renderedSeatPoint(seat);
6656
+ const perspectiveScale = this.viewMode === "perspective" ? this.perspectiveSeatScale.get(seat.id) ?? 1 : 1;
4932
6657
  const unavailable = status === "booked" || status === "held" && !this.ownedHold.has(seat.id) && !this.opts.manageMode;
4933
6658
  if (unavailable) {
4934
- const cue = new Group({ x: seat.x, y: seat.y, listening: false });
6659
+ const cue = new Group({ x: labelPoint.x, y: labelPoint.y, listening: false });
6660
+ cue.scale({ x: perspectiveScale, y: perspectiveScale });
4935
6661
  if (status === "held") {
4936
6662
  cue.add(new Rect({
4937
6663
  x: -4.2,
@@ -4960,14 +6686,14 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4960
6686
  listening: false
4961
6687
  }));
4962
6688
  }
4963
- this.labelGroup.add(cue);
6689
+ this.seatLabelContainer(seat.id).add(cue);
4964
6690
  if (++count >= MAX_LABELS) break;
4965
6691
  continue;
4966
6692
  }
4967
6693
  const authoredFontSize = SEAT_LABEL_FONT_SIZE * this.seatLabelScale(seat);
4968
6694
  const t2 = new Text({
4969
- x: seat.x,
4970
- y: seat.y,
6695
+ x: labelPoint.x,
6696
+ y: labelPoint.y,
4971
6697
  text: bookableMarkerLabel(seat.displayLabel ?? seat.label),
4972
6698
  fontSize: authoredFontSize,
4973
6699
  fontStyle: "600",
@@ -4988,11 +6714,36 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4988
6714
  }
4989
6715
  t2.offsetX(t2.width() / 2);
4990
6716
  t2.offsetY(t2.height() / 2);
4991
- this.labelGroup.add(t2);
6717
+ t2.scale({ x: perspectiveScale, y: perspectiveScale });
6718
+ this.seatLabelContainer(seat.id).add(t2);
4992
6719
  this.seatLabelById.set(seat.id, t2);
4993
6720
  if (++count >= MAX_LABELS) break;
4994
6721
  }
4995
- if (this.isoT > 0) this.applyUprightLabels();
6722
+ for (const rl of this.rowLabelPlan) {
6723
+ const screen = this.worldToScreen({ x: rl.x, y: rl.y });
6724
+ if (screen.x < -40 || screen.x > this.stage.width() + 40 || screen.y < -40 || screen.y > this.stage.height() + 40) continue;
6725
+ const t2 = new Text({
6726
+ x: rl.x,
6727
+ y: rl.y,
6728
+ text: rl.text,
6729
+ fontSize: rl.fontSize,
6730
+ fontStyle: "700",
6731
+ fontFamily: this.labelFont(),
6732
+ fill: rl.ink,
6733
+ rotation: rl.rotation,
6734
+ opacity: rl.opacity,
6735
+ shadowColor: "#05070c",
6736
+ shadowBlur: rl.ink.toLowerCase() === "#ffffff" ? 5 : 0,
6737
+ shadowOpacity: rl.ink.toLowerCase() === "#ffffff" ? 0.8 : 0,
6738
+ shadowForStrokeEnabled: false,
6739
+ listening: false,
6740
+ perfectDrawEnabled: false
6741
+ });
6742
+ t2.offsetX(t2.width() / 2);
6743
+ t2.offsetY(t2.height() / 2);
6744
+ this.labelGroup.add(t2);
6745
+ }
6746
+ if (this.isoT > 0 && this.viewMode !== "perspective") this.applyUprightLabels();
4996
6747
  this.overlayLayer.batchDraw();
4997
6748
  }
4998
6749
  handleResize() {
@@ -5058,6 +6809,7 @@ function strandedSingles(seats, statusOf, selectedIds) {
5058
6809
 
5059
6810
  // src/core/bestAvailable.ts
5060
6811
  function seatIndex2(seat, fallback) {
6812
+ if (Number.isInteger(seat.logicalSeatIndex)) return seat.logicalSeatIndex;
5061
6813
  const i = seat.id.lastIndexOf(":");
5062
6814
  if (i < 0) return fallback;
5063
6815
  const n = Number(seat.id.slice(i + 1));
@@ -5077,21 +6829,25 @@ function centroid(seats) {
5077
6829
  }
5078
6830
  return { x: x / seats.length, y: y / seats.length };
5079
6831
  }
6832
+ function candidateFocal(seats, fallback) {
6833
+ return seats.find((seat) => seat.focalPoint)?.focalPoint ?? fallback;
6834
+ }
5080
6835
  function isPremium(seat) {
5081
6836
  return seat.commercial?.premium === true;
5082
6837
  }
5083
6838
  function pickBestAvailable(seats, available, opts) {
5084
6839
  const qty = Math.floor(opts.qty);
5085
6840
  if (!Number.isFinite(qty) || qty <= 0) return { labels: [], reason: "sold_out" };
5086
- const { categoryKey, focal, preferPremium } = opts;
6841
+ const { categoryKey, zoneId, focal, preferPremium } = opts;
5087
6842
  const rows = /* @__PURE__ */ new Map();
5088
6843
  const eligibleAll = [];
5089
6844
  seats.forEach((seat, i) => {
5090
- const elig = available.has(seat.label) && (!categoryKey || seat.categoryKey === categoryKey);
5091
- let arr = rows.get(seat.rowId);
6845
+ const elig = available.has(seat.label) && (!categoryKey || seat.categoryKey === categoryKey) && (!zoneId || seat.zoneId === zoneId);
6846
+ const rowId = seat.logicalRowId ?? seat.rowId;
6847
+ let arr = rows.get(rowId);
5092
6848
  if (!arr) {
5093
6849
  arr = [];
5094
- rows.set(seat.rowId, arr);
6850
+ rows.set(rowId, arr);
5095
6851
  }
5096
6852
  arr.push({ seat, index: seatIndex2(seat, i), elig });
5097
6853
  if (elig) eligibleAll.push(seat);
@@ -5116,7 +6872,7 @@ function pickBestAvailable(seats, available, opts) {
5116
6872
  continue;
5117
6873
  }
5118
6874
  let j = i;
5119
- while (j < slots.length && slots[j].elig) j++;
6875
+ while (j < slots.length && slots[j].elig && (j === i || slots[j].index === slots[j - 1].index + 1)) j++;
5120
6876
  const segLen = j - i;
5121
6877
  for (let p = 0; p + qty <= segLen; p++) {
5122
6878
  const leftRem = p;
@@ -5130,7 +6886,7 @@ function pickBestAvailable(seats, available, opts) {
5130
6886
  startIndex: slots[i + p].index,
5131
6887
  orphan,
5132
6888
  nonPremium: preferPremium ? runSeats.reduce((n, s) => n + (isPremium(s) ? 0 : 1), 0) : 0,
5133
- d2: dist2(centroid(runSeats), focal)
6889
+ d2: dist2(centroid(runSeats), candidateFocal(runSeats, focal))
5134
6890
  };
5135
6891
  if (better(c)) best = c;
5136
6892
  }
@@ -5139,20 +6895,71 @@ function pickBestAvailable(seats, available, opts) {
5139
6895
  }
5140
6896
  if (best) return { labels: best.labels };
5141
6897
  if (eligibleAll.length < qty) return { labels: [], reason: "not_enough_together" };
5142
- const ranked = eligibleAll.map((seat, i) => ({ seat, i, d2: dist2(seat, focal) })).sort((a, b) => {
6898
+ const ranked = eligibleAll.map((seat, i) => ({ seat, i, d2: dist2(seat, seat.focalPoint ?? focal) })).sort((a, b) => {
5143
6899
  if (preferPremium) {
5144
6900
  const pa = isPremium(a.seat) ? 0 : 1;
5145
6901
  const pb = isPremium(b.seat) ? 0 : 1;
5146
6902
  if (pa !== pb) return pa - pb;
5147
6903
  }
5148
6904
  if (a.d2 !== b.d2) return a.d2 - b.d2;
5149
- const r = a.seat.rowId.localeCompare(b.seat.rowId);
6905
+ const r = (a.seat.logicalRowId ?? a.seat.rowId).localeCompare(b.seat.logicalRowId ?? b.seat.rowId);
5150
6906
  if (r !== 0) return r;
5151
6907
  return seatIndex2(a.seat, a.i) - seatIndex2(b.seat, b.i);
5152
6908
  });
5153
6909
  return { labels: ranked.slice(0, qty).map((r) => r.seat.label) };
5154
6910
  }
5155
6911
 
6912
+ // src/core/tableInventory.ts
6913
+ var GroupedTableProjectionError = class extends Error {
6914
+ constructor(objectId, message) {
6915
+ super(message);
6916
+ this.objectId = objectId;
6917
+ this.name = "GroupedTableProjectionError";
6918
+ }
6919
+ };
6920
+ function bounds(table) {
6921
+ const min = table.minOccupancy;
6922
+ const max = table.maxOccupancy;
6923
+ if (!Number.isInteger(min) || !Number.isInteger(max) || min < 1 || max < min || max > table.seatCount) {
6924
+ throw new GroupedTableProjectionError(
6925
+ table.id,
6926
+ `Table "${table.label}" has invalid variable-occupancy bounds`
6927
+ );
6928
+ }
6929
+ return { min, max };
6930
+ }
6931
+ function groupedTableInventory(doc, modelVersion) {
6932
+ if (modelVersion !== 2) return [];
6933
+ const units = [];
6934
+ for (const object of allObjects(doc)) {
6935
+ if (object.type !== "table" || !object.bookAsWhole && !object.variableOccupancy) continue;
6936
+ if (object.bookAsWhole && object.variableOccupancy) {
6937
+ throw new GroupedTableProjectionError(
6938
+ object.id,
6939
+ `Table "${object.label}" cannot be whole-table and variable-occupancy inventory`
6940
+ );
6941
+ }
6942
+ if (!Number.isInteger(object.seatCount) || object.seatCount < 1 || !object.label.trim()) {
6943
+ throw new GroupedTableProjectionError(object.id, "Grouped tables require a label and at least one chair");
6944
+ }
6945
+ const mode = object.variableOccupancy ? "variable" : "whole";
6946
+ const occupancy = mode === "variable" ? bounds(object) : { min: object.seatCount, max: object.seatCount };
6947
+ units.push({
6948
+ objectId: object.id,
6949
+ label: object.label,
6950
+ ...object.displayLabel ? { displayLabel: object.displayLabel } : {},
6951
+ ...object.displayType ? { displayType: object.displayType } : {},
6952
+ categoryKey: object.categoryKey,
6953
+ mode,
6954
+ capacity: object.seatCount,
6955
+ minOccupancy: occupancy.min,
6956
+ maxOccupancy: occupancy.max,
6957
+ chairs: expandTable(object)
6958
+ });
6959
+ }
6960
+ return units;
6961
+ }
6962
+
5156
6963
  // src/picker/PickerController.ts
5157
6964
  var DEFAULT_MAX_SELECTION = 10;
5158
6965
  var MAX_BACKOFF_MS = 15e3;
@@ -5184,6 +6991,14 @@ var PickerController = class {
5184
6991
  /** id → buyer-facing spatial metadata used by every tooltip/confirm surface. */
5185
6992
  this.seatContext = /* @__PURE__ */ new Map();
5186
6993
  this.allIds = [];
6994
+ /** Model-2 tables are one booking unit whose chairs remain renderer geometry. */
6995
+ this.groupedTablesByObject = /* @__PURE__ */ new Map();
6996
+ this.groupedTablesByLabel = /* @__PURE__ */ new Map();
6997
+ this.groupedTableBySeatId = /* @__PURE__ */ new Map();
6998
+ this.tableQuantities = /* @__PURE__ */ new Map();
6999
+ /** Variable quantity is a buyer contract, never an inferred default. */
7000
+ this.confirmedVariableTables = /* @__PURE__ */ new Set();
7001
+ this.groupedSelectionOverhead = 0;
5187
7002
  // realtime socket
5188
7003
  this.ws = null;
5189
7004
  this.reconnectTimer = null;
@@ -5245,6 +7060,20 @@ var PickerController = class {
5245
7060
  idForLabel(label) {
5246
7061
  return this.labelToId.get(label);
5247
7062
  }
7063
+ idsForLabel(label) {
7064
+ const table = this.groupedTablesByLabel.get(label);
7065
+ if (table) return table.chairs.map((chair) => chair.id);
7066
+ const id = this.labelToId.get(label);
7067
+ return id ? [id] : [];
7068
+ }
7069
+ idsForLabels(labels) {
7070
+ return [...new Set([...labels].flatMap((label) => this.idsForLabel(label)))];
7071
+ }
7072
+ /** Resolve a physical chair id (or table inventory label) to its table unit. */
7073
+ tableSelection(seatIdOrLabel) {
7074
+ const group = this.groupedTableBySeatId.get(seatIdOrLabel) ?? this.groupedTablesByLabel.get(seatIdOrLabel);
7075
+ return group ? this.toTableSeat(group) : null;
7076
+ }
5248
7077
  /** Fetch chart, build label maps, mount the renderer, seed statuses, go live. */
5249
7078
  async render(host) {
5250
7079
  if (this.renderer) return null;
@@ -5258,6 +7087,29 @@ var PickerController = class {
5258
7087
  }
5259
7088
  if (this.closed) return null;
5260
7089
  this._doc = res.doc;
7090
+ try {
7091
+ const groups = groupedTableInventory(
7092
+ res.doc,
7093
+ res.event.inventoryModelVersion === 2 ? 2 : 1
7094
+ );
7095
+ this.groupedTablesByObject = new Map(groups.map((group) => [group.objectId, group]));
7096
+ this.groupedTablesByLabel = new Map(groups.map((group) => [group.label, group]));
7097
+ this.groupedTableBySeatId = new Map(
7098
+ groups.flatMap((group) => group.chairs.map((chair) => [chair.id, group]))
7099
+ );
7100
+ this.tableQuantities = new Map(groups.map((group) => [
7101
+ group.label,
7102
+ group.mode === "whole" ? group.capacity : group.minOccupancy
7103
+ ]));
7104
+ this.confirmedVariableTables = /* @__PURE__ */ new Set();
7105
+ this.groupedSelectionOverhead = groups.reduce(
7106
+ (sum, group) => sum + Math.max(0, group.chairs.length - 1),
7107
+ 0
7108
+ );
7109
+ } catch (err) {
7110
+ this.emitError(err);
7111
+ return null;
7112
+ }
5261
7113
  this.labelToId = /* @__PURE__ */ new Map();
5262
7114
  this.labelToSeat = /* @__PURE__ */ new Map();
5263
7115
  this.seatById = /* @__PURE__ */ new Map();
@@ -5275,32 +7127,43 @@ var PickerController = class {
5275
7127
  this.allIds.push(s.id);
5276
7128
  const source = chartObjects.get(s.rowId);
5277
7129
  const sourceLabel = source && "label" in source && typeof source.label === "string" ? source.label : void 0;
5278
- const sourceDisplayLabel = source && "displayLabel" in source && typeof source.displayLabel === "string" && source.displayLabel ? source.displayLabel : sourceLabel;
5279
- const rowLabel = s.kind === "booth" ? void 0 : sourceDisplayLabel;
5280
- const rowType = source && "displayType" in source && typeof source.displayType === "string" && source.displayType.trim() ? source.displayType.trim() : void 0;
7130
+ const logicalRow = source?.type === "row" ? source.segmentedRow : void 0;
7131
+ const sourceDisplayLabel = logicalRow?.displayLabel ?? (source && "displayLabel" in source && typeof source.displayLabel === "string" && source.displayLabel ? source.displayLabel : sourceLabel);
7132
+ const groupedTable = this.groupedTablesByObject.get(s.rowId);
7133
+ const objectType = source?.type === "table" ? "table" : source?.type === "booth" ? "booth" : "seat";
7134
+ const rowLabel = sourceDisplayLabel;
7135
+ const rowType = logicalRow?.displayType?.trim() || (source && "displayType" in source && typeof source.displayType === "string" && source.displayType.trim() ? source.displayType.trim() : void 0);
5281
7136
  const visibleSeatLabel = s.displayLabel ?? s.label;
5282
7137
  const labelParts = visibleSeatLabel.split("-");
5283
- const seatNumber = sourceDisplayLabel && visibleSeatLabel.startsWith(`${sourceDisplayLabel}-`) ? visibleSeatLabel.slice(sourceDisplayLabel.length + 1) : s.kind === "booth" ? visibleSeatLabel : labelParts[labelParts.length - 1] ?? visibleSeatLabel;
7138
+ const seatNumber = groupedTable ? void 0 : s.kind === "booth" ? void 0 : sourceDisplayLabel && visibleSeatLabel.startsWith(`${sourceDisplayLabel}-`) ? visibleSeatLabel.slice(sourceDisplayLabel.length + 1) : labelParts[labelParts.length - 1] ?? visibleSeatLabel;
5284
7139
  this.seatContext.set(s.id, {
7140
+ objectId: s.rowId,
7141
+ objectType,
5285
7142
  sectionLabel: sectionLabels.get(membership.objectToSection.get(s.rowId) ?? ""),
5286
7143
  rowLabel,
5287
7144
  seatNumber,
5288
- rowType
7145
+ ...rowType ? { displayType: rowType, rowType } : {}
5289
7146
  });
5290
7147
  }
7148
+ for (const group of this.groupedTablesByLabel.values()) {
7149
+ const representative = group.chairs[0];
7150
+ if (!representative) continue;
7151
+ this.labelToId.set(group.label, representative.id);
7152
+ this.labelToSeat.set(group.label, representative);
7153
+ }
5291
7154
  const currency = res.event.currency ?? this.opts.currency;
5292
7155
  this.currency = currency ?? "USD";
5293
7156
  const renderer = createRenderer(host, {
5294
- maxSelection: this.maxSelection,
7157
+ // Group chairs are selected together for paint, but count as one logical
7158
+ // inventory line. The controller enforces the guest-weighted cap.
7159
+ maxSelection: this.rendererSelectionCap(),
5295
7160
  confirmSelection: this.opts.confirmSelection,
5296
7161
  currency,
5297
7162
  onSelect: (seat) => {
5298
- this.opts.onSelect?.(seat);
5299
- this.emitSelectionChange();
7163
+ this.handleRendererSelect(seat);
5300
7164
  },
5301
7165
  onDeselect: (seat) => {
5302
- this.opts.onDeselect?.(seat);
5303
- this.emitSelectionChange();
7166
+ this.handleRendererDeselect(seat);
5304
7167
  },
5305
7168
  onSelectionLimit: this.opts.onSelectionLimit,
5306
7169
  onHover: (seat) => {
@@ -5355,7 +7218,20 @@ var PickerController = class {
5355
7218
  // ---- selection ------------------------------------------------------------
5356
7219
  getSelection() {
5357
7220
  if (!this.renderer) return [];
5358
- return this.renderer.getSelection().map((s) => this.toSeat(s));
7221
+ const out = [];
7222
+ const grouped = /* @__PURE__ */ new Set();
7223
+ for (const seat of this.renderer.getSelection()) {
7224
+ const table = this.groupedTableBySeatId.get(seat.id);
7225
+ if (table) {
7226
+ if (!grouped.has(table.label)) {
7227
+ grouped.add(table.label);
7228
+ out.push(this.toTableSeat(table));
7229
+ }
7230
+ } else {
7231
+ out.push(this.toSeat(seat));
7232
+ }
7233
+ }
7234
+ return out;
5359
7235
  }
5360
7236
  /** Enriched metadata for a seat confirmation card or tooltip. */
5361
7237
  seatDetails(seatId) {
@@ -5364,20 +7240,168 @@ var PickerController = class {
5364
7240
  }
5365
7241
  clearSelection() {
5366
7242
  this.renderer?.clearSelection();
7243
+ this.resetUnheldTableQuantities();
5367
7244
  this.emitSelectionChange();
5368
7245
  }
5369
7246
  deselect(ids) {
5370
- this.renderer?.deselect(ids);
7247
+ const expanded = /* @__PURE__ */ new Set();
7248
+ for (const id of ids) {
7249
+ const table = this.groupedTableBySeatId.get(id) ?? this.groupedTablesByLabel.get(id);
7250
+ if (table) {
7251
+ table.chairs.forEach((chair) => expanded.add(chair.id));
7252
+ if (!this.hold_?.labels.includes(table.label)) {
7253
+ this.tableQuantities.set(
7254
+ table.label,
7255
+ table.mode === "whole" ? table.capacity : table.minOccupancy
7256
+ );
7257
+ if (table.mode === "variable") this.confirmedVariableTables.delete(table.label);
7258
+ }
7259
+ } else expanded.add(id);
7260
+ }
7261
+ this.renderer?.deselect([...expanded]);
5371
7262
  this.emitSelectionChange();
5372
7263
  }
5373
7264
  setMaxSelection(maxSelection) {
5374
7265
  this.maxSelection = Math.max(0, Math.floor(maxSelection));
5375
- this.renderer?.setMaxSelection?.(this.maxSelection);
7266
+ this.renderer?.setMaxSelection?.(this.rendererSelectionCap());
5376
7267
  }
5377
7268
  select(ids) {
5378
- const added = this.renderer?.select?.(ids) ?? [];
7269
+ const before = new Set(this.getSelection().map((seat) => seat.label));
7270
+ const expanded = /* @__PURE__ */ new Set();
7271
+ for (const id of ids) {
7272
+ const table = this.groupedTableBySeatId.get(id) ?? this.groupedTablesByLabel.get(id);
7273
+ if (table) table.chairs.forEach((chair) => expanded.add(chair.id));
7274
+ else expanded.add(id);
7275
+ }
7276
+ this.renderer?.select?.([...expanded]);
7277
+ if (this.selectionGuestCount() > this.maxSelection) {
7278
+ this.renderer?.deselect([...expanded]);
7279
+ this.opts.onSelectionLimit?.(this.maxSelection);
7280
+ return [];
7281
+ }
7282
+ const added = this.getSelection().filter((seat) => !before.has(seat.label));
5379
7283
  if (added.length) this.emitSelectionChange();
5380
- return added.map((seat) => this.toSeat(seat));
7284
+ return added;
7285
+ }
7286
+ /** Confirm/update the guest quantity for a selected variable table. */
7287
+ setTableQuantity(label, quantity) {
7288
+ const table = this.groupedTablesByLabel.get(label);
7289
+ if (!table) return false;
7290
+ const q = table.mode === "whole" ? table.capacity : Math.floor(quantity);
7291
+ if (!Number.isInteger(q) || q < table.minOccupancy || q > table.maxOccupancy) return false;
7292
+ const previous = this.tableQuantities.get(label) ?? table.minOccupancy;
7293
+ this.tableQuantities.set(label, q);
7294
+ if (this.selectionGuestCount() > this.maxSelection) {
7295
+ this.tableQuantities.set(label, previous);
7296
+ this.opts.onSelectionLimit?.(this.maxSelection);
7297
+ return false;
7298
+ }
7299
+ if (table.mode === "variable") this.confirmedVariableTables.add(label);
7300
+ this.emitSelectionChange();
7301
+ return true;
7302
+ }
7303
+ /** Atomically replace an active variable-table hold with a new guest count. */
7304
+ async replaceTableQuantity(label, quantity) {
7305
+ const table = this.groupedTablesByLabel.get(label);
7306
+ const hold = this.hold_;
7307
+ if (!table || table.mode !== "variable" || !hold?.labels.includes(label)) return null;
7308
+ const q = Math.floor(quantity);
7309
+ if (!Number.isInteger(q) || q < table.minOccupancy || q > table.maxOccupancy) return null;
7310
+ const otherGuests = (hold.items ?? []).filter((item) => item.label !== label).reduce((sum, item) => sum + (item.quantity ?? 1), 0);
7311
+ if (otherGuests + q > this.maxSelection) {
7312
+ this.opts.onSelectionLimit?.(this.maxSelection);
7313
+ return null;
7314
+ }
7315
+ const selections = (hold.items ?? []).map((item) => ({
7316
+ label: item.label,
7317
+ tierId: item.tierId,
7318
+ quantity: item.label === label ? q : item.quantity
7319
+ }));
7320
+ if (!selections.some((item) => item.label === label)) return null;
7321
+ const result = await this.api.hold(this.key, selections, void 0, hold.holdId);
7322
+ this.tableQuantities.set(label, q);
7323
+ this.setHold({
7324
+ holdId: result.holdId,
7325
+ labels: selections.map((item) => item.label),
7326
+ expiresAt: result.expiresAt,
7327
+ items: result.items
7328
+ });
7329
+ return this.hold_;
7330
+ }
7331
+ rendererSelectionCap() {
7332
+ return this.maxSelection + this.groupedSelectionOverhead;
7333
+ }
7334
+ selectionGuestCount() {
7335
+ return this.getSelection().reduce((sum, seat) => sum + (seat.quantity ?? 1), 0);
7336
+ }
7337
+ handleRendererSelect(seat) {
7338
+ const table = this.groupedTableBySeatId.get(seat.id);
7339
+ if (table) {
7340
+ if (this.selectionGuestCount() > this.maxSelection) {
7341
+ this.renderer?.deselect(table.chairs.map((chair) => chair.id));
7342
+ this.opts.onSelectionLimit?.(this.maxSelection);
7343
+ this.emitSelectionChange();
7344
+ return;
7345
+ }
7346
+ this.renderer?.select?.(table.chairs.map((chair) => chair.id));
7347
+ this.opts.onSelect?.(table.chairs[0] ?? seat);
7348
+ this.opts.onTableSelectionRequest?.(this.toTableSeat(table));
7349
+ this.emitSelectionChange();
7350
+ return;
7351
+ }
7352
+ if (this.selectionGuestCount() > this.maxSelection) {
7353
+ this.renderer?.deselect([seat.id]);
7354
+ this.opts.onSelectionLimit?.(this.maxSelection);
7355
+ this.emitSelectionChange();
7356
+ return;
7357
+ }
7358
+ this.opts.onSelect?.(seat);
7359
+ this.emitSelectionChange();
7360
+ }
7361
+ handleRendererDeselect(seat) {
7362
+ const table = this.groupedTableBySeatId.get(seat.id);
7363
+ if (table) {
7364
+ this.renderer?.deselect(table.chairs.map((chair) => chair.id));
7365
+ if (!this.hold_?.labels.includes(table.label)) {
7366
+ this.tableQuantities.set(
7367
+ table.label,
7368
+ table.mode === "whole" ? table.capacity : table.minOccupancy
7369
+ );
7370
+ if (table.mode === "variable") this.confirmedVariableTables.delete(table.label);
7371
+ }
7372
+ this.opts.onDeselect?.(table.chairs[0] ?? seat);
7373
+ } else {
7374
+ this.opts.onDeselect?.(seat);
7375
+ }
7376
+ this.emitSelectionChange();
7377
+ }
7378
+ resetUnheldTableQuantities() {
7379
+ for (const table of this.groupedTablesByLabel.values()) {
7380
+ if (this.hold_?.labels.includes(table.label)) continue;
7381
+ this.tableQuantities.set(
7382
+ table.label,
7383
+ table.mode === "whole" ? table.capacity : table.minOccupancy
7384
+ );
7385
+ if (table.mode === "variable") this.confirmedVariableTables.delete(table.label);
7386
+ }
7387
+ }
7388
+ resetTableQuantitiesForLabels(labels) {
7389
+ for (const label of labels) {
7390
+ const table = this.groupedTablesByLabel.get(label);
7391
+ if (!table) continue;
7392
+ this.tableQuantities.set(
7393
+ label,
7394
+ table.mode === "whole" ? table.capacity : table.minOccupancy
7395
+ );
7396
+ if (table.mode === "variable") this.confirmedVariableTables.delete(label);
7397
+ }
7398
+ }
7399
+ tableQuantityRequest(seat) {
7400
+ if (seat?.objectType !== "table") return {};
7401
+ if (seat.bookingMode === "variable" && !this.confirmedVariableTables.has(seat.label)) {
7402
+ throw new Error("picker: confirm a variable table guest quantity before holding");
7403
+ }
7404
+ return { quantity: seat.quantity };
5381
7405
  }
5382
7406
  // ---- booking machine ------------------------------------------------------
5383
7407
  /**
@@ -5389,7 +7413,7 @@ var PickerController = class {
5389
7413
  async hold(labelsArg, ttlMs) {
5390
7414
  const r = this.renderer;
5391
7415
  if (!r) return null;
5392
- const labels = labelsArg ?? r.getSelection().map((s) => s.label);
7416
+ const labels = labelsArg ?? this.getSelection().map((seat) => seat.label);
5393
7417
  const existingGA = (this.hold_?.items ?? []).filter((item) => item.objectType === "ga");
5394
7418
  const combinedLabels = [.../* @__PURE__ */ new Set([...existingGA.map((item) => item.label), ...labels])];
5395
7419
  if (!combinedLabels.length) return null;
@@ -5397,10 +7421,14 @@ var PickerController = class {
5397
7421
  try {
5398
7422
  const selections = combinedLabels.map((label) => {
5399
7423
  const ga = existingGA.find((item) => item.label === label);
5400
- if (ga) return { label, tierId: ga.tierId };
7424
+ if (ga) return { label, tierId: ga.tierId, quantity: ga.quantity };
5401
7425
  const seat = this.labelToSeat.get(label);
5402
7426
  const resolved = seat ? this.toSeat(seat) : null;
5403
- return { label, ...resolved?.tierId ? { tierId: resolved.tierId } : {} };
7427
+ return {
7428
+ label,
7429
+ ...resolved?.tierId ? { tierId: resolved.tierId } : {},
7430
+ ...this.tableQuantityRequest(resolved)
7431
+ };
5404
7432
  });
5405
7433
  const result = await this.api.hold(this.key, selections, ttlMs, this.hold_?.holdId);
5406
7434
  this.setHold({ holdId: result.holdId, labels: combinedLabels, expiresAt: result.expiresAt, items: result.items });
@@ -5452,11 +7480,19 @@ var PickerController = class {
5452
7480
  const out = {};
5453
7481
  const closedMembers = this.closedMemberIds();
5454
7482
  for (const [id, s] of this.seatById) {
7483
+ if (this.groupedTableBySeatId.has(id)) continue;
5455
7484
  if (closedMembers.has(id)) continue;
5456
7485
  if ((this.getStatus(id) ?? "free") === "free") {
5457
7486
  out[s.categoryKey] = (out[s.categoryKey] ?? 0) + 1;
5458
7487
  }
5459
7488
  }
7489
+ for (const table of this.groupedTablesByLabel.values()) {
7490
+ if (table.chairs.some((chair) => closedMembers.has(chair.id))) continue;
7491
+ const representative = table.chairs[0];
7492
+ if (representative && (this.getStatus(representative.id) ?? "free") === "free") {
7493
+ out[table.categoryKey] = (out[table.categoryKey] ?? 0) + table.maxOccupancy;
7494
+ }
7495
+ }
5460
7496
  return out;
5461
7497
  }
5462
7498
  /** Seat ids belonging to a currently-closed section (excluded from counts). */
@@ -5475,6 +7511,8 @@ var PickerController = class {
5475
7511
  return {
5476
7512
  id: area.id,
5477
7513
  label: area.label,
7514
+ ...area.displayLabel ? { displayLabel: area.displayLabel } : {},
7515
+ ...area.displayType ? { displayType: area.displayType } : {},
5478
7516
  capacity: Math.max(0, Math.floor(area.capacity)),
5479
7517
  available: gaUnitLabels(area).filter((label) => (this.liveStatuses.get(label) ?? "free") === "free").length,
5480
7518
  categoryKey: area.categoryKey,
@@ -5493,14 +7531,20 @@ var PickerController = class {
5493
7531
  const labels = gaUnitLabels(area).filter((label) => !this.hold_?.labels.includes(label) && (this.liveStatuses.get(label) ?? "free") === "free").slice(0, Math.floor(qty));
5494
7532
  if (labels.length !== Math.floor(qty)) return null;
5495
7533
  const selections = /* @__PURE__ */ new Map();
5496
- for (const item of this.hold_?.items ?? []) selections.set(item.label, { label: item.label, tierId: item.tierId });
7534
+ for (const item of this.hold_?.items ?? []) selections.set(item.label, {
7535
+ label: item.label,
7536
+ tierId: item.tierId,
7537
+ quantity: item.quantity
7538
+ });
5497
7539
  if (this.hold_ && !this.hold_?.items?.length) {
5498
7540
  for (const seat of this.hold_.seats) selections.set(seat.label, { label: seat.label, ...seat.tierId ? { tierId: seat.tierId } : {} });
5499
7541
  }
5500
- for (const seat of this.renderer?.getSelection() ?? []) {
5501
- const resolved = this.labelToSeat.get(seat.label);
5502
- const chosen = resolved ? this.toSeat(resolved) : null;
5503
- selections.set(seat.label, { label: seat.label, ...chosen?.tierId ? { tierId: chosen.tierId } : {} });
7542
+ for (const seat of this.getSelection()) {
7543
+ selections.set(seat.label, {
7544
+ label: seat.label,
7545
+ ...seat.tierId ? { tierId: seat.tierId } : {},
7546
+ ...this.tableQuantityRequest(seat)
7547
+ });
5504
7548
  }
5505
7549
  for (const label of labels) selections.set(label, { label, ...options.tierId ? { tierId: options.tierId } : {} });
5506
7550
  const combined = [...selections.values()];
@@ -5517,6 +7561,15 @@ var PickerController = class {
5517
7561
  }
5518
7562
  return false;
5519
7563
  }
7564
+ /** Zones which still own visible buyer inventory, in authored order. */
7565
+ getBestAvailableZones() {
7566
+ const doc = this.visibleDoc();
7567
+ if (!doc?.zones?.length) return [];
7568
+ const used = new Set(
7569
+ computeSections(doc).sections.filter((section) => section.seatCount > 0 && !!section.zone).map((section) => section.zone)
7570
+ );
7571
+ return doc.zones.filter((zone) => used.has(zone.id)).map((zone) => ({ id: zone.id, label: zone.label }));
7572
+ }
5520
7573
  /**
5521
7574
  * Client-side premium pre-pass: find the best contiguous block of `qty`
5522
7575
  * PREMIUM-flagged free seats (orphan-avoiding, closest to the focal point)
@@ -5525,11 +7578,12 @@ var PickerController = class {
5525
7578
  * seat status. Returns a FULL premium block of `qty`, or null when none
5526
7579
  * exists (the caller then falls back to the normal server pick).
5527
7580
  */
5528
- pickPremiumBlock(qty, categoryKey) {
7581
+ pickPremiumBlock(qty, categoryKey, zoneId) {
5529
7582
  const doc = this.visibleDoc();
5530
7583
  if (!doc?.objects?.length) return null;
5531
7584
  const focal = doc.focalPoint ?? { x: 0, y: 0 };
5532
- const seats = expandChart(doc);
7585
+ const groupedObjectIds = new Set(this.groupedTablesByObject.keys());
7586
+ const seats = expandChart(doc).filter((seat) => !groupedObjectIds.has(seat.rowId));
5533
7587
  const held = new Set(this.hold_?.labels ?? []);
5534
7588
  const available = /* @__PURE__ */ new Set();
5535
7589
  for (const seat of seats) {
@@ -5538,7 +7592,7 @@ var PickerController = class {
5538
7592
  const status = id ? this.getStatus(id) : void 0;
5539
7593
  if ((status ?? "free") === "free") available.add(seat.label);
5540
7594
  }
5541
- const pick = pickBestAvailable(seats, available, { qty, categoryKey, focal, preferPremium: true });
7595
+ const pick = pickBestAvailable(seats, available, { qty, categoryKey, zoneId, focal, preferPremium: true });
5542
7596
  if (pick.labels.length !== qty) return null;
5543
7597
  const allPremium = pick.labels.every((l) => this.labelToSeat.get(l)?.commercial?.premium);
5544
7598
  return allPremium ? [...pick.labels] : null;
@@ -5553,7 +7607,7 @@ var PickerController = class {
5553
7607
  const r = this.renderer;
5554
7608
  if (!r) return null;
5555
7609
  if (opts.preferPremium) {
5556
- const block = this.pickPremiumBlock(qty, categoryKey);
7610
+ const block = this.pickPremiumBlock(qty, categoryKey, opts.zoneId);
5557
7611
  if (block) {
5558
7612
  if (this.hold_ && !await this.release()) return null;
5559
7613
  try {
@@ -5564,7 +7618,7 @@ var PickerController = class {
5564
7618
  });
5565
7619
  const result = await this.api.hold(this.key, selection);
5566
7620
  r.clearSelection();
5567
- const ids = block.map((l) => this.labelToId.get(l)).filter((v) => !!v);
7621
+ const ids = this.idsForLabels(block);
5568
7622
  if (ids.length) r.setStatus(ids, "held");
5569
7623
  this.setHold({ holdId: result.holdId, labels: [...block], expiresAt: result.expiresAt, items: result.items });
5570
7624
  const seats = block.map((l) => this.labelToSeat.get(l)).filter((s) => !!s).map((s) => this.toSeat(s));
@@ -5581,9 +7635,9 @@ var PickerController = class {
5581
7635
  }
5582
7636
  if (this.hold_ && !await this.release()) return null;
5583
7637
  try {
5584
- const result = await this.api.bestAvailable(this.key, qty, categoryKey);
7638
+ const result = await this.api.bestAvailable(this.key, qty, categoryKey, opts.zoneId);
5585
7639
  r.clearSelection();
5586
- const ids = result.labels.map((l) => this.labelToId.get(l)).filter((v) => !!v);
7640
+ const ids = this.idsForLabels(result.labels);
5587
7641
  if (ids.length) r.setStatus(ids, "held");
5588
7642
  this.setHold({ holdId: result.holdId, labels: [...result.labels], expiresAt: result.expiresAt, items: result.items });
5589
7643
  const seats = result.labels.map((l) => this.labelToSeat.get(l)).filter((s) => !!s).map((s) => this.toSeat(s));
@@ -5607,7 +7661,7 @@ var PickerController = class {
5607
7661
  const r = this.renderer;
5608
7662
  if (!r) return null;
5609
7663
  if (!this.api.book) throw new Error("picker: transport has no book() \u2014 hold-only mode");
5610
- const labels = labelsArg ?? r.getSelection().map((s) => s.label);
7664
+ const labels = labelsArg ?? this.getSelection().map((seat) => seat.label);
5611
7665
  if (!labels.length) return null;
5612
7666
  let holdId;
5613
7667
  try {
@@ -5620,7 +7674,11 @@ var PickerController = class {
5620
7674
  labels.map((label) => {
5621
7675
  const seat = this.labelToSeat.get(label);
5622
7676
  const resolved = seat ? this.toSeat(seat) : null;
5623
- return { label, ...resolved?.tierId ? { tierId: resolved.tierId } : {} };
7677
+ return {
7678
+ label,
7679
+ ...resolved?.tierId ? { tierId: resolved.tierId } : {},
7680
+ ...this.tableQuantityRequest(resolved)
7681
+ };
5624
7682
  }),
5625
7683
  void 0,
5626
7684
  replaceHoldId
@@ -5636,10 +7694,11 @@ var PickerController = class {
5636
7694
  this.opts.onSalesClosed?.();
5637
7695
  } else if (status === 409 && conflicts?.length) {
5638
7696
  const takenLabels = new Set(conflicts.map((c) => c.label));
5639
- const takenIds = [...takenLabels].map((l) => this.labelToId.get(l)).filter((v) => !!v);
7697
+ const takenIds = this.idsForLabels(takenLabels);
5640
7698
  if (takenIds.length) {
5641
7699
  r.setStatus(takenIds, "booked");
5642
7700
  r.deselect(takenIds);
7701
+ this.resetTableQuantitiesForLabels(takenLabels);
5643
7702
  }
5644
7703
  const stillFree = labels.filter((l) => !takenLabels.has(l));
5645
7704
  if (stillFree.length && holdId) void this.api.release(this.key, stillFree, holdId).catch(() => {
@@ -5651,12 +7710,13 @@ var PickerController = class {
5651
7710
  }
5652
7711
  throw err;
5653
7712
  }
5654
- const ids = labels.map((l) => this.labelToId.get(l)).filter((v) => !!v);
7713
+ const ids = this.idsForLabels(labels);
5655
7714
  if (ids.length) {
5656
7715
  r.setStatus(ids, "booked");
5657
7716
  r.deselect(ids);
5658
7717
  }
5659
7718
  this.clearHold();
7719
+ this.resetTableQuantitiesForLabels(labels);
5660
7720
  this.emitSelectionChange();
5661
7721
  this.opts.onBook?.(bookingRef);
5662
7722
  return labels;
@@ -5677,7 +7737,8 @@ var PickerController = class {
5677
7737
  }
5678
7738
  if (this.hold_?.holdId !== hold.holdId) return true;
5679
7739
  this.clearHold();
5680
- const ids = hold.labels.map((l) => this.labelToId.get(l)).filter((v) => !!v);
7740
+ this.resetTableQuantitiesForLabels(hold.labels);
7741
+ const ids = this.idsForLabels(hold.labels);
5681
7742
  if (ids.length) {
5682
7743
  this.renderer?.deselect(ids);
5683
7744
  this.renderer?.setStatus(ids, "free");
@@ -5708,7 +7769,8 @@ var PickerController = class {
5708
7769
  const remainingItems = hold.items?.filter((item) => !drop.includes(item.label));
5709
7770
  if (remaining.length) this.setHold({ ...hold, labels: remaining, items: remainingItems });
5710
7771
  else this.clearHold();
5711
- const ids = drop.map((l) => this.labelToId.get(l)).filter((v) => !!v);
7772
+ this.resetTableQuantitiesForLabels(drop);
7773
+ const ids = this.idsForLabels(drop);
5712
7774
  if (ids.length) {
5713
7775
  this.renderer?.deselect(ids);
5714
7776
  this.renderer?.setStatus(ids, "free");
@@ -5797,7 +7859,7 @@ var PickerController = class {
5797
7859
  this.opts.onDeckTap?.(floorId);
5798
7860
  }
5799
7861
  // ---- big-venue: sections / rungs / projection (Slice 5) -------------------
5800
- /** Switch the map projection (2D flat ⇄ 3D isometric). No-op on a flat renderer. */
7862
+ /** Switch the map projection. No-op on a flat-only renderer. */
5801
7863
  setViewMode(mode) {
5802
7864
  this.renderer?.setViewMode?.(mode);
5803
7865
  }
@@ -5896,12 +7958,21 @@ var PickerController = class {
5896
7958
  if (!sec) return null;
5897
7959
  const memberIds = r.sectionMembers?.(id) ?? [];
5898
7960
  const byCat = /* @__PURE__ */ new Map();
7961
+ const seenTables = /* @__PURE__ */ new Set();
5899
7962
  let seatsLeft = 0;
5900
7963
  for (const sid of memberIds) {
5901
7964
  const seat = this.seatById.get(sid);
5902
7965
  if (!seat) continue;
5903
- byCat.set(seat.categoryKey, (byCat.get(seat.categoryKey) ?? 0) + 1);
5904
- if (r.getStatus(sid) === "free") seatsLeft++;
7966
+ const table = this.groupedTableBySeatId.get(sid);
7967
+ if (table) {
7968
+ if (seenTables.has(table.label)) continue;
7969
+ seenTables.add(table.label);
7970
+ byCat.set(table.categoryKey, (byCat.get(table.categoryKey) ?? 0) + table.maxOccupancy);
7971
+ if (r.getStatus(table.chairs[0]?.id ?? sid) === "free") seatsLeft += table.maxOccupancy;
7972
+ } else {
7973
+ byCat.set(seat.categoryKey, (byCat.get(seat.categoryKey) ?? 0) + 1);
7974
+ if (r.getStatus(sid) === "free") seatsLeft++;
7975
+ }
5905
7976
  }
5906
7977
  const categories = [...byCat.entries()].map(([key, count]) => {
5907
7978
  const cat = doc.categories.find((c) => c.key === key);
@@ -5918,7 +7989,7 @@ var PickerController = class {
5918
7989
  const color = sec.color ?? zone?.color ?? categories[0]?.color ?? "#6e7bff";
5919
7990
  return {
5920
7991
  id,
5921
- label: sec.label,
7992
+ label: sec.displayLabel ?? sec.label,
5922
7993
  zoneLabel: zone?.label ?? "",
5923
7994
  ...sec.entrance && sec.entrance.trim() ? { entrance: sec.entrance.trim() } : {},
5924
7995
  color,
@@ -5951,22 +8022,54 @@ var PickerController = class {
5951
8022
  }
5952
8023
  // ---- internals ------------------------------------------------------------
5953
8024
  toSeat(s) {
8025
+ const table = this.groupedTableBySeatId.get(s.id);
8026
+ if (table) return this.toTableSeat(table);
5954
8027
  const commercial = s.commercial ? { commercial: s.commercial } : void 0;
5955
8028
  const display = s.displayLabel ? { displayLabel: s.displayLabel } : void 0;
8029
+ const accessibility = s.accessibility?.length ? { accessibility: s.accessibility } : void 0;
8030
+ const wheelchair = s.wheelchairSpaceType ? { wheelchairSpaceType: s.wheelchairSpaceType } : void 0;
8031
+ const context = this.seatContext.get(s.id);
5956
8032
  const tiers = this.tiersFor(s.categoryKey);
5957
8033
  if (!tiers) {
5958
- return { id: s.id, label: s.label, ...display, categoryKey: s.categoryKey, price: this.priceFor(s.categoryKey), ...commercial };
8034
+ return { id: s.id, label: s.label, ...display, ...context, categoryKey: s.categoryKey, price: this.priceFor(s.categoryKey), ...commercial, ...accessibility, ...wheelchair };
5959
8035
  }
5960
8036
  const chosen = tiers.find((t2) => t2.id === this.seatTiers.get(s.id)) ?? tiers[0];
5961
8037
  return {
5962
8038
  id: s.id,
5963
8039
  label: s.label,
5964
8040
  ...display,
8041
+ ...context,
5965
8042
  categoryKey: s.categoryKey,
5966
8043
  price: chosen.price,
5967
8044
  tiers,
5968
8045
  tierId: chosen.id,
5969
- ...commercial
8046
+ ...commercial,
8047
+ ...accessibility,
8048
+ ...wheelchair
8049
+ };
8050
+ }
8051
+ toTableSeat(table) {
8052
+ const representative = table.chairs[0];
8053
+ const tiers = this.tiersFor(table.categoryKey);
8054
+ const chosen = tiers?.find((tier) => tier.id === this.seatTiers.get(representative?.id ?? "")) ?? tiers?.[0];
8055
+ return {
8056
+ id: representative?.id ?? table.objectId,
8057
+ label: table.label,
8058
+ displayLabel: table.displayLabel ?? table.label,
8059
+ ...table.displayType ? { displayType: table.displayType, rowType: table.displayType } : {},
8060
+ objectId: table.objectId,
8061
+ sectionLabel: representative ? this.seatContext.get(representative.id)?.sectionLabel : void 0,
8062
+ rowLabel: table.displayLabel ?? table.label,
8063
+ categoryKey: table.categoryKey,
8064
+ price: chosen?.price ?? this.priceFor(table.categoryKey),
8065
+ ...tiers ? { tiers, tierId: chosen?.id } : {},
8066
+ objectType: "table",
8067
+ bookingMode: table.mode,
8068
+ quantity: this.tableQuantities.get(table.label) ?? (table.mode === "whole" ? table.capacity : table.minOccupancy),
8069
+ capacity: table.capacity,
8070
+ minOccupancy: table.minOccupancy,
8071
+ maxOccupancy: table.maxOccupancy,
8072
+ physicalSeatIds: table.chairs.map((chair) => chair.id)
5970
8073
  };
5971
8074
  }
5972
8075
  /** Tooltip payload for a hovered seat — see PickerCallbacks.onSeatHover. */
@@ -6050,10 +8153,13 @@ var PickerController = class {
6050
8153
  const h = this.hold_;
6051
8154
  if (!h || h.labels.length !== labels.length || !labels.every((l) => h.labels.includes(l))) return false;
6052
8155
  const tiers = new Map((h.items ?? []).map((item) => [item.label, item.tierId]));
8156
+ const quantities = new Map((h.items ?? []).map((item) => [item.label, item.quantity ?? 1]));
6053
8157
  return labels.every((label) => {
6054
8158
  const seat = this.labelToSeat.get(label);
6055
- const currentTier = seat ? this.toSeat(seat).tierId ?? null : null;
6056
- return !tiers.has(label) || tiers.get(label) === currentTier;
8159
+ const resolved = seat ? this.toSeat(seat) : null;
8160
+ const currentTier = resolved?.tierId ?? null;
8161
+ const quantityMatches = resolved?.objectType !== "table" || (resolved.bookingMode !== "variable" || this.confirmedVariableTables.has(label)) && (!quantities.has(label) || quantities.get(label) === resolved.quantity);
8162
+ return quantityMatches && (!tiers.has(label) || tiers.get(label) === currentTier);
6057
8163
  });
6058
8164
  }
6059
8165
  handle409Conflicts(err) {
@@ -6061,19 +8167,28 @@ var PickerController = class {
6061
8167
  if (status !== 409 || !conflicts?.length) return;
6062
8168
  const r = this.renderer;
6063
8169
  if (!r) return;
6064
- const takenIds = conflicts.map((c) => this.labelToId.get(c.label)).filter((v) => !!v);
8170
+ const takenIds = this.idsForLabels(conflicts.map((conflict) => conflict.label));
6065
8171
  if (takenIds.length) {
6066
8172
  r.deselect(takenIds);
6067
8173
  r.setStatus(takenIds, "held");
8174
+ this.resetTableQuantitiesForLabels(conflicts.map((conflict) => conflict.label));
6068
8175
  }
6069
8176
  this.emitSelectionChange();
6070
8177
  }
6071
8178
  /** Set the open hold + (re)arm the server-authoritative expiry timer. */
6072
8179
  setHold(hold, source = "created") {
8180
+ for (const item of hold.items ?? []) {
8181
+ if (this.groupedTablesByLabel.has(item.label) && item.quantity != null) {
8182
+ this.tableQuantities.set(item.label, item.quantity);
8183
+ if (this.groupedTablesByLabel.get(item.label)?.mode === "variable") {
8184
+ this.confirmedVariableTables.add(item.label);
8185
+ }
8186
+ }
8187
+ }
6073
8188
  const full = { ...hold, seats: this.seatsForLabels(hold.labels) };
6074
8189
  this.hold_ = full;
6075
8190
  this.renderer?.setOwnedHold?.(
6076
- full.labels.map((label) => this.labelToId.get(label)).filter((id) => !!id)
8191
+ this.idsForLabels(full.labels)
6077
8192
  );
6078
8193
  if (this.expiryTimer) clearTimeout(this.expiryTimer);
6079
8194
  const ms = Math.max(0, full.expiresAt - Date.now());
@@ -6116,12 +8231,11 @@ var PickerController = class {
6116
8231
  this.liveStatuses = new Map(Object.entries(seats));
6117
8232
  if (this.allIds.length) r.setStatus(this.allIds, "free");
6118
8233
  r.setOwnedHold?.(
6119
- (this.hold_?.labels ?? []).map((label) => this.labelToId.get(label)).filter((id) => !!id)
8234
+ this.idsForLabels(this.hold_?.labels ?? [])
6120
8235
  );
6121
8236
  const byStatus = { free: [], held: [], booked: [], not_for_sale: [] };
6122
8237
  for (const [label, st] of Object.entries(seats)) {
6123
- const id = this.labelToId.get(label);
6124
- if (id) byStatus[mapStatus(st)].push(id);
8238
+ byStatus[mapStatus(st)].push(...this.idsForLabel(label));
6125
8239
  }
6126
8240
  ["held", "booked", "not_for_sale"].forEach((st) => {
6127
8241
  if (byStatus[st].length) r.setStatus(byStatus[st], st);
@@ -6130,7 +8244,11 @@ var PickerController = class {
6130
8244
  this.clearBookedHoldIfSettled();
6131
8245
  }
6132
8246
  clearBookedHoldIfSettled() {
6133
- if (this.hold_?.labels.every((label) => this.liveStatuses.get(label) === "booked")) this.clearHold();
8247
+ const hold = this.hold_;
8248
+ if (hold?.labels.every((label) => this.liveStatuses.get(label) === "booked")) {
8249
+ this.clearHold();
8250
+ this.resetTableQuantitiesForLabels(hold.labels);
8251
+ }
6134
8252
  }
6135
8253
  async resnapshot() {
6136
8254
  try {
@@ -6219,13 +8337,13 @@ var PickerController = class {
6219
8337
  } else if (Array.isArray(m.changes)) {
6220
8338
  for (const ch of m.changes) {
6221
8339
  this.liveStatuses.set(ch.label, ch.status);
6222
- const id = this.labelToId.get(ch.label);
6223
- if (!id) continue;
8340
+ const ids = this.idsForLabel(ch.label);
8341
+ if (!ids.length) continue;
6224
8342
  const next = mapStatus(ch.status);
6225
- if (this.opts.flashOnLiveChange && next !== "free" && r.getStatus(id) === "free" && !this.hold_?.labels.includes(ch.label)) {
6226
- r.flashSeat(id, next === "held" ? "#f4b740" : "#f43f5e");
8343
+ if (this.opts.flashOnLiveChange && next !== "free" && ids.some((id) => r.getStatus(id) === "free") && !this.hold_?.labels.includes(ch.label)) {
8344
+ ids.forEach((id) => r.flashSeat(id, next === "held" ? "#f4b740" : "#f43f5e"));
6227
8345
  }
6228
- r.setStatus([id], next);
8346
+ r.setStatus(ids, next);
6229
8347
  }
6230
8348
  if (this.opts.keepLiveWhileHidden && typeof document !== "undefined" && document.visibilityState === "hidden") {
6231
8349
  r.forceDraw();
@@ -6256,10 +8374,25 @@ var PickerController = class {
6256
8374
  }
6257
8375
  };
6258
8376
 
8377
+ // src/view/sightline.ts
8378
+ var STAGE_TOP_M = 5;
8379
+ var STAGE_BASE_M = -1.1;
8380
+ var MAX_LOOKDOWN_DEG = 35;
8381
+ function stageSightlinePitch(eyeHeightM, distM) {
8382
+ const extraEyeM = eyeHeightM - SEATED_EYE_HEIGHT_M;
8383
+ let topPitch = Math.atan2(STAGE_TOP_M - extraEyeM, distM) * 180 / Math.PI;
8384
+ let basePitch = Math.atan2(STAGE_BASE_M - extraEyeM, distM) * 180 / Math.PI;
8385
+ if (basePitch < -MAX_LOOKDOWN_DEG) {
8386
+ topPitch += -MAX_LOOKDOWN_DEG - basePitch;
8387
+ basePitch = -MAX_LOOKDOWN_DEG;
8388
+ }
8389
+ return { topPitch, basePitch };
8390
+ }
8391
+
6259
8392
  // src/view/generatePanorama.ts
6260
8393
  var W = 2048;
6261
8394
  var H = 1024;
6262
- var UNIT = 0.55 / 24;
8395
+ var UNIT = METRES_PER_CHART_UNIT;
6263
8396
  var yawToX = (yawDeg) => (yawDeg + 180) / 360 * W;
6264
8397
  var pitchToY = (pitchDeg) => (90 - pitchDeg) / 180 * H;
6265
8398
  function generateSeatPanorama(seat, focalPoint, neighborSeats) {
@@ -6279,9 +8412,11 @@ function generateSeatPanorama(seat, focalPoint, neighborSeats) {
6279
8412
  sky.addColorStop(1, "#07090f");
6280
8413
  ctx.fillStyle = sky;
6281
8414
  ctx.fillRect(0, 0, W, H);
8415
+ const eyeM = seat.eyeHeightM ?? SEATED_EYE_HEIGHT_M;
6282
8416
  const stageHalfYaw = Math.min(80, Math.atan2(4, distM) * 180 / Math.PI);
6283
- const stageTopPitch = Math.min(45, Math.atan2(5, distM) * 180 / Math.PI);
6284
- const stageBasePitch = Math.max(-30, -Math.atan2(1.1, distM) * 180 / Math.PI);
8417
+ const sightline = stageSightlinePitch(eyeM, distM);
8418
+ const stageTopPitch = Math.min(45, sightline.topPitch);
8419
+ const stageBasePitch = sightline.basePitch;
6285
8420
  const sx0 = yawToX(-stageHalfYaw);
6286
8421
  const sx1 = yawToX(stageHalfYaw);
6287
8422
  const sy0 = pitchToY(stageTopPitch);
@@ -6388,9 +8523,11 @@ function generateSeatThumb(seat, focalPoint, _neighborSeats) {
6388
8523
  sky.addColorStop(1, "#070910");
6389
8524
  ctx.fillStyle = sky;
6390
8525
  ctx.fillRect(0, 0, TW, TH);
8526
+ const eyeM = seat.eyeHeightM ?? SEATED_EYE_HEIGHT_M;
8527
+ const sightline = stageSightlinePitch(eyeM, distM);
6391
8528
  const stageHalfYaw = Math.min(HALF_HFOV - 2, Math.atan2(4, distM) * 180 / Math.PI);
6392
- const stageTopPitch = Math.min(HALF_VFOV - 2, Math.atan2(5, distM) * 180 / Math.PI);
6393
- const stageBasePitch = Math.max(-HALF_VFOV + 2, -Math.atan2(1.1, distM) * 180 / Math.PI);
8529
+ const stageTopPitch = Math.min(HALF_VFOV - 2, sightline.topPitch);
8530
+ const stageBasePitch = Math.max(-HALF_VFOV + 2, sightline.basePitch);
6394
8531
  const sx0 = yawToTX(-stageHalfYaw);
6395
8532
  const sx1 = yawToTX(stageHalfYaw);
6396
8533
  const sy0 = pitchToTY(stageTopPitch);
@@ -6454,9 +8591,9 @@ function generateSeatThumb(seat, focalPoint, _neighborSeats) {
6454
8591
 
6455
8592
  // src/i18n/bundles.ts
6456
8593
  var LOADERS = {
6457
- es: () => import("./es-SKAODLTR.js").then((m) => ({ default: m.es })),
6458
- de: () => import("./de-73TIXYJH.js").then((m) => ({ default: m.de })),
6459
- fr: () => import("./fr-J4637T6V.js").then((m) => ({ default: m.fr }))
8594
+ es: () => import("./es-6TJS2L7S.js").then((m) => ({ default: m.es })),
8595
+ de: () => import("./de-UHAOVDS3.js").then((m) => ({ default: m.de })),
8596
+ fr: () => import("./fr-MMSY7FPE.js").then((m) => ({ default: m.fr }))
6460
8597
  };
6461
8598
  var loaded = /* @__PURE__ */ new Set(["en"]);
6462
8599
  async function loadLocale(code) {
@@ -6487,7 +8624,9 @@ export {
6487
8624
  PickerController,
6488
8625
  RENDERED_QUALITY_REPORT_VERSION,
6489
8626
  SUPPORTED_LOCALES,
8627
+ SURROUNDINGS_SHAPE_ROLES,
6490
8628
  SeatmapRenderer,
8629
+ TIER_HEIGHT_M,
6491
8630
  UNGROUPED_ID,
6492
8631
  accessibilityMeta,
6493
8632
  accessibilityRingColor,
@@ -6502,16 +8641,19 @@ export {
6502
8641
  expandRow,
6503
8642
  expandRowSlots,
6504
8643
  expandTable,
8644
+ expandTableSlots,
6505
8645
  floorObjects,
6506
8646
  floorsOf,
6507
8647
  formatDate,
6508
8648
  formatMoney,
6509
8649
  gaAreasOf,
8650
+ gaInventorySegments,
6510
8651
  gaUnitLabel,
6511
8652
  gaUnitLabels,
6512
8653
  generateSeatPanorama,
6513
8654
  generateSeatThumb,
6514
8655
  getLocale,
8656
+ growJoinedGAInventory,
6515
8657
  hiddenObjectIds,
6516
8658
  inspectRenderedQualityEvidence,
6517
8659
  isGaUnitLabel,
@@ -6519,17 +8661,23 @@ export {
6519
8661
  layerOf,
6520
8662
  loadLocale,
6521
8663
  objectCenter,
8664
+ owningSectionForObject,
6522
8665
  pointInPolygon,
6523
8666
  pointInPolygonWithHoles,
6524
8667
  polygonLabelPoint,
6525
8668
  resolveLocale,
8669
+ rowInventoryCount,
6526
8670
  rowSeatPositions,
6527
8671
  seatLabelPart,
8672
+ sectionGeometry,
6528
8673
  setLocale,
6529
8674
  setMoneyLocale,
6530
8675
  setStringOverrides,
6531
8676
  stackFloors,
6532
8677
  t,
6533
- tCount
8678
+ tCount,
8679
+ tableInventoryCount,
8680
+ tableSeatCountsBySide,
8681
+ validGAInventorySegments
6534
8682
  };
6535
8683
  //# sourceMappingURL=index.js.map