@seatlayer/core 0.24.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.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);
@@ -723,20 +1020,6 @@ function applyHidden(doc, hidden) {
723
1020
  return { ...doc, objects };
724
1021
  }
725
1022
 
726
- // src/engine/SeatmapRenderer.ts
727
- import { Konva } from "konva/lib/Core";
728
- import { Stage } from "konva/lib/Stage";
729
- import { Layer } from "konva/lib/Layer";
730
- import { Group } from "konva/lib/Group";
731
- import { Circle } from "konva/lib/shapes/Circle";
732
- import { Rect } from "konva/lib/shapes/Rect";
733
- import { Ellipse } from "konva/lib/shapes/Ellipse";
734
- import { Line } from "konva/lib/shapes/Line";
735
- import { Text } from "konva/lib/shapes/Text";
736
- import { Path } from "konva/lib/shapes/Path";
737
- import { Image as KImage } from "konva/lib/shapes/Image";
738
- import { Shape } from "konva/lib/Shape";
739
-
740
1023
  // src/core/chartRenderRules.ts
741
1024
  var SEAT_LABEL_FONT_SIZE = 7;
742
1025
  var BOOTH_LABEL_FONT_SIZE = 10;
@@ -793,6 +1076,769 @@ function stateAwareBookableLabelInk(fill, preferred) {
793
1076
  return darkContrast >= lightContrast ? DARK_BOOKABLE_LABEL_INK : LIGHT_BOOKABLE_LABEL_INK;
794
1077
  }
795
1078
 
1079
+ // src/core/renderedQuality.ts
1080
+ var RENDERED_QUALITY_REPORT_VERSION = 3;
1081
+ var MIN_TEXT_CONTRAST = 4.5;
1082
+ var MIN_GRAPHICAL_CONTRAST = 3;
1083
+ var MIN_POINTER_TARGET_PX = 24;
1084
+ var MAX_SAMPLES_PER_FINDING = 20;
1085
+ function visibleWithBox(items) {
1086
+ return items.filter((item) => item.visible && Boolean(item.screenBox));
1087
+ }
1088
+ function intersects(left, right) {
1089
+ return left.x < right.x + right.width && left.x + left.width > right.x && left.y < right.y + right.height && left.y + left.height > right.y;
1090
+ }
1091
+ function minimum(values) {
1092
+ return values.length ? Math.round(Math.min(...values) * 100) / 100 : null;
1093
+ }
1094
+ function finding(code, message, samples) {
1095
+ if (!samples.length) return null;
1096
+ return {
1097
+ code,
1098
+ message,
1099
+ count: samples.length,
1100
+ samples: samples.slice(0, MAX_SAMPLES_PER_FINDING)
1101
+ };
1102
+ }
1103
+ function labelSample(label, measured, minimumValue) {
1104
+ return {
1105
+ primaryId: label.seatId,
1106
+ primaryLabel: label.label,
1107
+ ...measured == null ? {} : { measured: Math.round(measured * 100) / 100 },
1108
+ ...minimumValue == null ? {} : { minimum: minimumValue }
1109
+ };
1110
+ }
1111
+ function hierarchySample(label, measured, minimumValue) {
1112
+ return {
1113
+ primaryId: label.id,
1114
+ primaryLabel: label.label,
1115
+ ...measured == null ? {} : { measured: Math.round(measured * 100) / 100 },
1116
+ ...minimumValue == null ? {} : { minimum: minimumValue }
1117
+ };
1118
+ }
1119
+ function freeTextSample(label, measured, minimumValue) {
1120
+ return {
1121
+ primaryId: label.objectId,
1122
+ primaryLabel: label.text,
1123
+ ...measured == null ? {} : { measured: Math.round(measured * 100) / 100 },
1124
+ ...minimumValue == null ? {} : { minimum: minimumValue }
1125
+ };
1126
+ }
1127
+ function collisionSample(primaryId, primaryLabel, secondaryId, secondaryLabel) {
1128
+ return { primaryId, primaryLabel, secondaryId, secondaryLabel };
1129
+ }
1130
+ function inspectRenderedQualityEvidence(evidence, state = "overview", targetCategoryKey = null) {
1131
+ const bookable = visibleWithBox(evidence.labels);
1132
+ const hierarchy = visibleWithBox(evidence.hierarchyLabels);
1133
+ const freeText = visibleWithBox(evidence.freeTextLabels);
1134
+ const visibleGA = evidence.gaAreas.filter((area) => area.visible);
1135
+ const bookableContrast = bookable.map((label) => renderedTextContrast(label.ink, label.fill) ?? 0);
1136
+ const hierarchyContrast = hierarchy.map((label) => renderedTextContrast(label.ink, label.fill) ?? 0);
1137
+ const freeTextContrast = freeText.map((label) => renderedTextContrast(label.ink, label.background) ?? 0);
1138
+ const gaContrast = visibleGA.map((area) => renderedTextContrast(area.effectiveBackground, evidence.canvasBackground) ?? 0);
1139
+ const targetLabels = targetCategoryKey ? evidence.labels.filter((label) => label.categoryKey === targetCategoryKey) : evidence.labels;
1140
+ const targetGAAreas = targetCategoryKey ? evidence.gaAreas.filter((area) => area.categoryKey === targetCategoryKey) : evidence.gaAreas;
1141
+ const selected = targetLabels.filter((label) => label.selected);
1142
+ const held = targetLabels.filter((label) => label.status === "held");
1143
+ const booked = targetLabels.filter((label) => label.status === "booked");
1144
+ const activePointerTargets = targetLabels.filter((label) => label.pointerTarget.active);
1145
+ const activeCategoryKeys = /* @__PURE__ */ new Set([
1146
+ ...evidence.labels.filter((label) => label.visible && (label.opacity > 0.25 || label.selected || label.status !== "free")).map((label) => label.categoryKey),
1147
+ ...evidence.gaAreas.filter((area) => area.visible && area.opacity > 0.1).map((area) => area.categoryKey)
1148
+ ]);
1149
+ const selectedRingContrast = selected.length ? minimum(selected.flatMap((label) => [
1150
+ renderedTextContrast(evidence.selectionRingColor, evidence.canvasBackground) ?? 0,
1151
+ renderedTextContrast(evidence.selectionRingColor, label.fill) ?? 0
1152
+ ])) : null;
1153
+ const findings = [];
1154
+ const overviewCountSamples = (count, label) => Array.from({ length: count }, (_, index) => ({
1155
+ primaryId: `overview:${index + 1}`,
1156
+ primaryLabel: label
1157
+ }));
1158
+ if (state === "overview" && evidence.rung === "sections") {
1159
+ findings.push(finding(
1160
+ "overview-section-category-paint",
1161
+ "Section overview shells must use the neutral hierarchy palette, not category paint",
1162
+ overviewCountSamples(evidence.overviewStyle.categoryPaintedSectionShells, "category-painted section shell")
1163
+ ));
1164
+ findings.push(finding(
1165
+ "overview-category-detail-visible",
1166
+ "Category-tinted section detail must wait until section focus or seat zoom",
1167
+ overviewCountSamples(evidence.overviewStyle.visibleCategoryDetailOutlines, "category detail outline")
1168
+ ));
1169
+ findings.push(finding(
1170
+ "overview-row-hints-visible",
1171
+ "Row and seat patterns must not clutter the section overview",
1172
+ overviewCountSamples(evidence.overviewStyle.visibleSectionRowHints, "section row hint")
1173
+ ));
1174
+ findings.push(finding(
1175
+ "overview-availability-clutter",
1176
+ "Live availability counts belong in focused detail, not on section overview shells",
1177
+ overviewCountSamples(evidence.overviewStyle.visibleSectionAvailabilityLabels, "section availability label")
1178
+ ));
1179
+ findings.push(finding(
1180
+ "overview-ga-detail-visible",
1181
+ "Section-contained standing paint belongs in focused detail, not on section overview shells",
1182
+ overviewCountSamples(evidence.overviewStyle.visibleSectionGADetails, "section-contained GA detail")
1183
+ ));
1184
+ }
1185
+ findings.push(finding(
1186
+ "bookable-label-undersized",
1187
+ "Visible bookable labels must meet the rendered small-text size floor",
1188
+ bookable.filter((label) => label.renderedFontPx < evidence.minimumVisibleLabelPx).map((label) => labelSample(label, label.renderedFontPx, evidence.minimumVisibleLabelPx))
1189
+ ));
1190
+ if (state === "interaction") {
1191
+ const labelledInventory = targetLabels.length > 0;
1192
+ const gaInventory = targetGAAreas.length > 0;
1193
+ const detailInventory = labelledInventory || gaInventory;
1194
+ const visibleTargetGA = targetGAAreas.filter((area) => area.visible);
1195
+ const sections = /* @__PURE__ */ new Set([
1196
+ ...targetLabels.flatMap((label) => label.sectionId ? [label.sectionId] : []),
1197
+ ...targetGAAreas.flatMap((area) => area.sectionId ? [area.sectionId] : [])
1198
+ ]);
1199
+ const categories = /* @__PURE__ */ new Set([
1200
+ ...evidence.labels.map((label) => label.categoryKey),
1201
+ ...evidence.gaAreas.map((area) => area.categoryKey)
1202
+ ]);
1203
+ const inViewport = (label) => label.screenCenter.x >= 0 && label.screenCenter.x <= evidence.viewport.width && label.screenCenter.y >= 0 && label.screenCenter.y <= evidence.viewport.height;
1204
+ findings.push(finding(
1205
+ "detail-rung-missing",
1206
+ "Interaction evidence with bookable inventory must render the seat-detail rung",
1207
+ detailInventory && evidence.rung !== "seats" ? [{ primaryId: "renderer", primaryLabel: evidence.rung }] : []
1208
+ ));
1209
+ findings.push(finding(
1210
+ "detail-inventory-not-visible",
1211
+ "Interaction evidence must frame at least one real bookable unit",
1212
+ detailInventory && !targetLabels.some(inViewport) && !visibleTargetGA.length ? [{ primaryId: "renderer", primaryLabel: "no target inventory in viewport" }] : []
1213
+ ));
1214
+ findings.push(finding(
1215
+ "pointer-target-inactive",
1216
+ "Interaction evidence must expose a live production pointer target",
1217
+ labelledInventory && !activePointerTargets.some(inViewport) || !labelledInventory && gaInventory && !visibleTargetGA.some((area) => area.interactive) ? [{ primaryId: "renderer", primaryLabel: "no active pointer target in viewport" }] : []
1218
+ ));
1219
+ findings.push(finding(
1220
+ "pointer-target-undersized",
1221
+ "Every active production pointer target must reach at least 24 CSS pixels",
1222
+ activePointerTargets.filter((label) => inViewport(label) && label.pointerTarget.effectiveMinimumPx < MIN_POINTER_TARGET_PX).map((label) => labelSample(label, label.pointerTarget.effectiveMinimumPx, MIN_POINTER_TARGET_PX))
1223
+ ));
1224
+ findings.push(finding(
1225
+ "selected-state-missing",
1226
+ "Interaction evidence must paint a selected unit and its renderer-owned ring",
1227
+ labelledInventory && (!selected.length || !selected.some((label) => evidence.selectionRingSeatIds.includes(label.seatId))) ? [{ primaryId: "renderer", primaryLabel: "selected state" }] : []
1228
+ ));
1229
+ findings.push(finding(
1230
+ "selected-state-contrast-low",
1231
+ "The selected-state ring must maintain 3:1 graphical contrast with the canvas",
1232
+ selected.length && (selectedRingContrast ?? 0) < MIN_GRAPHICAL_CONTRAST ? [{
1233
+ primaryId: selected[0].seatId,
1234
+ primaryLabel: selected[0].label,
1235
+ measured: Math.round((selectedRingContrast ?? 0) * 100) / 100,
1236
+ minimum: MIN_GRAPHICAL_CONTRAST
1237
+ }] : []
1238
+ ));
1239
+ findings.push(finding(
1240
+ "held-state-missing",
1241
+ "Interaction evidence must paint a held state when the floor has at least two status-managed units",
1242
+ targetLabels.length >= 2 && !held.length ? [{ primaryId: "renderer", primaryLabel: "held state" }] : []
1243
+ ));
1244
+ findings.push(finding(
1245
+ "booked-state-missing",
1246
+ "Interaction evidence must paint a taken state when the floor has at least three status-managed units",
1247
+ targetLabels.length >= 3 && !booked.length ? [{ primaryId: "renderer", primaryLabel: "booked state" }] : []
1248
+ ));
1249
+ const heldSignatures = new Set(held.map((label) => `${label.fill.toLowerCase()}:${label.opacity}`));
1250
+ const bookedSignatures = new Set(booked.map((label) => `${label.fill.toLowerCase()}:${label.opacity}`));
1251
+ findings.push(finding(
1252
+ "status-state-indistinct",
1253
+ "Held and taken evidence must resolve to distinct renderer paint",
1254
+ held.length && booked.length && [...heldSignatures].some((signature) => bookedSignatures.has(signature)) ? [{ primaryId: held[0].seatId, primaryLabel: held[0].label, secondaryId: booked[0].seatId, secondaryLabel: booked[0].label }] : []
1255
+ ));
1256
+ findings.push(finding(
1257
+ "section-focus-missing",
1258
+ "Interaction evidence must exercise section focus and its backdrop when section membership exists",
1259
+ sections.size && (!evidence.focusedSectionId || !evidence.focusBackdropVisible) ? [{ primaryId: "renderer", primaryLabel: "section focus" }] : []
1260
+ ));
1261
+ findings.push(finding(
1262
+ "category-filter-missing",
1263
+ "Interaction evidence must exercise a category filter when multiple categories exist",
1264
+ categories.size >= 2 && (!evidence.categoryFilterKeys || !evidence.categoryFilterKeys.length) ? [{ primaryId: "renderer", primaryLabel: "category filter" }] : []
1265
+ ));
1266
+ const excludedFree = evidence.labels.filter((label) => label.status === "free" && !label.selected && evidence.categoryFilterKeys != null && !evidence.categoryFilterKeys.includes(label.categoryKey));
1267
+ const excludedGA = evidence.gaAreas.filter((area) => evidence.categoryFilterKeys != null && !evidence.categoryFilterKeys.includes(area.categoryKey));
1268
+ findings.push(finding(
1269
+ "category-filter-ineffective",
1270
+ "The active category filter must visibly dim excluded free inventory",
1271
+ (excludedFree.length || excludedGA.length) && !excludedFree.some((label) => label.opacity <= 0.25) && !excludedGA.some((area) => area.opacity <= 0.1) ? [
1272
+ ...excludedFree.map((label) => labelSample(label, label.opacity)),
1273
+ ...excludedGA.map((area) => ({ primaryId: area.areaId, primaryLabel: area.label, measured: area.opacity }))
1274
+ ] : []
1275
+ ));
1276
+ findings.push(finding(
1277
+ "target-category-not-visible",
1278
+ "A category-specific interaction scene must visibly paint its exact target category",
1279
+ targetCategoryKey && !activeCategoryKeys.has(targetCategoryKey) ? [{ primaryId: targetCategoryKey, primaryLabel: targetCategoryKey }] : []
1280
+ ));
1281
+ findings.push(finding(
1282
+ "target-category-filter-mismatch",
1283
+ "A category-specific interaction scene must bind its filter to only the exact target category",
1284
+ targetCategoryKey && (evidence.categoryFilterKeys?.length !== 1 || evidence.categoryFilterKeys[0] !== targetCategoryKey) ? [{ primaryId: targetCategoryKey, primaryLabel: targetCategoryKey }] : []
1285
+ ));
1286
+ }
1287
+ findings.push(finding(
1288
+ "bookable-label-contrast-low",
1289
+ "Visible bookable labels must meet 4.5:1 contrast against their actual paint",
1290
+ bookable.flatMap((label) => {
1291
+ const ratio = renderedTextContrast(label.ink, label.fill) ?? 0;
1292
+ return ratio < MIN_TEXT_CONTRAST ? [labelSample(label, ratio, MIN_TEXT_CONTRAST)] : [];
1293
+ })
1294
+ ));
1295
+ const bookableCollisions = [];
1296
+ for (let index = 0; index < bookable.length; index += 1) {
1297
+ for (let other = 0; other < index; other += 1) {
1298
+ if (intersects(bookable[index].screenBox, bookable[other].screenBox)) {
1299
+ bookableCollisions.push(collisionSample(
1300
+ bookable[other].seatId,
1301
+ bookable[other].label,
1302
+ bookable[index].seatId,
1303
+ bookable[index].label
1304
+ ));
1305
+ }
1306
+ }
1307
+ }
1308
+ findings.push(finding(
1309
+ "bookable-label-collision",
1310
+ "Visible bookable labels must not overlap",
1311
+ bookableCollisions
1312
+ ));
1313
+ findings.push(finding(
1314
+ "hierarchy-label-undersized",
1315
+ "Visible section and zone labels must meet the rendered small-text size floor",
1316
+ hierarchy.filter((label) => label.renderedFontPx < MIN_VISIBLE_BOOKABLE_LABEL_PX).map((label) => hierarchySample(label, label.renderedFontPx, MIN_VISIBLE_BOOKABLE_LABEL_PX))
1317
+ ));
1318
+ findings.push(finding(
1319
+ "hierarchy-label-contrast-low",
1320
+ "Visible section and zone labels must meet 4.5:1 contrast against their backing paint",
1321
+ hierarchy.flatMap((label) => {
1322
+ const ratio = renderedTextContrast(label.ink, label.fill) ?? 0;
1323
+ return ratio < MIN_TEXT_CONTRAST ? [hierarchySample(label, ratio, MIN_TEXT_CONTRAST)] : [];
1324
+ })
1325
+ ));
1326
+ findings.push(finding(
1327
+ "hierarchy-label-outside-section",
1328
+ "Visible section labels must remain inside the filled section surface and outside holes",
1329
+ hierarchy.filter((label) => label.kind === "section" && label.fitsContainer === false).map((label) => hierarchySample(label))
1330
+ ));
1331
+ const hierarchyCollisions = [];
1332
+ for (let index = 0; index < hierarchy.length; index += 1) {
1333
+ for (let other = 0; other < index; other += 1) {
1334
+ if (intersects(hierarchy[index].screenBox, hierarchy[other].screenBox)) {
1335
+ hierarchyCollisions.push(collisionSample(
1336
+ hierarchy[other].id,
1337
+ hierarchy[other].label,
1338
+ hierarchy[index].id,
1339
+ hierarchy[index].label
1340
+ ));
1341
+ }
1342
+ }
1343
+ }
1344
+ findings.push(finding(
1345
+ "hierarchy-label-collision",
1346
+ "Visible hierarchy labels must not overlap each other",
1347
+ hierarchyCollisions
1348
+ ));
1349
+ const hierarchyBookableCollisions = [];
1350
+ for (const upper of hierarchy) {
1351
+ for (const unit of bookable) {
1352
+ if (intersects(upper.screenBox, unit.screenBox)) {
1353
+ hierarchyBookableCollisions.push(collisionSample(upper.id, upper.label, unit.seatId, unit.label));
1354
+ }
1355
+ }
1356
+ }
1357
+ findings.push(finding(
1358
+ "hierarchy-bookable-collision",
1359
+ "Visible hierarchy labels must not overlap bookable labels",
1360
+ hierarchyBookableCollisions
1361
+ ));
1362
+ findings.push(finding(
1363
+ "free-text-undersized",
1364
+ "Visible chart text must meet the rendered small-text size floor",
1365
+ freeText.filter((label) => label.renderedFontPx < evidence.minimumVisibleLabelPx).map((label) => freeTextSample(label, label.renderedFontPx, evidence.minimumVisibleLabelPx))
1366
+ ));
1367
+ findings.push(finding(
1368
+ "free-text-contrast-low",
1369
+ "Visible chart text must meet 4.5:1 contrast against its measured background",
1370
+ freeText.flatMap((label) => {
1371
+ const ratio = renderedTextContrast(label.ink, label.background) ?? 0;
1372
+ return ratio < MIN_TEXT_CONTRAST ? [freeTextSample(label, ratio, MIN_TEXT_CONTRAST)] : [];
1373
+ })
1374
+ ));
1375
+ const freeTextCollisions = [];
1376
+ for (let index = 0; index < freeText.length; index += 1) {
1377
+ for (let other = 0; other < index; other += 1) {
1378
+ if (intersects(freeText[index].screenBox, freeText[other].screenBox)) {
1379
+ freeTextCollisions.push(collisionSample(
1380
+ freeText[other].objectId,
1381
+ freeText[other].text,
1382
+ freeText[index].objectId,
1383
+ freeText[index].text
1384
+ ));
1385
+ }
1386
+ }
1387
+ }
1388
+ findings.push(finding(
1389
+ "free-text-collision",
1390
+ "Visible chart text labels must not overlap each other",
1391
+ freeTextCollisions
1392
+ ));
1393
+ const freeTextBookableCollisions = [];
1394
+ for (const text of freeText) {
1395
+ for (const unit of bookable) {
1396
+ if (intersects(text.screenBox, unit.screenBox)) {
1397
+ freeTextBookableCollisions.push(collisionSample(text.objectId, text.text, unit.seatId, unit.label));
1398
+ }
1399
+ }
1400
+ }
1401
+ findings.push(finding(
1402
+ "free-text-bookable-collision",
1403
+ "Visible chart text must not overlap bookable labels",
1404
+ freeTextBookableCollisions
1405
+ ));
1406
+ const freeTextHierarchyCollisions = [];
1407
+ for (const text of freeText) {
1408
+ for (const upper of hierarchy) {
1409
+ if (intersects(text.screenBox, upper.screenBox)) {
1410
+ freeTextHierarchyCollisions.push(collisionSample(text.objectId, text.text, upper.id, upper.label));
1411
+ }
1412
+ }
1413
+ }
1414
+ findings.push(finding(
1415
+ "free-text-hierarchy-collision",
1416
+ "Visible chart text must not overlap hierarchy labels",
1417
+ freeTextHierarchyCollisions
1418
+ ));
1419
+ findings.push(finding(
1420
+ "ga-contrast-low",
1421
+ "Visible GA surfaces must maintain 3:1 graphical contrast with the canvas",
1422
+ visibleGA.flatMap((area) => {
1423
+ const ratio = renderedTextContrast(area.effectiveBackground, evidence.canvasBackground) ?? 0;
1424
+ return ratio < MIN_GRAPHICAL_CONTRAST ? [{
1425
+ primaryId: area.areaId,
1426
+ primaryLabel: area.label,
1427
+ measured: Math.round(ratio * 100) / 100,
1428
+ minimum: MIN_GRAPHICAL_CONTRAST
1429
+ }] : [];
1430
+ })
1431
+ ));
1432
+ const materialFindings = findings.filter((item) => Boolean(item));
1433
+ return {
1434
+ version: RENDERED_QUALITY_REPORT_VERSION,
1435
+ passed: materialFindings.length === 0,
1436
+ state,
1437
+ targetCategoryKey,
1438
+ resolvedRules: [
1439
+ "rendered-overview-label-size",
1440
+ "rendered-overview-label-contrast",
1441
+ "rendered-overview-label-collision",
1442
+ "rendered-overview-hierarchy-containment",
1443
+ "rendered-overview-section-first-style",
1444
+ "rendered-overview-ga-contrast",
1445
+ ...state === "interaction" ? [
1446
+ "rendered-detail-inventory",
1447
+ "rendered-pointer-target",
1448
+ "rendered-selected-held-taken-states",
1449
+ "rendered-section-focus",
1450
+ "rendered-category-filter"
1451
+ ] : []
1452
+ ],
1453
+ viewport: evidence.viewport,
1454
+ canvasBackground: evidence.canvasBackground,
1455
+ effectiveScale: evidence.effectiveScale,
1456
+ rung: evidence.rung,
1457
+ inventory: {
1458
+ totalBookableUnits: evidence.totalBookableUnits,
1459
+ totalLabelledBookableUnits: evidence.totalLabelledBookableUnits,
1460
+ visibleBookableLabels: bookable.length,
1461
+ hiddenBookableLabels: evidence.hiddenLabels,
1462
+ visibleHierarchyLabels: hierarchy.length,
1463
+ visibleFreeTextLabels: freeText.length,
1464
+ visibleGAAreas: visibleGA.length
1465
+ },
1466
+ overviewStyle: evidence.overviewStyle,
1467
+ composition: {
1468
+ hierarchy: evidence.hierarchyLabels.filter((label) => label.role === "name").map((label) => ({
1469
+ id: label.id,
1470
+ kind: label.kind,
1471
+ label: label.label,
1472
+ visible: label.visible
1473
+ })).sort((left, right) => left.kind.localeCompare(right.kind) || left.id.localeCompare(right.id)),
1474
+ labelledObjects: evidence.freeTextLabels.map((label) => ({
1475
+ objectId: label.objectId,
1476
+ kind: label.kind,
1477
+ text: label.text,
1478
+ visible: label.visible
1479
+ })).sort((left, right) => left.objectId.localeCompare(right.objectId) || left.kind.localeCompare(right.kind)),
1480
+ gaAreas: evidence.gaAreas.map((area) => ({
1481
+ areaId: area.areaId,
1482
+ label: area.label,
1483
+ categoryKey: area.categoryKey,
1484
+ ...area.sectionId ? { sectionId: area.sectionId } : {},
1485
+ visible: area.visible
1486
+ })).sort((left, right) => left.areaId.localeCompare(right.areaId)),
1487
+ bookableSectionIds: [...new Set(evidence.labels.flatMap((label) => label.sectionId ? [label.sectionId] : []))].sort(),
1488
+ categoryKeys: [.../* @__PURE__ */ new Set([
1489
+ ...evidence.labels.map((label) => label.categoryKey),
1490
+ ...evidence.gaAreas.map((area) => area.categoryKey)
1491
+ ])].sort(),
1492
+ activeCategoryKeys: [...activeCategoryKeys].sort()
1493
+ },
1494
+ metrics: {
1495
+ minimumRenderedBookableLabelPx: minimum(bookable.map((label) => label.renderedFontPx)),
1496
+ minimumBookableLabelContrast: minimum(bookableContrast),
1497
+ minimumRenderedHierarchyLabelPx: minimum(hierarchy.map((label) => label.renderedFontPx)),
1498
+ minimumHierarchyLabelContrast: minimum(hierarchyContrast),
1499
+ minimumRenderedFreeTextPx: minimum(freeText.map((label) => label.renderedFontPx)),
1500
+ minimumFreeTextContrast: minimum(freeTextContrast),
1501
+ minimumGAContrast: minimum(gaContrast),
1502
+ minimumEffectivePointerTargetPx: minimum(activePointerTargets.map((label) => label.pointerTarget.effectiveMinimumPx)),
1503
+ selectedRingContrast: selectedRingContrast == null ? null : Math.round(selectedRingContrast * 100) / 100
1504
+ },
1505
+ interaction: {
1506
+ applicable: {
1507
+ detail: targetLabels.length > 0 || targetGAAreas.length > 0,
1508
+ pointer: targetLabels.length > 0 || targetGAAreas.length > 0,
1509
+ held: targetLabels.length >= 2,
1510
+ booked: targetLabels.length >= 3,
1511
+ sectionFocus: targetLabels.some((label) => Boolean(label.sectionId)) || targetGAAreas.some((area) => Boolean(area.sectionId)),
1512
+ categoryFilter: (/* @__PURE__ */ new Set([
1513
+ ...evidence.labels.map((label) => label.categoryKey),
1514
+ ...evidence.gaAreas.map((area) => area.categoryKey)
1515
+ ])).size >= 2
1516
+ },
1517
+ selectedUnits: selected.length,
1518
+ heldUnits: held.length,
1519
+ bookedUnits: booked.length,
1520
+ activePointerTargets: activePointerTargets.length,
1521
+ focusedSectionId: evidence.focusedSectionId,
1522
+ focusBackdropVisible: evidence.focusBackdropVisible,
1523
+ categoryFilterKeys: evidence.categoryFilterKeys
1524
+ },
1525
+ findings: materialFindings
1526
+ };
1527
+ }
1528
+
1529
+ // src/engine/SeatmapRenderer.ts
1530
+ import { Konva } from "konva/lib/Core";
1531
+ import { Stage } from "konva/lib/Stage";
1532
+ import { Layer } from "konva/lib/Layer";
1533
+ import { Group } from "konva/lib/Group";
1534
+ import { Circle } from "konva/lib/shapes/Circle";
1535
+ import { Rect } from "konva/lib/shapes/Rect";
1536
+ import { Ellipse } from "konva/lib/shapes/Ellipse";
1537
+ import { Line } from "konva/lib/shapes/Line";
1538
+ import { Arrow } from "konva/lib/shapes/Arrow";
1539
+ import { Text } from "konva/lib/shapes/Text";
1540
+ import { Path } from "konva/lib/shapes/Path";
1541
+ import { Image as KImage } from "konva/lib/shapes/Image";
1542
+ import { Shape } from "konva/lib/Shape";
1543
+
1544
+ // src/core/chartBackgrounds.ts
1545
+ function buyerBackgroundImage(owner) {
1546
+ const background = owner.backgroundImage;
1547
+ return background?.url && !background.assetId ? background : void 0;
1548
+ }
1549
+
1550
+ // src/core/spatialIndex.ts
1551
+ var DEFAULT_SEAT_RADIUS = 9;
1552
+ function halfExtents(shape) {
1553
+ if (shape.kind === "circle") return { hx: shape.r, hy: shape.r };
1554
+ const hw = shape.width / 2;
1555
+ const hh = shape.height / 2;
1556
+ const rot = shape.rotation ?? 0;
1557
+ if (!rot) return { hx: hw, hy: hh };
1558
+ const rad = rot * Math.PI / 180;
1559
+ const c = Math.abs(Math.cos(rad));
1560
+ const s = Math.abs(Math.sin(rad));
1561
+ return { hx: hw * c + hh * s, hy: hw * s + hh * c };
1562
+ }
1563
+ function shapeIntersectsRect(e, x0, y0, x1, y1) {
1564
+ if (e.shape.kind === "circle") {
1565
+ const cx = Math.min(Math.max(e.seat.x, x0), x1);
1566
+ const cy = Math.min(Math.max(e.seat.y, y0), y1);
1567
+ const dx = e.seat.x - cx;
1568
+ const dy = e.seat.y - cy;
1569
+ return dx * dx + dy * dy <= e.shape.r * e.shape.r;
1570
+ }
1571
+ const rot = e.shape.rotation ?? 0;
1572
+ if (!rot) {
1573
+ return e.seat.x - e.hx <= x1 && e.seat.x + e.hx >= x0 && e.seat.y - e.hy <= y1 && e.seat.y + e.hy >= y0;
1574
+ }
1575
+ const rad = rot * Math.PI / 180;
1576
+ const c = Math.cos(rad);
1577
+ const s = Math.sin(rad);
1578
+ const hw = e.shape.width / 2;
1579
+ const hh = e.shape.height / 2;
1580
+ const corners = [
1581
+ [-hw, -hh],
1582
+ [hw, -hh],
1583
+ [hw, hh],
1584
+ [-hw, hh]
1585
+ ].map(([lx, ly]) => [e.seat.x + lx * c - ly * s, e.seat.y + lx * s + ly * c]);
1586
+ const query = [
1587
+ [x0, y0],
1588
+ [x1, y0],
1589
+ [x1, y1],
1590
+ [x0, y1]
1591
+ ];
1592
+ const axes = [
1593
+ [1, 0],
1594
+ [0, 1],
1595
+ [c, s],
1596
+ [-s, c]
1597
+ ];
1598
+ for (const [ax, ay] of axes) {
1599
+ let minA = Infinity;
1600
+ let maxA = -Infinity;
1601
+ for (const [px, py] of corners) {
1602
+ const d = px * ax + py * ay;
1603
+ if (d < minA) minA = d;
1604
+ if (d > maxA) maxA = d;
1605
+ }
1606
+ let minB = Infinity;
1607
+ let maxB = -Infinity;
1608
+ for (const [px, py] of query) {
1609
+ const d = px * ax + py * ay;
1610
+ if (d < minB) minB = d;
1611
+ if (d > maxB) maxB = d;
1612
+ }
1613
+ if (maxA < minB || maxB < minA) return false;
1614
+ }
1615
+ return true;
1616
+ }
1617
+ function buildSeatIndex(seats, opts = {}) {
1618
+ const seatRadius = opts.seatRadius ?? DEFAULT_SEAT_RADIUS;
1619
+ const n = seats.length;
1620
+ const entries = new Array(n);
1621
+ let minX = Infinity;
1622
+ let minY = Infinity;
1623
+ let maxX = -Infinity;
1624
+ let maxY = -Infinity;
1625
+ let maxHalf = 0;
1626
+ for (let i = 0; i < n; i++) {
1627
+ const seat = seats[i];
1628
+ const shape = opts.shapeOf?.(seat) ?? { kind: "circle", r: seatRadius };
1629
+ const { hx, hy } = halfExtents(shape);
1630
+ entries[i] = { seat, shape, hx, hy };
1631
+ if (seat.x - hx < minX) minX = seat.x - hx;
1632
+ if (seat.y - hy < minY) minY = seat.y - hy;
1633
+ if (seat.x + hx > maxX) maxX = seat.x + hx;
1634
+ if (seat.y + hy > maxY) maxY = seat.y + hy;
1635
+ if (hx > maxHalf) maxHalf = hx;
1636
+ if (hy > maxHalf) maxHalf = hy;
1637
+ }
1638
+ if (n === 0) {
1639
+ minX = 0;
1640
+ minY = 0;
1641
+ maxX = 0;
1642
+ maxY = 0;
1643
+ }
1644
+ const nominal = maxHalf > 0 ? maxHalf * 4 : 1;
1645
+ const spanX = Math.max(maxX - minX, 0);
1646
+ const spanY = Math.max(maxY - minY, 0);
1647
+ const maxCells = Math.max(64, opts.maxCells ?? n * 4);
1648
+ let cell = nominal;
1649
+ let cols = Math.max(1, Math.ceil(spanX / cell) || 1);
1650
+ let rows = Math.max(1, Math.ceil(spanY / cell) || 1);
1651
+ while (cols * rows > maxCells && (cols > 1 || rows > 1)) {
1652
+ cell *= 2;
1653
+ cols = Math.max(1, Math.ceil(spanX / cell) || 1);
1654
+ rows = Math.max(1, Math.ceil(spanY / cell) || 1);
1655
+ }
1656
+ const cellCount = cols * rows;
1657
+ const cellStart = new Int32Array(cellCount + 1);
1658
+ let total = 0;
1659
+ for (let i = 0; i < n; i++) {
1660
+ const e = entries[i];
1661
+ const cx0 = clampInt(Math.floor((e.seat.x - e.hx - minX) / cell), 0, cols - 1);
1662
+ const cx1 = clampInt(Math.floor((e.seat.x + e.hx - minX) / cell), 0, cols - 1);
1663
+ const cy0 = clampInt(Math.floor((e.seat.y - e.hy - minY) / cell), 0, rows - 1);
1664
+ const cy1 = clampInt(Math.floor((e.seat.y + e.hy - minY) / cell), 0, rows - 1);
1665
+ for (let cy = cy0; cy <= cy1; cy++) {
1666
+ for (let cx = cx0; cx <= cx1; cx++) {
1667
+ cellStart[cy * cols + cx + 1]++;
1668
+ total++;
1669
+ }
1670
+ }
1671
+ }
1672
+ for (let c = 0; c < cellCount; c++) cellStart[c + 1] += cellStart[c];
1673
+ const items = new Int32Array(total);
1674
+ const cursor = cellStart.slice(0, cellCount);
1675
+ for (let i = 0; i < n; i++) {
1676
+ const e = entries[i];
1677
+ const cx0 = clampInt(Math.floor((e.seat.x - e.hx - minX) / cell), 0, cols - 1);
1678
+ const cx1 = clampInt(Math.floor((e.seat.x + e.hx - minX) / cell), 0, cols - 1);
1679
+ const cy0 = clampInt(Math.floor((e.seat.y - e.hy - minY) / cell), 0, rows - 1);
1680
+ const cy1 = clampInt(Math.floor((e.seat.y + e.hy - minY) / cell), 0, rows - 1);
1681
+ for (let cy = cy0; cy <= cy1; cy++) {
1682
+ for (let cx = cx0; cx <= cx1; cx++) {
1683
+ const c = cy * cols + cx;
1684
+ items[cursor[c]++] = i;
1685
+ }
1686
+ }
1687
+ }
1688
+ return {
1689
+ seats,
1690
+ entries,
1691
+ minX,
1692
+ minY,
1693
+ cell,
1694
+ cols,
1695
+ rows,
1696
+ cellStart,
1697
+ items,
1698
+ stamp: new Int32Array(n),
1699
+ epoch: 0
1700
+ };
1701
+ }
1702
+ function clampInt(v, lo, hi) {
1703
+ return v < lo ? lo : v > hi ? hi : v;
1704
+ }
1705
+ function queryRect(index, rect, opts = {}) {
1706
+ const x0 = Math.min(rect.x, rect.x + rect.width);
1707
+ const x1 = Math.max(rect.x, rect.x + rect.width);
1708
+ const y0 = Math.min(rect.y, rect.y + rect.height);
1709
+ const y1 = Math.max(rect.y, rect.y + rect.height);
1710
+ const mode = opts.mode ?? "center";
1711
+ const { cols, rows, cell, minX, minY, cellStart, items, entries } = index;
1712
+ const cx0 = Math.max(0, Math.floor((x0 - minX) / cell));
1713
+ const cx1 = Math.min(cols - 1, Math.floor((x1 - minX) / cell));
1714
+ const cy0 = Math.max(0, Math.floor((y0 - minY) / cell));
1715
+ const cy1 = Math.min(rows - 1, Math.floor((y1 - minY) / cell));
1716
+ if (cx0 > cx1 || cy0 > cy1) return [];
1717
+ const found = [];
1718
+ const stamp = index.stamp;
1719
+ const ep = ++index.epoch;
1720
+ for (let cy = cy0; cy <= cy1; cy++) {
1721
+ const rowBase = cy * cols;
1722
+ for (let cx = cx0; cx <= cx1; cx++) {
1723
+ const c = rowBase + cx;
1724
+ const end = cellStart[c + 1];
1725
+ for (let k = cellStart[c]; k < end; k++) {
1726
+ const i = items[k];
1727
+ if (stamp[i] === ep) continue;
1728
+ stamp[i] = ep;
1729
+ const e = entries[i];
1730
+ if (opts.filter && !opts.filter(e.seat.id, e.seat)) continue;
1731
+ const ok = mode === "center" ? e.seat.x >= x0 && e.seat.x <= x1 && e.seat.y >= y0 && e.seat.y <= y1 : shapeIntersectsRect(e, x0, y0, x1, y1);
1732
+ if (ok) found.push(i);
1733
+ }
1734
+ }
1735
+ }
1736
+ found.sort((a, b) => a - b);
1737
+ return found.map((i) => entries[i].seat.id);
1738
+ }
1739
+
1740
+ // src/core/perspectiveProjection.ts
1741
+ function createPerspectiveCamera(bounds2, maxSurfaceHeightWorld = 0) {
1742
+ const span = Math.max(1, bounds2.width, bounds2.height);
1743
+ const target = {
1744
+ x: bounds2.x + bounds2.width / 2,
1745
+ y: bounds2.y + bounds2.height / 2
1746
+ };
1747
+ const distance = span * 1.55;
1748
+ const height = Math.max(span * 0.82, Math.max(0, maxSurfaceHeightWorld) + span * 0.45);
1749
+ return {
1750
+ target,
1751
+ distance,
1752
+ height,
1753
+ focalLength: Math.hypot(distance, height),
1754
+ yawRad: -8 * Math.PI / 180
1755
+ };
1756
+ }
1757
+ function projectPerspectivePoint(camera, point, heightWorld = 0) {
1758
+ const dx = point.x - camera.target.x;
1759
+ const dy = point.y - camera.target.y;
1760
+ const cos = Math.cos(camera.yawRad);
1761
+ const sin = Math.sin(camera.yawRad);
1762
+ const x = dx * cos - dy * sin;
1763
+ const y = dx * sin + dy * cos;
1764
+ const translatedY = y - camera.distance;
1765
+ const translatedZ = heightWorld - camera.height;
1766
+ const cameraLength = Math.hypot(camera.distance, camera.height);
1767
+ const depth = (-camera.distance * translatedY - camera.height * translatedZ) / cameraLength;
1768
+ const up = (-camera.height * translatedY + camera.distance * translatedZ) / cameraLength;
1769
+ const safeDepth = Math.max(cameraLength * 0.08, depth);
1770
+ const scale = camera.focalLength / safeDepth;
1771
+ return {
1772
+ x: camera.target.x + x * scale,
1773
+ y: camera.target.y - up * scale,
1774
+ depth: safeDepth,
1775
+ scale
1776
+ };
1777
+ }
1778
+ function applyAffine(affine, point) {
1779
+ return {
1780
+ x: affine.a * point.x + affine.c * point.y + affine.e,
1781
+ y: affine.b * point.x + affine.d * point.y + affine.f
1782
+ };
1783
+ }
1784
+ function invertAffine(affine) {
1785
+ const determinant = affine.a * affine.d - affine.b * affine.c;
1786
+ if (!Number.isFinite(determinant) || Math.abs(determinant) < 1e-9) {
1787
+ throw new Error("perspective projection produced a singular affine");
1788
+ }
1789
+ const a = affine.d / determinant;
1790
+ const b = -affine.b / determinant;
1791
+ const c = -affine.c / determinant;
1792
+ const d = affine.a / determinant;
1793
+ return {
1794
+ a,
1795
+ b,
1796
+ c,
1797
+ d,
1798
+ e: -(a * affine.e + c * affine.f),
1799
+ f: -(b * affine.e + d * affine.f)
1800
+ };
1801
+ }
1802
+ function composeAffine(left, right) {
1803
+ return {
1804
+ a: left.a * right.a + left.c * right.b,
1805
+ b: left.b * right.a + left.d * right.b,
1806
+ c: left.a * right.c + left.c * right.d,
1807
+ d: left.b * right.c + left.d * right.d,
1808
+ e: left.a * right.e + left.c * right.f + left.e,
1809
+ f: left.b * right.e + left.d * right.f + left.f
1810
+ };
1811
+ }
1812
+ function perspectiveTangentAffine(camera, anchor, heightAt = () => 0) {
1813
+ const origin = projectPerspectivePoint(camera, anchor, heightAt(anchor));
1814
+ const alongXPoint = { x: anchor.x + 1, y: anchor.y };
1815
+ const alongYPoint = { x: anchor.x, y: anchor.y + 1 };
1816
+ const alongX = projectPerspectivePoint(camera, alongXPoint, heightAt(alongXPoint));
1817
+ const alongY = projectPerspectivePoint(camera, alongYPoint, heightAt(alongYPoint));
1818
+ const a = alongX.x - origin.x;
1819
+ const b = alongX.y - origin.y;
1820
+ const c = alongY.x - origin.x;
1821
+ const d = alongY.y - origin.y;
1822
+ return {
1823
+ a,
1824
+ b,
1825
+ c,
1826
+ d,
1827
+ e: origin.x - a * anchor.x - c * anchor.y,
1828
+ f: origin.y - b * anchor.x - d * anchor.y
1829
+ };
1830
+ }
1831
+ function decomposeAffineLinear(affine) {
1832
+ const scaleX = Math.hypot(affine.a, affine.b);
1833
+ const determinant = affine.a * affine.d - affine.b * affine.c;
1834
+ return {
1835
+ rotationDeg: Math.atan2(affine.b, affine.a) * 180 / Math.PI,
1836
+ scaleX,
1837
+ scaleY: determinant / Math.max(scaleX, 1e-9),
1838
+ skewX: (affine.a * affine.c + affine.b * affine.d) / Math.max(determinant, 1e-9)
1839
+ };
1840
+ }
1841
+
796
1842
  // src/lib/money.ts
797
1843
  var DEFAULT_CURRENCY = "USD";
798
1844
  var displayLocale;
@@ -855,6 +1901,7 @@ var en = {
855
1901
  "picker.seatsLeftInSection.one": "{count} seat left",
856
1902
  "picker.seatsLeftInSection.other": "{count} seats left",
857
1903
  "picker.overview": "Overview",
1904
+ "picker.entrance": "Entrance",
858
1905
  "picker.tapSeatHint": "Tap any seat to check its view",
859
1906
  "picker.ticketTierFor": "Ticket tier for {label}",
860
1907
  "picker.viewFromSeat": "View from seat {label}",
@@ -912,6 +1959,25 @@ function formatDate(value, opts) {
912
1959
  return new Intl.DateTimeFormat(active, opts ?? { dateStyle: "medium", timeStyle: "short" }).format(value);
913
1960
  }
914
1961
 
1962
+ // src/core/shapeLineStyle.ts
1963
+ var DEFAULT_SHAPE_LINE_CAP = "round";
1964
+ var DEFAULT_SHAPE_LINE_JOIN = "round";
1965
+ var DEFAULT_SHAPE_LINE_ENDING = "none";
1966
+ function resolvedShapeLineStyle(shape) {
1967
+ return {
1968
+ lineCap: shape.lineCap ?? DEFAULT_SHAPE_LINE_CAP,
1969
+ lineJoin: shape.lineJoin ?? DEFAULT_SHAPE_LINE_JOIN,
1970
+ startEnding: shape.startEnding ?? DEFAULT_SHAPE_LINE_ENDING,
1971
+ endEnding: shape.endEnding ?? DEFAULT_SHAPE_LINE_ENDING
1972
+ };
1973
+ }
1974
+ function shapeArrowMetrics(strokeWidth) {
1975
+ return {
1976
+ pointerLength: Math.min(48, Math.max(8, strokeWidth * 3)),
1977
+ pointerWidth: Math.min(40, Math.max(8, strokeWidth * 2.25))
1978
+ };
1979
+ }
1980
+
915
1981
  // src/engine/SeatmapRenderer.ts
916
1982
  var SEAT_RADIUS = 9;
917
1983
  var SEAT_LEGIBLE_SCALE = 0.9;
@@ -919,17 +1985,18 @@ var CACHE_THRESHOLD = 0.55 * SEAT_LEGIBLE_SCALE;
919
1985
  var LABEL_SCALE = MIN_VISIBLE_BOOKABLE_LABEL_PX / SEAT_LABEL_FONT_SIZE;
920
1986
  var MIN_FITTED_SEAT_LABEL_FONT_SIZE = 4;
921
1987
  var SEAT_TAP_SLOP_PX = 14;
922
- var SEAT_GLYPH_MIN_PX = 6.5;
1988
+ var WHEELCHAIR_GLYPH_MIN_PX = 10;
1989
+ var FILTERED_WHEELCHAIR_GLYPH_MIN_PX = 14;
923
1990
  var SECTION_PROMINENT_SCALE = 0.45 * SEAT_LEGIBLE_SCALE;
924
1991
  var BLOCK_MELT_TOP = 0.9 * SEAT_LEGIBLE_SCALE;
925
1992
  var SEAT_FOCUS_SCALE = Math.max(SEAT_LEGIBLE_SCALE * 1.1, BLOCK_MELT_TOP);
926
1993
  var PAN_START_SLOP_PX = 8;
1994
+ var GHOST_CLICK_MS = 700;
927
1995
  var ZONE_PROMINENT_SCALE = 0.55 * SECTION_PROMINENT_SCALE;
928
1996
  var MAX_LABELS = 700;
929
1997
  var MARQUEE_RING_CAP = 2500;
930
1998
  var ISO_ANGLE_DEG = -11.5;
931
1999
  var ISO_SQUASH = 0.58;
932
- var LIFT_PER_STEP = 58;
933
2000
  var ISO_TWEEN_MS = 320;
934
2001
  var CAMERA_GLIDE_MS = 650;
935
2002
  var BLOCK_FILL_ALPHA = 1;
@@ -966,6 +2033,10 @@ var DEF_SELECTION = "#ffffff";
966
2033
  var DEF_SELECTION_ON_LIGHT = "#0b1220";
967
2034
  var DEF_DECOR_FILL = "#232c40";
968
2035
  var DEF_TEXT = "#8b93a7";
2036
+ function konvaFontStyle(bold, italic, defaultWeight = "normal") {
2037
+ const weight = bold ? "bold" : defaultWeight;
2038
+ return italic ? `italic ${weight}`.trim() : weight;
2039
+ }
969
2040
  var DEF_CANVAS_BACKGROUND = "#0e1117";
970
2041
  function colorLuminance(color) {
971
2042
  const s = color.trim();
@@ -1071,8 +2142,8 @@ function rotatedRectPoints(center, width, height, rotation) {
1071
2142
  }));
1072
2143
  }
1073
2144
  function pointsBounds(points) {
1074
- const bounds = polyBounds(points);
1075
- return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height };
2145
+ const bounds2 = polyBounds(points);
2146
+ return { x: bounds2.x, y: bounds2.y, width: bounds2.width, height: bounds2.height };
1076
2147
  }
1077
2148
  function rotatedRectFitsPolygon(center, width, height, rotation, outer, holes) {
1078
2149
  const radians = rotation * Math.PI / 180;
@@ -1092,14 +2163,14 @@ function rotatedRectFitsPolygon(center, width, height, rotation, outer, holes) {
1092
2163
  return true;
1093
2164
  }
1094
2165
  function polygonLabelCandidates(outer, holes, preferred) {
1095
- const bounds = polyBounds(outer);
1096
- const centre = { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 };
2166
+ const bounds2 = polyBounds(outer);
2167
+ const centre = { x: bounds2.x + bounds2.width / 2, y: bounds2.y + bounds2.height / 2 };
1097
2168
  const points = [preferred];
1098
2169
  for (let row = 1; row < 12; row += 1) {
1099
2170
  for (let column = 1; column < 12; column += 1) {
1100
2171
  const point = {
1101
- x: bounds.x + bounds.width * column / 12,
1102
- y: bounds.y + bounds.height * row / 12
2172
+ x: bounds2.x + bounds2.width * column / 12,
2173
+ y: bounds2.y + bounds2.height * row / 12
1103
2174
  };
1104
2175
  if (pointInPolygonWithHoles(point, outer, holes)) points.push(point);
1105
2176
  }
@@ -1164,6 +2235,33 @@ function rgba(hex, a) {
1164
2235
  const n = parseInt(m[1], 16);
1165
2236
  return `rgba(${n >> 16 & 255},${n >> 8 & 255},${n & 255},${a})`;
1166
2237
  }
2238
+ var ViewportGroup = class extends Group {
2239
+ constructor() {
2240
+ super(...arguments);
2241
+ this.viewportCulled = false;
2242
+ }
2243
+ setViewportCulled(culled) {
2244
+ if (culled === this.viewportCulled) return;
2245
+ this.viewportCulled = culled;
2246
+ if (!culled) super._clearSelfAndDescendantCache();
2247
+ }
2248
+ isViewportCulled() {
2249
+ return this.viewportCulled;
2250
+ }
2251
+ drawScene(...args) {
2252
+ return this.viewportCulled ? this : super.drawScene(...args);
2253
+ }
2254
+ drawHit(...args) {
2255
+ return this.viewportCulled ? this : super.drawHit(...args);
2256
+ }
2257
+ _clearSelfAndDescendantCache(attr) {
2258
+ if (!this.viewportCulled) {
2259
+ super._clearSelfAndDescendantCache(attr);
2260
+ return;
2261
+ }
2262
+ this._clearCache(attr);
2263
+ }
2264
+ };
1167
2265
  function hexToRgb(hex) {
1168
2266
  const m = /^#?([\da-f]{6})$/i.exec(hex.trim());
1169
2267
  if (!m) return null;
@@ -1194,8 +2292,15 @@ function lerpColor(a, b, t2) {
1194
2292
  }
1195
2293
  var _SeatmapRenderer = class _SeatmapRenderer {
1196
2294
  constructor(container, options = {}) {
2295
+ /** Per-elevated-section label containers. Keeping the labels grouped lets the
2296
+ * Phase-B lift move thousands of seat labels with one transform per section
2297
+ * instead of rewriting every label on every tween frame. */
2298
+ this.labelLiftGroups = /* @__PURE__ */ new Map();
1197
2299
  this.seats = [];
1198
2300
  this.seatById = /* @__PURE__ */ new Map();
2301
+ /** One build per chart/floor; section membership queries only inspect seats in
2302
+ * the section bounds instead of rescanning the full 13k venue per section. */
2303
+ this.seatIndex = null;
1199
2304
  /** Multi-floor (Batch 5): the last-set chart + which floor we're rendering. */
1200
2305
  this.chartDoc = null;
1201
2306
  this.activeFloorId = "";
@@ -1211,13 +2316,11 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1211
2316
  this.seatLabelById = /* @__PURE__ */ new Map();
1212
2317
  /** Coloured accommodation ring per accessible seat (few per chart). */
1213
2318
  this.accessRingById = /* @__PURE__ */ new Map();
1214
- /** Centred accessibility glyph per accessible seat shown once the seat is
1215
- * big enough on-screen (see {@link SEAT_GLYPH_MIN_PX}); the ring is the
1216
- * smaller-zoom fallback. Kept in a map so zoom toggles touch only the handful
1217
- * of accessible seats, never all 13k nodes. */
2319
+ /** Centred glyph per physical wheelchair provision. It keeps a small
2320
+ * screen-space floor at every LOD and grows when the Wheelchair filter is
2321
+ * active. Kept in a map so zoom updates touch only the handful of matching
2322
+ * seats, never all 13k nodes. */
1218
2323
  this.accessGlyphById = /* @__PURE__ */ new Map();
1219
- /** Whether the accessibility glyph is legible at the current camera scale. */
1220
- this.accessGlyphVisible = false;
1221
2324
  /** Authored free-text nodes obey the same rendered-size visibility floor. */
1222
2325
  this.freeTextById = /* @__PURE__ */ new Map();
1223
2326
  /** Stage/rink landmarks retain a readable screen-space caption at overview. */
@@ -1242,6 +2345,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1242
2345
  this.ownedHold = /* @__PURE__ */ new Set();
1243
2346
  /** One selected seat being inspected before it is committed to the cart. */
1244
2347
  this.selectionFocusId = null;
2348
+ /** Seat currently owning the shared hover ring (null while not hovering). */
2349
+ this.hoveredId = null;
1245
2350
  this.focusedId = null;
1246
2351
  /**
1247
2352
  * Accessibility filter: `null` = off; `[]` = dim all non-accessible free seats;
@@ -1260,6 +2365,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1260
2365
  this.sections = [];
1261
2366
  this.zones = [];
1262
2367
  this.seatSection = /* @__PURE__ */ new Map();
2368
+ /** Seats outside every authored section retain the legacy path in one group. */
2369
+ this.unsectionedSeatGroup = null;
1263
2370
  this.catPrice = /* @__PURE__ */ new Map();
1264
2371
  /** Section/zone ids to render dimmed (organizer manager: held-back inventory). */
1265
2372
  this.dimmedSections = /* @__PURE__ */ new Set();
@@ -1272,6 +2379,9 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1272
2379
  this.focusedSectionId = null;
1273
2380
  /** Light backdrop panel drawn behind the focused section (removed on clear). */
1274
2381
  this.focusBackdrop = null;
2382
+ /** Non-listening visual dim outside the focused section. One overlay replaces
2383
+ * descendant opacity mutations across every seat in the venue. */
2384
+ this.focusDimOverlay = null;
1275
2385
  /** Object id → floor id (multi-floor only) — resolves a deck tap in the 3D stack. */
1276
2386
  this.objectFloor = /* @__PURE__ */ new Map();
1277
2387
  /** Zone id → colour (drives extruded side faces in iso view). */
@@ -1284,10 +2394,33 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1284
2394
  this.isoT = 0;
1285
2395
  this.isoTarget = 0;
1286
2396
  this.isoRaf = 0;
2397
+ /** Public view target; perspective is a separate non-affine Phase-C lane. */
2398
+ this.viewMode = "flat";
2399
+ this.perspectiveCamera = null;
2400
+ /** Ground-plane tangent applied once to every renderer layer. */
2401
+ this.perspectiveBaseAffine = null;
2402
+ this.perspectiveBaseInverse = null;
2403
+ /** Exact pinhole anchor expressed in the overlay tangent-layer coordinates. */
2404
+ this.perspectiveSeatLocal = /* @__PURE__ */ new Map();
2405
+ /** Exact projected point (before Stage pan/zoom), keyed by seat. */
2406
+ this.perspectiveSeatProjected = /* @__PURE__ */ new Map();
2407
+ /** Positive camera depth, used for deterministic far→near paint order. */
2408
+ this.perspectiveSeatDepth = /* @__PURE__ */ new Map();
2409
+ /** Exact pinhole size ratio at each seat anchor. */
2410
+ this.perspectiveSeatScale = /* @__PURE__ */ new Map();
2411
+ /** Seats whose native Konva nodes currently carry the exact projected pose.
2412
+ * Large sectioned venues apply these lazily as a section enters the live-seat
2413
+ * viewport; the overview rung hides the seat layer completely. */
2414
+ this.perspectiveAppliedSeats = /* @__PURE__ */ new Set();
2415
+ this.perspectiveBounds = null;
1287
2416
  /** Chart centre the iso projection pivots about (bounds centre). */
1288
2417
  this.isoCentre = { x: 0, y: 0 };
1289
2418
  /** rAF for an in-flight camera glide (focusRegion / setRung); 0 = none. */
1290
2419
  this.glideRaf = 0;
2420
+ /** While the camera tween is active, defer polygon label fitting/collision to
2421
+ * the settled frame. Geometry remains smoothly stage-scaled, while expensive
2422
+ * point-in-polygon text work no longer repeats at 120 Hz. */
2423
+ this.glideInProgress = false;
1291
2424
  /** Set in destroy() so an in-flight iso tween bails. */
1292
2425
  this.destroyed = false;
1293
2426
  /** Cached scale the section/zone labels were last sized for (scale-compensation). */
@@ -1317,6 +2450,19 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1317
2450
  this.panStarted = false;
1318
2451
  /** Maximum displacement from gesture start — suppress taps only after a real pan/pinch. */
1319
2452
  this.moved = 0;
2453
+ /**
2454
+ * Ghost-click guard. Konva binds `_pointerup` to mouseup, touchend AND
2455
+ * pointerup, so `on('click tap')` is two handlers for one finger tap unless
2456
+ * the browser's synthesized compatibility click is suppressed. Konva only
2457
+ * suppresses it when the touch lands on a LISTENING SHAPE (Stage.js returns
2458
+ * early before its preventDefault() when `!shape || !shape.isListening()`),
2459
+ * so a near-miss rescued by nearestSeatToScreen used to fire `tap` (select)
2460
+ * then `click` (deselect) 1-3ms apart and net to nothing. With
2461
+ * SEAT_TAP_SLOP_PX=14 against a ~12px seat radius, that broken annulus is
2462
+ * ~3.7x the area of the seat itself — i.e. most successful mobile taps.
2463
+ * `touch-action: none` does NOT suppress compatibility mouse events.
2464
+ */
2465
+ this.lastTapAt = 0;
1320
2466
  /**
1321
2467
  * Manage-mode rubber-band marquee (option-gated). `start`/`cur` are WORLD-space
1322
2468
  * points (overlayLayer rides the stage transform); `rect` is the on-canvas
@@ -1413,6 +2559,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1413
2559
  x: mid.x - this.pinch.worldMid.x * scale,
1414
2560
  y: mid.y - this.pinch.worldMid.y * scale
1415
2561
  });
2562
+ this.updateSeatGroupVisibility();
1416
2563
  this.stage.batchDraw();
1417
2564
  this.scheduleViewChange();
1418
2565
  } else if (this.panLast && this.pointers.size === 1) {
@@ -1425,6 +2572,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1425
2572
  y: this.stage.y() + (p.y - this.panLast.y)
1426
2573
  });
1427
2574
  this.panLast = p;
2575
+ this.updateSeatGroupVisibility();
1428
2576
  this.stage.batchDraw();
1429
2577
  this.scheduleViewChange();
1430
2578
  }
@@ -1477,6 +2625,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1477
2625
  this.overlayLayer = new Layer({ listening: false });
1478
2626
  this.labelGroup = new Group({ listening: false });
1479
2627
  this.overlayLayer.add(this.labelGroup);
2628
+ this.fgDecorGroup = new Group({ listening: false });
2629
+ this.overlayLayer.add(this.fgDecorGroup);
1480
2630
  this.hoverRing = new Circle({
1481
2631
  radius: SEAT_RADIUS + 2,
1482
2632
  stroke: "#ffffff",
@@ -1511,6 +2661,21 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1511
2661
  this.resizeObs.observe(container);
1512
2662
  }
1513
2663
  }
2664
+ /**
2665
+ * True when this is the browser's synthesized compatibility click following a
2666
+ * finger tap we already handled. Discriminates on `e.type` deliberately —
2667
+ * `PointerEvent extends MouseEvent`, so an `instanceof MouseEvent` test
2668
+ * matches genuine pointer taps too. One shared timestamp across the layer and
2669
+ * stage handlers: a tap consumed at the layer must also suppress the ghost at
2670
+ * the stage, and only `click` is ever rejected, so bubbling stays intact.
2671
+ */
2672
+ isGhostClick(e) {
2673
+ if (e.type === "tap") {
2674
+ this.lastTapAt = performance.now();
2675
+ return false;
2676
+ }
2677
+ return this.lastTapAt > 0 && performance.now() - this.lastTapAt < GHOST_CLICK_MS;
2678
+ }
1514
2679
  // ---- ISeatmapRenderer -----------------------------------------------------
1515
2680
  setChart(doc, opts) {
1516
2681
  if (doc !== this.chartDoc) this.stacked = false;
@@ -1524,8 +2689,15 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1524
2689
  this.focusedId = null;
1525
2690
  this.focusRing.visible(false);
1526
2691
  this.bgLayer.destroyChildren();
2692
+ this.focusDimOverlay?.destroy();
2693
+ this.focusDimOverlay = null;
2694
+ this.seatLayer.clearCache();
2695
+ this.seatLayer.listening(true);
1527
2696
  this.seatLayer.destroyChildren();
2697
+ this.unsectionedSeatGroup = null;
1528
2698
  this.labelGroup.destroyChildren();
2699
+ this.labelLiftGroups.clear();
2700
+ this.fgDecorGroup.destroyChildren();
1529
2701
  this.circleById.clear();
1530
2702
  this.boothDims.clear();
1531
2703
  this.boothLabelById.clear();
@@ -1542,6 +2714,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1542
2714
  this.statusById.clear();
1543
2715
  this.selection.clear();
1544
2716
  this.seatById.clear();
2717
+ this.seatIndex = null;
1545
2718
  this.cached = false;
1546
2719
  this.accessFilter = null;
1547
2720
  this.sections = [];
@@ -1559,11 +2732,22 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1559
2732
  }
1560
2733
  this.isoT = 0;
1561
2734
  this.isoTarget = 0;
2735
+ this.viewMode = "flat";
2736
+ this.perspectiveCamera = null;
2737
+ this.perspectiveBaseAffine = null;
2738
+ this.perspectiveBaseInverse = null;
2739
+ this.perspectiveSeatLocal.clear();
2740
+ this.perspectiveSeatProjected.clear();
2741
+ this.perspectiveSeatDepth.clear();
2742
+ this.perspectiveSeatScale.clear();
2743
+ this.perspectiveAppliedSeats.clear();
2744
+ this.perspectiveBounds = null;
1562
2745
  this.resetLayerTransforms();
1563
2746
  this.hasSections = view.objects.some((o) => o.type === "section");
1564
2747
  for (const z of doc.zones ?? []) if (z.color) this.zoneColor.set(z.id, z.color);
1565
2748
  this.seatLayer.opacity(1);
1566
2749
  this.hoverRing.visible(false);
2750
+ this.hoveredId = null;
1567
2751
  this.theme = doc.theme ?? {};
1568
2752
  this.seatR = clamp(this.theme.seatScale ?? 1, 0.7, 1.6) * SEAT_RADIUS;
1569
2753
  this.container.style.background = "";
@@ -1584,26 +2768,61 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1584
2768
  this.boothDims.set(obj.id, { width: obj.width, height: obj.height, rotation: obj.rotation });
1585
2769
  }
1586
2770
  }
1587
- this.seats = expandChart(view);
2771
+ this.seats = expandChart(view, {
2772
+ // A stacked overview is presentation-only and never opens a seat POV. The
2773
+ // per-section renderer below still resolves each object's real floor base.
2774
+ floorBaseHeightM: this.stacked ? 0 : this.floorBaseHeightMFor()
2775
+ });
1588
2776
  for (const s of this.seats) {
1589
2777
  this.seatById.set(s.id, s);
1590
2778
  this.statusById.set(s.id, "free");
1591
2779
  }
2780
+ this.seatIndex = buildSeatIndex(this.seats, { seatRadius: this.seatR });
2781
+ this.bounds = chartBounds(view);
2782
+ this.isoCentre = { x: this.bounds.x + this.bounds.width / 2, y: this.bounds.y + this.bounds.height / 2 };
2783
+ this.buildPerspectiveProjection(view);
1592
2784
  this.renderBackground(view);
2785
+ this.unsectionedSeatGroup = new Group({ listening: true });
2786
+ this.seatLayer.add(this.unsectionedSeatGroup);
1593
2787
  this.renderSeats();
1594
2788
  this.overlayLayer.add(this.labelGroup);
2789
+ this.overlayLayer.add(this.fgDecorGroup);
1595
2790
  this.overlayLayer.add(this.hoverRing);
1596
- this.bounds = chartBounds(view);
1597
- this.isoCentre = { x: this.bounds.x + this.bounds.width / 2, y: this.bounds.y + this.bounds.height / 2 };
2791
+ this.overlayLayer.add(this.focusRing);
1598
2792
  this.zoomToFit();
1599
2793
  }
1600
2794
  /** The chart to render: all floors stacked (3D overview), the active floor, or
1601
2795
  * the whole chart for single-floor charts. */
1602
2796
  floorView(doc) {
1603
- if (!doc.floors || !doc.floors.length) return doc;
1604
- if (this.stacked && doc.floors.length >= 2) return stackFloors(doc);
2797
+ if (!doc.floors || !doc.floors.length) return {
2798
+ ...doc,
2799
+ referenceImage: void 0,
2800
+ backgroundImage: buyerBackgroundImage(doc)
2801
+ };
2802
+ if (this.stacked && doc.floors.length >= 2) {
2803
+ return {
2804
+ ...stackFloors(doc),
2805
+ referenceImage: void 0,
2806
+ backgroundImage: buyerBackgroundImage(doc)
2807
+ };
2808
+ }
1605
2809
  const floor = doc.floors.find((f) => f.id === this.activeFloorId) ?? doc.floors[0];
1606
- return { ...doc, objects: floor.objects, focalPoint: floor.focalPoint, backgroundImage: floor.backgroundImage, floors: void 0 };
2810
+ return {
2811
+ ...doc,
2812
+ objects: floor.objects,
2813
+ focalPoint: floor.focalPoint,
2814
+ referenceImage: void 0,
2815
+ backgroundImage: buyerBackgroundImage(floor),
2816
+ floors: void 0
2817
+ };
2818
+ }
2819
+ /** Physical floor height is authored data; the 900-unit stacked overview
2820
+ * spread is presentation-only and must never leak into real 3D geometry. */
2821
+ floorBaseHeightMFor(objectId) {
2822
+ const floors = this.chartDoc?.floors;
2823
+ if (!floors?.length) return 0;
2824
+ const floorId = this.stacked && objectId ? this.objectFloor.get(objectId) ?? this.activeFloorId : this.activeFloorId;
2825
+ return floors.find((floor) => floor.id === floorId)?.baseHeightM ?? 0;
1607
2826
  }
1608
2827
  /** Toggle the 3D all-floors stacked overview (Batch 5). Re-renders; no-op on
1609
2828
  * single-floor charts. The caller re-applies statuses + animates the iso view. */
@@ -1916,7 +3135,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1916
3135
  }
1917
3136
  const ids = [];
1918
3137
  for (const seat of this.seats) {
1919
- if (seat.x < x0 || seat.x > x1 || seat.y < y0 || seat.y > y1) continue;
3138
+ const rendered = this.renderedSeatPoint(seat);
3139
+ if (rendered.x < x0 || rendered.x > x1 || rendered.y < y0 || rendered.y > y1) continue;
1920
3140
  if (!this.isSelectable(seat.id)) continue;
1921
3141
  ids.push(seat.id);
1922
3142
  }
@@ -1926,9 +3146,10 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1926
3146
  flashSeat(seatId, color = "#f43f5e") {
1927
3147
  const seat = this.seatById.get(seatId);
1928
3148
  if (!seat) return;
3149
+ const rendered = this.renderedSeatPoint(seat);
1929
3150
  const ring = new Circle({
1930
- x: seat.x,
1931
- y: seat.y,
3151
+ x: rendered.x,
3152
+ y: rendered.y,
1932
3153
  radius: this.seatR,
1933
3154
  stroke: color,
1934
3155
  strokeWidth: 3,
@@ -1937,6 +3158,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1937
3158
  perfectDrawEnabled: false,
1938
3159
  shadowForStrokeEnabled: false
1939
3160
  });
3161
+ const projectionScale = this.viewMode === "perspective" ? this.perspectiveSeatScale.get(seatId) ?? 1 : 1;
3162
+ ring.scale({ x: projectionScale, y: projectionScale });
1940
3163
  this.overlayLayer.add(ring);
1941
3164
  const start = performance.now();
1942
3165
  const dur = 620;
@@ -1963,17 +3186,20 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1963
3186
  const matches = this.sections.filter((section) => section.id === sectionId || section.zone === sectionId);
1964
3187
  if (!matches.length) return;
1965
3188
  for (const section of matches) {
1966
- const centre = section.outline.reduce(
3189
+ const outline = this.viewMode === "perspective" && section.perspectiveAffine ? section.outline.map((point) => this.perspectiveLocalPoint(this.projectedSectionPoint(section, point))) : section.outline.map((point) => {
3190
+ const lift = section.liftWorld > 0 ? this.isoLiftLocal(section.liftWorld) : { x: 0, y: 0 };
3191
+ return { x: point.x + lift.x, y: point.y + lift.y };
3192
+ });
3193
+ const centre = outline.reduce(
1967
3194
  (sum, point) => ({ x: sum.x + point.x, y: sum.y + point.y }),
1968
3195
  { x: 0, y: 0 }
1969
3196
  );
1970
- centre.x /= section.outline.length;
1971
- centre.y /= section.outline.length;
1972
- const lift = section.elevation > 0 ? this.isoLiftLocal(section.elevation) : { x: 0, y: 0 };
3197
+ centre.x /= outline.length;
3198
+ centre.y /= outline.length;
1973
3199
  const halo = new Line({
1974
- x: centre.x + lift.x,
1975
- y: centre.y + lift.y,
1976
- points: section.outline.flatMap((point) => [point.x - centre.x, point.y - centre.y]),
3200
+ x: centre.x,
3201
+ y: centre.y,
3202
+ points: outline.flatMap((point) => [point.x - centre.x, point.y - centre.y]),
1977
3203
  closed: true,
1978
3204
  stroke: color,
1979
3205
  strokeWidth: 3,
@@ -2017,12 +3243,14 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2017
3243
  nearestSeat(fromId, dir) {
2018
3244
  const from = this.seatById.get(fromId);
2019
3245
  if (!from) return null;
3246
+ const fromPoint = this.viewMode === "perspective" ? this.perspectiveSeatProjected.get(from.id) ?? from : from;
2020
3247
  let best = null;
2021
3248
  let bestScore = Infinity;
2022
3249
  for (const s of this.seats) {
2023
3250
  if (s.id === fromId) continue;
2024
- const dx = s.x - from.x;
2025
- const dy = s.y - from.y;
3251
+ const candidatePoint = this.viewMode === "perspective" ? this.perspectiveSeatProjected.get(s.id) ?? s : s;
3252
+ const dx = candidatePoint.x - fromPoint.x;
3253
+ const dy = candidatePoint.y - fromPoint.y;
2026
3254
  const proj = dx * dir.x + dy * dir.y;
2027
3255
  if (proj <= 0.5) continue;
2028
3256
  const perp = Math.abs(dx * dir.y - dy * dir.x);
@@ -2039,7 +3267,9 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2039
3267
  if (!seat) return;
2040
3268
  this.focusedId = id;
2041
3269
  this.focusRing.radius(this.seatR + 3);
2042
- this.focusRing.position({ x: seat.x, y: seat.y });
3270
+ this.focusRing.position(this.renderedSeatPoint(seat));
3271
+ const projectionScale = this.viewMode === "perspective" ? this.perspectiveSeatScale.get(id) ?? 1 : 1;
3272
+ this.focusRing.scale({ x: projectionScale, y: projectionScale });
2043
3273
  this.focusRing.visible(true);
2044
3274
  this.ensureVisible(seat);
2045
3275
  this.overlayLayer.batchDraw();
@@ -2054,7 +3284,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2054
3284
  const margin = 70;
2055
3285
  const offscreen = p.x < margin || p.x > w - margin || p.y < margin || p.y > h - margin;
2056
3286
  if (this.stage.scaleX() < target || offscreen) {
2057
- const ip = this.isoT === 0 ? seat : this.isoForward(seat);
3287
+ const rendered = this.renderedSeatPoint(seat);
3288
+ const ip = this.viewMode === "perspective" && this.perspectiveBaseAffine ? applyAffine(this.perspectiveBaseAffine, rendered) : this.isoT === 0 ? rendered : this.isoForward(rendered);
2058
3289
  this.stage.scale({ x: target, y: target });
2059
3290
  this.stage.position({ x: w / 2 - ip.x * target, y: h / 2 - ip.y * target });
2060
3291
  this.afterViewChange();
@@ -2064,7 +3295,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2064
3295
  zoomToFit() {
2065
3296
  const w = this.stage.width();
2066
3297
  const h = this.stage.height();
2067
- const b = this.bounds;
3298
+ const b = this.viewMode === "perspective" && this.perspectiveBounds ? this.perspectiveBounds : this.bounds;
2068
3299
  this.fitScale = Math.min(w / b.width, h / b.height) || 1;
2069
3300
  const s = this.fitScale;
2070
3301
  this.stage.scale({ x: s, y: s });
@@ -2093,7 +3324,14 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2093
3324
  }
2094
3325
  worldToScreen(point) {
2095
3326
  const s = this.stage.scaleX();
2096
- const p = this.isoT === 0 ? point : this.isoForward(point);
3327
+ const candidateId = point.id;
3328
+ const seat = typeof candidateId === "string" ? this.seatById.get(candidateId) : void 0;
3329
+ if (this.viewMode === "perspective" && this.perspectiveBaseAffine) {
3330
+ const projected = seat ? this.perspectiveSeatProjected.get(seat.id) ?? applyAffine(this.perspectiveBaseAffine, point) : applyAffine(this.perspectiveBaseAffine, point);
3331
+ return { x: projected.x * s + this.stage.x(), y: projected.y * s + this.stage.y() };
3332
+ }
3333
+ const localPoint = seat ? this.renderedSeatPoint(seat) : point;
3334
+ const p = this.isoT === 0 ? localPoint : this.isoForward(localPoint);
2097
3335
  return { x: p.x * s + this.stage.x(), y: p.y * s + this.stage.y() };
2098
3336
  }
2099
3337
  setAccessibleFilter(on) {
@@ -2103,6 +3341,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2103
3341
  const next = types === null ? null : [...types];
2104
3342
  if (this.sameAccessFilter(next)) return;
2105
3343
  this.accessFilter = next;
3344
+ this.updateAccessGlyphs(this.effScale());
2106
3345
  for (const seat of this.seats) {
2107
3346
  const c = this.circleById.get(seat.id);
2108
3347
  if (c) this.paintSeat(c, seat.id);
@@ -2201,10 +3440,11 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2201
3440
  let minY = Infinity;
2202
3441
  let maxY = -Infinity;
2203
3442
  for (const seat of matching) {
2204
- minX = Math.min(minX, seat.x);
2205
- maxX = Math.max(maxX, seat.x);
2206
- minY = Math.min(minY, seat.y);
2207
- maxY = Math.max(maxY, seat.y);
3443
+ const point = this.renderedSeatPoint(seat);
3444
+ minX = Math.min(minX, point.x);
3445
+ maxX = Math.max(maxX, point.x);
3446
+ minY = Math.min(minY, point.y);
3447
+ maxY = Math.max(maxY, point.y);
2208
3448
  }
2209
3449
  const pad = Math.max(24, this.seatR * 3);
2210
3450
  minX -= pad;
@@ -2225,12 +3465,37 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2225
3465
  * inverse) keeps landing on the projected seats/sections.
2226
3466
  */
2227
3467
  setViewMode(mode) {
3468
+ if (mode === "perspective") {
3469
+ if (this.viewMode === mode) {
3470
+ this.afterViewChange();
3471
+ return;
3472
+ }
3473
+ const resetElevatedSeatGroups = this.viewMode === "isometric" && this.isoT > 0;
3474
+ this.viewMode = mode;
3475
+ this.isoTarget = 1;
3476
+ this.isoT = 1;
3477
+ if (this.isoRaf) {
3478
+ cancelAnimationFrame(this.isoRaf);
3479
+ this.isoRaf = 0;
3480
+ }
3481
+ this.applyPerspective(resetElevatedSeatGroups);
3482
+ this.zoomToFit();
3483
+ return;
3484
+ }
2228
3485
  const target = mode === "isometric" ? 1 : 0;
3486
+ const leavingPerspective = this.viewMode === "perspective";
3487
+ this.viewMode = mode;
2229
3488
  this.isoTarget = target;
2230
3489
  if (this.isoRaf) {
2231
3490
  cancelAnimationFrame(this.isoRaf);
2232
3491
  this.isoRaf = 0;
2233
3492
  }
3493
+ if (leavingPerspective) {
3494
+ this.isoT = target;
3495
+ this.applyIso();
3496
+ this.afterViewChange();
3497
+ return;
3498
+ }
2234
3499
  if (this.reducedMotion) {
2235
3500
  this.isoT = target;
2236
3501
  this.applyIso();
@@ -2264,7 +3529,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2264
3529
  }
2265
3530
  /** Current projection — reflects the tween target, not the mid-tween isoT. */
2266
3531
  getViewMode() {
2267
- return this.isoTarget === 1 ? "isometric" : "flat";
3532
+ return this.viewMode;
2268
3533
  }
2269
3534
  /** Iso angle (rad) + y-squash for the current isoT. */
2270
3535
  isoParams() {
@@ -2272,6 +3537,11 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2272
3537
  }
2273
3538
  /** Effective vertical scale = stage scale × iso squash — legibility math uses this. */
2274
3539
  effScale() {
3540
+ if (this.viewMode === "perspective" && this.perspectiveBaseAffine) {
3541
+ const xScale = Math.hypot(this.perspectiveBaseAffine.a, this.perspectiveBaseAffine.b);
3542
+ const yScale = Math.hypot(this.perspectiveBaseAffine.c, this.perspectiveBaseAffine.d);
3543
+ return this.stage.scaleX() * Math.min(xScale, yScale);
3544
+ }
2275
3545
  return this.stage.scaleX() * (1 - (1 - ISO_SQUASH) * this.isoT);
2276
3546
  }
2277
3547
  /** Project a world point through the iso affine about the chart centre (→ iso-world). */
@@ -2295,19 +3565,113 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2295
3565
  const wy = -dx * Math.sin(th) + uy * Math.cos(th);
2296
3566
  return { x: c.x + wx, y: c.y + wy };
2297
3567
  }
3568
+ /** Precompute the Phase-C pinhole camera and every exact seat anchor. */
3569
+ buildPerspectiveProjection(view) {
3570
+ let maxSurfaceHeightWorld = 0;
3571
+ for (const seat of this.seats) {
3572
+ const surfaceM = Math.max(0, (seat.eyeHeightM ?? SEATED_EYE_HEIGHT_M) - SEATED_EYE_HEIGHT_M);
3573
+ maxSurfaceHeightWorld = Math.max(maxSurfaceHeightWorld, surfaceM * CHART_UNITS_PER_METRE);
3574
+ }
3575
+ for (const object of view.objects) {
3576
+ if (object.type !== "section") continue;
3577
+ maxSurfaceHeightWorld = Math.max(
3578
+ maxSurfaceHeightWorld,
3579
+ sectionGeometry(object, { floorBaseHeightM: this.floorBaseHeightMFor(object.id) }).height * CHART_UNITS_PER_METRE
3580
+ );
3581
+ }
3582
+ const camera = createPerspectiveCamera(this.bounds, maxSurfaceHeightWorld);
3583
+ const base = perspectiveTangentAffine(camera, camera.target);
3584
+ const inverse = invertAffine(base);
3585
+ this.perspectiveCamera = camera;
3586
+ this.perspectiveBaseAffine = base;
3587
+ this.perspectiveBaseInverse = inverse;
3588
+ this.perspectiveSeatLocal.clear();
3589
+ this.perspectiveSeatProjected.clear();
3590
+ this.perspectiveSeatDepth.clear();
3591
+ this.perspectiveSeatScale.clear();
3592
+ this.perspectiveBounds = null;
3593
+ let minX = Infinity;
3594
+ let minY = Infinity;
3595
+ let maxX = -Infinity;
3596
+ let maxY = -Infinity;
3597
+ for (const seat of this.seats) {
3598
+ const surfaceM = Math.max(0, (seat.eyeHeightM ?? SEATED_EYE_HEIGHT_M) - SEATED_EYE_HEIGHT_M);
3599
+ const projected = projectPerspectivePoint(camera, seat, surfaceM * CHART_UNITS_PER_METRE);
3600
+ const point = { x: projected.x, y: projected.y };
3601
+ this.perspectiveSeatProjected.set(seat.id, point);
3602
+ this.perspectiveSeatLocal.set(seat.id, applyAffine(inverse, point));
3603
+ this.perspectiveSeatDepth.set(seat.id, projected.depth);
3604
+ this.perspectiveSeatScale.set(seat.id, projected.scale);
3605
+ minX = Math.min(minX, projected.x);
3606
+ minY = Math.min(minY, projected.y);
3607
+ maxX = Math.max(maxX, projected.x);
3608
+ maxY = Math.max(maxY, projected.y);
3609
+ }
3610
+ for (const point of [
3611
+ { x: this.bounds.x, y: this.bounds.y },
3612
+ { x: this.bounds.x + this.bounds.width, y: this.bounds.y },
3613
+ { x: this.bounds.x + this.bounds.width, y: this.bounds.y + this.bounds.height },
3614
+ { x: this.bounds.x, y: this.bounds.y + this.bounds.height }
3615
+ ]) {
3616
+ const projected = projectPerspectivePoint(camera, point, 0);
3617
+ minX = Math.min(minX, projected.x);
3618
+ minY = Math.min(minY, projected.y);
3619
+ maxX = Math.max(maxX, projected.x);
3620
+ maxY = Math.max(maxY, projected.y);
3621
+ }
3622
+ const pad = Math.max(24, this.seatR * 3);
3623
+ this.perspectiveBounds = Number.isFinite(minX) ? { x: minX - pad, y: minY - pad, width: Math.max(1, maxX - minX + pad * 2), height: Math.max(1, maxY - minY + pad * 2) } : null;
3624
+ }
3625
+ /** Apply an affine to a Konva container while keeping `pivot` fixed logically. */
3626
+ setContainerAffine(node, affine, pivot) {
3627
+ const decomposed = decomposeAffineLinear(affine);
3628
+ node.offset(pivot);
3629
+ node.position(applyAffine(affine, pivot));
3630
+ node.rotation(decomposed.rotationDeg);
3631
+ node.scale({ x: decomposed.scaleX, y: decomposed.scaleY });
3632
+ node.skewX(decomposed.skewX);
3633
+ node.skewY(0);
3634
+ }
3635
+ resetContainerTransform(node) {
3636
+ node.position({ x: 0, y: 0 });
3637
+ node.offset({ x: 0, y: 0 });
3638
+ node.scale({ x: 1, y: 1 });
3639
+ node.rotation(0);
3640
+ node.skewX(0);
3641
+ node.skewY(0);
3642
+ }
3643
+ /** Exact raked surface height used for the bounded section tangent plane. */
3644
+ sectionSurfaceHeight(section, point) {
3645
+ const radial = Math.hypot(point.x - section.surfaceFocal.x, point.y - section.surfaceFocal.y);
3646
+ const depth = Math.max(0, radial - section.frontDistanceWorld);
3647
+ return section.liftWorld + depth * Math.tan(section.rakeDeg * Math.PI / 180);
3648
+ }
3649
+ /** Desired perspective-space point for a section surface, before Stage pan/zoom. */
3650
+ projectedSectionPoint(section, point) {
3651
+ return section.perspectiveAffine ? applyAffine(section.perspectiveAffine, point) : applyAffine(this.perspectiveBaseAffine, point);
3652
+ }
3653
+ /** Shared-layer local point which the ground affine paints at `projected`. */
3654
+ perspectiveLocalPoint(projected) {
3655
+ return this.perspectiveBaseInverse ? applyAffine(this.perspectiveBaseInverse, projected) : projected;
3656
+ }
2298
3657
  /**
2299
3658
  * Local-space offset that, once the layer applies the iso affine, lifts an
2300
- * object straight UP in iso-world by `elevation × LIFT_PER_STEP × isoT`
2301
- * (= inverse-linear of the pure vertical lift). Zero at isoT=0.
3659
+ * object straight UP in iso-world by `liftWorld × isoT` world units
3660
+ * (= inverse-linear of the pure vertical lift). Zero at isoT=0. `liftWorld` is
3661
+ * the section's resolved real height in world units (Phase B1).
2302
3662
  */
2303
- isoLiftLocal(elevation) {
3663
+ isoLiftLocal(liftWorld) {
2304
3664
  const { th, sg } = this.isoParams();
2305
- const delta = elevation * LIFT_PER_STEP * this.isoT;
3665
+ const delta = liftWorld * this.isoT;
2306
3666
  return { x: -(delta / sg) * Math.sin(th), y: -(delta / sg) * Math.cos(th) };
2307
3667
  }
2308
- /** Restore the three layers to identity (flat) — byte-for-byte the original. */
2309
- resetLayerTransforms() {
2310
- for (const layer of [this.bgLayer, this.seatLayer, this.overlayLayer]) {
3668
+ /** Restore projection layers to identity (flat) — byte-for-byte the original.
3669
+ * `seatLayer` can be skipped when entering perspective from flat: assigning a
3670
+ * top-level transform invalidates all 14k descendant absolute transforms even
3671
+ * while the semantic overview keeps that layer fully transparent. */
3672
+ resetLayerTransforms(includeSeatLayer = true) {
3673
+ const layers = includeSeatLayer ? [this.bgLayer, this.seatLayer, this.overlayLayer] : [this.bgLayer, this.overlayLayer];
3674
+ for (const layer of layers) {
2311
3675
  layer.position({ x: 0, y: 0 });
2312
3676
  layer.offset({ x: 0, y: 0 });
2313
3677
  layer.scale({ x: 1, y: 1 });
@@ -2316,6 +3680,117 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2316
3680
  layer.skewY(0);
2317
3681
  }
2318
3682
  }
3683
+ /** Restore section-owned containers before applying another projection. */
3684
+ resetSectionProjection(resetSeatGroups = true) {
3685
+ for (const section of this.sections) {
3686
+ this.resetContainerTransform(section.rootGroupBg);
3687
+ this.resetContainerTransform(section.liftGroupBg);
3688
+ if (resetSeatGroups) this.resetContainerTransform(section.liftGroupSeat);
3689
+ section.rootGroupBg.zIndex(section.bgZIndex);
3690
+ if (resetSeatGroups) section.liftGroupSeat.zIndex(section.seatZIndex);
3691
+ const labelGroup = this.labelLiftGroups.get(section.id);
3692
+ if (labelGroup && resetSeatGroups) this.resetContainerTransform(labelGroup);
3693
+ section.perspectiveAffine = void 0;
3694
+ section.perspectiveCorrection = void 0;
3695
+ section.perspectiveDepth = void 0;
3696
+ }
3697
+ }
3698
+ /** Move native seat/booth hit shapes to or from their exact pinhole anchors. */
3699
+ applyExactSeatAnchors(perspective, seatIds) {
3700
+ const seats = seatIds ? [...seatIds].map((id) => this.seatById.get(id)).filter((seat) => !!seat) : perspective ? this.seats : [...this.perspectiveAppliedSeats].map((id) => this.seatById.get(id)).filter((seat) => !!seat);
3701
+ for (const seat of seats) {
3702
+ if (perspective && this.perspectiveAppliedSeats.has(seat.id)) continue;
3703
+ const point = perspective ? this.perspectiveSeatProjected.get(seat.id) ?? seat : seat;
3704
+ const scale = perspective ? this.perspectiveSeatScale.get(seat.id) ?? 1 : 1;
3705
+ const shape = this.circleById.get(seat.id);
3706
+ shape?.position(point);
3707
+ shape?.scale({ x: scale, y: scale });
3708
+ const ring = this.accessRingById.get(seat.id);
3709
+ ring?.position(point);
3710
+ ring?.scale({ x: scale, y: scale });
3711
+ const glyph = this.accessGlyphById.get(seat.id);
3712
+ if (glyph) {
3713
+ const baseGlyphScale = this.seatR * 1.5 / ACCESS_GLYPH_VIEWBOX;
3714
+ glyph.position(point);
3715
+ glyph.scale({ x: baseGlyphScale * scale, y: baseGlyphScale * scale });
3716
+ }
3717
+ const boothLabel = this.boothLabelById.get(seat.id);
3718
+ boothLabel?.position(point);
3719
+ boothLabel?.scale({ x: scale, y: scale });
3720
+ if (perspective) this.perspectiveAppliedSeats.add(seat.id);
3721
+ }
3722
+ if (!perspective) this.perspectiveAppliedSeats.clear();
3723
+ }
3724
+ /**
3725
+ * Phase C projected 2.5D. Seat/booth anchors are exact pinhole projections;
3726
+ * section top surfaces use one explicitly bounded tangent plane per authored
3727
+ * section. Native Konva shapes move with the anchors, so direct hit testing is
3728
+ * the same graph that paints the buyer-visible unit.
3729
+ */
3730
+ applyPerspective(resetElevatedSeatGroups = true) {
3731
+ const camera = this.perspectiveCamera;
3732
+ const base = this.perspectiveBaseAffine;
3733
+ const inverse = this.perspectiveBaseInverse;
3734
+ if (!camera || !base || !inverse) return;
3735
+ const lazySectionSeats = this.seats.length > 2500 && this.sections.length > 0;
3736
+ if (this.cached && !lazySectionSeats) {
3737
+ this.seatLayer.clearCache();
3738
+ this.seatLayer.listening(true);
3739
+ this.cached = false;
3740
+ }
3741
+ this.resetLayerTransforms(resetElevatedSeatGroups);
3742
+ this.resetSectionProjection(resetElevatedSeatGroups);
3743
+ for (const layer of [this.bgLayer, this.overlayLayer]) {
3744
+ this.setContainerAffine(layer, base, camera.target);
3745
+ }
3746
+ this.perspectiveAppliedSeats.clear();
3747
+ if (!lazySectionSeats) this.applyExactSeatAnchors(true);
3748
+ for (const section of this.sections) {
3749
+ const heightAt = (point) => this.sectionSurfaceHeight(section, point);
3750
+ const desired = perspectiveTangentAffine(camera, section.centroid, heightAt);
3751
+ const correction = composeAffine(inverse, desired);
3752
+ section.perspectiveAffine = desired;
3753
+ section.perspectiveCorrection = correction;
3754
+ section.perspectiveDepth = projectPerspectivePoint(
3755
+ camera,
3756
+ section.centroid,
3757
+ heightAt(section.centroid)
3758
+ ).depth;
3759
+ this.setContainerAffine(section.liftGroupBg, correction, section.centroid);
3760
+ for (let index = 0; index < section.sideFaces.length; index++) {
3761
+ const p0 = section.outline[index];
3762
+ const p1 = section.outline[(index + 1) % section.outline.length];
3763
+ const base0 = this.perspectiveLocalPoint(projectPerspectivePoint(camera, p0, 0));
3764
+ const base1 = this.perspectiveLocalPoint(projectPerspectivePoint(camera, p1, 0));
3765
+ const top1 = this.perspectiveLocalPoint(projectPerspectivePoint(camera, p1, heightAt(p1)));
3766
+ const top0 = this.perspectiveLocalPoint(projectPerspectivePoint(camera, p0, heightAt(p0)));
3767
+ section.sideFaces[index].points([
3768
+ base0.x,
3769
+ base0.y,
3770
+ base1.x,
3771
+ base1.y,
3772
+ top1.x,
3773
+ top1.y,
3774
+ top0.x,
3775
+ top0.y
3776
+ ]);
3777
+ section.sideFaces[index].opacity(0.9);
3778
+ }
3779
+ }
3780
+ const farToNear = [...this.sections].sort(
3781
+ (left, right) => (right.perspectiveDepth ?? 0) - (left.perspectiveDepth ?? 0)
3782
+ );
3783
+ for (const section of farToNear) {
3784
+ section.rootGroupBg.moveToTop();
3785
+ section.liftGroupSeat.moveToTop();
3786
+ }
3787
+ this.unsectionedSeatGroup?.moveToTop();
3788
+ this.syncSeatOverlayPositions();
3789
+ if (!lazySectionSeats) this.updateSeatGroupVisibility();
3790
+ this.bgLayer.batchDraw();
3791
+ this.seatLayer.batchDraw();
3792
+ this.overlayLayer.batchDraw();
3793
+ }
2319
3794
  /**
2320
3795
  * Apply the current isoT to the scene: the base rotate+squash as a decomposed
2321
3796
  * layer transform (so seats/décor/rings project together and Konva's own
@@ -2323,6 +3798,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2323
3798
  * lift + extruded side faces on elevated sections.
2324
3799
  */
2325
3800
  applyIso() {
3801
+ this.resetSectionProjection();
3802
+ this.applyExactSeatAnchors(false);
2326
3803
  const t2 = this.isoT;
2327
3804
  if (t2 === 0) {
2328
3805
  this.resetLayerTransforms();
@@ -2351,6 +3828,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2351
3828
  }
2352
3829
  this.applyUprightLabels();
2353
3830
  this.applyElevation();
3831
+ this.updateSeatGroupVisibility();
2354
3832
  this.bgLayer.batchDraw();
2355
3833
  this.seatLayer.batchDraw();
2356
3834
  this.overlayLayer.batchDraw();
@@ -2379,10 +3857,11 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2379
3857
  applyElevation() {
2380
3858
  const t2 = this.isoT;
2381
3859
  for (const sec of this.sections) {
2382
- if (sec.elevation <= 0) continue;
2383
- const off = this.isoLiftLocal(sec.elevation);
3860
+ if (sec.liftWorld <= 0) continue;
3861
+ const off = this.isoLiftLocal(sec.liftWorld);
2384
3862
  sec.liftGroupBg?.position(off);
2385
- sec.liftGroupSeat?.position(off);
3863
+ sec.liftGroupSeat.position(off);
3864
+ this.labelLiftGroups.get(sec.id)?.position(off);
2386
3865
  const alpha = 0.9 * t2;
2387
3866
  for (let i = 0; i < sec.sideFaces.length; i++) {
2388
3867
  const face = sec.sideFaces[i];
@@ -2392,6 +3871,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2392
3871
  face.opacity(alpha);
2393
3872
  }
2394
3873
  }
3874
+ this.syncSeatOverlayPositions();
2395
3875
  }
2396
3876
  destroy() {
2397
3877
  this.destroyed = true;
@@ -2418,16 +3898,166 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2418
3898
  * tier shifts with one offset in iso view) or the seat layer directly.
2419
3899
  */
2420
3900
  seatContainer(id) {
2421
- return this.seatSection.get(id)?.liftGroupSeat ?? this.seatLayer;
3901
+ return this.seatSection.get(id)?.liftGroupSeat ?? this.unsectionedSeatGroup ?? this.seatLayer;
2422
3902
  }
2423
- renderSeats() {
3903
+ /** True when a section belongs to the active logical section/zone focus. */
3904
+ sectionMatchesFocus(section) {
3905
+ const focus = this.focusedSectionId;
3906
+ return focus == null || section.id === focus || section.logicalId === focus || section.zone === focus;
3907
+ }
3908
+ /** Visual opacity after the section-focus overlay is composed. */
3909
+ focusedSeatOpacity(id, localOpacity) {
3910
+ if (!this.focusedSectionId) return localOpacity;
3911
+ const section = this.seatSection.get(id);
3912
+ return localOpacity * (section && this.sectionMatchesFocus(section) ? 1 : FOCUS_DIM_OPACITY);
3913
+ }
3914
+ /** Project a section outline through elevation + the active affine view and
3915
+ * then into viewport pixels. The result is conservative (AABB), so a visible
3916
+ * section is never clipped even for rotated or isometric outlines. */
3917
+ sectionScreenBounds(section) {
3918
+ if (this.viewMode === "perspective" && section.perspectiveAffine) {
3919
+ const scale2 = this.stage.scaleX();
3920
+ const stageX2 = this.stage.x();
3921
+ const stageY2 = this.stage.y();
3922
+ return polyBounds(section.outline.map((point) => {
3923
+ const projected = this.projectedSectionPoint(section, point);
3924
+ return { x: projected.x * scale2 + stageX2, y: projected.y * scale2 + stageY2 };
3925
+ }));
3926
+ }
3927
+ const offset = section.liftWorld > 0 ? this.isoLiftLocal(section.liftWorld) : { x: 0, y: 0 };
3928
+ const scale = this.stage.scaleX();
3929
+ const stageX = this.stage.x();
3930
+ const stageY = this.stage.y();
3931
+ return polyBounds(section.outline.map((point) => {
3932
+ const local = { x: point.x + offset.x, y: point.y + offset.y };
3933
+ const projected = this.isoT === 0 ? local : this.isoForward(local);
3934
+ return { x: projected.x * scale + stageX, y: projected.y * scale + stageY };
3935
+ }));
3936
+ }
3937
+ /** Hide only section groups wholly outside a padded viewport at the live-seat
3938
+ * rung. Overview caching always restores all groups first, so panning a
3939
+ * cached whole-venue bitmap can never reveal missing inventory. */
3940
+ updateSeatGroupVisibility() {
3941
+ const liveSeats = this.effScale() >= CACHE_THRESHOLD;
3942
+ const deferPerspectiveSeatReveal = this.viewMode === "perspective" && this.glideInProgress && this.seats.length > 2500;
3943
+ const padding = 96;
3944
+ const width = this.stage.width();
3945
+ const height = this.stage.height();
3946
+ for (const section of this.sections) {
3947
+ let visible = true;
3948
+ if (liveSeats) {
3949
+ const bounds2 = this.sectionScreenBounds(section);
3950
+ visible = bounds2.x + bounds2.width >= -padding && bounds2.y + bounds2.height >= -padding && bounds2.x <= width + padding && bounds2.y <= height + padding;
3951
+ if (visible && this.viewMode === "perspective" && !deferPerspectiveSeatReveal) {
3952
+ this.applyExactSeatAnchors(true, section.memberIds);
3953
+ }
3954
+ }
3955
+ section.liftGroupSeat.setViewportCulled(!visible);
3956
+ const labelGroup = this.labelLiftGroups.get(section.id);
3957
+ labelGroup?.setViewportCulled(!visible);
3958
+ }
3959
+ if (this.unsectionedSeatGroup && !this.unsectionedSeatGroup.visible()) {
3960
+ this.unsectionedSeatGroup.visible(true);
3961
+ }
3962
+ if (liveSeats && this.viewMode === "perspective" && !deferPerspectiveSeatReveal) {
3963
+ this.applyExactSeatAnchors(true, this.seats.filter((seat) => !this.seatSection.has(seat.id)).map((seat) => seat.id));
3964
+ }
3965
+ }
3966
+ /** Seats worth considering for viewport labels. Section culling is a safe
3967
+ * coarse index; the later screen test remains the exact filter. */
3968
+ visibleSeatCandidates() {
3969
+ if (!this.sections.length) return this.seats;
3970
+ const candidates = [];
3971
+ for (const section of this.sections) {
3972
+ if (section.liftGroupSeat.isViewportCulled()) continue;
3973
+ for (const id of section.memberIds) {
3974
+ const seat = this.seatById.get(id);
3975
+ if (seat) candidates.push(seat);
3976
+ }
3977
+ }
2424
3978
  for (const seat of this.seats) {
3979
+ if (!this.seatSection.has(seat.id)) candidates.push(seat);
3980
+ }
3981
+ return candidates;
3982
+ }
3983
+ seatViewportCulled(id) {
3984
+ return this.seatSection.get(id)?.liftGroupSeat.isViewportCulled() ?? false;
3985
+ }
3986
+ /** Local world coordinate at which an elevated seat is actually painted for
3987
+ * the current iso tween. Flat seats and the 2D endpoint are unchanged. */
3988
+ renderedSeatPoint(seat) {
3989
+ if (this.viewMode === "perspective") return this.perspectiveSeatLocal.get(seat.id) ?? seat;
3990
+ const section = this.seatSection.get(seat.id);
3991
+ if (!section || section.liftWorld <= 0 || this.isoT === 0) return seat;
3992
+ const offset = this.isoLiftLocal(section.liftWorld);
3993
+ return { x: seat.x + offset.x, y: seat.y + offset.y };
3994
+ }
3995
+ /** Overlay labels mirror the seat-layer lift without an O(seats) per-frame
3996
+ * rewrite. The groups are rebuilt with the zoom-dependent label set. */
3997
+ seatLabelContainer(id) {
3998
+ const section = this.seatSection.get(id);
3999
+ if (!section) return this.labelGroup;
4000
+ let group = this.labelLiftGroups.get(section.id);
4001
+ if (!group) {
4002
+ group = new ViewportGroup({
4003
+ listening: false
4004
+ });
4005
+ group.setViewportCulled(section.liftGroupSeat.isViewportCulled());
4006
+ group.position(this.viewMode === "perspective" ? { x: 0, y: 0 } : this.isoLiftLocal(section.liftWorld));
4007
+ this.labelGroup.add(group);
4008
+ this.labelLiftGroups.set(section.id, group);
4009
+ }
4010
+ return group;
4011
+ }
4012
+ /** Shared overlay furniture is not parented to section lift groups, so keep
4013
+ * the small transient set aligned explicitly. Selection is capped in buyer
4014
+ * mode, making this constant-sized work during the iso tween. */
4015
+ syncSeatOverlayPositions() {
4016
+ if (this.hoveredId) {
4017
+ const seat = this.seatById.get(this.hoveredId);
4018
+ if (seat) {
4019
+ this.hoverRing.position(this.renderedSeatPoint(seat));
4020
+ const scale = this.viewMode === "perspective" ? this.perspectiveSeatScale.get(seat.id) ?? 1 : 1;
4021
+ this.hoverRing.scale({ x: scale, y: scale });
4022
+ }
4023
+ }
4024
+ if (this.focusedId) {
4025
+ const seat = this.seatById.get(this.focusedId);
4026
+ if (seat) {
4027
+ this.focusRing.position(this.renderedSeatPoint(seat));
4028
+ const scale = this.viewMode === "perspective" ? this.perspectiveSeatScale.get(seat.id) ?? 1 : 1;
4029
+ this.focusRing.scale({ x: scale, y: scale });
4030
+ }
4031
+ }
4032
+ for (const [id, marker] of this.selectionMarkers) {
4033
+ const seat = this.seatById.get(id);
4034
+ if (seat) {
4035
+ marker.position(this.renderedSeatPoint(seat));
4036
+ const scale = this.viewMode === "perspective" ? this.perspectiveSeatScale.get(seat.id) ?? 1 : 1;
4037
+ marker.scale({ x: scale, y: scale });
4038
+ }
4039
+ }
4040
+ }
4041
+ renderSeats() {
4042
+ const paintOrder = [...this.seats].sort((left, right) => (this.perspectiveSeatDepth.get(right.id) ?? 0) - (this.perspectiveSeatDepth.get(left.id) ?? 0));
4043
+ for (const seat of paintOrder) {
2425
4044
  if (seat.kind === "booth") {
2426
4045
  this.renderBoothUnit(seat);
2427
4046
  continue;
2428
4047
  }
2429
4048
  const target = this.seatContainer(seat.id);
2430
- const c = new Circle({
4049
+ const c = seat.wheelchairSpaceType === "no-seat" ? new Rect({
4050
+ x: seat.x,
4051
+ y: seat.y,
4052
+ width: this.seatR * 2,
4053
+ height: this.seatR * 2,
4054
+ offsetX: this.seatR,
4055
+ offsetY: this.seatR,
4056
+ cornerRadius: 2,
4057
+ perfectDrawEnabled: false,
4058
+ shadowForStrokeEnabled: false,
4059
+ hitStrokeWidth: 0
4060
+ }) : new Circle({
2431
4061
  x: seat.x,
2432
4062
  y: seat.y,
2433
4063
  radius: this.seatR,
@@ -2440,21 +4070,25 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2440
4070
  this.paintSeat(c, seat.id);
2441
4071
  target.add(c);
2442
4072
  if (seat.accessible) {
2443
- const ring = new Circle({
2444
- x: seat.x,
2445
- y: seat.y,
2446
- radius: this.seatR + 1.5,
2447
- stroke: accessibilityRingColor(seat.accessibility),
2448
- strokeWidth: 2.5,
2449
- listening: false,
2450
- perfectDrawEnabled: false,
2451
- shadowForStrokeEnabled: false
2452
- });
2453
- this.accessRingById.set(seat.id, ring);
2454
- target.add(ring);
2455
- const glyph = this.buildAccessGlyph(seat, typeof c.fill() === "string" ? c.fill() : "");
2456
- this.accessGlyphById.set(seat.id, glyph);
2457
- target.add(glyph);
4073
+ if (seat.wheelchairSpaceType !== "no-seat") {
4074
+ const ring = new Circle({
4075
+ x: seat.x,
4076
+ y: seat.y,
4077
+ radius: this.seatR + 1.5,
4078
+ stroke: accessibilityRingColor(seat.accessibility),
4079
+ strokeWidth: 2.5,
4080
+ listening: false,
4081
+ perfectDrawEnabled: false,
4082
+ shadowForStrokeEnabled: false
4083
+ });
4084
+ this.accessRingById.set(seat.id, ring);
4085
+ target.add(ring);
4086
+ }
4087
+ if (seat.accessibility?.includes("wheelchair")) {
4088
+ const glyph = this.buildAccessGlyph(seat, typeof c.fill() === "string" ? c.fill() : "");
4089
+ this.accessGlyphById.set(seat.id, glyph);
4090
+ target.add(glyph);
4091
+ }
2458
4092
  }
2459
4093
  }
2460
4094
  }
@@ -2492,7 +4126,6 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2492
4126
  t2.offsetX(t2.width() / 2);
2493
4127
  t2.offsetY(t2.height() / 2);
2494
4128
  t2.visible(false);
2495
- this.boothLabelById.set(seat.id, t2);
2496
4129
  this.hasBoothText = true;
2497
4130
  this.boothLabelById.set(seat.id, t2);
2498
4131
  target.add(t2);
@@ -2515,24 +4148,40 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2515
4148
  scaleY: k,
2516
4149
  fill: stateAwareBookableLabelInk(seatFill, "#ffffff"),
2517
4150
  listening: false,
2518
- visible: this.accessGlyphVisible,
4151
+ visible: false,
2519
4152
  perfectDrawEnabled: false,
2520
4153
  shadowForStrokeEnabled: false
2521
4154
  });
2522
4155
  }
2523
4156
  /**
2524
- * Toggle the accessibility glyphs for the current camera scale: shown once the
2525
- * effective on-screen seat radius clears {@link SEAT_GLYPH_MIN_PX}, otherwise
2526
- * hidden so only the ring remains. Iterates the (small) accessible-seat set,
2527
- * never the full node graph, so it is cheap to call on every view change.
4157
+ * Keep physical wheelchair provision readable in screen space at every map
4158
+ * LOD. The active Wheelchair filter promotes it further. Iterates the small
4159
+ * wheelchair-seat set, never the full node graph.
2528
4160
  */
2529
4161
  updateAccessGlyphs(scale) {
2530
4162
  if (!this.accessGlyphById.size) return;
2531
- this.accessGlyphVisible = this.seatR * scale >= SEAT_GLYPH_MIN_PX;
2532
4163
  for (const [id, glyph] of this.accessGlyphById) {
2533
- glyph.visible(this.accessGlyphVisible && this.accessGlyphEligible(id));
4164
+ const perspectiveScale = this.viewMode === "perspective" ? this.perspectiveSeatScale.get(id) ?? 1 : 1;
4165
+ const baseScale = this.seatR * 1.5 / ACCESS_GLYPH_VIEWBOX * perspectiveScale;
4166
+ const emphasized = this.accessGlyphFilterEmphasized(id);
4167
+ const minimumScreenPx = emphasized ? FILTERED_WHEELCHAIR_GLYPH_MIN_PX : WHEELCHAIR_GLYPH_MIN_PX;
4168
+ const glyphScale = Math.max(
4169
+ baseScale,
4170
+ minimumScreenPx / (ACCESS_GLYPH_VIEWBOX * Math.max(scale, 1e-3))
4171
+ );
4172
+ glyph.scale({ x: glyphScale, y: glyphScale });
4173
+ glyph.visible(this.accessGlyphShouldShow(id));
2534
4174
  }
2535
4175
  }
4176
+ /** True only for a physical wheelchair provision matching an active buyer filter. */
4177
+ accessGlyphFilterEmphasized(id) {
4178
+ const seat = this.seatById.get(id);
4179
+ const filter = this.accessFilter;
4180
+ return !!seat && filter !== null && !!seat.accessibility?.includes("wheelchair") && (filter.length === 0 || filter.includes("wheelchair")) && seatMatchesAccess(seat, filter);
4181
+ }
4182
+ accessGlyphShouldShow(id) {
4183
+ return this.accessGlyphById.has(id) && this.accessGlyphEligible(id);
4184
+ }
2536
4185
  /**
2537
4186
  * Whether an accessible seat's glyph should show for its current status. It is
2538
4187
  * hidden on seats a buyer cannot take (sold, or another buyer's hold) where the
@@ -2612,6 +4261,11 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2612
4261
  c.dash([2, 2]);
2613
4262
  break;
2614
4263
  }
4264
+ if (seat.wheelchairSpaceType === "no-seat" && status === "free") {
4265
+ c.stroke(accessibilityRingColor(seat.accessibility));
4266
+ c.strokeWidth(selected ? 3 : 2);
4267
+ c.dash([3, 2]);
4268
+ }
2615
4269
  if (boothLabel) {
2616
4270
  boothLabel.text(status === "booked" ? "SOLD" : status === "held" ? "HELD" : seat.label);
2617
4271
  boothLabel.fontSize(status === "free" ? 10 : Math.min(10, Math.max(6, this.boothDims.get(seat.rowId)?.width ?? 40) / 6));
@@ -2645,11 +4299,6 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2645
4299
  c.dash([]);
2646
4300
  c.opacity(CLOSED_SEAT_OPACITY);
2647
4301
  }
2648
- if (this.focusedSectionId) {
2649
- const sec = this.seatSection.get(id);
2650
- const inFocus = !!sec && (sec.id === this.focusedSectionId || sec.logicalId === this.focusedSectionId || sec.zone === this.focusedSectionId);
2651
- if (!inFocus) c.opacity(FOCUS_DIM_OPACITY);
2652
- }
2653
4302
  if (this.selectionFocusId && id !== this.selectionFocusId) c.opacity(Math.min(c.opacity(), 0.16));
2654
4303
  const bookableLabel = this.boothLabelById.get(id) ?? this.seatLabelById.get(id);
2655
4304
  if (bookableLabel) {
@@ -2663,7 +4312,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2663
4312
  const fill = c.fill();
2664
4313
  accessGlyph.fill(stateAwareBookableLabelInk(typeof fill === "string" ? fill : "", "#ffffff"));
2665
4314
  accessGlyph.opacity(c.opacity());
2666
- accessGlyph.visible(this.accessGlyphVisible && this.accessGlyphEligible(id));
4315
+ accessGlyph.visible(this.accessGlyphShouldShow(id));
2667
4316
  }
2668
4317
  this.accessRingById.get(id)?.opacity(c.opacity());
2669
4318
  }
@@ -2732,7 +4381,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2732
4381
  if (!this.sections.some((section) => section.id === id || section.logicalId === id)) return;
2733
4382
  this.focusedSectionId = id;
2734
4383
  this.drawFocusBackdrop(id);
2735
- this.repaintSectionsAndSeats();
4384
+ this.bgLayer.batchDraw();
4385
+ this.overlayLayer.batchDraw();
2736
4386
  this.updateLOD();
2737
4387
  this.focusRegion(id, { minScale: SEAT_FOCUS_SCALE });
2738
4388
  }
@@ -2744,7 +4394,10 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2744
4394
  this.focusBackdrop.destroy();
2745
4395
  this.focusBackdrop = null;
2746
4396
  }
2747
- this.repaintSectionsAndSeats();
4397
+ this.focusDimOverlay?.destroy();
4398
+ this.focusDimOverlay = null;
4399
+ this.bgLayer.batchDraw();
4400
+ this.overlayLayer.batchDraw();
2748
4401
  this.updateLOD();
2749
4402
  }
2750
4403
  /** The currently AXS-focused section id, or null. */
@@ -2761,16 +4414,69 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2761
4414
  if (!sections.length) return;
2762
4415
  const backdrop = new Group({ listening: false });
2763
4416
  for (const section of sections) {
2764
- backdrop.add(polygonWithHolesShape(section.outline, section.holes, {
4417
+ const perspective = this.viewMode === "perspective" && section.perspectiveAffine;
4418
+ const outline = perspective ? section.outline.map((point) => this.perspectiveLocalPoint(this.projectedSectionPoint(section, point))) : section.outline;
4419
+ const holes = perspective ? section.holes.map((hole) => hole.map((point) => this.perspectiveLocalPoint(this.projectedSectionPoint(section, point)))) : section.holes;
4420
+ backdrop.add(polygonWithHolesShape(outline, holes, {
2765
4421
  fill: FOCUS_BACKDROP_FILL,
2766
4422
  stroke: rgba("#ffffff", 0.1),
2767
4423
  strokeWidth: 1,
2768
4424
  listening: false
2769
- }, section.outlinePath));
4425
+ }, perspective ? void 0 : section.outlinePath));
2770
4426
  }
2771
4427
  this.bgLayer.add(backdrop);
2772
4428
  backdrop.moveToTop();
2773
4429
  this.focusBackdrop = backdrop;
4430
+ this.drawFocusDimOverlay(sections);
4431
+ }
4432
+ /** Dim everything outside the active section with one paint-only shape. Group
4433
+ * opacity would make Konva recursively invalidate absolute-opacity caches on
4434
+ * all 14k descendants. The cut-out follows the live elevation offset and is
4435
+ * non-listening, so direct seat hit-testing is unchanged. */
4436
+ drawFocusDimOverlay(sections) {
4437
+ this.focusDimOverlay?.destroy();
4438
+ const span = Math.max(this.bounds.width, this.bounds.height, 1);
4439
+ const padding = span * 4 + 1e3;
4440
+ const outer = [
4441
+ { x: this.bounds.x - padding, y: this.bounds.y - padding },
4442
+ { x: this.bounds.x + this.bounds.width + padding, y: this.bounds.y - padding },
4443
+ { x: this.bounds.x + this.bounds.width + padding, y: this.bounds.y + this.bounds.height + padding },
4444
+ { x: this.bounds.x - padding, y: this.bounds.y + this.bounds.height + padding }
4445
+ ];
4446
+ const signedArea = (points) => points.reduce((sum, point, index) => {
4447
+ const next = points[(index + 1) % points.length];
4448
+ return sum + point.x * next.y - next.x * point.y;
4449
+ }, 0);
4450
+ const overlay = new Shape({
4451
+ fill: this.canvasBackground,
4452
+ opacity: 1 - FOCUS_DIM_OPACITY,
4453
+ listening: false,
4454
+ perfectDrawEnabled: false,
4455
+ sceneFunc: (context, shape) => {
4456
+ const polygonPath = (points) => {
4457
+ if (!points.length) return;
4458
+ context.moveTo(points[0].x, points[0].y);
4459
+ for (let index = 1; index < points.length; index++) context.lineTo(points[index].x, points[index].y);
4460
+ context.closePath();
4461
+ };
4462
+ context.beginPath();
4463
+ polygonPath(outer);
4464
+ for (const section of sections) {
4465
+ const hole = this.viewMode === "perspective" && section.perspectiveAffine ? section.outline.map((point) => this.perspectiveLocalPoint(this.projectedSectionPoint(section, point))) : section.outline.map((point) => {
4466
+ const offset = section.liftWorld > 0 ? this.isoLiftLocal(section.liftWorld) : { x: 0, y: 0 };
4467
+ return { x: point.x + offset.x, y: point.y + offset.y };
4468
+ });
4469
+ polygonPath(signedArea(hole) > 0 ? [...hole].reverse() : hole);
4470
+ }
4471
+ context.fillStrokeShape(shape);
4472
+ }
4473
+ });
4474
+ this.overlayLayer.add(overlay);
4475
+ overlay.moveToTop();
4476
+ for (const marker of this.selectionMarkers.values()) marker.moveToTop();
4477
+ this.hoverRing.moveToTop();
4478
+ this.focusRing.moveToTop();
4479
+ this.focusDimOverlay = overlay;
2774
4480
  }
2775
4481
  /** Repaint every seat + section block to reflect closed/focus state, then redraw. */
2776
4482
  repaintSectionsAndSeats() {
@@ -2834,7 +4540,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2834
4540
  this.applyGAFilterState();
2835
4541
  }
2836
4542
  renderBackground(doc) {
2837
- if (doc.backgroundImage) this.renderBackgroundImage(doc.backgroundImage);
4543
+ const buyerBackground = buyerBackgroundImage(doc);
4544
+ if (buyerBackground) this.renderBackgroundImage(buyerBackground);
2838
4545
  for (const obj of doc.objects) if (obj.type === "decorImage") this.renderDecorImage(obj);
2839
4546
  for (const obj of doc.objects) if (obj.type === "section") this.renderSection(obj);
2840
4547
  this.renderZones(doc);
@@ -2914,10 +4621,12 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2914
4621
  listening: false,
2915
4622
  perfectDrawEnabled: false
2916
4623
  });
2917
- this.bgLayer.add(node);
4624
+ if (obj.layer === "foreground") this.fgDecorGroup.add(node);
4625
+ else this.bgLayer.add(node);
2918
4626
  img.onload = () => {
2919
- if (!node.getLayer()) return;
2920
- this.bgLayer.batchDraw();
4627
+ const layer = node.getLayer();
4628
+ if (!layer) return;
4629
+ layer.batchDraw();
2921
4630
  };
2922
4631
  img.src = obj.href;
2923
4632
  }
@@ -2954,7 +4663,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2954
4663
  })
2955
4664
  );
2956
4665
  }
2957
- const label = this.addCentredLabel(this.bgLayer, obj.label, obj.center.x, obj.center.y, "#cbd5e1", 12, true);
4666
+ const label = this.addCentredLabel(this.bgLayer, obj.displayLabel ?? obj.label, obj.center.x, obj.center.y, "#cbd5e1", 12, true);
2958
4667
  this.freeTextById.set(obj.id, { node: label, background: "#232c40", kind: "table" });
2959
4668
  }
2960
4669
  renderText(obj) {
@@ -2966,11 +4675,12 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2966
4675
  text: obj.text,
2967
4676
  fontSize: obj.fontSize,
2968
4677
  rotation: obj.rotation,
4678
+ fontStyle: konvaFontStyle(obj.bold, obj.italic, "normal"),
2969
4679
  // Authored ink remains preferred, but an embed/theme surface can change
2970
4680
  // the actual canvas. Fail over to readable black/white instead of
2971
4681
  // painting an otherwise valid caption invisibly on that active surface.
2972
4682
  fill: stateAwareBookableLabelInk(background, preferredInk),
2973
- fontFamily: this.labelFont(),
4683
+ fontFamily: obj.fontFamily ?? this.labelFont(),
2974
4684
  listening: false,
2975
4685
  perfectDrawEnabled: false
2976
4686
  });
@@ -2988,11 +4698,40 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2988
4698
  const isDecor = !!obj.role && !isStage;
2989
4699
  const palette = overviewPalette(this.canvasBackground);
2990
4700
  const fill = referenceFocal ? palette.focalFill : authoredFill;
2991
- const stroke = isStage ? lighten(fill, 0.28) : referenceFocal ? palette.focalStroke : void 0;
2992
- const strokeWidth = isStage ? 1 : referenceFocal ? 2 : 0;
4701
+ const isOpenPath = obj.kind === "line" || obj.kind === "polyline";
4702
+ const stroke = obj.stroke?.color ?? (isStage ? lighten(fill, 0.28) : referenceFocal ? palette.focalStroke : isOpenPath ? "#9aa3b5" : void 0);
4703
+ const strokeWidth = obj.stroke?.width ?? (isStage ? 1 : referenceFocal ? 2 : isOpenPath ? 2 : 0);
4704
+ const opacity = obj.opacity != null ? clamp(obj.opacity, 0.1, 1) : 1;
2993
4705
  let cx = 0;
2994
4706
  let cy = 0;
2995
- if (obj.kind === "rect" && obj.x != null && obj.y != null && obj.width != null && obj.height != null) {
4707
+ if (isOpenPath && obj.points && obj.points.length >= 2) {
4708
+ cx = obj.points.reduce((a, p) => a + p.x, 0) / obj.points.length;
4709
+ cy = obj.points.reduce((a, p) => a + p.y, 0) / obj.points.length;
4710
+ const lineStyle = resolvedShapeLineStyle(obj);
4711
+ const arrow = shapeArrowMetrics(strokeWidth);
4712
+ const path = new Arrow({
4713
+ points: obj.points.flatMap((p) => [p.x, p.y]),
4714
+ closed: false,
4715
+ x: cx,
4716
+ y: cy,
4717
+ offsetX: cx,
4718
+ offsetY: cy,
4719
+ rotation: obj.rotation ?? 0,
4720
+ stroke,
4721
+ fill: stroke,
4722
+ strokeWidth,
4723
+ opacity,
4724
+ lineCap: lineStyle.lineCap,
4725
+ lineJoin: lineStyle.lineJoin,
4726
+ pointerAtBeginning: lineStyle.startEnding === "arrow",
4727
+ pointerAtEnding: lineStyle.endEnding === "arrow",
4728
+ ...arrow,
4729
+ listening: false,
4730
+ name: "buyer-shape-open-path"
4731
+ });
4732
+ path.setAttr("shapeObjectId", obj.id);
4733
+ this.bgLayer.add(path);
4734
+ } else if (obj.kind === "rect" && obj.x != null && obj.y != null && obj.width != null && obj.height != null) {
2996
4735
  const grad = isStage ? {
2997
4736
  fillLinearGradientStartPoint: { x: 0, y: 0 },
2998
4737
  fillLinearGradientEndPoint: { x: 0, y: obj.height },
@@ -3012,7 +4751,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3012
4751
  ...grad,
3013
4752
  stroke,
3014
4753
  strokeWidth,
3015
- cornerRadius: 4,
4754
+ cornerRadius: clamp(obj.cornerRadius ?? 4, 0, Math.min(obj.width, obj.height) / 2),
4755
+ opacity,
3016
4756
  listening: false
3017
4757
  })
3018
4758
  );
@@ -3025,7 +4765,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3025
4765
  fillLinearGradientColorStops: [0, darken(fill, 0.3), 1, lighten(fill, 0.12)]
3026
4766
  } : { fill };
3027
4767
  this.bgLayer.add(
3028
- new Ellipse({ x: cx, y: cy, rotation: obj.rotation ?? 0, radiusX: obj.width / 2, radiusY: obj.height / 2, ...grad, stroke, strokeWidth, listening: false })
4768
+ new Ellipse({ x: cx, y: cy, rotation: obj.rotation ?? 0, radiusX: obj.width / 2, radiusY: obj.height / 2, ...grad, stroke, strokeWidth, opacity, listening: false })
3029
4769
  );
3030
4770
  } else if (obj.kind === "polygon" && obj.points && obj.points.length) {
3031
4771
  const pts = obj.points.flatMap((p) => [p.x, p.y]);
@@ -3040,7 +4780,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3040
4780
  fillLinearGradientColorStops: [0, darken(fill, 0.3), 1, lighten(fill, 0.12)]
3041
4781
  } : { fill };
3042
4782
  this.bgLayer.add(
3043
- new Line({ points: pts, closed: true, x: cx, y: cy, offsetX: cx, offsetY: cy, rotation: obj.rotation ?? 0, ...grad, stroke, strokeWidth, listening: false })
4783
+ new Line({ points: pts, closed: true, x: cx, y: cy, offsetX: cx, offsetY: cy, rotation: obj.rotation ?? 0, ...grad, stroke, strokeWidth, opacity, listening: false })
3044
4784
  );
3045
4785
  }
3046
4786
  if (obj.label) {
@@ -3099,7 +4839,10 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3099
4839
  strokeWidth: 1.5
3100
4840
  });
3101
4841
  poly.setAttr("gaId", obj.id);
3102
- poly.on("click tap", () => this.opts.onGAClick?.(obj.id));
4842
+ poly.on("click tap", (e) => {
4843
+ if (this.isGhostClick(e)) return;
4844
+ this.opts.onGAClick?.(obj.id);
4845
+ });
3103
4846
  poly.on("mouseenter", () => {
3104
4847
  this.container.style.cursor = "pointer";
3105
4848
  });
@@ -3109,7 +4852,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3109
4852
  this.bgLayer.add(poly);
3110
4853
  const labelPoint = polygonLabelPoint(obj.points, obj.holes);
3111
4854
  const containingSection = this.sections.find((section) => pointInPolygonWithHoles(labelPoint, section.outline, section.holes));
3112
- const label = this.addCentredLabel(this.bgLayer, obj.label, labelPoint.x, labelPoint.y - 8, ink, GA_LABEL_FONT_SIZE, false);
4855
+ const label = this.addCentredLabel(this.bgLayer, obj.displayLabel ?? obj.label, labelPoint.x, labelPoint.y - 8, ink, GA_LABEL_FONT_SIZE, false);
3113
4856
  const capacity = this.addCentredLabel(this.bgLayer, `cap ${obj.capacity}`, labelPoint.x, labelPoint.y + 10, ink, GA_CAPACITY_LABEL_FONT_SIZE, false);
3114
4857
  this.freeTextById.set(`${obj.id}:label`, {
3115
4858
  objectId: obj.id,
@@ -3126,7 +4869,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3126
4869
  categoryKey: obj.categoryKey
3127
4870
  });
3128
4871
  this.gaById.set(obj.id, {
3129
- label: obj.label,
4872
+ label: obj.displayLabel ?? obj.label,
3130
4873
  capacity: obj.capacity,
3131
4874
  categoryKey: obj.categoryKey,
3132
4875
  points: obj.points,
@@ -3149,7 +4892,9 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3149
4892
  const memberIds = [];
3150
4893
  const catCounts = /* @__PURE__ */ new Map();
3151
4894
  let free = 0;
3152
- for (const seat of this.seats) {
4895
+ const bounds2 = polyBounds(obj.outline);
4896
+ const candidates = this.seatIndex ? queryRect(this.seatIndex, bounds2).map((id) => this.seatById.get(id)).filter((seat) => !!seat) : this.seats;
4897
+ for (const seat of candidates) {
3153
4898
  if (this.seatSection.has(seat.id)) continue;
3154
4899
  if (!pointInPolygonWithHoles(seat, obj.outline, obj.holes)) continue;
3155
4900
  memberIds.push(seat.id);
@@ -3160,11 +4905,19 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3160
4905
  [...catCounts].map(([key, w]) => ({ hex: this.catColor.get(key) ?? "#6e7bff", w })),
3161
4906
  "#3a4358"
3162
4907
  );
3163
- const elevation = Math.max(0, Math.round(obj.elevation ?? 0));
3164
- let liftGroupBg = null;
3165
- let liftGroupSeat = null;
4908
+ const elevation = sectionElevationTier(obj.elevation);
4909
+ const geometry = sectionGeometry(obj, {
4910
+ floorBaseHeightM: this.floorBaseHeightMFor(obj.id)
4911
+ });
4912
+ const liftWorld = geometry.height * CHART_UNITS_PER_METRE;
4913
+ const rootGroupBg = new Group({ listening: false });
4914
+ this.bgLayer.add(rootGroupBg);
4915
+ const liftGroupBg = new Group({ listening: false });
4916
+ rootGroupBg.add(liftGroupBg);
4917
+ const liftGroupSeat = new ViewportGroup({ listening: true });
4918
+ this.seatLayer.add(liftGroupSeat);
3166
4919
  const sideFaces = [];
3167
- if (elevation > 0) {
4920
+ if (liftWorld > 0) {
3168
4921
  const faceFill = darken(this.zoneColor.get(obj.zone ?? "") ?? baseFill, 0.42);
3169
4922
  for (let i = 0; i < obj.outline.length; i++) {
3170
4923
  const face = new Line({
@@ -3178,14 +4931,11 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3178
4931
  perfectDrawEnabled: false
3179
4932
  });
3180
4933
  sideFaces.push(face);
3181
- this.bgLayer.add(face);
4934
+ rootGroupBg.add(face);
3182
4935
  }
3183
- liftGroupBg = new Group({ listening: false });
3184
- this.bgLayer.add(liftGroupBg);
3185
- liftGroupSeat = new Group({ listening: false });
3186
- this.seatLayer.add(liftGroupSeat);
3187
4936
  }
3188
- const bgTarget = liftGroupBg ?? this.bgLayer;
4937
+ liftGroupBg.moveToTop();
4938
+ const bgTarget = liftGroupBg;
3189
4939
  const outlineTint = obj.color ?? this.zoneColor.get(obj.zone ?? "") ?? "#3a4358";
3190
4940
  const outlinePoly = polygonWithHolesShape(obj.outline, obj.holes, {
3191
4941
  stroke: rgba(outlineTint, 0.5),
@@ -3259,10 +5009,23 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3259
5009
  nameLabelFits: true,
3260
5010
  subLabelFits: true,
3261
5011
  elevation,
5012
+ liftWorld,
5013
+ rakeDeg: geometry.rake,
5014
+ surfaceFocal: memberIds.length ? { ...this.seatById.get(memberIds[0])?.focalPoint ?? this.isoCentre } : { ...this.chartDoc?.focalPoint ?? this.isoCentre },
5015
+ frontDistanceWorld: 0,
5016
+ rootGroupBg,
5017
+ bgZIndex: rootGroupBg.zIndex(),
5018
+ seatZIndex: liftGroupSeat.zIndex(),
3262
5019
  liftGroupBg,
3263
5020
  liftGroupSeat,
3264
5021
  sideFaces
3265
5022
  };
5023
+ const surfaceCandidates = memberIds.map((id) => this.seatById.get(id)).filter((seat) => !!seat);
5024
+ const distanceCandidates = surfaceCandidates.length ? surfaceCandidates : obj.outline;
5025
+ sec.frontDistanceWorld = Math.min(...distanceCandidates.map((point) => Math.hypot(
5026
+ point.x - sec.surfaceFocal.x,
5027
+ point.y - sec.surfaceFocal.y
5028
+ )));
3266
5029
  for (const id of memberIds) this.seatSection.set(id, sec);
3267
5030
  this.refreshSectionFill(sec);
3268
5031
  this.refreshSectionHeat(sec);
@@ -3408,15 +5171,16 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3408
5171
  const sectionOverview = scale < CACHE_THRESHOLD;
3409
5172
  if (sectionOverview) blockT = 1;
3410
5173
  if (!this.zones.length) zoneT = 0;
3411
- this.seatLayer.opacity(sectionOverview ? 0 : 1 - blockT);
5174
+ const deferPerspectiveSeatReveal = this.viewMode === "perspective" && this.glideInProgress && this.seats.length > 2500;
5175
+ this.seatLayer.opacity(sectionOverview || deferPerspectiveSeatReveal ? 0 : 1 - blockT);
3412
5176
  const sx = this.stage.scaleX();
3413
- const rescale = this.lodScale === 0 || Math.abs(scale - this.lodScale) / (this.lodScale || 1) > 0.02;
5177
+ const rescale = !this.glideInProgress && (this.lodScale === 0 || Math.abs(scale - this.lodScale) / (this.lodScale || 1) > 0.08);
3414
5178
  if (rescale) this.lodScale = scale;
3415
5179
  const focus = this.focusedSectionId;
3416
5180
  const palette = overviewPalette(this.canvasBackground);
3417
5181
  const sectionLabelT = clamp((blockT - 0.2) / 0.8, 0, 1);
3418
5182
  for (const sec of this.sections) {
3419
- const dim = focus && sec.id !== focus && sec.logicalId !== focus && sec.zone !== focus ? FOCUS_DIM_OPACITY : 1;
5183
+ const dim = this.focusDimOverlay ? 1 : focus && sec.id !== focus && sec.logicalId !== focus && sec.zone !== focus ? FOCUS_DIM_OPACITY : 1;
3420
5184
  sec.outlinePoly.opacity(sectionOverview ? 0 : (1 - blockT) * dim);
3421
5185
  sec.blockPoly.opacity(BLOCK_FILL_ALPHA * blockT * dim);
3422
5186
  sec.blockPoly.stroke(palette.sectionStroke);
@@ -3428,7 +5192,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3428
5192
  );
3429
5193
  sec.nameLabel.fill(sectionInk);
3430
5194
  sec.subLabel.fill(sectionInk);
3431
- if (rescale) this.fitSectionRungLabels(sec, sx);
5195
+ if (rescale && sectionLabelT > 0.01) this.fitSectionRungLabels(sec, sx);
3432
5196
  const labelOpacity = sectionLabelT * (1 - zoneT) * dim;
3433
5197
  sec.nameLabel.opacity(sec.nameLabelFits ? labelOpacity : 0);
3434
5198
  sec.subLabel.opacity(0);
@@ -3440,12 +5204,14 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3440
5204
  if (zone.sub) zone.sub.opacity(zoneOpacity);
3441
5205
  if (rescale) this.sizeZonePill(zone, sx);
3442
5206
  }
3443
- this.decollideRungLabels(sx);
3444
- this.dedupeLogicalSectionLabels();
3445
- for (const zone of this.zones) {
3446
- const opacity = zone.label.opacity();
3447
- zone.back.opacity(opacity);
3448
- if (zone.sub) zone.sub.opacity(opacity);
5207
+ if (!this.glideInProgress && (sectionLabelT > 0.01 || zoneOpacity > 0.01)) {
5208
+ this.decollideRungLabels(sx);
5209
+ this.dedupeLogicalSectionLabels();
5210
+ for (const zone of this.zones) {
5211
+ const opacity = zone.label.opacity();
5212
+ zone.back.opacity(opacity);
5213
+ if (zone.sub) zone.sub.opacity(opacity);
5214
+ }
3449
5215
  }
3450
5216
  this.bgLayer.batchDraw();
3451
5217
  }
@@ -3572,13 +5338,30 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3572
5338
  screenToWorld(clientPoint) {
3573
5339
  const s = this.stage.scaleX();
3574
5340
  const iso = { x: (clientPoint.x - this.stage.x()) / s, y: (clientPoint.y - this.stage.y()) / s };
5341
+ if (this.viewMode === "perspective" && this.perspectiveBaseInverse) {
5342
+ return applyAffine(this.perspectiveBaseInverse, iso);
5343
+ }
3575
5344
  return this.isoT === 0 ? iso : this.isoInverse(iso);
3576
5345
  }
3577
5346
  /** Section id under a container-relative screen point, or null (Slice 5 tap-to-zoom). */
3578
5347
  sectionAt(clientPoint) {
3579
5348
  if (!this.sections.length) return null;
3580
5349
  const world = this.screenToWorld(clientPoint);
3581
- const hit = this.sections.find((sec) => pointInPolygonWithHoles(world, sec.outline, sec.holes));
5350
+ const hit = this.sections.find((sec) => {
5351
+ if (this.viewMode === "perspective" && sec.perspectiveAffine) {
5352
+ return pointInPolygonWithHoles(
5353
+ world,
5354
+ sec.outline.map((point) => this.perspectiveLocalPoint(this.projectedSectionPoint(sec, point))),
5355
+ sec.holes.map((hole) => hole.map((point) => this.perspectiveLocalPoint(this.projectedSectionPoint(sec, point))))
5356
+ );
5357
+ }
5358
+ const offset = sec.liftWorld > 0 ? this.isoLiftLocal(sec.liftWorld) : { x: 0, y: 0 };
5359
+ return pointInPolygonWithHoles(
5360
+ { x: world.x - offset.x, y: world.y - offset.y },
5361
+ sec.outline,
5362
+ sec.holes
5363
+ );
5364
+ });
3582
5365
  return hit ? hit.logicalId : null;
3583
5366
  }
3584
5367
  /** Seat ids belonging to a section (Slice 5 section-summary card). */
@@ -3674,15 +5457,20 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3674
5457
  if (!seat) return;
3675
5458
  const candidate = this.selectionFocusId === id;
3676
5459
  const dims = this.boothDims.get(seat.rowId);
5460
+ const rendered = this.renderedSeatPoint(seat);
3677
5461
  const marker = new Group({
3678
5462
  name: "selection-ring",
3679
- x: seat.x,
3680
- y: seat.y,
5463
+ x: rendered.x,
5464
+ y: rendered.y,
3681
5465
  rotation: dims?.rotation ?? 0,
3682
5466
  listening: false,
3683
5467
  perfectDrawEnabled: false,
3684
5468
  opacity: this.selectionFocusId && !candidate ? 0.2 : 1
3685
5469
  });
5470
+ if (this.viewMode === "perspective") {
5471
+ const projectionScale = this.perspectiveSeatScale.get(id) ?? 1;
5472
+ marker.scale({ x: projectionScale, y: projectionScale });
5473
+ }
3686
5474
  marker.setAttr("seatId", id);
3687
5475
  const common = {
3688
5476
  stroke: this.effSelection,
@@ -3761,12 +5549,22 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3761
5549
  * must fall through and pick.
3762
5550
  */
3763
5551
  sectionBounds(id) {
3764
- const bounds = this.sections.filter((section) => section.id === id || section.logicalId === id).map((section) => polyBounds(section.outline));
3765
- if (!bounds.length) return null;
3766
- const left = Math.min(...bounds.map((box) => box.x));
3767
- const top = Math.min(...bounds.map((box) => box.y));
3768
- const right = Math.max(...bounds.map((box) => box.x + box.width));
3769
- const bottom = Math.max(...bounds.map((box) => box.y + box.height));
5552
+ const bounds2 = this.sections.filter((section) => section.id === id || section.logicalId === id).map((section) => {
5553
+ if (this.viewMode === "perspective" && section.perspectiveAffine) {
5554
+ return polyBounds(section.outline.map((point) => this.projectedSectionPoint(section, point)));
5555
+ }
5556
+ const offset = section.liftWorld > 0 ? this.isoLiftLocal(section.liftWorld) : { x: 0, y: 0 };
5557
+ const points = section.outline.map((point) => {
5558
+ const local = { x: point.x + offset.x, y: point.y + offset.y };
5559
+ return this.isoT === 0 ? local : this.isoForward(local);
5560
+ });
5561
+ return polyBounds(points);
5562
+ });
5563
+ if (!bounds2.length) return null;
5564
+ const left = Math.min(...bounds2.map((box) => box.x));
5565
+ const top = Math.min(...bounds2.map((box) => box.y));
5566
+ const right = Math.max(...bounds2.map((box) => box.x + box.width));
5567
+ const bottom = Math.max(...bounds2.map((box) => box.y + box.height));
3770
5568
  return { x: left, y: top, width: right - left, height: bottom - top };
3771
5569
  }
3772
5570
  sectionFrameScale(id) {
@@ -3819,15 +5617,16 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3819
5617
  * hijacking a clean tap on empty space beyond the slop.
3820
5618
  */
3821
5619
  nearestSeatToScreen(screen, slopPx) {
3822
- const s = this.stage.scaleX() || 1;
3823
- const reachWorld = this.seatR + slopPx / s;
3824
- const world = this.screenToWorld(screen);
3825
5620
  let best = null;
3826
- let bestD = reachWorld;
5621
+ let bestD = Infinity;
5622
+ const stageScale = this.stage.scaleX() || 1;
3827
5623
  for (const seat of this.seats) {
3828
5624
  if (!this.selection.has(seat.id) && !this.isSelectable(seat.id)) continue;
3829
- const d = Math.hypot(seat.x - world.x, seat.y - world.y);
3830
- if (d < bestD) {
5625
+ const centre = this.worldToScreen(seat);
5626
+ const perspectiveScale = this.viewMode === "perspective" ? this.perspectiveSeatScale.get(seat.id) ?? 1 : 1;
5627
+ const reachPx = this.seatR * stageScale * perspectiveScale + slopPx;
5628
+ const d = Math.hypot(centre.x - screen.x, centre.y - screen.y);
5629
+ if (d <= reachPx && d < bestD) {
3831
5630
  bestD = d;
3832
5631
  best = seat.id;
3833
5632
  }
@@ -3837,6 +5636,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3837
5636
  wireInteraction() {
3838
5637
  this.seatLayer.on("click tap", (e) => {
3839
5638
  if (this.moved > PAN_START_SLOP_PX) return;
5639
+ if (this.isGhostClick(e)) return;
3840
5640
  const id = seatIdOf(e.target);
3841
5641
  if (!id) return;
3842
5642
  this.handleSeatTap(id);
@@ -3846,13 +5646,17 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3846
5646
  if (!id) return;
3847
5647
  const seat = this.seatById.get(id);
3848
5648
  if (!seat) return;
3849
- this.hoverRing.position({ x: seat.x, y: seat.y });
5649
+ this.hoveredId = id;
5650
+ this.hoverRing.position(this.renderedSeatPoint(seat));
5651
+ const projectionScale = this.viewMode === "perspective" ? this.perspectiveSeatScale.get(id) ?? 1 : 1;
5652
+ this.hoverRing.scale({ x: projectionScale, y: projectionScale });
3850
5653
  this.hoverRing.visible(true);
3851
5654
  this.overlayLayer.batchDraw();
3852
5655
  this.container.style.cursor = "pointer";
3853
5656
  this.opts.onHover?.(seat);
3854
5657
  });
3855
5658
  this.seatLayer.on("mouseout", () => {
5659
+ this.hoveredId = null;
3856
5660
  this.hoverRing.visible(false);
3857
5661
  this.overlayLayer.batchDraw();
3858
5662
  this.container.style.cursor = "default";
@@ -3867,6 +5671,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3867
5671
  });
3868
5672
  this.stage.on("click tap", (e) => {
3869
5673
  if (this.moved > PAN_START_SLOP_PX) return;
5674
+ if (this.isGhostClick(e)) return;
3870
5675
  const pointer = this.stage.getPointerPosition();
3871
5676
  if (!pointer) return;
3872
5677
  if (!this.cached) {
@@ -3883,11 +5688,10 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3883
5688
  }
3884
5689
  }
3885
5690
  if (this.sections.length) {
3886
- const world = this.screenToWorld(pointer);
3887
- const hit = this.sections.find((sn) => pointInPolygonWithHoles(world, sn.outline, sn.holes));
3888
- if (hit) {
3889
- if (this.opts.onSectionTap) this.opts.onSectionTap(hit.logicalId);
3890
- else this.focusRegion(hit.logicalId);
5691
+ const sectionId = this.sectionAt(pointer);
5692
+ if (sectionId) {
5693
+ if (this.opts.onSectionTap) this.opts.onSectionTap(sectionId);
5694
+ else this.focusRegion(sectionId);
3891
5695
  return;
3892
5696
  }
3893
5697
  }
@@ -3904,13 +5708,15 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3904
5708
  */
3905
5709
  deckFloorAt() {
3906
5710
  if (!this.objectFloor.size) return null;
3907
- const p = this.seatLayer.getRelativePointerPosition();
3908
- if (!p) return null;
5711
+ const pointer = this.stage.getPointerPosition();
5712
+ if (!pointer) return null;
5713
+ const p = this.screenToWorld(pointer);
3909
5714
  let best = null;
3910
5715
  let bestD = Infinity;
3911
5716
  for (const s of this.seats) {
3912
- const dx = s.x - p.x;
3913
- const dy = s.y - p.y;
5717
+ const rendered = this.renderedSeatPoint(s);
5718
+ const dx = rendered.x - p.x;
5719
+ const dy = rendered.y - p.y;
3914
5720
  const d = dx * dx + dy * dy;
3915
5721
  if (d < bestD) {
3916
5722
  bestD = d;
@@ -3997,9 +5803,11 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3997
5803
  }
3998
5804
  const start = performance.now();
3999
5805
  const duration = Math.max(180, Math.min(1200, opts?.durationMs ?? CAMERA_GLIDE_MS));
5806
+ this.glideInProgress = true;
4000
5807
  const step = (now) => {
4001
5808
  if (this.destroyed) {
4002
5809
  this.glideRaf = 0;
5810
+ this.glideInProgress = false;
4003
5811
  return;
4004
5812
  }
4005
5813
  const raw = Math.min(1, (now - start) / duration);
@@ -4007,6 +5815,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4007
5815
  const sc = fromScale + (toScale - fromScale) * e;
4008
5816
  this.stage.scale({ x: sc, y: sc });
4009
5817
  this.stage.position({ x: fromX + (toX - fromX) * e, y: fromY + (toY - fromY) * e });
5818
+ this.updateSeatGroupVisibility();
4010
5819
  this.updateLOD();
4011
5820
  this.scheduleViewChange();
4012
5821
  this.stage.batchDraw();
@@ -4014,6 +5823,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4014
5823
  this.glideRaf = requestAnimationFrame(step);
4015
5824
  } else {
4016
5825
  this.glideRaf = 0;
5826
+ this.glideInProgress = false;
4017
5827
  this.afterViewChange();
4018
5828
  }
4019
5829
  };
@@ -4025,6 +5835,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4025
5835
  cancelAnimationFrame(this.glideRaf);
4026
5836
  this.glideRaf = 0;
4027
5837
  }
5838
+ this.glideInProgress = false;
4028
5839
  }
4029
5840
  /** Current LOD rung derived from effective zoom — drives the ZONES/SECTIONS/SEATS pill. */
4030
5841
  getRung() {
@@ -4041,11 +5852,14 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4041
5852
  const labels = this.seats.map((seat) => {
4042
5853
  const shape = this.circleById.get(seat.id);
4043
5854
  const label = this.boothLabelById.get(seat.id) ?? this.seatLabelById.get(seat.id);
5855
+ const localPerspectiveScale = this.viewMode === "perspective" ? this.perspectiveSeatScale.get(seat.id) ?? 1 : 1;
5856
+ const seatEffectiveScale = (this.viewMode === "perspective" ? stageScale : effectiveScale) * localPerspectiveScale;
4044
5857
  const authoredFontSize = seat.kind === "booth" ? BOOTH_LABEL_FONT_SIZE : SEAT_LABEL_FONT_SIZE * this.seatLabelScale(seat);
4045
- const renderedFontPx = rounded((label?.fontSize() ?? authoredFontSize) * effectiveScale);
5858
+ const labelEffectiveScale = this.viewMode === "perspective" && seat.kind !== "booth" ? effectiveScale * localPerspectiveScale : seatEffectiveScale;
5859
+ const renderedFontPx = rounded((label?.fontSize() ?? authoredFontSize) * labelEffectiveScale);
4046
5860
  const screen = this.worldToScreen(seat);
4047
5861
  const outside = screen.x < 0 || screen.x > viewport.width || screen.y < 0 || screen.y > viewport.height;
4048
- const opacity = shape?.opacity() ?? 0;
5862
+ const opacity = this.focusedSeatOpacity(seat.id, shape?.getAbsoluteOpacity() ?? 0);
4049
5863
  const section = this.seatSection.get(seat.id);
4050
5864
  const visible = Boolean(label?.isVisible()) && opacity >= 0.5 && !outside;
4051
5865
  let hiddenReason;
@@ -4056,17 +5870,21 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4056
5870
  else if (!label) hiddenReason = "clutter-or-fit";
4057
5871
  else hiddenReason = "renderer-hidden";
4058
5872
  }
4059
- const labelWidth = label ? label.width() * stageScale : 0;
4060
- const labelHeight = label ? label.height() * effectiveScale : 0;
4061
- const directWidthPx = shape instanceof Rect ? shape.width() * stageScale : this.seatR * 2 * effectiveScale;
4062
- const directHeightPx = shape instanceof Rect ? shape.height() * stageScale : this.seatR * 2 * effectiveScale;
4063
- const assistedDiameterPx = 2 * (this.seatR * effectiveScale + SEAT_TAP_SLOP_PX);
5873
+ const labelWidth = label ? label.width() * stageScale * localPerspectiveScale : 0;
5874
+ const labelHeight = label ? label.height() * (this.viewMode === "perspective" && seat.kind === "booth" ? stageScale : effectiveScale) * localPerspectiveScale : 0;
5875
+ const directWidthPx = shape instanceof Rect ? shape.width() * stageScale * localPerspectiveScale : this.seatR * 2 * seatEffectiveScale;
5876
+ const directHeightPx = shape instanceof Rect ? shape.height() * (this.viewMode === "perspective" ? stageScale : effectiveScale) * localPerspectiveScale : this.seatR * 2 * seatEffectiveScale;
5877
+ const assistedDiameterPx = 2 * (this.seatR * seatEffectiveScale + SEAT_TAP_SLOP_PX);
4064
5878
  const fill = shape?.fill();
4065
5879
  const ink = label?.fill();
5880
+ const accessGlyph = this.accessGlyphById.get(seat.id);
5881
+ const accessGlyphScale = accessGlyph?.getAbsoluteScale();
4066
5882
  return {
4067
5883
  seatId: seat.id,
4068
5884
  label: seat.label,
4069
5885
  kind: seat.kind === "booth" ? "booth" : "seat",
5886
+ markerShape: seat.kind === "booth" ? "booth" : seat.wheelchairSpaceType === "no-seat" ? "square" : "circle",
5887
+ ...seat.wheelchairSpaceType ? { wheelchairSpaceType: seat.wheelchairSpaceType } : {},
4070
5888
  categoryKey: seat.categoryKey,
4071
5889
  ...section ? { sectionId: section.id } : {},
4072
5890
  ...section?.zone ? { zoneId: section.zone } : {},
@@ -4077,8 +5895,15 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4077
5895
  fill: typeof fill === "string" ? fill : "",
4078
5896
  ink: typeof ink === "string" ? ink : this.theme.seatLabelColor ?? DEF_SEAT_LABEL,
4079
5897
  opacity: rounded(opacity),
5898
+ ...accessGlyph ? {
5899
+ accessibilityMarker: {
5900
+ glyphVisible: accessGlyph.isVisible(),
5901
+ glyphWidthPx: rounded(ACCESS_GLYPH_VIEWBOX * Math.abs(accessGlyphScale?.x ?? 0)),
5902
+ emphasizedByFilter: this.accessGlyphFilterEmphasized(seat.id)
5903
+ }
5904
+ } : {},
4080
5905
  pointerTarget: {
4081
- active: !this.cached && this.isSelectable(seat.id),
5906
+ active: !this.cached && !this.seatViewportCulled(seat.id) && Boolean(shape?.isVisible()) && Boolean(shape?.isListening()) && this.isSelectable(seat.id),
4082
5907
  directWidthPx: rounded(directWidthPx),
4083
5908
  directHeightPx: rounded(directHeightPx),
4084
5909
  effectiveMinimumPx: rounded(Math.max(
@@ -4106,7 +5931,11 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4106
5931
  node.height(),
4107
5932
  node.rotation()
4108
5933
  );
4109
- const screenBounds = pointsBounds(worldCorners.map((corner) => this.worldToScreen(corner)));
5934
+ const lift = section && section.liftWorld > 0 ? this.isoLiftLocal(section.liftWorld) : { x: 0, y: 0 };
5935
+ const screenBounds = pointsBounds(worldCorners.map((corner) => this.worldToScreen({
5936
+ x: corner.x + lift.x,
5937
+ y: corner.y + lift.y
5938
+ })));
4110
5939
  const opacity = rounded(node.opacity());
4111
5940
  const ink = node.fill();
4112
5941
  const outside = screenBounds.x + screenBounds.width < 0 || screenBounds.x > viewport.width || screenBounds.y + screenBounds.height < 0 || screenBounds.y > viewport.height;
@@ -4245,6 +6074,15 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4245
6074
  const visibleSectionShells = this.sections.filter((section) => section.blockPoly.opacity() > 0.05);
4246
6075
  return {
4247
6076
  viewport,
6077
+ projection: this.viewMode,
6078
+ ...this.viewMode === "perspective" ? {
6079
+ perspective: {
6080
+ model: "pinhole-exact-seat-anchors",
6081
+ sectionSurfaceModel: "tangent-plane",
6082
+ exactSeatAnchorCount: this.perspectiveSeatProjected.size,
6083
+ depthSorted: true
6084
+ }
6085
+ } : {},
4248
6086
  canvasBackground: this.canvasBackground,
4249
6087
  effectiveScale: rounded(effectiveScale),
4250
6088
  rung: this.getRung(),
@@ -4299,10 +6137,10 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4299
6137
  let cy = viewCentre.y;
4300
6138
  if (rung === "sections" && this.sections.length > 0) {
4301
6139
  const sectionCentres = this.sections.map((section) => {
4302
- const bounds = polyBounds(section.outline);
6140
+ const bounds2 = polyBounds(section.outline);
4303
6141
  return {
4304
- x: bounds.x + bounds.width / 2,
4305
- y: bounds.y + bounds.height / 2
6142
+ x: bounds2.x + bounds2.width / 2,
6143
+ y: bounds2.y + bounds2.height / 2
4306
6144
  };
4307
6145
  });
4308
6146
  const halfWidth = w / (target * 2);
@@ -4369,6 +6207,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4369
6207
  }
4370
6208
  /** Recompute LOD (cache/labels) after any pan/zoom settles. */
4371
6209
  afterViewChange() {
6210
+ this.updateSeatGroupVisibility();
4372
6211
  this.updateLOD();
4373
6212
  this.updateFreeTextVisibility();
4374
6213
  this.updateLabels();
@@ -4393,10 +6232,15 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4393
6232
  this.paintGAStateForView();
4394
6233
  this.updateAccessGlyphs(scale);
4395
6234
  const shouldCache = scale < CACHE_THRESHOLD;
6235
+ const suppressPerspectiveSeatCache = this.viewMode === "perspective" && this.hasSections && this.seats.length > 2500 && shouldCache;
6236
+ if (suppressPerspectiveSeatCache) {
6237
+ this.seatLayer.listening(false);
6238
+ return;
6239
+ }
4396
6240
  if (shouldCache && !this.cached) {
4397
6241
  this.cacheSeatLayer();
4398
- } else if (!shouldCache && this.cached) {
4399
- this.seatLayer.clearCache();
6242
+ } else if (!shouldCache && (this.cached || !this.seatLayer.listening())) {
6243
+ if (this.cached) this.seatLayer.clearCache();
4400
6244
  this.seatLayer.listening(true);
4401
6245
  this.cached = false;
4402
6246
  this.seatLayer.batchDraw();
@@ -4405,6 +6249,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4405
6249
  /** Rebuild the seat-layer bitmap synchronously (no paint). Shared by the
4406
6250
  * debounced cacheSeatLayer() and the synchronous forceDraw() catch-up. */
4407
6251
  rebuildSeatCache() {
6252
+ this.updateSeatGroupVisibility();
4408
6253
  const pr = clamp(this.stage.scaleX() * this.dpr, 0.15, 2);
4409
6254
  this.seatLayer.clearCache();
4410
6255
  this.seatLayer.cache({ pixelRatio: pr });
@@ -4456,31 +6301,31 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4456
6301
  for (const [id, label] of this.boothLabelById) {
4457
6302
  const shape = this.circleById.get(id);
4458
6303
  label.visible(
4459
- isBookableLabelLegibleAtScale(label.fontSize(), effectiveScale) && (shape?.opacity() ?? 1) >= 0.5
6304
+ isBookableLabelLegibleAtScale(label.fontSize(), effectiveScale) && Boolean(shape?.isVisible()) && this.focusedSeatOpacity(id, shape?.getAbsoluteOpacity() ?? 1) >= 0.5
4460
6305
  );
4461
6306
  }
4462
6307
  this.labelGroup.destroyChildren();
6308
+ this.labelLiftGroups.clear();
4463
6309
  this.seatLabelById.clear();
4464
6310
  if (!show) {
4465
6311
  this.overlayLayer.batchDraw();
4466
6312
  return;
4467
6313
  }
4468
- const s = this.stage.scaleX();
4469
- const x0 = -this.stage.x() / s;
4470
- const y0 = -this.stage.y() / s;
4471
- const x1 = (this.stage.width() - this.stage.x()) / s;
4472
- const y1 = (this.stage.height() - this.stage.y()) / s;
4473
6314
  let count = 0;
4474
- for (const seat of this.seats) {
6315
+ for (const seat of this.visibleSeatCandidates()) {
4475
6316
  if (seat.kind === "booth") continue;
4476
- if (seat.x < x0 || seat.x > x1 || seat.y < y0 || seat.y > y1) continue;
4477
- if (seat.accessible && this.accessGlyphVisible) continue;
6317
+ const screen = this.worldToScreen(seat);
6318
+ if (screen.x < -20 || screen.x > this.stage.width() + 20 || screen.y < -20 || screen.y > this.stage.height() + 20) continue;
6319
+ if (this.accessGlyphById.has(seat.id) && this.accessGlyphShouldShow(seat.id)) continue;
4478
6320
  const shape = this.circleById.get(seat.id);
4479
- if ((shape?.opacity() ?? 1) < 0.5) continue;
6321
+ if (!shape?.isVisible() || this.focusedSeatOpacity(seat.id, shape.getAbsoluteOpacity()) < 0.5) continue;
4480
6322
  const status = this.statusById.get(seat.id) ?? "free";
6323
+ const labelPoint = this.renderedSeatPoint(seat);
6324
+ const perspectiveScale = this.viewMode === "perspective" ? this.perspectiveSeatScale.get(seat.id) ?? 1 : 1;
4481
6325
  const unavailable = status === "booked" || status === "held" && !this.ownedHold.has(seat.id) && !this.opts.manageMode;
4482
6326
  if (unavailable) {
4483
- const cue = new Group({ x: seat.x, y: seat.y, listening: false });
6327
+ const cue = new Group({ x: labelPoint.x, y: labelPoint.y, listening: false });
6328
+ cue.scale({ x: perspectiveScale, y: perspectiveScale });
4484
6329
  if (status === "held") {
4485
6330
  cue.add(new Rect({
4486
6331
  x: -4.2,
@@ -4509,14 +6354,14 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4509
6354
  listening: false
4510
6355
  }));
4511
6356
  }
4512
- this.labelGroup.add(cue);
6357
+ this.seatLabelContainer(seat.id).add(cue);
4513
6358
  if (++count >= MAX_LABELS) break;
4514
6359
  continue;
4515
6360
  }
4516
6361
  const authoredFontSize = SEAT_LABEL_FONT_SIZE * this.seatLabelScale(seat);
4517
6362
  const t2 = new Text({
4518
- x: seat.x,
4519
- y: seat.y,
6363
+ x: labelPoint.x,
6364
+ y: labelPoint.y,
4520
6365
  text: bookableMarkerLabel(seat.displayLabel ?? seat.label),
4521
6366
  fontSize: authoredFontSize,
4522
6367
  fontStyle: "600",
@@ -4537,11 +6382,12 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4537
6382
  }
4538
6383
  t2.offsetX(t2.width() / 2);
4539
6384
  t2.offsetY(t2.height() / 2);
4540
- this.labelGroup.add(t2);
6385
+ t2.scale({ x: perspectiveScale, y: perspectiveScale });
6386
+ this.seatLabelContainer(seat.id).add(t2);
4541
6387
  this.seatLabelById.set(seat.id, t2);
4542
6388
  if (++count >= MAX_LABELS) break;
4543
6389
  }
4544
- if (this.isoT > 0) this.applyUprightLabels();
6390
+ if (this.isoT > 0 && this.viewMode !== "perspective") this.applyUprightLabels();
4545
6391
  this.overlayLayer.batchDraw();
4546
6392
  }
4547
6393
  handleResize() {
@@ -4607,6 +6453,7 @@ function strandedSingles(seats, statusOf, selectedIds) {
4607
6453
 
4608
6454
  // src/core/bestAvailable.ts
4609
6455
  function seatIndex2(seat, fallback) {
6456
+ if (Number.isInteger(seat.logicalSeatIndex)) return seat.logicalSeatIndex;
4610
6457
  const i = seat.id.lastIndexOf(":");
4611
6458
  if (i < 0) return fallback;
4612
6459
  const n = Number(seat.id.slice(i + 1));
@@ -4626,21 +6473,25 @@ function centroid(seats) {
4626
6473
  }
4627
6474
  return { x: x / seats.length, y: y / seats.length };
4628
6475
  }
6476
+ function candidateFocal(seats, fallback) {
6477
+ return seats.find((seat) => seat.focalPoint)?.focalPoint ?? fallback;
6478
+ }
4629
6479
  function isPremium(seat) {
4630
6480
  return seat.commercial?.premium === true;
4631
6481
  }
4632
6482
  function pickBestAvailable(seats, available, opts) {
4633
6483
  const qty = Math.floor(opts.qty);
4634
6484
  if (!Number.isFinite(qty) || qty <= 0) return { labels: [], reason: "sold_out" };
4635
- const { categoryKey, focal, preferPremium } = opts;
6485
+ const { categoryKey, zoneId, focal, preferPremium } = opts;
4636
6486
  const rows = /* @__PURE__ */ new Map();
4637
6487
  const eligibleAll = [];
4638
6488
  seats.forEach((seat, i) => {
4639
- const elig = available.has(seat.label) && (!categoryKey || seat.categoryKey === categoryKey);
4640
- let arr = rows.get(seat.rowId);
6489
+ const elig = available.has(seat.label) && (!categoryKey || seat.categoryKey === categoryKey) && (!zoneId || seat.zoneId === zoneId);
6490
+ const rowId = seat.logicalRowId ?? seat.rowId;
6491
+ let arr = rows.get(rowId);
4641
6492
  if (!arr) {
4642
6493
  arr = [];
4643
- rows.set(seat.rowId, arr);
6494
+ rows.set(rowId, arr);
4644
6495
  }
4645
6496
  arr.push({ seat, index: seatIndex2(seat, i), elig });
4646
6497
  if (elig) eligibleAll.push(seat);
@@ -4665,7 +6516,7 @@ function pickBestAvailable(seats, available, opts) {
4665
6516
  continue;
4666
6517
  }
4667
6518
  let j = i;
4668
- while (j < slots.length && slots[j].elig) j++;
6519
+ while (j < slots.length && slots[j].elig && (j === i || slots[j].index === slots[j - 1].index + 1)) j++;
4669
6520
  const segLen = j - i;
4670
6521
  for (let p = 0; p + qty <= segLen; p++) {
4671
6522
  const leftRem = p;
@@ -4679,7 +6530,7 @@ function pickBestAvailable(seats, available, opts) {
4679
6530
  startIndex: slots[i + p].index,
4680
6531
  orphan,
4681
6532
  nonPremium: preferPremium ? runSeats.reduce((n, s) => n + (isPremium(s) ? 0 : 1), 0) : 0,
4682
- d2: dist2(centroid(runSeats), focal)
6533
+ d2: dist2(centroid(runSeats), candidateFocal(runSeats, focal))
4683
6534
  };
4684
6535
  if (better(c)) best = c;
4685
6536
  }
@@ -4688,20 +6539,71 @@ function pickBestAvailable(seats, available, opts) {
4688
6539
  }
4689
6540
  if (best) return { labels: best.labels };
4690
6541
  if (eligibleAll.length < qty) return { labels: [], reason: "not_enough_together" };
4691
- const ranked = eligibleAll.map((seat, i) => ({ seat, i, d2: dist2(seat, focal) })).sort((a, b) => {
6542
+ const ranked = eligibleAll.map((seat, i) => ({ seat, i, d2: dist2(seat, seat.focalPoint ?? focal) })).sort((a, b) => {
4692
6543
  if (preferPremium) {
4693
6544
  const pa = isPremium(a.seat) ? 0 : 1;
4694
6545
  const pb = isPremium(b.seat) ? 0 : 1;
4695
6546
  if (pa !== pb) return pa - pb;
4696
6547
  }
4697
6548
  if (a.d2 !== b.d2) return a.d2 - b.d2;
4698
- const r = a.seat.rowId.localeCompare(b.seat.rowId);
6549
+ const r = (a.seat.logicalRowId ?? a.seat.rowId).localeCompare(b.seat.logicalRowId ?? b.seat.rowId);
4699
6550
  if (r !== 0) return r;
4700
6551
  return seatIndex2(a.seat, a.i) - seatIndex2(b.seat, b.i);
4701
6552
  });
4702
6553
  return { labels: ranked.slice(0, qty).map((r) => r.seat.label) };
4703
6554
  }
4704
6555
 
6556
+ // src/core/tableInventory.ts
6557
+ var GroupedTableProjectionError = class extends Error {
6558
+ constructor(objectId, message) {
6559
+ super(message);
6560
+ this.objectId = objectId;
6561
+ this.name = "GroupedTableProjectionError";
6562
+ }
6563
+ };
6564
+ function bounds(table) {
6565
+ const min = table.minOccupancy;
6566
+ const max = table.maxOccupancy;
6567
+ if (!Number.isInteger(min) || !Number.isInteger(max) || min < 1 || max < min || max > table.seatCount) {
6568
+ throw new GroupedTableProjectionError(
6569
+ table.id,
6570
+ `Table "${table.label}" has invalid variable-occupancy bounds`
6571
+ );
6572
+ }
6573
+ return { min, max };
6574
+ }
6575
+ function groupedTableInventory(doc, modelVersion) {
6576
+ if (modelVersion !== 2) return [];
6577
+ const units = [];
6578
+ for (const object of allObjects(doc)) {
6579
+ if (object.type !== "table" || !object.bookAsWhole && !object.variableOccupancy) continue;
6580
+ if (object.bookAsWhole && object.variableOccupancy) {
6581
+ throw new GroupedTableProjectionError(
6582
+ object.id,
6583
+ `Table "${object.label}" cannot be whole-table and variable-occupancy inventory`
6584
+ );
6585
+ }
6586
+ if (!Number.isInteger(object.seatCount) || object.seatCount < 1 || !object.label.trim()) {
6587
+ throw new GroupedTableProjectionError(object.id, "Grouped tables require a label and at least one chair");
6588
+ }
6589
+ const mode = object.variableOccupancy ? "variable" : "whole";
6590
+ const occupancy = mode === "variable" ? bounds(object) : { min: object.seatCount, max: object.seatCount };
6591
+ units.push({
6592
+ objectId: object.id,
6593
+ label: object.label,
6594
+ ...object.displayLabel ? { displayLabel: object.displayLabel } : {},
6595
+ ...object.displayType ? { displayType: object.displayType } : {},
6596
+ categoryKey: object.categoryKey,
6597
+ mode,
6598
+ capacity: object.seatCount,
6599
+ minOccupancy: occupancy.min,
6600
+ maxOccupancy: occupancy.max,
6601
+ chairs: expandTable(object)
6602
+ });
6603
+ }
6604
+ return units;
6605
+ }
6606
+
4705
6607
  // src/picker/PickerController.ts
4706
6608
  var DEFAULT_MAX_SELECTION = 10;
4707
6609
  var MAX_BACKOFF_MS = 15e3;
@@ -4733,6 +6635,14 @@ var PickerController = class {
4733
6635
  /** id → buyer-facing spatial metadata used by every tooltip/confirm surface. */
4734
6636
  this.seatContext = /* @__PURE__ */ new Map();
4735
6637
  this.allIds = [];
6638
+ /** Model-2 tables are one booking unit whose chairs remain renderer geometry. */
6639
+ this.groupedTablesByObject = /* @__PURE__ */ new Map();
6640
+ this.groupedTablesByLabel = /* @__PURE__ */ new Map();
6641
+ this.groupedTableBySeatId = /* @__PURE__ */ new Map();
6642
+ this.tableQuantities = /* @__PURE__ */ new Map();
6643
+ /** Variable quantity is a buyer contract, never an inferred default. */
6644
+ this.confirmedVariableTables = /* @__PURE__ */ new Set();
6645
+ this.groupedSelectionOverhead = 0;
4736
6646
  // realtime socket
4737
6647
  this.ws = null;
4738
6648
  this.reconnectTimer = null;
@@ -4794,6 +6704,20 @@ var PickerController = class {
4794
6704
  idForLabel(label) {
4795
6705
  return this.labelToId.get(label);
4796
6706
  }
6707
+ idsForLabel(label) {
6708
+ const table = this.groupedTablesByLabel.get(label);
6709
+ if (table) return table.chairs.map((chair) => chair.id);
6710
+ const id = this.labelToId.get(label);
6711
+ return id ? [id] : [];
6712
+ }
6713
+ idsForLabels(labels) {
6714
+ return [...new Set([...labels].flatMap((label) => this.idsForLabel(label)))];
6715
+ }
6716
+ /** Resolve a physical chair id (or table inventory label) to its table unit. */
6717
+ tableSelection(seatIdOrLabel) {
6718
+ const group = this.groupedTableBySeatId.get(seatIdOrLabel) ?? this.groupedTablesByLabel.get(seatIdOrLabel);
6719
+ return group ? this.toTableSeat(group) : null;
6720
+ }
4797
6721
  /** Fetch chart, build label maps, mount the renderer, seed statuses, go live. */
4798
6722
  async render(host) {
4799
6723
  if (this.renderer) return null;
@@ -4807,6 +6731,29 @@ var PickerController = class {
4807
6731
  }
4808
6732
  if (this.closed) return null;
4809
6733
  this._doc = res.doc;
6734
+ try {
6735
+ const groups = groupedTableInventory(
6736
+ res.doc,
6737
+ res.event.inventoryModelVersion === 2 ? 2 : 1
6738
+ );
6739
+ this.groupedTablesByObject = new Map(groups.map((group) => [group.objectId, group]));
6740
+ this.groupedTablesByLabel = new Map(groups.map((group) => [group.label, group]));
6741
+ this.groupedTableBySeatId = new Map(
6742
+ groups.flatMap((group) => group.chairs.map((chair) => [chair.id, group]))
6743
+ );
6744
+ this.tableQuantities = new Map(groups.map((group) => [
6745
+ group.label,
6746
+ group.mode === "whole" ? group.capacity : group.minOccupancy
6747
+ ]));
6748
+ this.confirmedVariableTables = /* @__PURE__ */ new Set();
6749
+ this.groupedSelectionOverhead = groups.reduce(
6750
+ (sum, group) => sum + Math.max(0, group.chairs.length - 1),
6751
+ 0
6752
+ );
6753
+ } catch (err) {
6754
+ this.emitError(err);
6755
+ return null;
6756
+ }
4810
6757
  this.labelToId = /* @__PURE__ */ new Map();
4811
6758
  this.labelToSeat = /* @__PURE__ */ new Map();
4812
6759
  this.seatById = /* @__PURE__ */ new Map();
@@ -4824,30 +6771,43 @@ var PickerController = class {
4824
6771
  this.allIds.push(s.id);
4825
6772
  const source = chartObjects.get(s.rowId);
4826
6773
  const sourceLabel = source && "label" in source && typeof source.label === "string" ? source.label : void 0;
4827
- const sourceDisplayLabel = source && "displayLabel" in source && typeof source.displayLabel === "string" && source.displayLabel ? source.displayLabel : sourceLabel;
4828
- const rowLabel = s.kind === "booth" ? void 0 : sourceDisplayLabel;
6774
+ const logicalRow = source?.type === "row" ? source.segmentedRow : void 0;
6775
+ const sourceDisplayLabel = logicalRow?.displayLabel ?? (source && "displayLabel" in source && typeof source.displayLabel === "string" && source.displayLabel ? source.displayLabel : sourceLabel);
6776
+ const groupedTable = this.groupedTablesByObject.get(s.rowId);
6777
+ const objectType = source?.type === "table" ? "table" : source?.type === "booth" ? "booth" : "seat";
6778
+ const rowLabel = sourceDisplayLabel;
6779
+ const rowType = logicalRow?.displayType?.trim() || (source && "displayType" in source && typeof source.displayType === "string" && source.displayType.trim() ? source.displayType.trim() : void 0);
4829
6780
  const visibleSeatLabel = s.displayLabel ?? s.label;
4830
6781
  const labelParts = visibleSeatLabel.split("-");
4831
- const seatNumber = sourceDisplayLabel && visibleSeatLabel.startsWith(`${sourceDisplayLabel}-`) ? visibleSeatLabel.slice(sourceDisplayLabel.length + 1) : s.kind === "booth" ? visibleSeatLabel : labelParts[labelParts.length - 1] ?? visibleSeatLabel;
6782
+ const seatNumber = groupedTable ? void 0 : s.kind === "booth" ? void 0 : sourceDisplayLabel && visibleSeatLabel.startsWith(`${sourceDisplayLabel}-`) ? visibleSeatLabel.slice(sourceDisplayLabel.length + 1) : labelParts[labelParts.length - 1] ?? visibleSeatLabel;
4832
6783
  this.seatContext.set(s.id, {
6784
+ objectId: s.rowId,
6785
+ objectType,
4833
6786
  sectionLabel: sectionLabels.get(membership.objectToSection.get(s.rowId) ?? ""),
4834
6787
  rowLabel,
4835
- seatNumber
6788
+ seatNumber,
6789
+ ...rowType ? { displayType: rowType, rowType } : {}
4836
6790
  });
4837
6791
  }
6792
+ for (const group of this.groupedTablesByLabel.values()) {
6793
+ const representative = group.chairs[0];
6794
+ if (!representative) continue;
6795
+ this.labelToId.set(group.label, representative.id);
6796
+ this.labelToSeat.set(group.label, representative);
6797
+ }
4838
6798
  const currency = res.event.currency ?? this.opts.currency;
4839
6799
  this.currency = currency ?? "USD";
4840
6800
  const renderer = createRenderer(host, {
4841
- maxSelection: this.maxSelection,
6801
+ // Group chairs are selected together for paint, but count as one logical
6802
+ // inventory line. The controller enforces the guest-weighted cap.
6803
+ maxSelection: this.rendererSelectionCap(),
4842
6804
  confirmSelection: this.opts.confirmSelection,
4843
6805
  currency,
4844
6806
  onSelect: (seat) => {
4845
- this.opts.onSelect?.(seat);
4846
- this.emitSelectionChange();
6807
+ this.handleRendererSelect(seat);
4847
6808
  },
4848
6809
  onDeselect: (seat) => {
4849
- this.opts.onDeselect?.(seat);
4850
- this.emitSelectionChange();
6810
+ this.handleRendererDeselect(seat);
4851
6811
  },
4852
6812
  onSelectionLimit: this.opts.onSelectionLimit,
4853
6813
  onHover: (seat) => {
@@ -4902,7 +6862,20 @@ var PickerController = class {
4902
6862
  // ---- selection ------------------------------------------------------------
4903
6863
  getSelection() {
4904
6864
  if (!this.renderer) return [];
4905
- return this.renderer.getSelection().map((s) => this.toSeat(s));
6865
+ const out = [];
6866
+ const grouped = /* @__PURE__ */ new Set();
6867
+ for (const seat of this.renderer.getSelection()) {
6868
+ const table = this.groupedTableBySeatId.get(seat.id);
6869
+ if (table) {
6870
+ if (!grouped.has(table.label)) {
6871
+ grouped.add(table.label);
6872
+ out.push(this.toTableSeat(table));
6873
+ }
6874
+ } else {
6875
+ out.push(this.toSeat(seat));
6876
+ }
6877
+ }
6878
+ return out;
4906
6879
  }
4907
6880
  /** Enriched metadata for a seat confirmation card or tooltip. */
4908
6881
  seatDetails(seatId) {
@@ -4911,20 +6884,168 @@ var PickerController = class {
4911
6884
  }
4912
6885
  clearSelection() {
4913
6886
  this.renderer?.clearSelection();
6887
+ this.resetUnheldTableQuantities();
4914
6888
  this.emitSelectionChange();
4915
6889
  }
4916
6890
  deselect(ids) {
4917
- this.renderer?.deselect(ids);
6891
+ const expanded = /* @__PURE__ */ new Set();
6892
+ for (const id of ids) {
6893
+ const table = this.groupedTableBySeatId.get(id) ?? this.groupedTablesByLabel.get(id);
6894
+ if (table) {
6895
+ table.chairs.forEach((chair) => expanded.add(chair.id));
6896
+ if (!this.hold_?.labels.includes(table.label)) {
6897
+ this.tableQuantities.set(
6898
+ table.label,
6899
+ table.mode === "whole" ? table.capacity : table.minOccupancy
6900
+ );
6901
+ if (table.mode === "variable") this.confirmedVariableTables.delete(table.label);
6902
+ }
6903
+ } else expanded.add(id);
6904
+ }
6905
+ this.renderer?.deselect([...expanded]);
4918
6906
  this.emitSelectionChange();
4919
6907
  }
4920
6908
  setMaxSelection(maxSelection) {
4921
6909
  this.maxSelection = Math.max(0, Math.floor(maxSelection));
4922
- this.renderer?.setMaxSelection?.(this.maxSelection);
6910
+ this.renderer?.setMaxSelection?.(this.rendererSelectionCap());
4923
6911
  }
4924
6912
  select(ids) {
4925
- const added = this.renderer?.select?.(ids) ?? [];
6913
+ const before = new Set(this.getSelection().map((seat) => seat.label));
6914
+ const expanded = /* @__PURE__ */ new Set();
6915
+ for (const id of ids) {
6916
+ const table = this.groupedTableBySeatId.get(id) ?? this.groupedTablesByLabel.get(id);
6917
+ if (table) table.chairs.forEach((chair) => expanded.add(chair.id));
6918
+ else expanded.add(id);
6919
+ }
6920
+ this.renderer?.select?.([...expanded]);
6921
+ if (this.selectionGuestCount() > this.maxSelection) {
6922
+ this.renderer?.deselect([...expanded]);
6923
+ this.opts.onSelectionLimit?.(this.maxSelection);
6924
+ return [];
6925
+ }
6926
+ const added = this.getSelection().filter((seat) => !before.has(seat.label));
4926
6927
  if (added.length) this.emitSelectionChange();
4927
- return added.map((seat) => this.toSeat(seat));
6928
+ return added;
6929
+ }
6930
+ /** Confirm/update the guest quantity for a selected variable table. */
6931
+ setTableQuantity(label, quantity) {
6932
+ const table = this.groupedTablesByLabel.get(label);
6933
+ if (!table) return false;
6934
+ const q = table.mode === "whole" ? table.capacity : Math.floor(quantity);
6935
+ if (!Number.isInteger(q) || q < table.minOccupancy || q > table.maxOccupancy) return false;
6936
+ const previous = this.tableQuantities.get(label) ?? table.minOccupancy;
6937
+ this.tableQuantities.set(label, q);
6938
+ if (this.selectionGuestCount() > this.maxSelection) {
6939
+ this.tableQuantities.set(label, previous);
6940
+ this.opts.onSelectionLimit?.(this.maxSelection);
6941
+ return false;
6942
+ }
6943
+ if (table.mode === "variable") this.confirmedVariableTables.add(label);
6944
+ this.emitSelectionChange();
6945
+ return true;
6946
+ }
6947
+ /** Atomically replace an active variable-table hold with a new guest count. */
6948
+ async replaceTableQuantity(label, quantity) {
6949
+ const table = this.groupedTablesByLabel.get(label);
6950
+ const hold = this.hold_;
6951
+ if (!table || table.mode !== "variable" || !hold?.labels.includes(label)) return null;
6952
+ const q = Math.floor(quantity);
6953
+ if (!Number.isInteger(q) || q < table.minOccupancy || q > table.maxOccupancy) return null;
6954
+ const otherGuests = (hold.items ?? []).filter((item) => item.label !== label).reduce((sum, item) => sum + (item.quantity ?? 1), 0);
6955
+ if (otherGuests + q > this.maxSelection) {
6956
+ this.opts.onSelectionLimit?.(this.maxSelection);
6957
+ return null;
6958
+ }
6959
+ const selections = (hold.items ?? []).map((item) => ({
6960
+ label: item.label,
6961
+ tierId: item.tierId,
6962
+ quantity: item.label === label ? q : item.quantity
6963
+ }));
6964
+ if (!selections.some((item) => item.label === label)) return null;
6965
+ const result = await this.api.hold(this.key, selections, void 0, hold.holdId);
6966
+ this.tableQuantities.set(label, q);
6967
+ this.setHold({
6968
+ holdId: result.holdId,
6969
+ labels: selections.map((item) => item.label),
6970
+ expiresAt: result.expiresAt,
6971
+ items: result.items
6972
+ });
6973
+ return this.hold_;
6974
+ }
6975
+ rendererSelectionCap() {
6976
+ return this.maxSelection + this.groupedSelectionOverhead;
6977
+ }
6978
+ selectionGuestCount() {
6979
+ return this.getSelection().reduce((sum, seat) => sum + (seat.quantity ?? 1), 0);
6980
+ }
6981
+ handleRendererSelect(seat) {
6982
+ const table = this.groupedTableBySeatId.get(seat.id);
6983
+ if (table) {
6984
+ if (this.selectionGuestCount() > this.maxSelection) {
6985
+ this.renderer?.deselect(table.chairs.map((chair) => chair.id));
6986
+ this.opts.onSelectionLimit?.(this.maxSelection);
6987
+ this.emitSelectionChange();
6988
+ return;
6989
+ }
6990
+ this.renderer?.select?.(table.chairs.map((chair) => chair.id));
6991
+ this.opts.onSelect?.(table.chairs[0] ?? seat);
6992
+ this.opts.onTableSelectionRequest?.(this.toTableSeat(table));
6993
+ this.emitSelectionChange();
6994
+ return;
6995
+ }
6996
+ if (this.selectionGuestCount() > this.maxSelection) {
6997
+ this.renderer?.deselect([seat.id]);
6998
+ this.opts.onSelectionLimit?.(this.maxSelection);
6999
+ this.emitSelectionChange();
7000
+ return;
7001
+ }
7002
+ this.opts.onSelect?.(seat);
7003
+ this.emitSelectionChange();
7004
+ }
7005
+ handleRendererDeselect(seat) {
7006
+ const table = this.groupedTableBySeatId.get(seat.id);
7007
+ if (table) {
7008
+ this.renderer?.deselect(table.chairs.map((chair) => chair.id));
7009
+ if (!this.hold_?.labels.includes(table.label)) {
7010
+ this.tableQuantities.set(
7011
+ table.label,
7012
+ table.mode === "whole" ? table.capacity : table.minOccupancy
7013
+ );
7014
+ if (table.mode === "variable") this.confirmedVariableTables.delete(table.label);
7015
+ }
7016
+ this.opts.onDeselect?.(table.chairs[0] ?? seat);
7017
+ } else {
7018
+ this.opts.onDeselect?.(seat);
7019
+ }
7020
+ this.emitSelectionChange();
7021
+ }
7022
+ resetUnheldTableQuantities() {
7023
+ for (const table of this.groupedTablesByLabel.values()) {
7024
+ if (this.hold_?.labels.includes(table.label)) continue;
7025
+ this.tableQuantities.set(
7026
+ table.label,
7027
+ table.mode === "whole" ? table.capacity : table.minOccupancy
7028
+ );
7029
+ if (table.mode === "variable") this.confirmedVariableTables.delete(table.label);
7030
+ }
7031
+ }
7032
+ resetTableQuantitiesForLabels(labels) {
7033
+ for (const label of labels) {
7034
+ const table = this.groupedTablesByLabel.get(label);
7035
+ if (!table) continue;
7036
+ this.tableQuantities.set(
7037
+ label,
7038
+ table.mode === "whole" ? table.capacity : table.minOccupancy
7039
+ );
7040
+ if (table.mode === "variable") this.confirmedVariableTables.delete(label);
7041
+ }
7042
+ }
7043
+ tableQuantityRequest(seat) {
7044
+ if (seat?.objectType !== "table") return {};
7045
+ if (seat.bookingMode === "variable" && !this.confirmedVariableTables.has(seat.label)) {
7046
+ throw new Error("picker: confirm a variable table guest quantity before holding");
7047
+ }
7048
+ return { quantity: seat.quantity };
4928
7049
  }
4929
7050
  // ---- booking machine ------------------------------------------------------
4930
7051
  /**
@@ -4936,7 +7057,7 @@ var PickerController = class {
4936
7057
  async hold(labelsArg, ttlMs) {
4937
7058
  const r = this.renderer;
4938
7059
  if (!r) return null;
4939
- const labels = labelsArg ?? r.getSelection().map((s) => s.label);
7060
+ const labels = labelsArg ?? this.getSelection().map((seat) => seat.label);
4940
7061
  const existingGA = (this.hold_?.items ?? []).filter((item) => item.objectType === "ga");
4941
7062
  const combinedLabels = [.../* @__PURE__ */ new Set([...existingGA.map((item) => item.label), ...labels])];
4942
7063
  if (!combinedLabels.length) return null;
@@ -4944,10 +7065,14 @@ var PickerController = class {
4944
7065
  try {
4945
7066
  const selections = combinedLabels.map((label) => {
4946
7067
  const ga = existingGA.find((item) => item.label === label);
4947
- if (ga) return { label, tierId: ga.tierId };
7068
+ if (ga) return { label, tierId: ga.tierId, quantity: ga.quantity };
4948
7069
  const seat = this.labelToSeat.get(label);
4949
7070
  const resolved = seat ? this.toSeat(seat) : null;
4950
- return { label, ...resolved?.tierId ? { tierId: resolved.tierId } : {} };
7071
+ return {
7072
+ label,
7073
+ ...resolved?.tierId ? { tierId: resolved.tierId } : {},
7074
+ ...this.tableQuantityRequest(resolved)
7075
+ };
4951
7076
  });
4952
7077
  const result = await this.api.hold(this.key, selections, ttlMs, this.hold_?.holdId);
4953
7078
  this.setHold({ holdId: result.holdId, labels: combinedLabels, expiresAt: result.expiresAt, items: result.items });
@@ -4999,11 +7124,19 @@ var PickerController = class {
4999
7124
  const out = {};
5000
7125
  const closedMembers = this.closedMemberIds();
5001
7126
  for (const [id, s] of this.seatById) {
7127
+ if (this.groupedTableBySeatId.has(id)) continue;
5002
7128
  if (closedMembers.has(id)) continue;
5003
7129
  if ((this.getStatus(id) ?? "free") === "free") {
5004
7130
  out[s.categoryKey] = (out[s.categoryKey] ?? 0) + 1;
5005
7131
  }
5006
7132
  }
7133
+ for (const table of this.groupedTablesByLabel.values()) {
7134
+ if (table.chairs.some((chair) => closedMembers.has(chair.id))) continue;
7135
+ const representative = table.chairs[0];
7136
+ if (representative && (this.getStatus(representative.id) ?? "free") === "free") {
7137
+ out[table.categoryKey] = (out[table.categoryKey] ?? 0) + table.maxOccupancy;
7138
+ }
7139
+ }
5007
7140
  return out;
5008
7141
  }
5009
7142
  /** Seat ids belonging to a currently-closed section (excluded from counts). */
@@ -5022,6 +7155,8 @@ var PickerController = class {
5022
7155
  return {
5023
7156
  id: area.id,
5024
7157
  label: area.label,
7158
+ ...area.displayLabel ? { displayLabel: area.displayLabel } : {},
7159
+ ...area.displayType ? { displayType: area.displayType } : {},
5025
7160
  capacity: Math.max(0, Math.floor(area.capacity)),
5026
7161
  available: gaUnitLabels(area).filter((label) => (this.liveStatuses.get(label) ?? "free") === "free").length,
5027
7162
  categoryKey: area.categoryKey,
@@ -5040,14 +7175,20 @@ var PickerController = class {
5040
7175
  const labels = gaUnitLabels(area).filter((label) => !this.hold_?.labels.includes(label) && (this.liveStatuses.get(label) ?? "free") === "free").slice(0, Math.floor(qty));
5041
7176
  if (labels.length !== Math.floor(qty)) return null;
5042
7177
  const selections = /* @__PURE__ */ new Map();
5043
- for (const item of this.hold_?.items ?? []) selections.set(item.label, { label: item.label, tierId: item.tierId });
7178
+ for (const item of this.hold_?.items ?? []) selections.set(item.label, {
7179
+ label: item.label,
7180
+ tierId: item.tierId,
7181
+ quantity: item.quantity
7182
+ });
5044
7183
  if (this.hold_ && !this.hold_?.items?.length) {
5045
7184
  for (const seat of this.hold_.seats) selections.set(seat.label, { label: seat.label, ...seat.tierId ? { tierId: seat.tierId } : {} });
5046
7185
  }
5047
- for (const seat of this.renderer?.getSelection() ?? []) {
5048
- const resolved = this.labelToSeat.get(seat.label);
5049
- const chosen = resolved ? this.toSeat(resolved) : null;
5050
- selections.set(seat.label, { label: seat.label, ...chosen?.tierId ? { tierId: chosen.tierId } : {} });
7186
+ for (const seat of this.getSelection()) {
7187
+ selections.set(seat.label, {
7188
+ label: seat.label,
7189
+ ...seat.tierId ? { tierId: seat.tierId } : {},
7190
+ ...this.tableQuantityRequest(seat)
7191
+ });
5051
7192
  }
5052
7193
  for (const label of labels) selections.set(label, { label, ...options.tierId ? { tierId: options.tierId } : {} });
5053
7194
  const combined = [...selections.values()];
@@ -5064,6 +7205,15 @@ var PickerController = class {
5064
7205
  }
5065
7206
  return false;
5066
7207
  }
7208
+ /** Zones which still own visible buyer inventory, in authored order. */
7209
+ getBestAvailableZones() {
7210
+ const doc = this.visibleDoc();
7211
+ if (!doc?.zones?.length) return [];
7212
+ const used = new Set(
7213
+ computeSections(doc).sections.filter((section) => section.seatCount > 0 && !!section.zone).map((section) => section.zone)
7214
+ );
7215
+ return doc.zones.filter((zone) => used.has(zone.id)).map((zone) => ({ id: zone.id, label: zone.label }));
7216
+ }
5067
7217
  /**
5068
7218
  * Client-side premium pre-pass: find the best contiguous block of `qty`
5069
7219
  * PREMIUM-flagged free seats (orphan-avoiding, closest to the focal point)
@@ -5072,11 +7222,12 @@ var PickerController = class {
5072
7222
  * seat status. Returns a FULL premium block of `qty`, or null when none
5073
7223
  * exists (the caller then falls back to the normal server pick).
5074
7224
  */
5075
- pickPremiumBlock(qty, categoryKey) {
7225
+ pickPremiumBlock(qty, categoryKey, zoneId) {
5076
7226
  const doc = this.visibleDoc();
5077
7227
  if (!doc?.objects?.length) return null;
5078
7228
  const focal = doc.focalPoint ?? { x: 0, y: 0 };
5079
- const seats = expandChart(doc);
7229
+ const groupedObjectIds = new Set(this.groupedTablesByObject.keys());
7230
+ const seats = expandChart(doc).filter((seat) => !groupedObjectIds.has(seat.rowId));
5080
7231
  const held = new Set(this.hold_?.labels ?? []);
5081
7232
  const available = /* @__PURE__ */ new Set();
5082
7233
  for (const seat of seats) {
@@ -5085,7 +7236,7 @@ var PickerController = class {
5085
7236
  const status = id ? this.getStatus(id) : void 0;
5086
7237
  if ((status ?? "free") === "free") available.add(seat.label);
5087
7238
  }
5088
- const pick = pickBestAvailable(seats, available, { qty, categoryKey, focal, preferPremium: true });
7239
+ const pick = pickBestAvailable(seats, available, { qty, categoryKey, zoneId, focal, preferPremium: true });
5089
7240
  if (pick.labels.length !== qty) return null;
5090
7241
  const allPremium = pick.labels.every((l) => this.labelToSeat.get(l)?.commercial?.premium);
5091
7242
  return allPremium ? [...pick.labels] : null;
@@ -5100,7 +7251,7 @@ var PickerController = class {
5100
7251
  const r = this.renderer;
5101
7252
  if (!r) return null;
5102
7253
  if (opts.preferPremium) {
5103
- const block = this.pickPremiumBlock(qty, categoryKey);
7254
+ const block = this.pickPremiumBlock(qty, categoryKey, opts.zoneId);
5104
7255
  if (block) {
5105
7256
  if (this.hold_ && !await this.release()) return null;
5106
7257
  try {
@@ -5111,7 +7262,7 @@ var PickerController = class {
5111
7262
  });
5112
7263
  const result = await this.api.hold(this.key, selection);
5113
7264
  r.clearSelection();
5114
- const ids = block.map((l) => this.labelToId.get(l)).filter((v) => !!v);
7265
+ const ids = this.idsForLabels(block);
5115
7266
  if (ids.length) r.setStatus(ids, "held");
5116
7267
  this.setHold({ holdId: result.holdId, labels: [...block], expiresAt: result.expiresAt, items: result.items });
5117
7268
  const seats = block.map((l) => this.labelToSeat.get(l)).filter((s) => !!s).map((s) => this.toSeat(s));
@@ -5128,9 +7279,9 @@ var PickerController = class {
5128
7279
  }
5129
7280
  if (this.hold_ && !await this.release()) return null;
5130
7281
  try {
5131
- const result = await this.api.bestAvailable(this.key, qty, categoryKey);
7282
+ const result = await this.api.bestAvailable(this.key, qty, categoryKey, opts.zoneId);
5132
7283
  r.clearSelection();
5133
- const ids = result.labels.map((l) => this.labelToId.get(l)).filter((v) => !!v);
7284
+ const ids = this.idsForLabels(result.labels);
5134
7285
  if (ids.length) r.setStatus(ids, "held");
5135
7286
  this.setHold({ holdId: result.holdId, labels: [...result.labels], expiresAt: result.expiresAt, items: result.items });
5136
7287
  const seats = result.labels.map((l) => this.labelToSeat.get(l)).filter((s) => !!s).map((s) => this.toSeat(s));
@@ -5154,7 +7305,7 @@ var PickerController = class {
5154
7305
  const r = this.renderer;
5155
7306
  if (!r) return null;
5156
7307
  if (!this.api.book) throw new Error("picker: transport has no book() \u2014 hold-only mode");
5157
- const labels = labelsArg ?? r.getSelection().map((s) => s.label);
7308
+ const labels = labelsArg ?? this.getSelection().map((seat) => seat.label);
5158
7309
  if (!labels.length) return null;
5159
7310
  let holdId;
5160
7311
  try {
@@ -5167,7 +7318,11 @@ var PickerController = class {
5167
7318
  labels.map((label) => {
5168
7319
  const seat = this.labelToSeat.get(label);
5169
7320
  const resolved = seat ? this.toSeat(seat) : null;
5170
- return { label, ...resolved?.tierId ? { tierId: resolved.tierId } : {} };
7321
+ return {
7322
+ label,
7323
+ ...resolved?.tierId ? { tierId: resolved.tierId } : {},
7324
+ ...this.tableQuantityRequest(resolved)
7325
+ };
5171
7326
  }),
5172
7327
  void 0,
5173
7328
  replaceHoldId
@@ -5183,10 +7338,11 @@ var PickerController = class {
5183
7338
  this.opts.onSalesClosed?.();
5184
7339
  } else if (status === 409 && conflicts?.length) {
5185
7340
  const takenLabels = new Set(conflicts.map((c) => c.label));
5186
- const takenIds = [...takenLabels].map((l) => this.labelToId.get(l)).filter((v) => !!v);
7341
+ const takenIds = this.idsForLabels(takenLabels);
5187
7342
  if (takenIds.length) {
5188
7343
  r.setStatus(takenIds, "booked");
5189
7344
  r.deselect(takenIds);
7345
+ this.resetTableQuantitiesForLabels(takenLabels);
5190
7346
  }
5191
7347
  const stillFree = labels.filter((l) => !takenLabels.has(l));
5192
7348
  if (stillFree.length && holdId) void this.api.release(this.key, stillFree, holdId).catch(() => {
@@ -5198,12 +7354,13 @@ var PickerController = class {
5198
7354
  }
5199
7355
  throw err;
5200
7356
  }
5201
- const ids = labels.map((l) => this.labelToId.get(l)).filter((v) => !!v);
7357
+ const ids = this.idsForLabels(labels);
5202
7358
  if (ids.length) {
5203
7359
  r.setStatus(ids, "booked");
5204
7360
  r.deselect(ids);
5205
7361
  }
5206
7362
  this.clearHold();
7363
+ this.resetTableQuantitiesForLabels(labels);
5207
7364
  this.emitSelectionChange();
5208
7365
  this.opts.onBook?.(bookingRef);
5209
7366
  return labels;
@@ -5224,7 +7381,8 @@ var PickerController = class {
5224
7381
  }
5225
7382
  if (this.hold_?.holdId !== hold.holdId) return true;
5226
7383
  this.clearHold();
5227
- const ids = hold.labels.map((l) => this.labelToId.get(l)).filter((v) => !!v);
7384
+ this.resetTableQuantitiesForLabels(hold.labels);
7385
+ const ids = this.idsForLabels(hold.labels);
5228
7386
  if (ids.length) {
5229
7387
  this.renderer?.deselect(ids);
5230
7388
  this.renderer?.setStatus(ids, "free");
@@ -5255,7 +7413,8 @@ var PickerController = class {
5255
7413
  const remainingItems = hold.items?.filter((item) => !drop.includes(item.label));
5256
7414
  if (remaining.length) this.setHold({ ...hold, labels: remaining, items: remainingItems });
5257
7415
  else this.clearHold();
5258
- const ids = drop.map((l) => this.labelToId.get(l)).filter((v) => !!v);
7416
+ this.resetTableQuantitiesForLabels(drop);
7417
+ const ids = this.idsForLabels(drop);
5259
7418
  if (ids.length) {
5260
7419
  this.renderer?.deselect(ids);
5261
7420
  this.renderer?.setStatus(ids, "free");
@@ -5344,7 +7503,7 @@ var PickerController = class {
5344
7503
  this.opts.onDeckTap?.(floorId);
5345
7504
  }
5346
7505
  // ---- big-venue: sections / rungs / projection (Slice 5) -------------------
5347
- /** Switch the map projection (2D flat ⇄ 3D isometric). No-op on a flat renderer. */
7506
+ /** Switch the map projection. No-op on a flat-only renderer. */
5348
7507
  setViewMode(mode) {
5349
7508
  this.renderer?.setViewMode?.(mode);
5350
7509
  }
@@ -5443,12 +7602,21 @@ var PickerController = class {
5443
7602
  if (!sec) return null;
5444
7603
  const memberIds = r.sectionMembers?.(id) ?? [];
5445
7604
  const byCat = /* @__PURE__ */ new Map();
7605
+ const seenTables = /* @__PURE__ */ new Set();
5446
7606
  let seatsLeft = 0;
5447
7607
  for (const sid of memberIds) {
5448
7608
  const seat = this.seatById.get(sid);
5449
7609
  if (!seat) continue;
5450
- byCat.set(seat.categoryKey, (byCat.get(seat.categoryKey) ?? 0) + 1);
5451
- if (r.getStatus(sid) === "free") seatsLeft++;
7610
+ const table = this.groupedTableBySeatId.get(sid);
7611
+ if (table) {
7612
+ if (seenTables.has(table.label)) continue;
7613
+ seenTables.add(table.label);
7614
+ byCat.set(table.categoryKey, (byCat.get(table.categoryKey) ?? 0) + table.maxOccupancy);
7615
+ if (r.getStatus(table.chairs[0]?.id ?? sid) === "free") seatsLeft += table.maxOccupancy;
7616
+ } else {
7617
+ byCat.set(seat.categoryKey, (byCat.get(seat.categoryKey) ?? 0) + 1);
7618
+ if (r.getStatus(sid) === "free") seatsLeft++;
7619
+ }
5452
7620
  }
5453
7621
  const categories = [...byCat.entries()].map(([key, count]) => {
5454
7622
  const cat = doc.categories.find((c) => c.key === key);
@@ -5467,6 +7635,7 @@ var PickerController = class {
5467
7635
  id,
5468
7636
  label: sec.label,
5469
7637
  zoneLabel: zone?.label ?? "",
7638
+ ...sec.entrance && sec.entrance.trim() ? { entrance: sec.entrance.trim() } : {},
5470
7639
  color,
5471
7640
  seatsLeft,
5472
7641
  priceMin: prices.length ? prices[0] : 0,
@@ -5497,22 +7666,54 @@ var PickerController = class {
5497
7666
  }
5498
7667
  // ---- internals ------------------------------------------------------------
5499
7668
  toSeat(s) {
7669
+ const table = this.groupedTableBySeatId.get(s.id);
7670
+ if (table) return this.toTableSeat(table);
5500
7671
  const commercial = s.commercial ? { commercial: s.commercial } : void 0;
5501
7672
  const display = s.displayLabel ? { displayLabel: s.displayLabel } : void 0;
7673
+ const accessibility = s.accessibility?.length ? { accessibility: s.accessibility } : void 0;
7674
+ const wheelchair = s.wheelchairSpaceType ? { wheelchairSpaceType: s.wheelchairSpaceType } : void 0;
7675
+ const context = this.seatContext.get(s.id);
5502
7676
  const tiers = this.tiersFor(s.categoryKey);
5503
7677
  if (!tiers) {
5504
- return { id: s.id, label: s.label, ...display, categoryKey: s.categoryKey, price: this.priceFor(s.categoryKey), ...commercial };
7678
+ return { id: s.id, label: s.label, ...display, ...context, categoryKey: s.categoryKey, price: this.priceFor(s.categoryKey), ...commercial, ...accessibility, ...wheelchair };
5505
7679
  }
5506
7680
  const chosen = tiers.find((t2) => t2.id === this.seatTiers.get(s.id)) ?? tiers[0];
5507
7681
  return {
5508
7682
  id: s.id,
5509
7683
  label: s.label,
5510
7684
  ...display,
7685
+ ...context,
5511
7686
  categoryKey: s.categoryKey,
5512
7687
  price: chosen.price,
5513
7688
  tiers,
5514
7689
  tierId: chosen.id,
5515
- ...commercial
7690
+ ...commercial,
7691
+ ...accessibility,
7692
+ ...wheelchair
7693
+ };
7694
+ }
7695
+ toTableSeat(table) {
7696
+ const representative = table.chairs[0];
7697
+ const tiers = this.tiersFor(table.categoryKey);
7698
+ const chosen = tiers?.find((tier) => tier.id === this.seatTiers.get(representative?.id ?? "")) ?? tiers?.[0];
7699
+ return {
7700
+ id: representative?.id ?? table.objectId,
7701
+ label: table.label,
7702
+ displayLabel: table.displayLabel ?? table.label,
7703
+ ...table.displayType ? { displayType: table.displayType, rowType: table.displayType } : {},
7704
+ objectId: table.objectId,
7705
+ sectionLabel: representative ? this.seatContext.get(representative.id)?.sectionLabel : void 0,
7706
+ rowLabel: table.displayLabel ?? table.label,
7707
+ categoryKey: table.categoryKey,
7708
+ price: chosen?.price ?? this.priceFor(table.categoryKey),
7709
+ ...tiers ? { tiers, tierId: chosen?.id } : {},
7710
+ objectType: "table",
7711
+ bookingMode: table.mode,
7712
+ quantity: this.tableQuantities.get(table.label) ?? (table.mode === "whole" ? table.capacity : table.minOccupancy),
7713
+ capacity: table.capacity,
7714
+ minOccupancy: table.minOccupancy,
7715
+ maxOccupancy: table.maxOccupancy,
7716
+ physicalSeatIds: table.chairs.map((chair) => chair.id)
5516
7717
  };
5517
7718
  }
5518
7719
  /** Tooltip payload for a hovered seat — see PickerCallbacks.onSeatHover. */
@@ -5596,10 +7797,13 @@ var PickerController = class {
5596
7797
  const h = this.hold_;
5597
7798
  if (!h || h.labels.length !== labels.length || !labels.every((l) => h.labels.includes(l))) return false;
5598
7799
  const tiers = new Map((h.items ?? []).map((item) => [item.label, item.tierId]));
7800
+ const quantities = new Map((h.items ?? []).map((item) => [item.label, item.quantity ?? 1]));
5599
7801
  return labels.every((label) => {
5600
7802
  const seat = this.labelToSeat.get(label);
5601
- const currentTier = seat ? this.toSeat(seat).tierId ?? null : null;
5602
- return !tiers.has(label) || tiers.get(label) === currentTier;
7803
+ const resolved = seat ? this.toSeat(seat) : null;
7804
+ const currentTier = resolved?.tierId ?? null;
7805
+ const quantityMatches = resolved?.objectType !== "table" || (resolved.bookingMode !== "variable" || this.confirmedVariableTables.has(label)) && (!quantities.has(label) || quantities.get(label) === resolved.quantity);
7806
+ return quantityMatches && (!tiers.has(label) || tiers.get(label) === currentTier);
5603
7807
  });
5604
7808
  }
5605
7809
  handle409Conflicts(err) {
@@ -5607,19 +7811,28 @@ var PickerController = class {
5607
7811
  if (status !== 409 || !conflicts?.length) return;
5608
7812
  const r = this.renderer;
5609
7813
  if (!r) return;
5610
- const takenIds = conflicts.map((c) => this.labelToId.get(c.label)).filter((v) => !!v);
7814
+ const takenIds = this.idsForLabels(conflicts.map((conflict) => conflict.label));
5611
7815
  if (takenIds.length) {
5612
7816
  r.deselect(takenIds);
5613
7817
  r.setStatus(takenIds, "held");
7818
+ this.resetTableQuantitiesForLabels(conflicts.map((conflict) => conflict.label));
5614
7819
  }
5615
7820
  this.emitSelectionChange();
5616
7821
  }
5617
7822
  /** Set the open hold + (re)arm the server-authoritative expiry timer. */
5618
7823
  setHold(hold, source = "created") {
7824
+ for (const item of hold.items ?? []) {
7825
+ if (this.groupedTablesByLabel.has(item.label) && item.quantity != null) {
7826
+ this.tableQuantities.set(item.label, item.quantity);
7827
+ if (this.groupedTablesByLabel.get(item.label)?.mode === "variable") {
7828
+ this.confirmedVariableTables.add(item.label);
7829
+ }
7830
+ }
7831
+ }
5619
7832
  const full = { ...hold, seats: this.seatsForLabels(hold.labels) };
5620
7833
  this.hold_ = full;
5621
7834
  this.renderer?.setOwnedHold?.(
5622
- full.labels.map((label) => this.labelToId.get(label)).filter((id) => !!id)
7835
+ this.idsForLabels(full.labels)
5623
7836
  );
5624
7837
  if (this.expiryTimer) clearTimeout(this.expiryTimer);
5625
7838
  const ms = Math.max(0, full.expiresAt - Date.now());
@@ -5662,12 +7875,11 @@ var PickerController = class {
5662
7875
  this.liveStatuses = new Map(Object.entries(seats));
5663
7876
  if (this.allIds.length) r.setStatus(this.allIds, "free");
5664
7877
  r.setOwnedHold?.(
5665
- (this.hold_?.labels ?? []).map((label) => this.labelToId.get(label)).filter((id) => !!id)
7878
+ this.idsForLabels(this.hold_?.labels ?? [])
5666
7879
  );
5667
7880
  const byStatus = { free: [], held: [], booked: [], not_for_sale: [] };
5668
7881
  for (const [label, st] of Object.entries(seats)) {
5669
- const id = this.labelToId.get(label);
5670
- if (id) byStatus[mapStatus(st)].push(id);
7882
+ byStatus[mapStatus(st)].push(...this.idsForLabel(label));
5671
7883
  }
5672
7884
  ["held", "booked", "not_for_sale"].forEach((st) => {
5673
7885
  if (byStatus[st].length) r.setStatus(byStatus[st], st);
@@ -5676,7 +7888,11 @@ var PickerController = class {
5676
7888
  this.clearBookedHoldIfSettled();
5677
7889
  }
5678
7890
  clearBookedHoldIfSettled() {
5679
- if (this.hold_?.labels.every((label) => this.liveStatuses.get(label) === "booked")) this.clearHold();
7891
+ const hold = this.hold_;
7892
+ if (hold?.labels.every((label) => this.liveStatuses.get(label) === "booked")) {
7893
+ this.clearHold();
7894
+ this.resetTableQuantitiesForLabels(hold.labels);
7895
+ }
5680
7896
  }
5681
7897
  async resnapshot() {
5682
7898
  try {
@@ -5765,13 +7981,13 @@ var PickerController = class {
5765
7981
  } else if (Array.isArray(m.changes)) {
5766
7982
  for (const ch of m.changes) {
5767
7983
  this.liveStatuses.set(ch.label, ch.status);
5768
- const id = this.labelToId.get(ch.label);
5769
- if (!id) continue;
7984
+ const ids = this.idsForLabel(ch.label);
7985
+ if (!ids.length) continue;
5770
7986
  const next = mapStatus(ch.status);
5771
- if (this.opts.flashOnLiveChange && next !== "free" && r.getStatus(id) === "free" && !this.hold_?.labels.includes(ch.label)) {
5772
- r.flashSeat(id, next === "held" ? "#f4b740" : "#f43f5e");
7987
+ if (this.opts.flashOnLiveChange && next !== "free" && ids.some((id) => r.getStatus(id) === "free") && !this.hold_?.labels.includes(ch.label)) {
7988
+ ids.forEach((id) => r.flashSeat(id, next === "held" ? "#f4b740" : "#f43f5e"));
5773
7989
  }
5774
- r.setStatus([id], next);
7990
+ r.setStatus(ids, next);
5775
7991
  }
5776
7992
  if (this.opts.keepLiveWhileHidden && typeof document !== "undefined" && document.visibilityState === "hidden") {
5777
7993
  r.forceDraw();
@@ -5802,10 +8018,25 @@ var PickerController = class {
5802
8018
  }
5803
8019
  };
5804
8020
 
8021
+ // src/view/sightline.ts
8022
+ var STAGE_TOP_M = 5;
8023
+ var STAGE_BASE_M = -1.1;
8024
+ var MAX_LOOKDOWN_DEG = 35;
8025
+ function stageSightlinePitch(eyeHeightM, distM) {
8026
+ const extraEyeM = eyeHeightM - SEATED_EYE_HEIGHT_M;
8027
+ let topPitch = Math.atan2(STAGE_TOP_M - extraEyeM, distM) * 180 / Math.PI;
8028
+ let basePitch = Math.atan2(STAGE_BASE_M - extraEyeM, distM) * 180 / Math.PI;
8029
+ if (basePitch < -MAX_LOOKDOWN_DEG) {
8030
+ topPitch += -MAX_LOOKDOWN_DEG - basePitch;
8031
+ basePitch = -MAX_LOOKDOWN_DEG;
8032
+ }
8033
+ return { topPitch, basePitch };
8034
+ }
8035
+
5805
8036
  // src/view/generatePanorama.ts
5806
8037
  var W = 2048;
5807
8038
  var H = 1024;
5808
- var UNIT = 0.55 / 24;
8039
+ var UNIT = METRES_PER_CHART_UNIT;
5809
8040
  var yawToX = (yawDeg) => (yawDeg + 180) / 360 * W;
5810
8041
  var pitchToY = (pitchDeg) => (90 - pitchDeg) / 180 * H;
5811
8042
  function generateSeatPanorama(seat, focalPoint, neighborSeats) {
@@ -5825,9 +8056,11 @@ function generateSeatPanorama(seat, focalPoint, neighborSeats) {
5825
8056
  sky.addColorStop(1, "#07090f");
5826
8057
  ctx.fillStyle = sky;
5827
8058
  ctx.fillRect(0, 0, W, H);
8059
+ const eyeM = seat.eyeHeightM ?? SEATED_EYE_HEIGHT_M;
5828
8060
  const stageHalfYaw = Math.min(80, Math.atan2(4, distM) * 180 / Math.PI);
5829
- const stageTopPitch = Math.min(45, Math.atan2(5, distM) * 180 / Math.PI);
5830
- const stageBasePitch = Math.max(-30, -Math.atan2(1.1, distM) * 180 / Math.PI);
8061
+ const sightline = stageSightlinePitch(eyeM, distM);
8062
+ const stageTopPitch = Math.min(45, sightline.topPitch);
8063
+ const stageBasePitch = sightline.basePitch;
5831
8064
  const sx0 = yawToX(-stageHalfYaw);
5832
8065
  const sx1 = yawToX(stageHalfYaw);
5833
8066
  const sy0 = pitchToY(stageTopPitch);
@@ -5934,9 +8167,11 @@ function generateSeatThumb(seat, focalPoint, _neighborSeats) {
5934
8167
  sky.addColorStop(1, "#070910");
5935
8168
  ctx.fillStyle = sky;
5936
8169
  ctx.fillRect(0, 0, TW, TH);
8170
+ const eyeM = seat.eyeHeightM ?? SEATED_EYE_HEIGHT_M;
8171
+ const sightline = stageSightlinePitch(eyeM, distM);
5937
8172
  const stageHalfYaw = Math.min(HALF_HFOV - 2, Math.atan2(4, distM) * 180 / Math.PI);
5938
- const stageTopPitch = Math.min(HALF_VFOV - 2, Math.atan2(5, distM) * 180 / Math.PI);
5939
- const stageBasePitch = Math.max(-HALF_VFOV + 2, -Math.atan2(1.1, distM) * 180 / Math.PI);
8173
+ const stageTopPitch = Math.min(HALF_VFOV - 2, sightline.topPitch);
8174
+ const stageBasePitch = Math.max(-HALF_VFOV + 2, sightline.basePitch);
5940
8175
  const sx0 = yawToTX(-stageHalfYaw);
5941
8176
  const sx1 = yawToTX(stageHalfYaw);
5942
8177
  const sy0 = pitchToTY(stageTopPitch);
@@ -6000,9 +8235,9 @@ function generateSeatThumb(seat, focalPoint, _neighborSeats) {
6000
8235
 
6001
8236
  // src/i18n/bundles.ts
6002
8237
  var LOADERS = {
6003
- es: () => import("./es-47HFAWS6.js").then((m) => ({ default: m.es })),
6004
- de: () => import("./de-EIG65UFU.js").then((m) => ({ default: m.de })),
6005
- fr: () => import("./fr-7IXNNFBS.js").then((m) => ({ default: m.fr }))
8238
+ es: () => import("./es-SKAODLTR.js").then((m) => ({ default: m.es })),
8239
+ de: () => import("./de-73TIXYJH.js").then((m) => ({ default: m.de })),
8240
+ fr: () => import("./fr-J4637T6V.js").then((m) => ({ default: m.fr }))
6006
8241
  };
6007
8242
  var loaded = /* @__PURE__ */ new Set(["en"]);
6008
8243
  async function loadLocale(code) {
@@ -6031,8 +8266,11 @@ export {
6031
8266
  MAX_EVENT_INVENTORY,
6032
8267
  MAX_GA_CAPACITY,
6033
8268
  PickerController,
8269
+ RENDERED_QUALITY_REPORT_VERSION,
6034
8270
  SUPPORTED_LOCALES,
8271
+ SURROUNDINGS_SHAPE_ROLES,
6035
8272
  SeatmapRenderer,
8273
+ TIER_HEIGHT_M,
6036
8274
  UNGROUPED_ID,
6037
8275
  accessibilityMeta,
6038
8276
  accessibilityRingColor,
@@ -6047,33 +8285,43 @@ export {
6047
8285
  expandRow,
6048
8286
  expandRowSlots,
6049
8287
  expandTable,
8288
+ expandTableSlots,
6050
8289
  floorObjects,
6051
8290
  floorsOf,
6052
8291
  formatDate,
6053
8292
  formatMoney,
6054
8293
  gaAreasOf,
8294
+ gaInventorySegments,
6055
8295
  gaUnitLabel,
6056
8296
  gaUnitLabels,
6057
8297
  generateSeatPanorama,
6058
8298
  generateSeatThumb,
6059
8299
  getLocale,
8300
+ growJoinedGAInventory,
6060
8301
  hiddenObjectIds,
8302
+ inspectRenderedQualityEvidence,
6061
8303
  isGaUnitLabel,
6062
8304
  isSectionHidden,
6063
8305
  layerOf,
6064
8306
  loadLocale,
6065
8307
  objectCenter,
8308
+ owningSectionForObject,
6066
8309
  pointInPolygon,
6067
8310
  pointInPolygonWithHoles,
6068
8311
  polygonLabelPoint,
6069
8312
  resolveLocale,
8313
+ rowInventoryCount,
6070
8314
  rowSeatPositions,
6071
8315
  seatLabelPart,
8316
+ sectionGeometry,
6072
8317
  setLocale,
6073
8318
  setMoneyLocale,
6074
8319
  setStringOverrides,
6075
8320
  stackFloors,
6076
8321
  t,
6077
- tCount
8322
+ tCount,
8323
+ tableInventoryCount,
8324
+ tableSeatCountsBySide,
8325
+ validGAInventorySegments
6078
8326
  };
6079
8327
  //# sourceMappingURL=index.js.map