@seatlayer/core 0.16.1 → 0.18.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
@@ -12,6 +12,21 @@ var ACCESSIBILITY_LABEL = new Map(ACCESSIBILITY_TYPES.map((a) => [a.key, a]));
12
12
  function accessibilityMeta(key) {
13
13
  return ACCESSIBILITY_LABEL.get(key);
14
14
  }
15
+ var ACCESSIBILITY_RING_COLOR = {
16
+ wheelchair: "#3b82f6",
17
+ companion: "#8b5cf6",
18
+ "semi-ambulatory": "#0ea5e9",
19
+ hearing: "#14b8a6",
20
+ "sign-language": "#f59e0b",
21
+ "plus-size": "#ec4899",
22
+ "lift-armrest": "#22c55e"
23
+ };
24
+ function accessibilityRingColor(types) {
25
+ const primary = types?.[0];
26
+ return primary && ACCESSIBILITY_RING_COLOR[primary] || "#3b82f6";
27
+ }
28
+ var LABEL_STYLE_MIN_SIZE = 8;
29
+ var LABEL_STYLE_MAX_SIZE = 24;
15
30
  function layerOf(obj) {
16
31
  switch (obj.type) {
17
32
  case "row":
@@ -31,6 +46,71 @@ function layerOf(obj) {
31
46
  }
32
47
  var CHART_STORAGE_KEY = "seatmap.chart";
33
48
 
49
+ // src/core/complexGeometry.ts
50
+ function cubicPoint(path, t2) {
51
+ const u = 1 - t2;
52
+ const a = u * u * u;
53
+ const b = 3 * u * u * t2;
54
+ const c = 3 * u * t2 * t2;
55
+ const d = t2 * t2 * t2;
56
+ return {
57
+ x: a * path.start.x + b * path.control1.x + c * path.control2.x + d * path.end.x,
58
+ y: a * path.start.y + b * path.control1.y + c * path.control2.y + d * path.end.y
59
+ };
60
+ }
61
+ function distributeAlongCubic(path, count, resolution = 192) {
62
+ if (!Number.isInteger(count) || count < 1) throw new Error("Path point count must be a positive integer");
63
+ if (count === 1) return [cubicPoint(path, 0.5)];
64
+ const samples = Array.from({ length: resolution + 1 }, (_, index) => cubicPoint(path, index / resolution));
65
+ const lengths = new Float64Array(samples.length);
66
+ for (let index = 1; index < samples.length; index += 1) {
67
+ const dx = samples[index].x - samples[index - 1].x;
68
+ const dy = samples[index].y - samples[index - 1].y;
69
+ lengths[index] = lengths[index - 1] + Math.hypot(dx, dy);
70
+ }
71
+ const total = lengths[lengths.length - 1];
72
+ if (total <= 1e-9) return Array.from({ length: count }, () => ({ ...path.start }));
73
+ const output = [];
74
+ let segment = 1;
75
+ for (let index = 0; index < count; index += 1) {
76
+ const target = total * index / (count - 1);
77
+ while (segment < lengths.length - 1 && lengths[segment] < target) segment += 1;
78
+ const before = lengths[segment - 1];
79
+ const after = lengths[segment];
80
+ const ratio = after === before ? 0 : (target - before) / (after - before);
81
+ output.push({
82
+ x: samples[segment - 1].x + (samples[segment].x - samples[segment - 1].x) * ratio,
83
+ y: samples[segment - 1].y + (samples[segment].y - samples[segment - 1].y) * ratio
84
+ });
85
+ }
86
+ return output;
87
+ }
88
+
89
+ // src/core/sectionPath.ts
90
+ var TAU = Math.PI * 2;
91
+ function translateSectionOutlinePath(path, dx, dy) {
92
+ const translate = (point) => ({ x: point.x + dx, y: point.y + dy });
93
+ return transformSectionOutlinePath(path, translate);
94
+ }
95
+ function transformSectionOutlinePath(path, transform, radiusScale = 1, reflected = false) {
96
+ return {
97
+ ...path,
98
+ start: transform(path.start),
99
+ segments: path.segments.map((segment) => segment.kind === "line" ? { ...segment, end: transform(segment.end) } : segment.kind === "arc" ? {
100
+ ...segment,
101
+ center: transform(segment.center),
102
+ radius: segment.radius * Math.abs(radiusScale),
103
+ clockwise: reflected ? !segment.clockwise : segment.clockwise,
104
+ end: transform(segment.end)
105
+ } : {
106
+ ...segment,
107
+ control1: transform(segment.control1),
108
+ control2: transform(segment.control2),
109
+ end: transform(segment.end)
110
+ })
111
+ };
112
+ }
113
+
34
114
  // src/core/layout.ts
35
115
  function overrideAccessibility(o) {
36
116
  if (!o) return [];
@@ -52,6 +132,7 @@ function place(lx, ly, deg, origin) {
52
132
  function rowSeatPositions(row) {
53
133
  const { seatCount, seatSpacing, curve, rotation, origin } = row;
54
134
  const out = [];
135
+ if (row.path) return distributeAlongCubic(row.path, seatCount);
55
136
  if (seatCount <= 1) {
56
137
  if (seatCount === 1) out.push({ x: origin.x, y: origin.y });
57
138
  return out;
@@ -92,15 +173,21 @@ function expandRowSlots(row) {
92
173
  return rowSeatPositions(row).map((p, i) => {
93
174
  const o = ov.get(i);
94
175
  const accessibility = overrideAccessibility(o);
176
+ const inventoryLabel = o?.label ?? `${row.label}-${seatNumber(i)}`;
177
+ const displayPrefix = row.displayLabel ?? row.label;
178
+ const commercial = { ...row.commercial, ...o?.commercial };
95
179
  return {
96
180
  index: i,
97
181
  x: p.x + (o?.dx ?? 0),
98
182
  y: p.y + (o?.dy ?? 0),
99
- label: o?.label ?? `${row.label}-${seatNumber(i)}`,
183
+ label: inventoryLabel,
184
+ displayLabel: o?.displayLabel ?? `${displayPrefix}-${seatNumber(i)}`,
100
185
  categoryKey: o?.categoryKey ?? row.categoryKey,
101
186
  skipped: !!o?.skip,
102
187
  accessible: accessibility.length > 0,
103
- accessibility
188
+ accessibility,
189
+ commercial: Object.values(commercial).some((value) => value !== void 0 && value !== false && value !== "") ? commercial : void 0,
190
+ viewUrl: o?.viewFromSeatUrl ?? row.viewFromSeatUrl
104
191
  };
105
192
  });
106
193
  }
@@ -111,13 +198,15 @@ function expandRow(row) {
111
198
  seats.push({
112
199
  id: `${row.id}:${slot.index}`,
113
200
  label: slot.label,
201
+ displayLabel: slot.displayLabel === slot.label ? void 0 : slot.displayLabel,
114
202
  x: slot.x,
115
203
  y: slot.y,
116
204
  rowId: row.id,
117
205
  categoryKey: slot.categoryKey,
118
206
  accessible: slot.accessible || void 0,
119
207
  accessibility: slot.accessibility.length ? slot.accessibility : void 0,
120
- viewUrl: row.viewFromSeatUrl
208
+ commercial: slot.commercial,
209
+ viewUrl: slot.viewUrl
121
210
  });
122
211
  }
123
212
  return seats;
@@ -214,6 +303,53 @@ function pointInPolygon(p, poly) {
214
303
  }
215
304
  return inside;
216
305
  }
306
+ function pointOnPolygonBoundary(p, poly) {
307
+ return poly.some((start, index) => {
308
+ const end = poly[(index + 1) % poly.length];
309
+ const cross = (p.y - start.y) * (end.x - start.x) - (p.x - start.x) * (end.y - start.y);
310
+ if (Math.abs(cross) > 1e-7) return false;
311
+ const dot = (p.x - start.x) * (end.x - start.x) + (p.y - start.y) * (end.y - start.y);
312
+ const lengthSquared = (end.x - start.x) ** 2 + (end.y - start.y) ** 2;
313
+ return dot >= -1e-7 && dot <= lengthSquared + 1e-7;
314
+ });
315
+ }
316
+ function pointInPolygonWithHoles(p, outer, holes) {
317
+ return pointInPolygon(p, outer) && !(holes ?? []).some((hole) => pointInPolygon(p, hole) || pointOnPolygonBoundary(p, hole));
318
+ }
319
+ function polygonLabelPoint(outer, holes) {
320
+ if (!outer.length) return { x: 0, y: 0 };
321
+ const xs = outer.map((point) => point.x);
322
+ const ys = outer.map((point) => point.y);
323
+ const bounds = { minX: Math.min(...xs), maxX: Math.max(...xs), minY: Math.min(...ys), maxY: Math.max(...ys) };
324
+ const centroid = polygonCentroid(outer);
325
+ if (pointInPolygonWithHoles(centroid, outer, holes)) return centroid;
326
+ let best = outer[0];
327
+ let bestScore = -Infinity;
328
+ const rings = [outer, ...holes ?? []];
329
+ for (let row = 1; row < 24; row += 1) {
330
+ for (let column = 1; column < 24; column += 1) {
331
+ const point = {
332
+ x: bounds.minX + (bounds.maxX - bounds.minX) * column / 24,
333
+ y: bounds.minY + (bounds.maxY - bounds.minY) * row / 24
334
+ };
335
+ if (!pointInPolygonWithHoles(point, outer, holes)) continue;
336
+ const score = Math.min(...rings.flatMap((ring) => ring.map((start, index) => {
337
+ const end = ring[(index + 1) % ring.length];
338
+ const dx = end.x - start.x;
339
+ const dy = end.y - start.y;
340
+ const denominator = dx * dx + dy * dy;
341
+ const projection = denominator ? ((point.x - start.x) * dx + (point.y - start.y) * dy) / denominator : 0;
342
+ const t2 = Math.max(0, Math.min(1, projection));
343
+ return Math.hypot(point.x - (start.x + t2 * dx), point.y - (start.y + t2 * dy));
344
+ })));
345
+ if (score > bestScore) {
346
+ best = point;
347
+ bestScore = score;
348
+ }
349
+ }
350
+ }
351
+ return best;
352
+ }
217
353
  function polygonCentroid(pts) {
218
354
  if (!pts.length) return { x: 0, y: 0 };
219
355
  let x = 0;
@@ -277,9 +413,14 @@ function translateObject(o, dx, dy) {
277
413
  case "booth":
278
414
  return { ...o, center: p(o.center) };
279
415
  case "gaArea":
280
- return { ...o, points: pts(o.points) };
416
+ return { ...o, points: pts(o.points), ...o.holes ? { holes: o.holes.map(pts) } : {} };
281
417
  case "section":
282
- return { ...o, outline: pts(o.outline) };
418
+ return {
419
+ ...o,
420
+ outline: pts(o.outline),
421
+ ...o.outlinePath ? { outlinePath: translateSectionOutlinePath(o.outlinePath, dx, dy) } : {},
422
+ ...o.holes ? { holes: o.holes.map(pts) } : {}
423
+ };
283
424
  case "text":
284
425
  return { ...o, position: p(o.position) };
285
426
  case "shape":
@@ -402,12 +543,33 @@ function objectSeatLabels(o) {
402
543
  function isSeatObject(o) {
403
544
  return o.type === "row" || o.type === "table" || o.type === "booth" || o.type === "gaArea";
404
545
  }
546
+ function samePoints(left, right) {
547
+ return left.length === right.length && left.every((point, index) => point.x === right[index].x && point.y === right[index].y);
548
+ }
549
+ function sameGASurfaceAsSection(object, section) {
550
+ if (object.type !== "gaArea" || !samePoints(object.points, section.outline)) return false;
551
+ const objectHoles = object.holes ?? [];
552
+ const sectionHoles = section.holes ?? [];
553
+ return objectHoles.length === sectionHoles.length && objectHoles.every((hole, index) => samePoints(hole, sectionHoles[index]));
554
+ }
405
555
  function computeSections(doc) {
406
556
  const objs = allObjects(doc);
407
557
  const sectionObjs = objs.filter((o) => o.type === "section");
408
558
  const nodes = /* @__PURE__ */ new Map();
409
559
  for (const s of sectionObjs) {
410
- nodes.set(s.id, { id: s.id, label: s.label || "Section", zone: s.zone, seatCount: 0, objectIds: [], seatLabels: [] });
560
+ const logicalId = s.logicalSectionId ?? s.id;
561
+ const existing = nodes.get(logicalId);
562
+ if (existing) {
563
+ continue;
564
+ }
565
+ nodes.set(logicalId, {
566
+ id: logicalId,
567
+ label: s.displayLabel || s.label || "Section",
568
+ zone: s.zone,
569
+ seatCount: 0,
570
+ objectIds: [],
571
+ seatLabels: []
572
+ });
411
573
  }
412
574
  const ungrouped = { id: UNGROUPED_ID, label: "Other seats", seatCount: 0, objectIds: [], seatLabels: [] };
413
575
  const objectToSection = /* @__PURE__ */ new Map();
@@ -415,22 +577,24 @@ function computeSections(doc) {
415
577
  if (!isSeatObject(obj)) continue;
416
578
  const labels = objectSeatLabels(obj);
417
579
  if (labels.length === 0) continue;
580
+ const referencedLogicalId = obj.referenceInventorySource?.logicalSectionId;
418
581
  const c = objectCenter(obj);
419
- const owner = sectionObjs.find((s) => pointInPolygon(c, s.outline));
420
- const node = owner ? nodes.get(owner.id) : ungrouped;
582
+ const referencedOwner = referencedLogicalId ? sectionObjs.find((section) => (section.logicalSectionId ?? section.id) === referencedLogicalId && (sameGASurfaceAsSection(obj, section) || pointInPolygonWithHoles(c, section.outline, section.holes))) : void 0;
583
+ const owner = referencedOwner ?? sectionObjs.find((s) => pointInPolygonWithHoles(c, s.outline, s.holes));
584
+ const node = owner ? nodes.get(owner.logicalSectionId ?? owner.id) : ungrouped;
421
585
  node.seatCount += labels.length;
422
586
  node.objectIds.push(obj.id);
423
587
  node.seatLabels.push(...labels);
424
588
  objectToSection.set(obj.id, node.id);
425
589
  }
426
590
  return {
427
- sections: sectionObjs.map((s) => nodes.get(s.id)),
591
+ sections: [...nodes.values()],
428
592
  ungrouped: ungrouped.objectIds.length ? ungrouped : null,
429
593
  objectToSection
430
594
  };
431
595
  }
432
596
  function isSectionHidden(s, hidden) {
433
- return hidden.has(s.id) || !!s.zone && hidden.has(s.zone);
597
+ return hidden.has(s.id) || !!s.logicalSectionId && hidden.has(s.logicalSectionId) || !!s.zone && hidden.has(s.zone);
434
598
  }
435
599
  function hiddenObjectIds(doc, hidden) {
436
600
  const out = /* @__PURE__ */ new Set();
@@ -483,6 +647,61 @@ import { Ellipse } from "konva/lib/shapes/Ellipse";
483
647
  import { Line } from "konva/lib/shapes/Line";
484
648
  import { Text } from "konva/lib/shapes/Text";
485
649
  import { Image as KImage } from "konva/lib/shapes/Image";
650
+ import { Shape } from "konva/lib/Shape";
651
+
652
+ // src/core/chartRenderRules.ts
653
+ var SEAT_LABEL_FONT_SIZE = 7;
654
+ var BOOTH_LABEL_FONT_SIZE = 10;
655
+ var GA_LABEL_FONT_SIZE = 15;
656
+ var GA_CAPACITY_LABEL_FONT_SIZE = 11;
657
+ var GA_FILL_OPACITY = 0.85;
658
+ var MIN_VISIBLE_BOOKABLE_LABEL_PX = 12;
659
+ var SMALL_TEXT_CONTRAST = 4.5;
660
+ var DARK_BOOKABLE_LABEL_INK = "#000000";
661
+ var LIGHT_BOOKABLE_LABEL_INK = "#ffffff";
662
+ function isBookableLabelLegibleAtScale(fontSize, effectiveScale) {
663
+ return fontSize * effectiveScale >= MIN_VISIBLE_BOOKABLE_LABEL_PX;
664
+ }
665
+ function bookableMarkerLabel(publicLabel) {
666
+ return /-(\d{1,5})$/.exec(publicLabel)?.[1] ?? publicLabel;
667
+ }
668
+ function luminance(value) {
669
+ const match = /^#([0-9a-f]{6})$/i.exec(value.trim());
670
+ if (!match) return null;
671
+ const channel = (offset) => {
672
+ const encoded = Number.parseInt(match[1].slice(offset, offset + 2), 16) / 255;
673
+ return encoded <= 0.04045 ? encoded / 12.92 : ((encoded + 0.055) / 1.055) ** 2.4;
674
+ };
675
+ return 0.2126 * channel(0) + 0.7152 * channel(2) + 0.0722 * channel(4);
676
+ }
677
+ function renderedTextContrast(ink, fill) {
678
+ const inkLuminance = luminance(ink);
679
+ const fillLuminance = luminance(fill);
680
+ if (inkLuminance == null || fillLuminance == null) return null;
681
+ return (Math.max(inkLuminance, fillLuminance) + 0.05) / (Math.min(inkLuminance, fillLuminance) + 0.05);
682
+ }
683
+ function rgb(value) {
684
+ const match = /^#([0-9a-f]{6})$/i.exec(value.trim());
685
+ if (!match) return null;
686
+ const packed = Number.parseInt(match[1], 16);
687
+ return [packed >> 16 & 255, packed >> 8 & 255, packed & 255];
688
+ }
689
+ function compositeHexOver(foreground, background, opacity) {
690
+ const front = rgb(foreground);
691
+ const back = rgb(background);
692
+ if (!front || !back) return background;
693
+ const alpha = Math.max(0, Math.min(1, opacity));
694
+ const channels = front.map((value, index) => Math.round(value * alpha + back[index] * (1 - alpha)));
695
+ return `#${channels.map((value) => value.toString(16).padStart(2, "0")).join("")}`;
696
+ }
697
+ function stateAwareBookableLabelInk(fill, preferred) {
698
+ const preferredContrast = renderedTextContrast(preferred, fill);
699
+ if (preferredContrast != null && preferredContrast >= SMALL_TEXT_CONTRAST) return preferred;
700
+ const darkContrast = renderedTextContrast(DARK_BOOKABLE_LABEL_INK, fill) ?? 0;
701
+ const lightContrast = renderedTextContrast(LIGHT_BOOKABLE_LABEL_INK, fill) ?? 0;
702
+ if (darkContrast === 0 && lightContrast === 0) return preferred;
703
+ return darkContrast >= lightContrast ? DARK_BOOKABLE_LABEL_INK : LIGHT_BOOKABLE_LABEL_INK;
704
+ }
486
705
 
487
706
  // src/lib/money.ts
488
707
  var DEFAULT_CURRENCY = "USD";
@@ -542,6 +761,8 @@ var en = {
542
761
  // buyer picker page (src/pages/PickerPage.tsx)
543
762
  "picker.language": "Language",
544
763
  "picker.zoomToFit": "Zoom to fit",
764
+ "picker.seatCountLabel": "seats",
765
+ "picker.capacity": "capacity",
545
766
  "picker.viewMode": "View mode",
546
767
  "picker.floor": "Floor",
547
768
  "picker.zoomLevel": "Zoom level",
@@ -631,7 +852,8 @@ function formatDate(value, opts) {
631
852
  var SEAT_RADIUS = 9;
632
853
  var SEAT_LEGIBLE_SCALE = 0.9;
633
854
  var CACHE_THRESHOLD = 0.55 * SEAT_LEGIBLE_SCALE;
634
- var LABEL_SCALE = 1;
855
+ var LABEL_SCALE = MIN_VISIBLE_BOOKABLE_LABEL_PX / SEAT_LABEL_FONT_SIZE;
856
+ var MIN_FITTED_SEAT_LABEL_FONT_SIZE = 4;
635
857
  var SEAT_TAP_SLOP_PX = 14;
636
858
  var SECTION_PROMINENT_SCALE = 0.45 * SEAT_LEGIBLE_SCALE;
637
859
  var BLOCK_MELT_TOP = 0.9 * SEAT_LEGIBLE_SCALE;
@@ -645,32 +867,31 @@ var ISO_SQUASH = 0.58;
645
867
  var LIFT_PER_STEP = 58;
646
868
  var ISO_TWEEN_MS = 320;
647
869
  var CAMERA_GLIDE_MS = 650;
648
- var BLOCK_FILL_ALPHA = 0.85;
649
- var SOLD_DARKEN = 0.5;
870
+ var BLOCK_FILL_ALPHA = 1;
871
+ var SECTION_STROKE_PX = 2;
872
+ var LIGHT_OVERVIEW_SECTION_FILL = "#e5e7eb";
873
+ var LIGHT_OVERVIEW_SECTION_STROKE = "#c7cbd1";
874
+ var LIGHT_OVERVIEW_SECTION_INK = "#595f69";
875
+ var LIGHT_OVERVIEW_FOCAL_FILL = "#d1d5db";
876
+ var LIGHT_OVERVIEW_FOCAL_STROKE = "#b8bdc4";
877
+ var DARK_OVERVIEW_SECTION_FILL = "#273142";
878
+ var DARK_OVERVIEW_SECTION_STROKE = "#526078";
879
+ var DARK_OVERVIEW_SECTION_INK = "#f1f5f9";
880
+ var DARK_OVERVIEW_FOCAL_FILL = "#374151";
881
+ var DARK_OVERVIEW_FOCAL_STROKE = "#64748b";
650
882
  var SECTION_LABEL_PX = 20;
651
- var SECTION_SUB_PX = 12.5;
652
- var ZONE_LABEL_PX = 30;
883
+ var MIN_SECTION_LABEL_PX = 12;
884
+ var ZONE_LABEL_PX = 18;
653
885
  var ZONE_SUB_PX = 12;
886
+ var HIERARCHY_PILL_BACKGROUND = "#111827";
654
887
  var HELD_FILL = "#6b7280";
655
888
  var TAKEN_FILL = "#374151";
656
889
  var NFS_STROKE = "#4b5563";
657
- var CLOSED_SECTION_FILL = "#586070";
658
890
  var CLOSED_SEAT_FILL = "#4b5563";
659
891
  var CLOSED_SEAT_OPACITY = 0.4;
660
892
  var FOCUS_DIM_OPACITY = 0.16;
661
- var FOCUS_DESATURATE = 0.72;
662
- var FOCUS_NEUTRAL = "#6b7280";
663
893
  var FOCUS_BACKDROP_FILL = "rgba(244,246,248,0.06)";
664
894
  var CB_PALETTE = ["#E69F00", "#56B4E9", "#009E73", "#F0E442", "#0072B2", "#D55E00", "#CC79A7"];
665
- var ACCESS_RING = {
666
- wheelchair: "#3b82f6",
667
- companion: "#8b5cf6",
668
- "semi-ambulatory": "#0ea5e9",
669
- hearing: "#14b8a6",
670
- "sign-language": "#f59e0b",
671
- "plus-size": "#ec4899",
672
- "lift-armrest": "#22c55e"
673
- };
674
895
  function seatMatchesAccess(seat, filter) {
675
896
  if (filter.length === 0) return !!seat.accessible;
676
897
  return !!seat.accessibility?.some((t2) => filter.includes(t2));
@@ -680,6 +901,7 @@ var DEF_SELECTION = "#ffffff";
680
901
  var DEF_SELECTION_ON_LIGHT = "#0b1220";
681
902
  var DEF_DECOR_FILL = "#232c40";
682
903
  var DEF_TEXT = "#8b93a7";
904
+ var DEF_CANVAS_BACKGROUND = "#0e1117";
683
905
  function colorLuminance(color) {
684
906
  const s = color.trim();
685
907
  let r = NaN;
@@ -692,11 +914,11 @@ function colorLuminance(color) {
692
914
  g = parseInt(h.slice(2, 4), 16);
693
915
  b = parseInt(h.slice(4, 6), 16);
694
916
  } else {
695
- const rgb = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/.exec(s);
696
- if (rgb) {
697
- r = +rgb[1];
698
- g = +rgb[2];
699
- b = +rgb[3];
917
+ const rgb2 = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/.exec(s);
918
+ if (rgb2) {
919
+ r = +rgb2[1];
920
+ g = +rgb2[2];
921
+ b = +rgb2[3];
700
922
  }
701
923
  }
702
924
  if (Number.isNaN(r)) return NaN;
@@ -706,6 +928,34 @@ function isLightColor(color) {
706
928
  const lum = colorLuminance(color);
707
929
  return !Number.isNaN(lum) && lum > 0.6;
708
930
  }
931
+ function opaqueColorHex(color) {
932
+ const value = color.trim();
933
+ const hex = /^#([\da-f]{3}|[\da-f]{6})$/i.exec(value);
934
+ if (hex) {
935
+ const expanded = hex[1].length === 3 ? hex[1].split("").map((channel) => channel + channel).join("") : hex[1];
936
+ return `#${expanded.toLowerCase()}`;
937
+ }
938
+ const rgb2 = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([\d.]+))?\s*\)$/i.exec(value);
939
+ if (!rgb2 || rgb2[4] != null && Number(rgb2[4]) < 0.999) return null;
940
+ const channels = [Number(rgb2[1]), Number(rgb2[2]), Number(rgb2[3])];
941
+ if (channels.some((channel) => !Number.isInteger(channel) || channel < 0 || channel > 255)) return null;
942
+ return `#${channels.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;
943
+ }
944
+ function overviewPalette(canvasBackground) {
945
+ return isLightColor(canvasBackground) ? {
946
+ sectionFill: LIGHT_OVERVIEW_SECTION_FILL,
947
+ sectionStroke: LIGHT_OVERVIEW_SECTION_STROKE,
948
+ sectionInk: LIGHT_OVERVIEW_SECTION_INK,
949
+ focalFill: LIGHT_OVERVIEW_FOCAL_FILL,
950
+ focalStroke: LIGHT_OVERVIEW_FOCAL_STROKE
951
+ } : {
952
+ sectionFill: DARK_OVERVIEW_SECTION_FILL,
953
+ sectionStroke: DARK_OVERVIEW_SECTION_STROKE,
954
+ sectionInk: DARK_OVERVIEW_SECTION_INK,
955
+ focalFill: DARK_OVERVIEW_FOCAL_FILL,
956
+ focalStroke: DARK_OVERVIEW_FOCAL_STROKE
957
+ };
958
+ }
709
959
  var clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
710
960
  function seatIdOf(target) {
711
961
  const n = target;
@@ -741,6 +991,108 @@ function polyBounds(pts) {
741
991
  }
742
992
  return { x: minX, y: minY, width: Math.max(1, maxX - minX), height: Math.max(1, maxY - minY) };
743
993
  }
994
+ function rotatedRectPoints(center, width, height, rotation) {
995
+ const radians = rotation * Math.PI / 180;
996
+ const cos = Math.cos(radians);
997
+ const sin = Math.sin(radians);
998
+ return [
999
+ { x: -width / 2, y: -height / 2 },
1000
+ { x: width / 2, y: -height / 2 },
1001
+ { x: width / 2, y: height / 2 },
1002
+ { x: -width / 2, y: height / 2 }
1003
+ ].map((point) => ({
1004
+ x: center.x + point.x * cos - point.y * sin,
1005
+ y: center.y + point.x * sin + point.y * cos
1006
+ }));
1007
+ }
1008
+ function pointsBounds(points) {
1009
+ const bounds = polyBounds(points);
1010
+ return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height };
1011
+ }
1012
+ function rotatedRectFitsPolygon(center, width, height, rotation, outer, holes) {
1013
+ const radians = rotation * Math.PI / 180;
1014
+ const cos = Math.cos(radians);
1015
+ const sin = Math.sin(radians);
1016
+ for (let yStep = 0; yStep <= 4; yStep++) {
1017
+ for (let xStep = 0; xStep <= 6; xStep++) {
1018
+ const localX = width * (xStep / 6 - 0.5);
1019
+ const localY = height * (yStep / 4 - 0.5);
1020
+ const point = {
1021
+ x: center.x + localX * cos - localY * sin,
1022
+ y: center.y + localX * sin + localY * cos
1023
+ };
1024
+ if (!pointInPolygonWithHoles(point, outer, holes)) return false;
1025
+ }
1026
+ }
1027
+ return true;
1028
+ }
1029
+ function polygonLabelCandidates(outer, holes, preferred) {
1030
+ const bounds = polyBounds(outer);
1031
+ const centre = { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 };
1032
+ const points = [preferred];
1033
+ for (let row = 1; row < 12; row += 1) {
1034
+ for (let column = 1; column < 12; column += 1) {
1035
+ const point = {
1036
+ x: bounds.x + bounds.width * column / 12,
1037
+ y: bounds.y + bounds.height * row / 12
1038
+ };
1039
+ if (pointInPolygonWithHoles(point, outer, holes)) points.push(point);
1040
+ }
1041
+ }
1042
+ return points.sort((left, right) => Math.hypot(left.x - centre.x, left.y - centre.y) - Math.hypot(right.x - centre.x, right.y - centre.y)).filter((point, index, all) => index === all.findIndex((other) => Math.abs(other.x - point.x) < 1e-6 && Math.abs(other.y - point.y) < 1e-6));
1043
+ }
1044
+ function polygonWithHolesShape(outer, holes, attrs, outerPath) {
1045
+ const signedArea = (points) => points.reduce((sum, point, index) => {
1046
+ const next = points[(index + 1) % points.length];
1047
+ return sum + point.x * next.y - next.x * point.y;
1048
+ }, 0);
1049
+ const outerClockwise = signedArea(outer) > 0;
1050
+ return new Shape({
1051
+ ...attrs,
1052
+ sceneFunc(context, shape) {
1053
+ context.beginPath();
1054
+ const polygonPath = (points) => {
1055
+ if (!points.length) return;
1056
+ context.moveTo(points[0].x, points[0].y);
1057
+ for (let index = 1; index < points.length; index += 1) context.lineTo(points[index].x, points[index].y);
1058
+ context.closePath();
1059
+ };
1060
+ const vectorPath = (path) => {
1061
+ context.moveTo(path.start.x, path.start.y);
1062
+ let current = path.start;
1063
+ for (const segment of path.segments) {
1064
+ if (segment.kind === "line") context.lineTo(segment.end.x, segment.end.y);
1065
+ else if (segment.kind === "arc") context.arc(
1066
+ segment.center.x,
1067
+ segment.center.y,
1068
+ segment.radius,
1069
+ Math.atan2(current.y - segment.center.y, current.x - segment.center.x),
1070
+ Math.atan2(segment.end.y - segment.center.y, segment.end.x - segment.center.x),
1071
+ !segment.clockwise
1072
+ );
1073
+ else context.bezierCurveTo(
1074
+ segment.control1.x,
1075
+ segment.control1.y,
1076
+ segment.control2.x,
1077
+ segment.control2.y,
1078
+ segment.end.x,
1079
+ segment.end.y
1080
+ );
1081
+ current = segment.end;
1082
+ }
1083
+ context.closePath();
1084
+ };
1085
+ if (outerPath) vectorPath(outerPath);
1086
+ else polygonPath(outer);
1087
+ for (const hole of holes ?? []) {
1088
+ const holeClockwise = signedArea(hole) > 0;
1089
+ polygonPath(holeClockwise === outerClockwise ? [...hole].reverse() : hole);
1090
+ }
1091
+ context.fillStrokeShape(shape);
1092
+ },
1093
+ perfectDrawEnabled: false
1094
+ });
1095
+ }
744
1096
  function rgba(hex, a) {
745
1097
  const m = /^#?([\da-f]{6})$/i.exec(hex.trim());
746
1098
  if (!m) return hex;
@@ -760,11 +1112,11 @@ function mixColors(parts, fallback) {
760
1112
  let b = 0;
761
1113
  let tw = 0;
762
1114
  for (const p of parts) {
763
- const rgb = hexToRgb(p.hex);
764
- if (!rgb || p.w <= 0) continue;
765
- r += rgb[0] * p.w;
766
- g += rgb[1] * p.w;
767
- b += rgb[2] * p.w;
1115
+ const rgb2 = hexToRgb(p.hex);
1116
+ if (!rgb2 || p.w <= 0) continue;
1117
+ r += rgb2[0] * p.w;
1118
+ g += rgb2[1] * p.w;
1119
+ b += rgb2[2] * p.w;
768
1120
  tw += p.w;
769
1121
  }
770
1122
  return tw > 0 ? toHex(r / tw, g / tw, b / tw) : fallback;
@@ -788,11 +1140,21 @@ var _SeatmapRenderer = class _SeatmapRenderer {
788
1140
  this.circleById = /* @__PURE__ */ new Map();
789
1141
  /** Booth block geometry, keyed by booth id (= the unit's rowId). */
790
1142
  this.boothDims = /* @__PURE__ */ new Map();
791
- /** Booth label node so status changes can say HELD/SOLD on the full block. */
1143
+ /** Booth labels live with the booth shape but obey the shared rendered-size LOD. */
792
1144
  this.boothLabelById = /* @__PURE__ */ new Map();
1145
+ /** Viewport seat labels are rebuilt after each settled camera change. */
1146
+ this.seatLabelById = /* @__PURE__ */ new Map();
1147
+ /** Authored free-text nodes obey the same rendered-size visibility floor. */
1148
+ this.freeTextById = /* @__PURE__ */ new Map();
1149
+ /** Stage/rink landmarks retain a readable screen-space caption at overview. */
1150
+ this.primaryFocalLabels = /* @__PURE__ */ new Map();
1151
+ /** GA paint and text share price/highlight filter state. */
1152
+ this.gaById = /* @__PURE__ */ new Map();
793
1153
  this.statusById = /* @__PURE__ */ new Map();
794
1154
  this.catColor = /* @__PURE__ */ new Map();
795
1155
  this.theme = {};
1156
+ /** Opaque paint actually visible behind transparent Konva canvases. */
1157
+ this.canvasBackground = DEF_CANVAS_BACKGROUND;
796
1158
  /** Effective selection/hover ring color — resolved per chart in setChart(). */
797
1159
  this.effSelection = DEF_SELECTION;
798
1160
  /** Colorblind-safe mode (Okabe-Ito hues + hollow booked seats). */
@@ -1090,6 +1452,11 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1090
1452
  this.circleById.clear();
1091
1453
  this.boothDims.clear();
1092
1454
  this.boothLabelById.clear();
1455
+ this.seatLabelById.clear();
1456
+ this.freeTextById.clear();
1457
+ this.primaryFocalLabels.clear();
1458
+ this.gaById.clear();
1459
+ for (const marker of this.selectionMarkers.values()) marker.destroy();
1093
1460
  this.selectionMarkers.clear();
1094
1461
  this.ownedHold.clear();
1095
1462
  this.selectionFocusId = null;
@@ -1101,6 +1468,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1101
1468
  this.sections = [];
1102
1469
  this.zones = [];
1103
1470
  this.seatSection.clear();
1471
+ this.focusedSectionId = null;
1472
+ this.focusBackdrop = null;
1104
1473
  this.catPrice.clear();
1105
1474
  this.zoneColor.clear();
1106
1475
  this.lodScale = 0;
@@ -1118,7 +1487,9 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1118
1487
  this.hoverRing.visible(false);
1119
1488
  this.theme = doc.theme ?? {};
1120
1489
  this.seatR = clamp(this.theme.seatScale ?? 1, 0.7, 1.6) * SEAT_RADIUS;
1121
- this.container.style.background = this.theme.background ?? "";
1490
+ this.container.style.background = "";
1491
+ this.canvasBackground = this.resolveCanvasBackground();
1492
+ this.container.style.background = this.canvasBackground;
1122
1493
  this.effSelection = this.resolveSelectionColor();
1123
1494
  this.hoverRing.stroke(this.effSelection);
1124
1495
  this.hoverRing.radius(this.seatR + 2);
@@ -1347,12 +1718,21 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1347
1718
  }
1348
1719
  return this.selectMany(ids);
1349
1720
  }
1721
+ /** Exact SDK capture helper. MCP never accepts this id; the SDK derives it
1722
+ * from the persisted floor and uses the normal selected paint/ring path. */
1723
+ setEvidenceSelection(seatId) {
1724
+ if (!this.seatById.has(seatId) || !this.isSelectable(seatId)) return false;
1725
+ if (this.selection.size) this.clearSelection();
1726
+ this.setSelected(seatId, true);
1727
+ this.overlayLayer.batchDraw();
1728
+ return this.selection.has(seatId);
1729
+ }
1350
1730
  /** Selectable seats in a section OR zone id — pure read (no selection change). */
1351
1731
  getSelectableInSection(sectionId) {
1352
1732
  const out = [];
1353
1733
  const seen = /* @__PURE__ */ new Set();
1354
1734
  for (const sec of this.sections) {
1355
- if (sec.id !== sectionId && sec.zone !== sectionId) continue;
1735
+ if (sec.id !== sectionId && sec.logicalId !== sectionId && sec.zone !== sectionId) continue;
1356
1736
  for (const id of sec.memberIds) {
1357
1737
  if (seen.has(id)) continue;
1358
1738
  seen.add(id);
@@ -1495,6 +1875,65 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1495
1875
  };
1496
1876
  requestAnimationFrame(step);
1497
1877
  }
1878
+ /**
1879
+ * Pulse a section outline without moving the camera or mutating the authored
1880
+ * geometry. The temporary halo is drawn in the non-listening overlay layer,
1881
+ * so the apparent 4% lift never changes hit testing or selection bounds.
1882
+ */
1883
+ flashSection(sectionId, color = "#22a06b") {
1884
+ const matches = this.sections.filter((section) => section.id === sectionId || section.zone === sectionId);
1885
+ if (!matches.length) return;
1886
+ for (const section of matches) {
1887
+ const centre = section.outline.reduce(
1888
+ (sum, point) => ({ x: sum.x + point.x, y: sum.y + point.y }),
1889
+ { x: 0, y: 0 }
1890
+ );
1891
+ centre.x /= section.outline.length;
1892
+ centre.y /= section.outline.length;
1893
+ const lift = section.elevation > 0 ? this.isoLiftLocal(section.elevation) : { x: 0, y: 0 };
1894
+ const halo = new Line({
1895
+ x: centre.x + lift.x,
1896
+ y: centre.y + lift.y,
1897
+ points: section.outline.flatMap((point) => [point.x - centre.x, point.y - centre.y]),
1898
+ closed: true,
1899
+ stroke: color,
1900
+ strokeWidth: 3,
1901
+ strokeScaleEnabled: false,
1902
+ opacity: 0.92,
1903
+ listening: false,
1904
+ perfectDrawEnabled: false,
1905
+ shadowForStrokeEnabled: true,
1906
+ shadowColor: color,
1907
+ shadowBlur: 14,
1908
+ shadowOpacity: 0.7
1909
+ });
1910
+ this.overlayLayer.add(halo);
1911
+ this.overlayLayer.batchDraw();
1912
+ const remove = () => {
1913
+ if (!halo.getLayer()) return;
1914
+ halo.destroy();
1915
+ this.overlayLayer.batchDraw();
1916
+ };
1917
+ if (this.reducedMotion || typeof document !== "undefined" && document.hidden) {
1918
+ setTimeout(remove, 520);
1919
+ continue;
1920
+ }
1921
+ const start = performance.now();
1922
+ const duration = 820;
1923
+ const step = (now) => {
1924
+ if (this.destroyed || !halo.getLayer()) return;
1925
+ const t2 = Math.min(1, (now - start) / duration);
1926
+ const eased = 1 - Math.pow(1 - t2, 3);
1927
+ const scale = 1 + eased * 0.04;
1928
+ halo.scale({ x: scale, y: scale });
1929
+ halo.opacity(0.92 * (1 - t2));
1930
+ this.overlayLayer.batchDraw();
1931
+ if (t2 < 1) requestAnimationFrame(step);
1932
+ else remove();
1933
+ };
1934
+ requestAnimationFrame(step);
1935
+ }
1936
+ }
1498
1937
  /** Nearest seat from `fromId` in a cardinal direction (aligned + close wins). */
1499
1938
  nearestSeat(fromId, dir) {
1500
1939
  const from = this.seatById.get(fromId);
@@ -1568,6 +2007,11 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1568
2007
  seatCount() {
1569
2008
  return this.seats.length;
1570
2009
  }
2010
+ bookableCount() {
2011
+ let total = this.seats.length;
2012
+ for (const area of this.gaById.values()) total += area.capacity;
2013
+ return total;
2014
+ }
1571
2015
  worldToScreen(point) {
1572
2016
  const s = this.stage.scaleX();
1573
2017
  const p = this.isoT === 0 ? point : this.isoForward(point);
@@ -1584,6 +2028,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1584
2028
  const c = this.circleById.get(seat.id);
1585
2029
  if (c) this.paintSeat(c, seat.id);
1586
2030
  }
2031
+ this.updateLabels();
1587
2032
  if (this.cached) {
1588
2033
  this.seatLayer.clearCache();
1589
2034
  this.cacheSeatLayer();
@@ -1610,12 +2055,33 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1610
2055
  const c = this.circleById.get(seat.id);
1611
2056
  if (c) this.paintSeat(c, seat.id);
1612
2057
  }
2058
+ this.updateLabels();
1613
2059
  if (this.cached) {
1614
2060
  this.seatLayer.clearCache();
1615
2061
  this.cacheSeatLayer();
1616
2062
  } else {
1617
2063
  this.seatLayer.batchDraw();
1618
2064
  }
2065
+ this.applyGAFilterState();
2066
+ }
2067
+ gaCategoryDimmed(categoryKey) {
2068
+ return Boolean(
2069
+ this.categoryHighlight && categoryKey !== this.categoryHighlight || this.categoryFilter && !this.categoryFilter.has(categoryKey)
2070
+ );
2071
+ }
2072
+ /** Keep GA paint and its two labels in the same legend/price-filter state. */
2073
+ applyGAFilterState() {
2074
+ this.paintGAStateForView();
2075
+ this.updateFreeTextVisibility();
2076
+ this.bgLayer.batchDraw();
2077
+ }
2078
+ paintGAStateForView() {
2079
+ for (const ga of this.gaById.values()) {
2080
+ const filteredOut = Boolean(this.categoryFilter && !this.categoryFilter.has(ga.categoryKey));
2081
+ const overviewHidden = ga.sectionId != null && this.effScale() < CACHE_THRESHOLD;
2082
+ ga.polygon.opacity(overviewHidden ? 0 : this.gaCategoryDimmed(ga.categoryKey) ? GA_FILL_OPACITY * 0.08 : GA_FILL_OPACITY);
2083
+ ga.polygon.listening(!overviewHidden && !filteredOut);
2084
+ }
1619
2085
  }
1620
2086
  /** Frame the currently available inventory that survived a buyer price
1621
2087
  * filter. Clearing the filter glides back to the full venue. */
@@ -1875,13 +2341,12 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1875
2341
  this.paintSeat(c, seat.id);
1876
2342
  target.add(c);
1877
2343
  if (seat.accessible) {
1878
- const primary = seat.accessibility?.[0];
1879
2344
  target.add(
1880
2345
  new Circle({
1881
2346
  x: seat.x,
1882
2347
  y: seat.y,
1883
2348
  radius: this.seatR + 1,
1884
- stroke: primary && ACCESS_RING[primary] || "#3b82f6",
2349
+ stroke: accessibilityRingColor(seat.accessibility),
1885
2350
  strokeWidth: 2,
1886
2351
  listening: false,
1887
2352
  perfectDrawEnabled: false,
@@ -1910,13 +2375,12 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1910
2375
  });
1911
2376
  rect.setAttr("seatId", seat.id);
1912
2377
  this.circleById.set(seat.id, rect);
1913
- this.paintSeat(rect, seat.id);
1914
2378
  target.add(rect);
1915
2379
  const t2 = new Text({
1916
2380
  x: seat.x,
1917
2381
  y: seat.y,
1918
- text: seat.label,
1919
- fontSize: 10,
2382
+ text: seat.displayLabel ?? seat.label,
2383
+ fontSize: BOOTH_LABEL_FONT_SIZE,
1920
2384
  fontStyle: "600",
1921
2385
  fontFamily: this.labelFont(),
1922
2386
  fill: this.theme.seatLabelColor ?? DEF_SEAT_LABEL,
@@ -1925,9 +2389,12 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1925
2389
  });
1926
2390
  t2.offsetX(t2.width() / 2);
1927
2391
  t2.offsetY(t2.height() / 2);
2392
+ t2.visible(false);
2393
+ this.boothLabelById.set(seat.id, t2);
1928
2394
  this.hasBoothText = true;
1929
2395
  this.boothLabelById.set(seat.id, t2);
1930
2396
  target.add(t2);
2397
+ this.paintSeat(rect, seat.id);
1931
2398
  }
1932
2399
  /** The category's display color — Okabe-Ito hue when colorblind-safe is on. */
1933
2400
  seatBaseColor(categoryKey) {
@@ -1935,6 +2402,15 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1935
2402
  const idx = this.catOrder.indexOf(categoryKey);
1936
2403
  return CB_PALETTE[(idx >= 0 ? idx : 0) % CB_PALETTE.length];
1937
2404
  }
2405
+ /** Resolve label ink against the paint that is actually visible. The theme
2406
+ * ink remains preferred, but mixed category palettes cannot always share one
2407
+ * accessible text colour, so every free and transient state gets the same
2408
+ * deterministic dark/light fallback used by Designer. */
2409
+ renderedBookableLabelInk(shape) {
2410
+ const preferred = this.theme.seatLabelColor ?? DEF_SEAT_LABEL;
2411
+ const fill = shape.fill();
2412
+ return stateAwareBookableLabelInk(typeof fill === "string" ? fill : "", preferred);
2413
+ }
1938
2414
  /** Apply fill/stroke/opacity for a seat's current status + selection. */
1939
2415
  paintSeat(c, id) {
1940
2416
  const seat = this.seatById.get(id);
@@ -1998,7 +2474,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1998
2474
  }
1999
2475
  if (this.dimmedSections.size) {
2000
2476
  const sec = this.seatSection.get(id);
2001
- if (sec && (this.dimmedSections.has(sec.id) || sec.zone != null && this.dimmedSections.has(sec.zone))) {
2477
+ if (sec && (this.dimmedSections.has(sec.id) || this.dimmedSections.has(sec.logicalId) || sec.zone != null && this.dimmedSections.has(sec.zone))) {
2002
2478
  c.opacity(0.18);
2003
2479
  }
2004
2480
  }
@@ -2011,16 +2487,23 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2011
2487
  }
2012
2488
  if (this.focusedSectionId) {
2013
2489
  const sec = this.seatSection.get(id);
2014
- const inFocus = !!sec && (sec.id === this.focusedSectionId || sec.zone === this.focusedSectionId);
2490
+ const inFocus = !!sec && (sec.id === this.focusedSectionId || sec.logicalId === this.focusedSectionId || sec.zone === this.focusedSectionId);
2015
2491
  if (!inFocus) c.opacity(FOCUS_DIM_OPACITY);
2016
2492
  }
2017
2493
  if (this.selectionFocusId && id !== this.selectionFocusId) c.opacity(Math.min(c.opacity(), 0.16));
2494
+ const bookableLabel = this.boothLabelById.get(id) ?? this.seatLabelById.get(id);
2495
+ if (bookableLabel) {
2496
+ bookableLabel.fill(this.renderedBookableLabelInk(c));
2497
+ bookableLabel.visible(
2498
+ isBookableLabelLegibleAtScale(bookableLabel.fontSize(), this.effScale()) && c.opacity() >= 0.5
2499
+ );
2500
+ }
2018
2501
  }
2019
2502
  /** True when a seat sits in a section/zone currently marked `closed`. */
2020
2503
  seatInClosedSection(id) {
2021
2504
  if (!this.closedSections.size) return false;
2022
2505
  const sec = this.seatSection.get(id);
2023
- return !!sec && (this.closedSections.has(sec.id) || sec.zone != null && this.closedSections.has(sec.zone));
2506
+ return !!sec && (this.closedSections.has(sec.id) || this.closedSections.has(sec.logicalId) || sec.zone != null && this.closedSections.has(sec.zone));
2024
2507
  }
2025
2508
  /**
2026
2509
  * Colorblind-safe mode: swap category hues for the Okabe-Ito palette and
@@ -2033,6 +2516,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2033
2516
  const c = this.circleById.get(seat.id);
2034
2517
  if (c) this.paintSeat(c, seat.id);
2035
2518
  }
2519
+ this.updateLabels();
2036
2520
  if (this.cached) {
2037
2521
  this.seatLayer.clearCache();
2038
2522
  this.cacheSeatLayer();
@@ -2077,7 +2561,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2077
2561
  * seat-pick gate below only lets buyers pick once seats are ≥ LABEL_SCALE big).
2078
2562
  */
2079
2563
  focusSection(id) {
2080
- if (!this.sections.some((s) => s.id === id)) return;
2564
+ if (!this.sections.some((section) => section.id === id || section.logicalId === id)) return;
2081
2565
  this.focusedSectionId = id;
2082
2566
  this.drawFocusBackdrop(id);
2083
2567
  this.repaintSectionsAndSeats();
@@ -2105,20 +2589,20 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2105
2589
  this.focusBackdrop.destroy();
2106
2590
  this.focusBackdrop = null;
2107
2591
  }
2108
- const sec = this.sections.find((s) => s.id === id);
2109
- if (!sec) return;
2110
- const panel = new Line({
2111
- points: sec.outline.flatMap((p) => [p.x, p.y]),
2112
- closed: true,
2113
- fill: FOCUS_BACKDROP_FILL,
2114
- stroke: rgba("#ffffff", 0.1),
2115
- strokeWidth: 1,
2116
- listening: false,
2117
- perfectDrawEnabled: false
2118
- });
2119
- this.bgLayer.add(panel);
2120
- panel.moveToTop();
2121
- this.focusBackdrop = panel;
2592
+ const sections = this.sections.filter((section) => section.id === id || section.logicalId === id);
2593
+ if (!sections.length) return;
2594
+ const backdrop = new Group({ listening: false });
2595
+ for (const section of sections) {
2596
+ backdrop.add(polygonWithHolesShape(section.outline, section.holes, {
2597
+ fill: FOCUS_BACKDROP_FILL,
2598
+ stroke: rgba("#ffffff", 0.1),
2599
+ strokeWidth: 1,
2600
+ listening: false
2601
+ }, section.outlinePath));
2602
+ }
2603
+ this.bgLayer.add(backdrop);
2604
+ backdrop.moveToTop();
2605
+ this.focusBackdrop = backdrop;
2122
2606
  }
2123
2607
  /** Repaint every seat + section block to reflect closed/focus state, then redraw. */
2124
2608
  repaintSectionsAndSeats() {
@@ -2146,7 +2630,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2146
2630
  height: Math.abs(br.y - tl.y)
2147
2631
  };
2148
2632
  }
2149
- /** Axis-aligned world bounds of all seats + section outlines (minimap F3 frame). */
2633
+ /** Axis-aligned world bounds of seats, section outlines, and GA polygons (minimap F3 frame). */
2150
2634
  getWorldBounds() {
2151
2635
  let minX = Infinity;
2152
2636
  let minY = Infinity;
@@ -2160,6 +2644,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2160
2644
  };
2161
2645
  for (const s of this.seats) grow(s.x, s.y);
2162
2646
  for (const sec of this.sections) for (const p of sec.outline) grow(p.x, p.y);
2647
+ for (const area of this.gaById.values()) for (const p of area.points) grow(p.x, p.y);
2163
2648
  if (!Number.isFinite(minX)) return { x: 0, y: 0, width: 1, height: 1 };
2164
2649
  return { x: minX, y: minY, width: Math.max(1, maxX - minX), height: Math.max(1, maxY - minY) };
2165
2650
  }
@@ -2171,12 +2656,14 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2171
2656
  const c = this.circleById.get(seat.id);
2172
2657
  if (c) this.paintSeat(c, seat.id);
2173
2658
  }
2659
+ this.updateLabels();
2174
2660
  if (this.cached) {
2175
2661
  this.seatLayer.clearCache();
2176
2662
  this.cacheSeatLayer();
2177
2663
  } else {
2178
2664
  this.seatLayer.batchDraw();
2179
2665
  }
2666
+ this.applyGAFilterState();
2180
2667
  }
2181
2668
  renderBackground(doc) {
2182
2669
  if (doc.backgroundImage) this.renderBackgroundImage(doc.backgroundImage);
@@ -2194,32 +2681,40 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2194
2681
  this.renderText(obj);
2195
2682
  }
2196
2683
  }
2197
- const f = doc.focalPoint;
2198
- if (f) {
2199
- const size = 14;
2200
- const cross = new Group({ listening: false });
2201
- cross.add(
2202
- new Line({ points: [f.x - size, f.y, f.x + size, f.y], stroke: "#4b5563", strokeWidth: 1.5 }),
2203
- new Line({ points: [f.x, f.y - size, f.x, f.y + size], stroke: "#4b5563", strokeWidth: 1.5 }),
2204
- new Circle({ x: f.x, y: f.y, radius: 3, fill: "#4b5563" })
2205
- );
2206
- this.bgLayer.add(cross);
2207
- }
2208
2684
  }
2209
2685
  /** Organizer floor-plan photo, dimmed, at the very bottom of the bg layer. */
2210
2686
  renderBackgroundImage(bg) {
2687
+ if (!bg.url || bg.visible === false) return;
2211
2688
  const img = new window.Image();
2212
2689
  img.onload = () => {
2213
2690
  const natW = img.naturalWidth || 4;
2214
2691
  const natH = img.naturalHeight || 3;
2692
+ const rawCrop = bg.crop ?? { x: 0, y: 0, width: 1, height: 1 };
2693
+ const cropX = Math.max(0, Math.min(0.99, rawCrop.x));
2694
+ const cropY = Math.max(0, Math.min(0.99, rawCrop.y));
2695
+ const crop = {
2696
+ x: cropX,
2697
+ y: cropY,
2698
+ width: Math.max(0.01, Math.min(1 - cropX, rawCrop.width)),
2699
+ height: Math.max(0.01, Math.min(1 - cropY, rawCrop.height))
2700
+ };
2215
2701
  const w = bg.width;
2216
- const h = w * (natH / natW);
2702
+ const h = w * (natH * crop.height / (natW * crop.width));
2217
2703
  const node = new KImage({
2218
2704
  image: img,
2219
- x: bg.center.x - w / 2,
2220
- y: bg.center.y - h / 2,
2705
+ x: bg.center.x,
2706
+ y: bg.center.y,
2707
+ offsetX: w / 2,
2708
+ offsetY: h / 2,
2221
2709
  width: w,
2222
2710
  height: h,
2711
+ rotation: bg.rotation ?? 0,
2712
+ crop: {
2713
+ x: crop.x * natW,
2714
+ y: crop.y * natH,
2715
+ width: crop.width * natW,
2716
+ height: crop.height * natH
2717
+ },
2223
2718
  opacity: bg.opacity,
2224
2719
  listening: false
2225
2720
  });
@@ -2291,28 +2786,42 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2291
2786
  })
2292
2787
  );
2293
2788
  }
2294
- this.addCentredLabel(this.bgLayer, obj.label, obj.center.x, obj.center.y, "#cbd5e1", 12, true);
2789
+ const label = this.addCentredLabel(this.bgLayer, obj.label, obj.center.x, obj.center.y, "#cbd5e1", 12, true);
2790
+ this.freeTextById.set(obj.id, { node: label, background: "#232c40", kind: "table" });
2295
2791
  }
2296
2792
  renderText(obj) {
2297
- this.bgLayer.add(
2298
- new Text({
2299
- x: obj.position.x,
2300
- y: obj.position.y,
2301
- text: obj.text,
2302
- fontSize: obj.fontSize,
2303
- rotation: obj.rotation,
2304
- fill: obj.color ?? this.theme.textColor ?? DEF_TEXT,
2305
- fontFamily: this.labelFont(),
2306
- listening: false,
2307
- perfectDrawEnabled: false
2308
- })
2309
- );
2793
+ const background = this.canvasBackground;
2794
+ const preferredInk = obj.color ?? this.theme.textColor ?? DEF_TEXT;
2795
+ const node = new Text({
2796
+ x: obj.position.x,
2797
+ y: obj.position.y,
2798
+ text: obj.text,
2799
+ fontSize: obj.fontSize,
2800
+ rotation: obj.rotation,
2801
+ // Authored ink remains preferred, but an embed/theme surface can change
2802
+ // the actual canvas. Fail over to readable black/white instead of
2803
+ // painting an otherwise valid caption invisibly on that active surface.
2804
+ fill: stateAwareBookableLabelInk(background, preferredInk),
2805
+ fontFamily: this.labelFont(),
2806
+ listening: false,
2807
+ perfectDrawEnabled: false
2808
+ });
2809
+ this.freeTextById.set(obj.id, {
2810
+ node,
2811
+ background,
2812
+ kind: "free-text"
2813
+ });
2814
+ this.bgLayer.add(node);
2310
2815
  }
2311
2816
  renderShape(obj) {
2312
- const fill = obj.fill ?? this.theme.decorFill ?? DEF_DECOR_FILL;
2817
+ const authoredFill = obj.fill ?? this.theme.decorFill ?? DEF_DECOR_FILL;
2313
2818
  const isStage = obj.role === "stage";
2819
+ const referenceFocal = obj.role === "reference-focal";
2314
2820
  const isDecor = !!obj.role && !isStage;
2315
- const stroke = isStage ? lighten(fill, 0.28) : void 0;
2821
+ const palette = overviewPalette(this.canvasBackground);
2822
+ const fill = referenceFocal ? palette.focalFill : authoredFill;
2823
+ const stroke = isStage ? lighten(fill, 0.28) : referenceFocal ? palette.focalStroke : void 0;
2824
+ const strokeWidth = isStage ? 1 : referenceFocal ? 2 : 0;
2316
2825
  let cx = 0;
2317
2826
  let cy = 0;
2318
2827
  if (obj.kind === "rect" && obj.x != null && obj.y != null && obj.width != null && obj.height != null) {
@@ -2334,7 +2843,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2334
2843
  height: obj.height,
2335
2844
  ...grad,
2336
2845
  stroke,
2337
- strokeWidth: isStage ? 1 : 0,
2846
+ strokeWidth,
2338
2847
  cornerRadius: 4,
2339
2848
  listening: false
2340
2849
  })
@@ -2348,7 +2857,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2348
2857
  fillLinearGradientColorStops: [0, darken(fill, 0.3), 1, lighten(fill, 0.12)]
2349
2858
  } : { fill };
2350
2859
  this.bgLayer.add(
2351
- new Ellipse({ x: cx, y: cy, rotation: obj.rotation ?? 0, radiusX: obj.width / 2, radiusY: obj.height / 2, ...grad, stroke, strokeWidth: isStage ? 1 : 0, listening: false })
2860
+ new Ellipse({ x: cx, y: cy, rotation: obj.rotation ?? 0, radiusX: obj.width / 2, radiusY: obj.height / 2, ...grad, stroke, strokeWidth, listening: false })
2352
2861
  );
2353
2862
  } else if (obj.kind === "polygon" && obj.points && obj.points.length) {
2354
2863
  const pts = obj.points.flatMap((p) => [p.x, p.y]);
@@ -2363,17 +2872,35 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2363
2872
  fillLinearGradientColorStops: [0, darken(fill, 0.3), 1, lighten(fill, 0.12)]
2364
2873
  } : { fill };
2365
2874
  this.bgLayer.add(
2366
- new Line({ points: pts, closed: true, x: cx, y: cy, offsetX: cx, offsetY: cy, rotation: obj.rotation ?? 0, ...grad, stroke, strokeWidth: isStage ? 1 : 0, listening: false })
2875
+ new Line({ points: pts, closed: true, x: cx, y: cy, offsetX: cx, offsetY: cy, rotation: obj.rotation ?? 0, ...grad, stroke, strokeWidth, listening: false })
2367
2876
  );
2368
2877
  }
2369
2878
  if (obj.label) {
2370
- if (isStage) this.addStageLabel(cx, cy, obj.label);
2371
- else if (isDecor) this.addCentredLabel(this.bgLayer, obj.label, cx, cy, "#9aa3b5", 12, false);
2372
- else this.addCentredLabel(this.bgLayer, obj.label, cx, cy, "#cbd5e1", 16, true);
2879
+ if (isStage) {
2880
+ const node = this.addStageLabel(cx, cy, obj.label, fill);
2881
+ this.primaryFocalLabels.set(node, 22);
2882
+ this.freeTextById.set(obj.id, { node, background: fill, kind: "stage" });
2883
+ } else if (isDecor) {
2884
+ const node = this.addCentredLabel(
2885
+ this.bgLayer,
2886
+ obj.label,
2887
+ cx,
2888
+ cy,
2889
+ referenceFocal ? stateAwareBookableLabelInk(fill, "#e6e9f0") : "#9aa3b5",
2890
+ referenceFocal ? 18 : 12,
2891
+ false
2892
+ );
2893
+ if (referenceFocal) this.primaryFocalLabels.set(node, 18);
2894
+ this.freeTextById.set(obj.id, { node, background: fill, kind: "decor" });
2895
+ } else {
2896
+ const node = this.addCentredLabel(this.bgLayer, obj.label, cx, cy, "#cbd5e1", 16, true);
2897
+ this.freeTextById.set(obj.id, { node, background: fill, kind: "decor" });
2898
+ }
2373
2899
  }
2374
2900
  }
2375
2901
  /** Prominent stage caption: uppercase, letter-spaced, larger, softly dimmed. */
2376
- addStageLabel(x, y, text) {
2902
+ addStageLabel(x, y, text, background) {
2903
+ const ink = stateAwareBookableLabelInk(background, "#e6e9f0");
2377
2904
  const t2 = new Text({
2378
2905
  x,
2379
2906
  y,
@@ -2382,22 +2909,24 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2382
2909
  fontStyle: "700",
2383
2910
  letterSpacing: 4,
2384
2911
  fontFamily: this.labelFont(),
2385
- fill: rgba("#e6e9f0", 0.62),
2912
+ fill: ink,
2386
2913
  listening: false,
2387
2914
  perfectDrawEnabled: false
2388
2915
  });
2389
2916
  t2.offsetX(t2.width() / 2);
2390
2917
  t2.offsetY(t2.height() / 2);
2391
2918
  this.bgLayer.add(t2);
2919
+ return t2;
2392
2920
  }
2393
2921
  renderGA(obj) {
2394
2922
  const color = this.catColor.get(obj.categoryKey) ?? "#6e7bff";
2395
- const pts = obj.points.flatMap((p) => [p.x, p.y]);
2396
- const poly = new Line({
2397
- points: pts,
2398
- closed: true,
2923
+ const canvas = this.canvasBackground;
2924
+ const effectiveBackground = compositeHexOver(color, canvas, GA_FILL_OPACITY);
2925
+ const preferredInk = this.theme.textColor ?? "#e6e9f0";
2926
+ const ink = stateAwareBookableLabelInk(effectiveBackground, preferredInk);
2927
+ const poly = polygonWithHolesShape(obj.points, obj.holes, {
2399
2928
  fill: color,
2400
- opacity: 0.22,
2929
+ opacity: GA_FILL_OPACITY,
2401
2930
  stroke: color,
2402
2931
  strokeWidth: 1.5
2403
2932
  });
@@ -2410,31 +2939,51 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2410
2939
  this.container.style.cursor = "default";
2411
2940
  });
2412
2941
  this.bgLayer.add(poly);
2413
- const cx = obj.points.reduce((a, p) => a + p.x, 0) / obj.points.length;
2414
- const cy = obj.points.reduce((a, p) => a + p.y, 0) / obj.points.length;
2415
- this.addCentredLabel(this.bgLayer, obj.label, cx, cy - 8, "#e6e9f0", 15, false);
2416
- this.addCentredLabel(this.bgLayer, `cap ${obj.capacity}`, cx, cy + 10, "#8b93a7", 11, false);
2942
+ const labelPoint = polygonLabelPoint(obj.points, obj.holes);
2943
+ const containingSection = this.sections.find((section) => pointInPolygonWithHoles(labelPoint, section.outline, section.holes));
2944
+ const label = this.addCentredLabel(this.bgLayer, obj.label, labelPoint.x, labelPoint.y - 8, ink, GA_LABEL_FONT_SIZE, false);
2945
+ const capacity = this.addCentredLabel(this.bgLayer, `cap ${obj.capacity}`, labelPoint.x, labelPoint.y + 10, ink, GA_CAPACITY_LABEL_FONT_SIZE, false);
2946
+ this.freeTextById.set(`${obj.id}:label`, {
2947
+ objectId: obj.id,
2948
+ node: label,
2949
+ background: effectiveBackground,
2950
+ kind: "ga-label",
2951
+ categoryKey: obj.categoryKey
2952
+ });
2953
+ this.freeTextById.set(`${obj.id}:capacity`, {
2954
+ objectId: obj.id,
2955
+ node: capacity,
2956
+ background: effectiveBackground,
2957
+ kind: "ga-capacity",
2958
+ categoryKey: obj.categoryKey
2959
+ });
2960
+ this.gaById.set(obj.id, {
2961
+ label: obj.label,
2962
+ capacity: obj.capacity,
2963
+ categoryKey: obj.categoryKey,
2964
+ points: obj.points,
2965
+ polygon: poly,
2966
+ effectiveBackground,
2967
+ ...containingSection ? { sectionId: containingSection.logicalId } : {}
2968
+ });
2417
2969
  }
2418
2970
  /**
2419
2971
  * A section renders in three coordinated layers driven by the LOD melt:
2420
2972
  * • a faint outline (the existing near-zoom look, untouched),
2421
- * • a solid category-mix block that fades in at the block rung, and
2422
- * • a name + "N LEFT" sublabel.
2423
- * Membership (which seats live inside the outline) + the mix fill + the live
2424
- * availability count are precomputed here (once), not per frame.
2973
+ * • a neutral solid shell that fades in at the overview rung, and
2974
+ * • one readable, contained section name.
2975
+ * Category, row, seat, and availability detail belongs to section focus/zoom.
2976
+ * Membership and category mix are still precomputed for the detailed state.
2425
2977
  */
2426
2978
  renderSection(obj) {
2427
- const pts = obj.outline.flatMap((p) => [p.x, p.y]);
2428
- const centroid = {
2429
- x: obj.outline.reduce((a, p) => a + p.x, 0) / obj.outline.length,
2430
- y: obj.outline.reduce((a, p) => a + p.y, 0) / obj.outline.length
2431
- };
2979
+ const centroid = polygonLabelPoint(obj.outline, obj.holes);
2980
+ const palette = overviewPalette(this.canvasBackground);
2432
2981
  const memberIds = [];
2433
2982
  const catCounts = /* @__PURE__ */ new Map();
2434
2983
  let free = 0;
2435
2984
  for (const seat of this.seats) {
2436
2985
  if (this.seatSection.has(seat.id)) continue;
2437
- if (!pointInPolygon(seat, obj.outline)) continue;
2986
+ if (!pointInPolygonWithHoles(seat, obj.outline, obj.holes)) continue;
2438
2987
  memberIds.push(seat.id);
2439
2988
  catCounts.set(seat.categoryKey, (catCounts.get(seat.categoryKey) ?? 0) + 1);
2440
2989
  if ((this.statusById.get(seat.id) ?? "free") === "free") free++;
@@ -2470,65 +3019,34 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2470
3019
  }
2471
3020
  const bgTarget = liftGroupBg ?? this.bgLayer;
2472
3021
  const outlineTint = obj.color ?? this.zoneColor.get(obj.zone ?? "") ?? "#3a4358";
2473
- const outlinePoly = new Line({
2474
- points: pts,
2475
- closed: true,
3022
+ const outlinePoly = polygonWithHolesShape(obj.outline, obj.holes, {
2476
3023
  stroke: rgba(outlineTint, 0.5),
2477
3024
  strokeWidth: 1.75,
2478
3025
  fill: rgba(outlineTint, 0.08),
2479
- lineJoin: "round",
2480
- listening: false,
2481
- perfectDrawEnabled: false
2482
- });
3026
+ listening: false
3027
+ }, obj.outlinePath);
2483
3028
  bgTarget.add(outlinePoly);
2484
- const blockPoly = new Line({
2485
- points: pts,
2486
- closed: true,
2487
- fill: baseFill,
2488
- stroke: rgba("#ffffff", 0.12),
2489
- strokeWidth: 1,
3029
+ const blockPoly = polygonWithHolesShape(obj.outline, obj.holes, {
3030
+ fill: palette.sectionFill,
3031
+ stroke: palette.sectionStroke,
3032
+ strokeWidth: SECTION_STROKE_PX,
2490
3033
  opacity: 0,
2491
- listening: false,
2492
- perfectDrawEnabled: false
2493
- });
3034
+ listening: false
3035
+ }, obj.outlinePath);
2494
3036
  bgTarget.add(blockPoly);
2495
- const rowSeats = /* @__PURE__ */ new Map();
2496
- for (const id of memberIds) {
2497
- const s = this.seatById.get(id);
2498
- if (!s) continue;
2499
- const i = Number(id.slice(id.lastIndexOf(":") + 1)) || 0;
2500
- (rowSeats.get(s.rowId) ?? rowSeats.set(s.rowId, []).get(s.rowId)).push({ i, x: s.x, y: s.y });
2501
- }
2502
- const rowLines = [];
2503
- for (const arr of rowSeats.values()) {
2504
- if (arr.length < 2) continue;
2505
- arr.sort((a, b) => a.i - b.i);
2506
- const line = new Line({
2507
- points: arr.flatMap((p) => [p.x, p.y]),
2508
- stroke: rgba("#ffffff", 0.34),
2509
- strokeWidth: SEAT_RADIUS * 0.55,
2510
- lineCap: "round",
2511
- lineJoin: "round",
2512
- opacity: 0,
2513
- listening: false,
2514
- perfectDrawEnabled: false
2515
- });
2516
- rowLines.push(line);
2517
- bgTarget.add(line);
2518
- }
3037
+ const labelStyle = obj.labelPresentation?.labelStyle;
3038
+ const preferredInk = labelStyle?.color ?? palette.sectionInk;
3039
+ const labelScale = (labelStyle?.size ?? 18) / 18;
2519
3040
  const nameLabel = new Text({
2520
- x: centroid.x,
2521
- y: centroid.y,
2522
- text: obj.label,
2523
- fontSize: 22,
3041
+ x: obj.labelPresentation?.position?.x ?? centroid.x,
3042
+ y: obj.labelPresentation?.position?.y ?? centroid.y,
3043
+ text: obj.displayLabel ?? obj.label,
3044
+ rotation: obj.labelPresentation?.rotation ?? 0,
3045
+ visible: obj.labelPresentation?.visible !== false,
3046
+ fontSize: 22 * labelScale,
2524
3047
  fontStyle: "700",
2525
3048
  fontFamily: this.labelFont(),
2526
- fill: "#8b93a7",
2527
- // Dark halo so the label reads over the seat dots at any zoom.
2528
- shadowColor: "#05070c",
2529
- shadowBlur: 6,
2530
- shadowOpacity: 0.9,
2531
- shadowForStrokeEnabled: false,
3049
+ fill: stateAwareBookableLabelInk(palette.sectionFill, preferredInk),
2532
3050
  listening: false,
2533
3051
  perfectDrawEnabled: false
2534
3052
  });
@@ -2542,11 +3060,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2542
3060
  fontSize: 12,
2543
3061
  fontStyle: "700",
2544
3062
  fontFamily: "JetBrains Mono, ui-monospace, monospace",
2545
- fill: "#f4f6fb",
2546
- shadowColor: "#05070c",
2547
- shadowBlur: 5,
2548
- shadowOpacity: 0.9,
2549
- shadowForStrokeEnabled: false,
3063
+ fill: palette.sectionInk,
2550
3064
  opacity: 0,
2551
3065
  listening: false,
2552
3066
  perfectDrawEnabled: false
@@ -2555,9 +3069,13 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2555
3069
  bgTarget.add(subLabel);
2556
3070
  const sec = {
2557
3071
  id: obj.id,
2558
- label: obj.label,
3072
+ logicalId: obj.logicalSectionId ?? obj.id,
3073
+ label: obj.displayLabel ?? obj.label,
2559
3074
  outline: obj.outline,
3075
+ ...obj.outlinePath ? { outlinePath: obj.outlinePath } : {},
3076
+ holes: obj.holes ?? [],
2560
3077
  centroid,
3078
+ labelAnchors: polygonLabelCandidates(obj.outline, obj.holes ?? [], centroid),
2561
3079
  zone: obj.zone,
2562
3080
  memberIds,
2563
3081
  total: memberIds.length,
@@ -2566,9 +3084,12 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2566
3084
  outlineTint,
2567
3085
  outlinePoly,
2568
3086
  blockPoly,
2569
- rowLines,
2570
3087
  nameLabel,
2571
3088
  subLabel,
3089
+ preferredInk,
3090
+ labelScale,
3091
+ nameLabelFits: true,
3092
+ subLabelFits: true,
2572
3093
  elevation,
2573
3094
  liftGroupBg,
2574
3095
  liftGroupSeat,
@@ -2580,7 +3101,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2580
3101
  this.sections.push(sec);
2581
3102
  }
2582
3103
  refreshSectionHeat(sec) {
2583
- const raw = this.sectionHeat.get(sec.id) ?? (sec.zone ? this.sectionHeat.get(sec.zone) : void 0);
3104
+ const raw = this.sectionHeat.get(sec.id) ?? this.sectionHeat.get(sec.logicalId) ?? (sec.zone ? this.sectionHeat.get(sec.zone) : void 0);
2584
3105
  if (raw == null || raw <= 0) {
2585
3106
  sec.outlinePoly.stroke(rgba(sec.outlineTint, 0.5));
2586
3107
  sec.outlinePoly.fill(rgba(sec.outlineTint, 0.08));
@@ -2596,7 +3117,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2596
3117
  sec.outlinePoly.shadowBlur(4 + raw * 12);
2597
3118
  sec.outlinePoly.shadowOpacity(0.25 + raw * 0.45);
2598
3119
  }
2599
- /** Recompute a section's availability-tinted fill + "N LEFT" (cheap; on status change). */
3120
+ /** Recompute a section's neutral overview state and retained detail count. */
2600
3121
  refreshSectionFill(sec) {
2601
3122
  sec.blockPoly.fill(this.sectionBlockFill(sec));
2602
3123
  sec.subLabel.text(t("map.seatsLeft", { count: sec.free }));
@@ -2604,25 +3125,12 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2604
3125
  }
2605
3126
  /** True when a section/zone is currently in the `closed` event-state. */
2606
3127
  isSectionClosed(sec) {
2607
- return this.closedSections.has(sec.id) || sec.zone != null && this.closedSections.has(sec.zone);
3128
+ return this.closedSections.has(sec.id) || this.closedSections.has(sec.logicalId) || sec.zone != null && this.closedSections.has(sec.zone);
2608
3129
  }
2609
- /**
2610
- * The block-fill colour for a section: flat desaturated grey when `closed`,
2611
- * else the availability-darkened category mix; then desaturated toward neutral
2612
- * when another section holds focus (AXS dim treatment).
2613
- */
3130
+ /** Clean overview shells never leak category, price, or live availability paint. */
2614
3131
  sectionBlockFill(sec) {
2615
- let fill;
2616
- if (this.isSectionClosed(sec)) {
2617
- fill = CLOSED_SECTION_FILL;
2618
- } else {
2619
- const sold = sec.total > 0 ? (sec.total - sec.free) / sec.total : 0;
2620
- fill = darken(sec.baseFill, sold * SOLD_DARKEN);
2621
- }
2622
- if (this.focusedSectionId && sec.id !== this.focusedSectionId && sec.zone !== this.focusedSectionId) {
2623
- fill = lerpColor(fill, FOCUS_NEUTRAL, FOCUS_DESATURATE);
2624
- }
2625
- return fill;
3132
+ const fill = overviewPalette(this.canvasBackground).sectionFill;
3133
+ return this.isSectionClosed(sec) ? darken(fill, 0.12) : fill;
2626
3134
  }
2627
3135
  /**
2628
3136
  * Zone rung: one giant screen-constant label per zone (+ optional "FROM $n"),
@@ -2651,19 +3159,31 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2651
3159
  if (typeof p === "number" && p < minPrice) minPrice = p;
2652
3160
  }
2653
3161
  }
3162
+ const back = new Rect({
3163
+ x: cx,
3164
+ y: cy,
3165
+ width: 1,
3166
+ height: 1,
3167
+ offsetX: 0.5,
3168
+ offsetY: 0.5,
3169
+ cornerRadius: 1,
3170
+ fill: HIERARCHY_PILL_BACKGROUND,
3171
+ stroke: z.color ?? anchor.outlineTint,
3172
+ strokeWidth: 1,
3173
+ opacity: 0,
3174
+ listening: false,
3175
+ perfectDrawEnabled: false
3176
+ });
3177
+ this.bgLayer.add(back);
2654
3178
  const label = new Text({
2655
3179
  x: cx,
2656
3180
  y: cy,
2657
3181
  text: z.label.toUpperCase(),
2658
- fontSize: 34,
3182
+ fontSize: ZONE_LABEL_PX,
2659
3183
  fontStyle: "800",
2660
- letterSpacing: 2,
3184
+ letterSpacing: 0.5,
2661
3185
  fontFamily: this.labelFont(),
2662
- fill: z.color ?? "#f2f4f8",
2663
- shadowColor: "#05070c",
2664
- shadowBlur: 10,
2665
- shadowOpacity: 0.95,
2666
- shadowForStrokeEnabled: false,
3186
+ fill: "#f4f6fb",
2667
3187
  opacity: 0,
2668
3188
  listening: false,
2669
3189
  perfectDrawEnabled: false
@@ -2680,7 +3200,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2680
3200
  fontSize: 14,
2681
3201
  fontStyle: "600",
2682
3202
  fontFamily: "JetBrains Mono, ui-monospace, monospace",
2683
- fill: rgba("#e6e9f0", 0.75),
3203
+ fill: "#cbd5e1",
2684
3204
  opacity: 0,
2685
3205
  listening: false,
2686
3206
  perfectDrawEnabled: false
@@ -2688,7 +3208,14 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2688
3208
  sub.offsetX(sub.width() / 2);
2689
3209
  this.bgLayer.add(sub);
2690
3210
  }
2691
- this.zones.push({ id: z.id, label, sub });
3211
+ this.zones.push({
3212
+ id: z.id,
3213
+ anchor: { x: cx, y: cy },
3214
+ back,
3215
+ background: HIERARCHY_PILL_BACKGROUND,
3216
+ label,
3217
+ sub
3218
+ });
2692
3219
  }
2693
3220
  }
2694
3221
  /**
@@ -2710,74 +3237,106 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2710
3237
  blockT = clamp((BLOCK_MELT_TOP - scale) / (BLOCK_MELT_TOP - SECTION_PROMINENT_SCALE), 0, 1);
2711
3238
  zoneT = clamp((SECTION_PROMINENT_SCALE - scale) / (SECTION_PROMINENT_SCALE - ZONE_PROMINENT_SCALE), 0, 1);
2712
3239
  }
3240
+ const sectionOverview = scale < CACHE_THRESHOLD;
3241
+ if (sectionOverview) blockT = 1;
2713
3242
  if (!this.zones.length) zoneT = 0;
2714
- this.seatLayer.opacity(1 - blockT);
3243
+ this.seatLayer.opacity(sectionOverview ? 0 : 1 - blockT);
2715
3244
  const sx = this.stage.scaleX();
2716
3245
  const rescale = this.lodScale === 0 || Math.abs(scale - this.lodScale) / (this.lodScale || 1) > 0.02;
2717
3246
  if (rescale) this.lodScale = scale;
2718
3247
  const focus = this.focusedSectionId;
3248
+ const palette = overviewPalette(this.canvasBackground);
3249
+ const sectionLabelT = clamp((blockT - 0.2) / 0.8, 0, 1);
2719
3250
  for (const sec of this.sections) {
2720
- const dim = focus && sec.id !== focus && sec.zone !== focus ? FOCUS_DIM_OPACITY : 1;
3251
+ const dim = focus && sec.id !== focus && sec.logicalId !== focus && sec.zone !== focus ? FOCUS_DIM_OPACITY : 1;
3252
+ sec.outlinePoly.opacity(sectionOverview ? 0 : (1 - blockT) * dim);
2721
3253
  sec.blockPoly.opacity(BLOCK_FILL_ALPHA * blockT * dim);
2722
- for (const line of sec.rowLines) line.opacity(blockT * (1 - zoneT) * dim);
2723
- sec.nameLabel.fill(lerpColor("#aab3c5", "#ffffff", blockT));
2724
- sec.nameLabel.opacity((1 - zoneT) * dim);
2725
- sec.subLabel.opacity(blockT * (1 - zoneT) * dim);
2726
- if (rescale) {
2727
- this.sizeLabel(sec.nameLabel, SECTION_LABEL_PX / sx, sec.centroid.y - SECTION_SUB_PX / sx);
2728
- this.sizeLabel(sec.subLabel, SECTION_SUB_PX / sx, sec.centroid.y + SECTION_LABEL_PX / sx);
2729
- }
3254
+ sec.blockPoly.stroke(palette.sectionStroke);
3255
+ sec.blockPoly.strokeWidth(SECTION_STROKE_PX / Math.max(sx, 1e-4));
3256
+ const sectionFill = sec.blockPoly.fill();
3257
+ const sectionInk = stateAwareBookableLabelInk(
3258
+ typeof sectionFill === "string" ? sectionFill : sec.baseFill,
3259
+ sec.preferredInk
3260
+ );
3261
+ sec.nameLabel.fill(sectionInk);
3262
+ sec.subLabel.fill(sectionInk);
3263
+ if (rescale) this.fitSectionRungLabels(sec, sx);
3264
+ const labelOpacity = sectionLabelT * (1 - zoneT) * dim;
3265
+ sec.nameLabel.opacity(sec.nameLabelFits ? labelOpacity : 0);
3266
+ sec.subLabel.opacity(0);
2730
3267
  }
2731
3268
  const zoneOpacity = zoneT * (1 - this.isoT);
2732
3269
  for (const zone of this.zones) {
3270
+ zone.back.opacity(zoneOpacity);
2733
3271
  zone.label.opacity(zoneOpacity);
2734
3272
  if (zone.sub) zone.sub.opacity(zoneOpacity);
2735
- if (rescale) {
2736
- const cy = zone.label.y();
2737
- this.sizeLabel(zone.label, ZONE_LABEL_PX / sx, cy);
2738
- if (zone.sub) this.sizeLabel(zone.sub, ZONE_SUB_PX / sx, cy + ZONE_LABEL_PX / sx);
2739
- }
3273
+ if (rescale) this.sizeZonePill(zone, sx);
2740
3274
  }
2741
3275
  this.decollideRungLabels(sx);
3276
+ this.dedupeLogicalSectionLabels();
3277
+ for (const zone of this.zones) {
3278
+ const opacity = zone.label.opacity();
3279
+ zone.back.opacity(opacity);
3280
+ if (zone.sub) zone.sub.opacity(opacity);
3281
+ }
2742
3282
  this.bgLayer.batchDraw();
2743
3283
  }
3284
+ /** One semantic section gets one overview label, even across split contours. */
3285
+ dedupeLogicalSectionLabels() {
3286
+ const byLogical = /* @__PURE__ */ new Map();
3287
+ for (const section of this.sections) {
3288
+ (byLogical.get(section.logicalId) ?? byLogical.set(section.logicalId, []).get(section.logicalId)).push(section);
3289
+ }
3290
+ for (const components of byLogical.values()) {
3291
+ if (components.length < 2) continue;
3292
+ const visible = components.filter((component) => component.nameLabel.opacity() > 0.05).sort((left, right) => {
3293
+ const leftBounds = polyBounds(left.outline);
3294
+ const rightBounds = polyBounds(right.outline);
3295
+ return rightBounds.width * rightBounds.height - leftBounds.width * leftBounds.height;
3296
+ });
3297
+ for (const component of visible.slice(1)) component.nameLabel.opacity(0);
3298
+ }
3299
+ }
2744
3300
  /**
2745
- * Greedy label de-collision for the zone/section rungs (same approach as the
2746
- * designer's cullRowLabels): price/"N LEFT" sublabels are lowest priority and
2747
- * drop first; name labels keep top-to-bottom, left-to-right; anything whose
2748
- * on-screen box (+4px gap) overlaps an already-kept box hides. Recomputed on
2749
- * every LOD pass so hidden labels reappear as zoom spreads them apart.
2750
- * Culling multiplies the opacity applySectionLod just assigned (never raises).
3301
+ * Keep transitional zone pills from covering section names. Section names
3302
+ * are already proven inside disjoint shells, so they must not cull each other.
2751
3303
  */
2752
3304
  decollideRungLabels(sx) {
2753
3305
  const GAP = 4;
2754
3306
  const cands = [];
2755
3307
  const boxOf = (t2) => {
2756
3308
  const p = this.worldToScreen({ x: t2.x(), y: t2.y() });
2757
- const w = t2.width() * sx;
2758
- const h = t2.height() * sx;
3309
+ const rotated = pointsBounds(rotatedRectPoints(
3310
+ { x: 0, y: 0 },
3311
+ t2.width() * sx,
3312
+ t2.height() * sx,
3313
+ t2.rotation()
3314
+ ));
3315
+ const w = rotated.width;
3316
+ const h = rotated.height;
2759
3317
  return { x: p.x - w / 2, y: p.y - h / 2, w, h };
2760
3318
  };
2761
3319
  for (const zone of this.zones) {
2762
- if (zone.label.opacity() > 0.05) cands.push({ node: zone.label, tier: 0, box: boxOf(zone.label) });
2763
- if (zone.sub && zone.sub.opacity() > 0.05) cands.push({ node: zone.sub, tier: 2, owner: zone.label, box: boxOf(zone.sub) });
3320
+ if (zone.label.opacity() > 0.05) {
3321
+ const p = this.worldToScreen(zone.anchor);
3322
+ const w = zone.back.width() * sx;
3323
+ const h = zone.back.height() * sx;
3324
+ cands.push({ node: zone.label, tier: 0, section: false, box: { x: p.x - w / 2, y: p.y - h / 2, w, h } });
3325
+ }
2764
3326
  }
2765
3327
  for (const sec of this.sections) {
2766
- if (sec.nameLabel.opacity() > 0.05) cands.push({ node: sec.nameLabel, tier: 1, box: boxOf(sec.nameLabel) });
2767
- if (sec.subLabel.opacity() > 0.05) cands.push({ node: sec.subLabel, tier: 3, owner: sec.nameLabel, box: boxOf(sec.subLabel) });
3328
+ if (sec.nameLabel.opacity() > 0.05) cands.push({ node: sec.nameLabel, tier: 1, section: true, box: boxOf(sec.nameLabel) });
2768
3329
  }
2769
3330
  if (cands.length < 2) return;
2770
3331
  cands.sort((a, b) => a.tier - b.tier || a.box.y - b.box.y || a.box.x - b.box.x);
2771
3332
  const kept = [];
2772
- const culled = /* @__PURE__ */ new Set();
2773
3333
  const collides = (b) => kept.some(
2774
3334
  (k) => b.x < k.x + k.w + GAP && k.x < b.x + b.w + GAP && b.y < k.y + k.h + GAP && k.y < b.y + b.h + GAP
2775
3335
  );
2776
3336
  for (const c of cands) {
2777
- if (c.owner && culled.has(c.owner) || collides(c.box)) {
3337
+ if (collides(c.box)) {
2778
3338
  c.node.opacity(0);
2779
- culled.add(c.node);
2780
- } else {
3339
+ } else if (!c.section) {
2781
3340
  kept.push(c.box);
2782
3341
  }
2783
3342
  }
@@ -2789,6 +3348,54 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2789
3348
  t2.offsetY(t2.height() / 2);
2790
3349
  t2.y(y);
2791
3350
  }
3351
+ /** Fit one centred section name, rotating narrow shells like the target chart. */
3352
+ fitSectionRungLabels(sec, sx) {
3353
+ const paddingPx = 8;
3354
+ sec.subLabelFits = false;
3355
+ const maxPx = SECTION_LABEL_PX * sec.labelScale;
3356
+ const minPx = MIN_SECTION_LABEL_PX * sec.labelScale;
3357
+ for (let fontPx = maxPx; fontPx >= minPx; fontPx -= 1) {
3358
+ for (const rotation of [0, -90]) {
3359
+ sec.nameLabel.rotation(rotation);
3360
+ this.sizeLabel(sec.nameLabel, fontPx / sx, sec.nameLabel.y());
3361
+ for (const anchor of sec.labelAnchors) {
3362
+ sec.nameLabel.position(anchor);
3363
+ const paddingWorld = paddingPx / sx;
3364
+ if (rotatedRectFitsPolygon(
3365
+ anchor,
3366
+ sec.nameLabel.width() + paddingWorld,
3367
+ sec.nameLabel.height() + paddingWorld,
3368
+ rotation,
3369
+ sec.outline,
3370
+ sec.holes
3371
+ )) {
3372
+ sec.nameLabelFits = true;
3373
+ return;
3374
+ }
3375
+ }
3376
+ }
3377
+ }
3378
+ sec.nameLabel.position(sec.centroid);
3379
+ sec.nameLabel.rotation(0);
3380
+ sec.nameLabelFits = false;
3381
+ }
3382
+ /** Size one screen-constant zone name/price pill around its shared anchor. */
3383
+ sizeZonePill(zone, sx) {
3384
+ const padX = 10 / sx;
3385
+ const padY = 6 / sx;
3386
+ const gap = zone.sub ? 3 / sx : 0;
3387
+ this.sizeLabel(zone.label, ZONE_LABEL_PX / sx, zone.anchor.y);
3388
+ if (zone.sub) this.sizeLabel(zone.sub, ZONE_SUB_PX / sx, zone.anchor.y);
3389
+ const width = Math.max(zone.label.width(), zone.sub?.width() ?? 0) + padX * 2;
3390
+ const height = zone.label.height() + (zone.sub ? gap + zone.sub.height() : 0) + padY * 2;
3391
+ zone.label.y(zone.anchor.y - (zone.sub ? (gap + zone.sub.height()) / 2 : 0));
3392
+ if (zone.sub) zone.sub.y(zone.anchor.y + (zone.label.height() + gap) / 2);
3393
+ zone.back.position(zone.anchor);
3394
+ zone.back.size({ width, height });
3395
+ zone.back.offset({ x: width / 2, y: height / 2 });
3396
+ zone.back.cornerRadius(7 / sx);
3397
+ zone.back.strokeWidth(1 / sx);
3398
+ }
2792
3399
  /**
2793
3400
  * Map a container-relative screen point back to world coords. Inverts the
2794
3401
  * stage (scale/pos) and, in iso view, the iso affine — so screen-space taps
@@ -2803,12 +3410,12 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2803
3410
  sectionAt(clientPoint) {
2804
3411
  if (!this.sections.length) return null;
2805
3412
  const world = this.screenToWorld(clientPoint);
2806
- const hit = this.sections.find((sec) => pointInPolygon(world, sec.outline));
2807
- return hit ? hit.id : null;
3413
+ const hit = this.sections.find((sec) => pointInPolygonWithHoles(world, sec.outline, sec.holes));
3414
+ return hit ? hit.logicalId : null;
2808
3415
  }
2809
3416
  /** Seat ids belonging to a section (Slice 5 section-summary card). */
2810
3417
  sectionMembers(id) {
2811
- return this.sections.find((s) => s.id === id)?.memberIds.slice() ?? [];
3418
+ return [...new Set(this.sections.filter((section) => section.id === id || section.logicalId === id || section.zone === id).flatMap((section) => section.memberIds))];
2812
3419
  }
2813
3420
  addCentredLabel(layer, text, x, y, fill, fontSize, bold) {
2814
3421
  const t2 = new Text({
@@ -2825,6 +3432,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2825
3432
  t2.offsetX(t2.width() / 2);
2826
3433
  t2.offsetY(t2.height() / 2);
2827
3434
  layer.add(t2);
3435
+ return t2;
2828
3436
  }
2829
3437
  // ---- selection ------------------------------------------------------------
2830
3438
  isSelectable(id) {
@@ -2858,21 +3466,22 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2858
3466
  * the container's computed CSS background (walking up past transparent
2859
3467
  * ancestors). Unknown/unparseable backgrounds keep the dark default.
2860
3468
  */
2861
- resolveSelectionColor() {
2862
- if (this.theme.selectionColor) return this.theme.selectionColor;
2863
- let bg = this.theme.background ?? "";
2864
- if (!bg && typeof getComputedStyle === "function") {
2865
- let el = this.container;
2866
- while (el) {
2867
- const c = getComputedStyle(el).backgroundColor;
2868
- if (c && c !== "transparent" && !/^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0\s*\)$/.test(c)) {
2869
- bg = c;
2870
- break;
2871
- }
2872
- el = el.parentElement;
3469
+ resolveCanvasBackground() {
3470
+ const themed = this.theme.background ? opaqueColorHex(this.theme.background) : null;
3471
+ if (themed) return themed;
3472
+ if (typeof getComputedStyle === "function") {
3473
+ let element = this.container;
3474
+ while (element) {
3475
+ const resolved = opaqueColorHex(getComputedStyle(element).backgroundColor);
3476
+ if (resolved) return resolved;
3477
+ element = element.parentElement;
2873
3478
  }
2874
3479
  }
2875
- return isLightColor(bg) ? DEF_SELECTION_ON_LIGHT : DEF_SELECTION;
3480
+ return DEF_CANVAS_BACKGROUND;
3481
+ }
3482
+ resolveSelectionColor() {
3483
+ if (this.theme.selectionColor) return this.theme.selectionColor;
3484
+ return isLightColor(this.canvasBackground) ? DEF_SELECTION_ON_LIGHT : DEF_SELECTION;
2876
3485
  }
2877
3486
  setSelected(id, on, silent = false) {
2878
3487
  const c = this.circleById.get(id);
@@ -2898,6 +3507,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2898
3507
  const candidate = this.selectionFocusId === id;
2899
3508
  const dims = this.boothDims.get(seat.rowId);
2900
3509
  const marker = new Group({
3510
+ name: "selection-ring",
2901
3511
  x: seat.x,
2902
3512
  y: seat.y,
2903
3513
  rotation: dims?.rotation ?? 0,
@@ -2905,6 +3515,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2905
3515
  perfectDrawEnabled: false,
2906
3516
  opacity: this.selectionFocusId && !candidate ? 0.2 : 1
2907
3517
  });
3518
+ marker.setAttr("seatId", id);
2908
3519
  const common = {
2909
3520
  stroke: this.effSelection,
2910
3521
  listening: false,
@@ -2981,10 +3592,18 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2981
3592
  * whether the section already fills the viewport (small container) so the tap
2982
3593
  * must fall through and pick.
2983
3594
  */
3595
+ sectionBounds(id) {
3596
+ const bounds = this.sections.filter((section) => section.id === id || section.logicalId === id).map((section) => polyBounds(section.outline));
3597
+ if (!bounds.length) return null;
3598
+ const left = Math.min(...bounds.map((box) => box.x));
3599
+ const top = Math.min(...bounds.map((box) => box.y));
3600
+ const right = Math.max(...bounds.map((box) => box.x + box.width));
3601
+ const bottom = Math.max(...bounds.map((box) => box.y + box.height));
3602
+ return { x: left, y: top, width: right - left, height: bottom - top };
3603
+ }
2984
3604
  sectionFrameScale(id) {
2985
- const sec = this.sections.find((s) => s.id === id);
2986
- if (!sec) return this.stage.scaleX();
2987
- const b = polyBounds(sec.outline);
3605
+ const b = this.sectionBounds(id);
3606
+ if (!b) return this.stage.scaleX();
2988
3607
  const w = this.stage.width();
2989
3608
  const h = this.stage.height();
2990
3609
  const { min, max } = this.zoomBounds();
@@ -3014,11 +3633,11 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3014
3633
  if (this.effScale() < LABEL_SCALE && this.sections.length) {
3015
3634
  const sec = this.seatSection.get(id);
3016
3635
  if (sec) {
3017
- const alreadyFocused = this.focusedSectionId === sec.id;
3018
- const canZoomInFurther = this.sectionFrameScale(sec.id) > this.stage.scaleX() * 1.02;
3636
+ const alreadyFocused = this.focusedSectionId === sec.logicalId;
3637
+ const canZoomInFurther = this.sectionFrameScale(sec.logicalId) > this.stage.scaleX() * 1.02;
3019
3638
  if (!alreadyFocused && canZoomInFurther) {
3020
- if (this.opts.onSectionTap) this.opts.onSectionTap(sec.id);
3021
- else this.focusSection(sec.id);
3639
+ if (this.opts.onSectionTap) this.opts.onSectionTap(sec.logicalId);
3640
+ else this.focusSection(sec.logicalId);
3022
3641
  return;
3023
3642
  }
3024
3643
  }
@@ -3097,10 +3716,10 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3097
3716
  }
3098
3717
  if (this.sections.length) {
3099
3718
  const world = this.screenToWorld(pointer);
3100
- const hit = this.sections.find((sn) => pointInPolygon(world, sn.outline));
3719
+ const hit = this.sections.find((sn) => pointInPolygonWithHoles(world, sn.outline, sn.holes));
3101
3720
  if (hit) {
3102
- if (this.opts.onSectionTap) this.opts.onSectionTap(hit.id);
3103
- else this.focusRegion(hit.id);
3721
+ if (this.opts.onSectionTap) this.opts.onSectionTap(hit.logicalId);
3722
+ else this.focusRegion(hit.logicalId);
3104
3723
  return;
3105
3724
  }
3106
3725
  }
@@ -3186,10 +3805,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3186
3805
  * newer glide cancels an in-flight one.
3187
3806
  */
3188
3807
  focusRegion(target, opts) {
3189
- const b = typeof target === "string" ? (() => {
3190
- const sec = this.sections.find((s) => s.id === target);
3191
- return sec ? polyBounds(sec.outline) : null;
3192
- })() : target;
3808
+ const b = typeof target === "string" ? this.sectionBounds(target) : target;
3193
3809
  if (!b) return;
3194
3810
  this.cancelGlide();
3195
3811
  if (opts?.animate === false || this.reducedMotion) {
@@ -3249,25 +3865,343 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3249
3865
  if (this.zones.length && scale < ZONE_PROMINENT_SCALE) return "zones";
3250
3866
  return "sections";
3251
3867
  }
3252
- /** Jump the camera to a rung's zoom band, centred on the chart (glided). */
3868
+ getRenderedQualityEvidence() {
3869
+ const effectiveScale = this.effScale();
3870
+ const stageScale = this.stage.scaleX();
3871
+ const viewport = { width: this.stage.width(), height: this.stage.height() };
3872
+ const rounded = (value) => Math.round(value * 100) / 100;
3873
+ const labels = this.seats.map((seat) => {
3874
+ const shape = this.circleById.get(seat.id);
3875
+ const label = this.boothLabelById.get(seat.id) ?? this.seatLabelById.get(seat.id);
3876
+ const authoredFontSize = seat.kind === "booth" ? BOOTH_LABEL_FONT_SIZE : SEAT_LABEL_FONT_SIZE;
3877
+ const renderedFontPx = rounded((label?.fontSize() ?? authoredFontSize) * effectiveScale);
3878
+ const screen = this.worldToScreen(seat);
3879
+ const outside = screen.x < 0 || screen.x > viewport.width || screen.y < 0 || screen.y > viewport.height;
3880
+ const opacity = shape?.opacity() ?? 0;
3881
+ const section = this.seatSection.get(seat.id);
3882
+ const visible = Boolean(label?.isVisible()) && opacity >= 0.5 && !outside;
3883
+ let hiddenReason;
3884
+ if (!visible) {
3885
+ if (opacity < 0.5) hiddenReason = "dimmed-or-unavailable";
3886
+ else if (renderedFontPx < MIN_VISIBLE_BOOKABLE_LABEL_PX) hiddenReason = "below-minimum-size";
3887
+ else if (outside) hiddenReason = "outside-viewport";
3888
+ else if (!label) hiddenReason = "clutter-or-fit";
3889
+ else hiddenReason = "renderer-hidden";
3890
+ }
3891
+ const labelWidth = label ? label.width() * stageScale : 0;
3892
+ const labelHeight = label ? label.height() * effectiveScale : 0;
3893
+ const directWidthPx = shape instanceof Rect ? shape.width() * stageScale : this.seatR * 2 * effectiveScale;
3894
+ const directHeightPx = shape instanceof Rect ? shape.height() * stageScale : this.seatR * 2 * effectiveScale;
3895
+ const assistedDiameterPx = 2 * (this.seatR * effectiveScale + SEAT_TAP_SLOP_PX);
3896
+ const fill = shape?.fill();
3897
+ const ink = label?.fill();
3898
+ return {
3899
+ seatId: seat.id,
3900
+ label: seat.label,
3901
+ kind: seat.kind === "booth" ? "booth" : "seat",
3902
+ categoryKey: seat.categoryKey,
3903
+ ...section ? { sectionId: section.id } : {},
3904
+ ...section?.zone ? { zoneId: section.zone } : {},
3905
+ status: this.statusById.get(seat.id) ?? "free",
3906
+ selected: this.selection.has(seat.id),
3907
+ visible,
3908
+ renderedFontPx,
3909
+ fill: typeof fill === "string" ? fill : "",
3910
+ ink: typeof ink === "string" ? ink : this.theme.seatLabelColor ?? DEF_SEAT_LABEL,
3911
+ opacity: rounded(opacity),
3912
+ pointerTarget: {
3913
+ active: !this.cached && this.isSelectable(seat.id),
3914
+ directWidthPx: rounded(directWidthPx),
3915
+ directHeightPx: rounded(directHeightPx),
3916
+ effectiveMinimumPx: rounded(Math.max(
3917
+ Math.min(directWidthPx, directHeightPx),
3918
+ assistedDiameterPx
3919
+ ))
3920
+ },
3921
+ screenCenter: { x: rounded(screen.x), y: rounded(screen.y) },
3922
+ ...visible ? {
3923
+ screenBox: {
3924
+ x: rounded(screen.x - labelWidth / 2),
3925
+ y: rounded(screen.y - labelHeight / 2),
3926
+ width: rounded(labelWidth),
3927
+ height: rounded(labelHeight)
3928
+ }
3929
+ } : {},
3930
+ ...hiddenReason ? { hiddenReason } : {}
3931
+ };
3932
+ });
3933
+ const visibleLabels = labels.filter((label) => label.visible).length;
3934
+ const hierarchyEvidence = (id, kind, role, node, backgroundFill, section) => {
3935
+ const worldCorners = rotatedRectPoints(
3936
+ { x: node.x(), y: node.y() },
3937
+ node.width(),
3938
+ node.height(),
3939
+ node.rotation()
3940
+ );
3941
+ const screenBounds = pointsBounds(worldCorners.map((corner) => this.worldToScreen(corner)));
3942
+ const opacity = rounded(node.opacity());
3943
+ const ink = node.fill();
3944
+ const outside = screenBounds.x + screenBounds.width < 0 || screenBounds.x > viewport.width || screenBounds.y + screenBounds.height < 0 || screenBounds.y > viewport.height;
3945
+ const visible = node.isVisible() && opacity > 0.05 && !outside;
3946
+ const fitsContainer = section ? rotatedRectFitsPolygon(
3947
+ { x: node.x(), y: node.y() },
3948
+ node.width(),
3949
+ node.height(),
3950
+ node.rotation(),
3951
+ section.outline,
3952
+ section.holes
3953
+ ) : void 0;
3954
+ return {
3955
+ id,
3956
+ kind,
3957
+ role,
3958
+ label: node.text(),
3959
+ visible,
3960
+ renderedFontPx: rounded(node.fontSize() * stageScale),
3961
+ opacity,
3962
+ fill: backgroundFill,
3963
+ ink: typeof ink === "string" ? ink : "",
3964
+ ...fitsContainer == null ? {} : { fitsContainer },
3965
+ ...visible ? {
3966
+ screenBox: {
3967
+ x: rounded(screenBounds.x),
3968
+ y: rounded(screenBounds.y),
3969
+ width: rounded(screenBounds.width),
3970
+ height: rounded(screenBounds.height)
3971
+ }
3972
+ } : {}
3973
+ };
3974
+ };
3975
+ const hierarchyLabels = [
3976
+ ...this.sections.map((section) => {
3977
+ const fill = section.blockPoly.fill();
3978
+ return hierarchyEvidence(
3979
+ section.id,
3980
+ "section",
3981
+ "name",
3982
+ section.nameLabel,
3983
+ typeof fill === "string" ? fill : section.baseFill,
3984
+ section
3985
+ );
3986
+ }),
3987
+ ...this.sections.map((section) => {
3988
+ const fill = section.blockPoly.fill();
3989
+ return hierarchyEvidence(
3990
+ `${section.id}:availability`,
3991
+ "section",
3992
+ "availability",
3993
+ section.subLabel,
3994
+ typeof fill === "string" ? fill : section.baseFill,
3995
+ section
3996
+ );
3997
+ }),
3998
+ ...this.zones.flatMap((zone) => [
3999
+ hierarchyEvidence(zone.id, "zone", "name", zone.label, zone.background),
4000
+ ...zone.sub ? [hierarchyEvidence(`${zone.id}:price`, "zone", "price", zone.sub, zone.background)] : []
4001
+ ])
4002
+ ];
4003
+ const gaAreas = [...this.gaById].map(([areaId, ga]) => {
4004
+ const screenPoints = ga.points.map((point) => this.worldToScreen(point));
4005
+ const left = Math.min(...screenPoints.map((point) => point.x));
4006
+ const top = Math.min(...screenPoints.map((point) => point.y));
4007
+ const right = Math.max(...screenPoints.map((point) => point.x));
4008
+ const bottom = Math.max(...screenPoints.map((point) => point.y));
4009
+ const outside = right < 0 || left > viewport.width || bottom < 0 || top > viewport.height;
4010
+ const opacity = rounded(ga.polygon.opacity());
4011
+ const visible = opacity >= 0.1 && !outside;
4012
+ const fill = ga.polygon.fill();
4013
+ return {
4014
+ areaId,
4015
+ label: ga.label,
4016
+ capacity: ga.capacity,
4017
+ categoryKey: ga.categoryKey,
4018
+ ...ga.sectionId ? { sectionId: ga.sectionId } : {},
4019
+ visible,
4020
+ interactive: ga.polygon.listening(),
4021
+ opacity,
4022
+ fill: typeof fill === "string" ? fill : "",
4023
+ effectiveBackground: ga.effectiveBackground,
4024
+ ...visible ? {
4025
+ screenBox: {
4026
+ x: rounded(left),
4027
+ y: rounded(top),
4028
+ width: rounded(right - left),
4029
+ height: rounded(bottom - top)
4030
+ }
4031
+ } : {}
4032
+ };
4033
+ });
4034
+ const freeTextLabels = [...this.freeTextById].map(([recordKey, record]) => {
4035
+ const { node, background, kind } = record;
4036
+ const point = this.worldToScreen({ x: node.x(), y: node.y() });
4037
+ const width = node.width() * stageScale;
4038
+ const height = node.height() * effectiveScale;
4039
+ const left = point.x - node.offsetX() * stageScale;
4040
+ const top = point.y - node.offsetY() * effectiveScale;
4041
+ const renderedFontPx = rounded(node.fontSize() * effectiveScale);
4042
+ const outside = left + width < 0 || left > viewport.width || top + height < 0 || top > viewport.height;
4043
+ const visible = node.isVisible() && !outside;
4044
+ const ink = node.fill();
4045
+ const opacity = rounded(node.getAbsoluteOpacity());
4046
+ let hiddenReason;
4047
+ if (!visible) {
4048
+ if (renderedFontPx < MIN_VISIBLE_BOOKABLE_LABEL_PX) hiddenReason = "below-minimum-size";
4049
+ else if (outside) hiddenReason = "outside-viewport";
4050
+ else hiddenReason = "renderer-hidden";
4051
+ }
4052
+ return {
4053
+ objectId: record.objectId ?? recordKey,
4054
+ kind,
4055
+ text: node.text(),
4056
+ visible,
4057
+ renderedFontPx,
4058
+ ink: typeof ink === "string" ? ink : "",
4059
+ background,
4060
+ opacity,
4061
+ ...visible ? {
4062
+ screenBox: {
4063
+ x: rounded(left),
4064
+ y: rounded(top),
4065
+ width: rounded(width),
4066
+ height: rounded(height)
4067
+ }
4068
+ } : {},
4069
+ ...hiddenReason ? { hiddenReason } : {}
4070
+ };
4071
+ });
4072
+ const palette = overviewPalette(this.canvasBackground);
4073
+ const neutralSectionFills = /* @__PURE__ */ new Set([
4074
+ palette.sectionFill.toLowerCase(),
4075
+ darken(palette.sectionFill, 0.12).toLowerCase()
4076
+ ]);
4077
+ const visibleSectionShells = this.sections.filter((section) => section.blockPoly.opacity() > 0.05);
4078
+ return {
4079
+ viewport,
4080
+ canvasBackground: this.canvasBackground,
4081
+ effectiveScale: rounded(effectiveScale),
4082
+ rung: this.getRung(),
4083
+ minimumVisibleLabelPx: MIN_VISIBLE_BOOKABLE_LABEL_PX,
4084
+ totalLabelledBookableUnits: labels.length,
4085
+ visibleLabels,
4086
+ hiddenLabels: labels.length - visibleLabels,
4087
+ totalBookableUnits: labels.length + gaAreas.reduce((sum, area) => sum + area.capacity, 0),
4088
+ selectionRingSeatIds: this.overlayLayer.find(".selection-ring").map((node) => String(node.getAttr("seatId") ?? "")).filter(Boolean),
4089
+ selectionRingColor: this.effSelection,
4090
+ focusedSectionId: this.focusedSectionId,
4091
+ focusBackdropVisible: Boolean(this.focusBackdrop?.isVisible()),
4092
+ categoryFilterKeys: this.categoryFilter ? [...this.categoryFilter].sort() : null,
4093
+ overviewStyle: {
4094
+ visibleSectionShells: visibleSectionShells.length,
4095
+ categoryPaintedSectionShells: visibleSectionShells.filter((section) => {
4096
+ const fill = section.blockPoly.fill();
4097
+ return typeof fill !== "string" || !neutralSectionFills.has(fill.toLowerCase());
4098
+ }).length,
4099
+ visibleCategoryDetailOutlines: this.sections.filter((section) => section.outlinePoly.opacity() > 0.05).length,
4100
+ // Row-hint nodes no longer exist in the production overview scene.
4101
+ visibleSectionRowHints: 0,
4102
+ visibleSectionAvailabilityLabels: this.sections.filter((section) => section.subLabel.opacity() > 0.05).length,
4103
+ visibleSectionGADetails: [...this.gaById.values()].filter((area) => area.sectionId != null && area.polygon.opacity() > 0.05).length
4104
+ },
4105
+ labels,
4106
+ gaAreas,
4107
+ hierarchyLabels,
4108
+ freeTextLabels
4109
+ };
4110
+ }
4111
+ /** Jump the camera to a rung's zoom band (glided). */
3253
4112
  setRung(rung) {
3254
4113
  if (rung === "zones") {
3255
4114
  this.cancelGlide();
3256
4115
  this.zoomToFit();
3257
4116
  return;
3258
4117
  }
3259
- const target = rung === "sections" ? (SECTION_PROMINENT_SCALE + CACHE_THRESHOLD) / 2 : Math.max(SEAT_FOCUS_SCALE, CACHE_THRESHOLD * 1.3);
4118
+ const target = rung === "sections" ? (SECTION_PROMINENT_SCALE + CACHE_THRESHOLD) / 2 : Math.max(
4119
+ this.seatLabelTargetScale() * 1.05,
4120
+ SEAT_FOCUS_SCALE,
4121
+ CACHE_THRESHOLD * 1.3
4122
+ );
3260
4123
  const w = this.stage.width();
3261
4124
  const h = this.stage.height();
3262
- const cx = this.bounds.x + this.bounds.width / 2;
3263
- const cy = this.bounds.y + this.bounds.height / 2;
4125
+ const visible = this.getVisibleWorldRect();
4126
+ const viewCentre = {
4127
+ x: visible.x + visible.width / 2,
4128
+ y: visible.y + visible.height / 2
4129
+ };
4130
+ let cx = viewCentre.x;
4131
+ let cy = viewCentre.y;
4132
+ if (rung === "sections" && this.sections.length > 0) {
4133
+ const sectionCentres = this.sections.map((section) => {
4134
+ const bounds = polyBounds(section.outline);
4135
+ return {
4136
+ x: bounds.x + bounds.width / 2,
4137
+ y: bounds.y + bounds.height / 2
4138
+ };
4139
+ });
4140
+ const halfWidth = w / (target * 2);
4141
+ const halfHeight = h / (target * 2);
4142
+ const hierarchyWillBeVisible = sectionCentres.some((point) => Math.abs(point.x - viewCentre.x) <= halfWidth && Math.abs(point.y - viewCentre.y) <= halfHeight);
4143
+ if (!hierarchyWillBeVisible) {
4144
+ const nearest = sectionCentres.reduce((best, point) => {
4145
+ const distance = (point.x - viewCentre.x) ** 2 + (point.y - viewCentre.y) ** 2;
4146
+ return distance < best.distance ? { point, distance } : best;
4147
+ }, { point: sectionCentres[0], distance: Infinity });
4148
+ cx = nearest.point.x;
4149
+ cy = nearest.point.y;
4150
+ }
4151
+ }
4152
+ const seatAnchors = rung === "seats" ? this.seats.filter((seat) => seat.kind !== "booth") : [];
4153
+ if (seatAnchors.length > 0) {
4154
+ let nearest = seatAnchors[0];
4155
+ let nearestDistance = Infinity;
4156
+ for (const seat of seatAnchors) {
4157
+ const dx = seat.x - viewCentre.x;
4158
+ const dy = seat.y - viewCentre.y;
4159
+ const distance = dx * dx + dy * dy;
4160
+ if (distance < nearestDistance) {
4161
+ nearest = seat;
4162
+ nearestDistance = distance;
4163
+ }
4164
+ }
4165
+ cx = nearest.x;
4166
+ cy = nearest.y;
4167
+ }
3264
4168
  const bw = w / (target * 1.12);
3265
4169
  const bh = h / (target * 1.12);
3266
4170
  this.focusRegion({ x: cx - bw / 2, y: cy - bh / 2, width: bw, height: bh });
3267
4171
  }
4172
+ /**
4173
+ * The seat rung must account for labels that auto-fit inside a seat circle.
4174
+ * A short `A-1` remains at the normal 7u target; a table label such as
4175
+ * `T13-10` may fit at 4u and therefore needs a deeper camera target to reach
4176
+ * the same 12 CSS-pixel floor. Measurement happens only on explicit rung
4177
+ * navigation, never during pan/zoom frames.
4178
+ */
4179
+ seatLabelTargetScale() {
4180
+ let minimumFont = BOOTH_LABEL_FONT_SIZE;
4181
+ const measure = new Text({
4182
+ fontSize: SEAT_LABEL_FONT_SIZE,
4183
+ fontStyle: "600",
4184
+ fontFamily: this.labelFont(),
4185
+ listening: false
4186
+ });
4187
+ const maxWidth = this.seatR * 2 - 3;
4188
+ for (const seat of this.seats) {
4189
+ if (seat.kind === "booth") {
4190
+ minimumFont = Math.min(minimumFont, BOOTH_LABEL_FONT_SIZE);
4191
+ continue;
4192
+ }
4193
+ measure.fontSize(SEAT_LABEL_FONT_SIZE);
4194
+ measure.text(bookableMarkerLabel(seat.displayLabel ?? seat.label));
4195
+ const fitted = measure.width() > maxWidth ? Math.max(MIN_FITTED_SEAT_LABEL_FONT_SIZE, SEAT_LABEL_FONT_SIZE * maxWidth / measure.width()) : SEAT_LABEL_FONT_SIZE;
4196
+ minimumFont = Math.min(minimumFont, fitted);
4197
+ }
4198
+ measure.destroy();
4199
+ return MIN_VISIBLE_BOOKABLE_LABEL_PX / Math.max(MIN_FITTED_SEAT_LABEL_FONT_SIZE, minimumFont);
4200
+ }
3268
4201
  /** Recompute LOD (cache/labels) after any pan/zoom settles. */
3269
4202
  afterViewChange() {
3270
4203
  this.updateLOD();
4204
+ this.updateFreeTextVisibility();
3271
4205
  this.updateLabels();
3272
4206
  this.scheduleViewChange();
3273
4207
  }
@@ -3281,7 +4215,13 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3281
4215
  }
3282
4216
  updateLOD() {
3283
4217
  const scale = this.effScale();
4218
+ const focalScale = Math.max(scale, 1e-4);
4219
+ for (const [label, targetPx] of this.primaryFocalLabels) {
4220
+ this.sizeLabel(label, targetPx / focalScale, label.y());
4221
+ }
3284
4222
  if (this.hasSections) this.applySectionLod(scale);
4223
+ else if (this.primaryFocalLabels.size) this.bgLayer.batchDraw();
4224
+ this.paintGAStateForView();
3285
4225
  const shouldCache = scale < CACHE_THRESHOLD;
3286
4226
  if (shouldCache && !this.cached) {
3287
4227
  this.cacheSeatLayer();
@@ -3320,15 +4260,37 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3320
4260
  if (this.recacheTimer) {
3321
4261
  clearTimeout(this.recacheTimer);
3322
4262
  this.recacheTimer = null;
3323
- this.rebuildSeatCache();
4263
+ if (this.effScale() < CACHE_THRESHOLD) {
4264
+ this.rebuildSeatCache();
4265
+ } else if (this.cached) {
4266
+ this.seatLayer.clearCache();
4267
+ this.seatLayer.listening(true);
4268
+ this.cached = false;
4269
+ }
3324
4270
  }
3325
4271
  this.bgLayer.draw();
3326
4272
  this.seatLayer.draw();
3327
4273
  this.overlayLayer.draw();
3328
4274
  }
4275
+ updateFreeTextVisibility() {
4276
+ const effectiveScale = this.effScale();
4277
+ for (const { objectId, node, categoryKey, kind } of this.freeTextById.values()) {
4278
+ const gaDimmed = categoryKey != null && (kind === "ga-label" || kind === "ga-capacity") && this.gaCategoryDimmed(categoryKey);
4279
+ const gaOverviewHidden = objectId != null && (kind === "ga-label" || kind === "ga-capacity") && this.gaById.get(objectId)?.sectionId != null && effectiveScale < CACHE_THRESHOLD;
4280
+ node.visible(!gaDimmed && !gaOverviewHidden && isBookableLabelLegibleAtScale(node.fontSize(), effectiveScale));
4281
+ }
4282
+ }
3329
4283
  updateLabels() {
3330
- const show = this.effScale() > LABEL_SCALE;
4284
+ const effectiveScale = this.effScale();
4285
+ const show = effectiveScale >= LABEL_SCALE;
4286
+ for (const [id, label] of this.boothLabelById) {
4287
+ const shape = this.circleById.get(id);
4288
+ label.visible(
4289
+ isBookableLabelLegibleAtScale(label.fontSize(), effectiveScale) && (shape?.opacity() ?? 1) >= 0.5
4290
+ );
4291
+ }
3331
4292
  this.labelGroup.destroyChildren();
4293
+ this.seatLabelById.clear();
3332
4294
  if (!show) {
3333
4295
  this.overlayLayer.batchDraw();
3334
4296
  return;
@@ -3342,8 +4304,10 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3342
4304
  for (const seat of this.seats) {
3343
4305
  if (seat.kind === "booth") continue;
3344
4306
  if (seat.x < x0 || seat.x > x1 || seat.y < y0 || seat.y > y1) continue;
4307
+ const shape = this.circleById.get(seat.id);
4308
+ if ((shape?.opacity() ?? 1) < 0.5) continue;
3345
4309
  const status = this.statusById.get(seat.id) ?? "free";
3346
- const unavailable = status === "booked" || status === "held" && !this.ownedHold.has(seat.id);
4310
+ const unavailable = status === "booked" || status === "held" && !this.ownedHold.has(seat.id) && !this.opts.manageMode;
3347
4311
  if (unavailable) {
3348
4312
  const cue = new Group({ x: seat.x, y: seat.y, listening: false });
3349
4313
  if (status === "held") {
@@ -3381,23 +4345,28 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3381
4345
  const t2 = new Text({
3382
4346
  x: seat.x,
3383
4347
  y: seat.y,
3384
- text: seat.label,
3385
- fontSize: 7,
4348
+ text: bookableMarkerLabel(seat.displayLabel ?? seat.label),
4349
+ fontSize: SEAT_LABEL_FONT_SIZE,
3386
4350
  fontStyle: "600",
3387
4351
  fontFamily: this.labelFont(),
3388
- fill: this.theme.seatLabelColor ?? DEF_SEAT_LABEL,
4352
+ fill: shape ? this.renderedBookableLabelInk(shape) : this.theme.seatLabelColor ?? DEF_SEAT_LABEL,
3389
4353
  listening: false,
3390
4354
  perfectDrawEnabled: false
3391
4355
  });
3392
4356
  const maxW = this.seatR * 2 - 3;
3393
- if (t2.width() > maxW) t2.fontSize(Math.max(4, t2.fontSize() * maxW / t2.width()));
3394
- if (t2.fontSize() < 4.2) {
4357
+ if (t2.width() > maxW) t2.fontSize(Math.max(MIN_FITTED_SEAT_LABEL_FONT_SIZE, SEAT_LABEL_FONT_SIZE * maxW / t2.width()));
4358
+ if (t2.width() > maxW + 0.01) {
4359
+ t2.destroy();
4360
+ continue;
4361
+ }
4362
+ if (!isBookableLabelLegibleAtScale(t2.fontSize(), effectiveScale)) {
3395
4363
  t2.destroy();
3396
4364
  continue;
3397
4365
  }
3398
4366
  t2.offsetX(t2.width() / 2);
3399
4367
  t2.offsetY(t2.height() / 2);
3400
4368
  this.labelGroup.add(t2);
4369
+ this.seatLabelById.set(seat.id, t2);
3401
4370
  if (++count >= MAX_LABELS) break;
3402
4371
  }
3403
4372
  if (this.isoT > 0) this.applyUprightLabels();
@@ -4649,9 +5618,9 @@ function generateSeatThumb(seat, focalPoint, _neighborSeats) {
4649
5618
 
4650
5619
  // src/i18n/bundles.ts
4651
5620
  var LOADERS = {
4652
- es: () => import("./es-EWHL5CZR.js").then((m) => ({ default: m.es })),
4653
- de: () => import("./de-PQ5BE3PL.js").then((m) => ({ default: m.de })),
4654
- fr: () => import("./fr-FIEG227H.js").then((m) => ({ default: m.fr }))
5621
+ es: () => import("./es-NLD3NL3W.js").then((m) => ({ default: m.es })),
5622
+ de: () => import("./de-ZTKJOFHT.js").then((m) => ({ default: m.de })),
5623
+ fr: () => import("./fr-KSFKWSAT.js").then((m) => ({ default: m.fr }))
4655
5624
  };
4656
5625
  var loaded = /* @__PURE__ */ new Set(["en"]);
4657
5626
  async function loadLocale(code) {
@@ -4671,9 +5640,12 @@ async function loadLocale(code) {
4671
5640
  }
4672
5641
  }
4673
5642
  export {
5643
+ ACCESSIBILITY_RING_COLOR,
4674
5644
  ACCESSIBILITY_TYPES,
4675
5645
  CHART_STORAGE_KEY,
4676
5646
  DEFAULT_CURRENCY,
5647
+ LABEL_STYLE_MAX_SIZE,
5648
+ LABEL_STYLE_MIN_SIZE,
4677
5649
  MAX_EVENT_INVENTORY,
4678
5650
  MAX_GA_CAPACITY,
4679
5651
  PickerController,
@@ -4681,6 +5653,7 @@ export {
4681
5653
  SeatmapRenderer,
4682
5654
  UNGROUPED_ID,
4683
5655
  accessibilityMeta,
5656
+ accessibilityRingColor,
4684
5657
  allObjects,
4685
5658
  applyHidden,
4686
5659
  chartBounds,
@@ -4709,6 +5682,8 @@ export {
4709
5682
  loadLocale,
4710
5683
  objectCenter,
4711
5684
  pointInPolygon,
5685
+ pointInPolygonWithHoles,
5686
+ polygonLabelPoint,
4712
5687
  resolveLocale,
4713
5688
  setLocale,
4714
5689
  setMoneyLocale,