@seatlayer/core 0.16.1 → 0.17.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
@@ -31,6 +31,71 @@ function layerOf(obj) {
31
31
  }
32
32
  var CHART_STORAGE_KEY = "seatmap.chart";
33
33
 
34
+ // src/core/complexGeometry.ts
35
+ function cubicPoint(path, t2) {
36
+ const u = 1 - t2;
37
+ const a = u * u * u;
38
+ const b = 3 * u * u * t2;
39
+ const c = 3 * u * t2 * t2;
40
+ const d = t2 * t2 * t2;
41
+ return {
42
+ x: a * path.start.x + b * path.control1.x + c * path.control2.x + d * path.end.x,
43
+ y: a * path.start.y + b * path.control1.y + c * path.control2.y + d * path.end.y
44
+ };
45
+ }
46
+ function distributeAlongCubic(path, count, resolution = 192) {
47
+ if (!Number.isInteger(count) || count < 1) throw new Error("Path point count must be a positive integer");
48
+ if (count === 1) return [cubicPoint(path, 0.5)];
49
+ const samples = Array.from({ length: resolution + 1 }, (_, index) => cubicPoint(path, index / resolution));
50
+ const lengths = new Float64Array(samples.length);
51
+ for (let index = 1; index < samples.length; index += 1) {
52
+ const dx = samples[index].x - samples[index - 1].x;
53
+ const dy = samples[index].y - samples[index - 1].y;
54
+ lengths[index] = lengths[index - 1] + Math.hypot(dx, dy);
55
+ }
56
+ const total = lengths[lengths.length - 1];
57
+ if (total <= 1e-9) return Array.from({ length: count }, () => ({ ...path.start }));
58
+ const output = [];
59
+ let segment = 1;
60
+ for (let index = 0; index < count; index += 1) {
61
+ const target = total * index / (count - 1);
62
+ while (segment < lengths.length - 1 && lengths[segment] < target) segment += 1;
63
+ const before = lengths[segment - 1];
64
+ const after = lengths[segment];
65
+ const ratio = after === before ? 0 : (target - before) / (after - before);
66
+ output.push({
67
+ x: samples[segment - 1].x + (samples[segment].x - samples[segment - 1].x) * ratio,
68
+ y: samples[segment - 1].y + (samples[segment].y - samples[segment - 1].y) * ratio
69
+ });
70
+ }
71
+ return output;
72
+ }
73
+
74
+ // src/core/sectionPath.ts
75
+ var TAU = Math.PI * 2;
76
+ function translateSectionOutlinePath(path, dx, dy) {
77
+ const translate = (point) => ({ x: point.x + dx, y: point.y + dy });
78
+ return transformSectionOutlinePath(path, translate);
79
+ }
80
+ function transformSectionOutlinePath(path, transform, radiusScale = 1, reflected = false) {
81
+ return {
82
+ ...path,
83
+ start: transform(path.start),
84
+ segments: path.segments.map((segment) => segment.kind === "line" ? { ...segment, end: transform(segment.end) } : segment.kind === "arc" ? {
85
+ ...segment,
86
+ center: transform(segment.center),
87
+ radius: segment.radius * Math.abs(radiusScale),
88
+ clockwise: reflected ? !segment.clockwise : segment.clockwise,
89
+ end: transform(segment.end)
90
+ } : {
91
+ ...segment,
92
+ control1: transform(segment.control1),
93
+ control2: transform(segment.control2),
94
+ end: transform(segment.end)
95
+ })
96
+ };
97
+ }
98
+
34
99
  // src/core/layout.ts
35
100
  function overrideAccessibility(o) {
36
101
  if (!o) return [];
@@ -52,6 +117,7 @@ function place(lx, ly, deg, origin) {
52
117
  function rowSeatPositions(row) {
53
118
  const { seatCount, seatSpacing, curve, rotation, origin } = row;
54
119
  const out = [];
120
+ if (row.path) return distributeAlongCubic(row.path, seatCount);
55
121
  if (seatCount <= 1) {
56
122
  if (seatCount === 1) out.push({ x: origin.x, y: origin.y });
57
123
  return out;
@@ -214,6 +280,53 @@ function pointInPolygon(p, poly) {
214
280
  }
215
281
  return inside;
216
282
  }
283
+ function pointOnPolygonBoundary(p, poly) {
284
+ return poly.some((start, index) => {
285
+ const end = poly[(index + 1) % poly.length];
286
+ const cross = (p.y - start.y) * (end.x - start.x) - (p.x - start.x) * (end.y - start.y);
287
+ if (Math.abs(cross) > 1e-7) return false;
288
+ const dot = (p.x - start.x) * (end.x - start.x) + (p.y - start.y) * (end.y - start.y);
289
+ const lengthSquared = (end.x - start.x) ** 2 + (end.y - start.y) ** 2;
290
+ return dot >= -1e-7 && dot <= lengthSquared + 1e-7;
291
+ });
292
+ }
293
+ function pointInPolygonWithHoles(p, outer, holes) {
294
+ return pointInPolygon(p, outer) && !(holes ?? []).some((hole) => pointInPolygon(p, hole) || pointOnPolygonBoundary(p, hole));
295
+ }
296
+ function polygonLabelPoint(outer, holes) {
297
+ if (!outer.length) return { x: 0, y: 0 };
298
+ const xs = outer.map((point) => point.x);
299
+ const ys = outer.map((point) => point.y);
300
+ const bounds = { minX: Math.min(...xs), maxX: Math.max(...xs), minY: Math.min(...ys), maxY: Math.max(...ys) };
301
+ const centroid = polygonCentroid(outer);
302
+ if (pointInPolygonWithHoles(centroid, outer, holes)) return centroid;
303
+ let best = outer[0];
304
+ let bestScore = -Infinity;
305
+ const rings = [outer, ...holes ?? []];
306
+ for (let row = 1; row < 24; row += 1) {
307
+ for (let column = 1; column < 24; column += 1) {
308
+ const point = {
309
+ x: bounds.minX + (bounds.maxX - bounds.minX) * column / 24,
310
+ y: bounds.minY + (bounds.maxY - bounds.minY) * row / 24
311
+ };
312
+ if (!pointInPolygonWithHoles(point, outer, holes)) continue;
313
+ const score = Math.min(...rings.flatMap((ring) => ring.map((start, index) => {
314
+ const end = ring[(index + 1) % ring.length];
315
+ const dx = end.x - start.x;
316
+ const dy = end.y - start.y;
317
+ const denominator = dx * dx + dy * dy;
318
+ const projection = denominator ? ((point.x - start.x) * dx + (point.y - start.y) * dy) / denominator : 0;
319
+ const t2 = Math.max(0, Math.min(1, projection));
320
+ return Math.hypot(point.x - (start.x + t2 * dx), point.y - (start.y + t2 * dy));
321
+ })));
322
+ if (score > bestScore) {
323
+ best = point;
324
+ bestScore = score;
325
+ }
326
+ }
327
+ }
328
+ return best;
329
+ }
217
330
  function polygonCentroid(pts) {
218
331
  if (!pts.length) return { x: 0, y: 0 };
219
332
  let x = 0;
@@ -277,9 +390,14 @@ function translateObject(o, dx, dy) {
277
390
  case "booth":
278
391
  return { ...o, center: p(o.center) };
279
392
  case "gaArea":
280
- return { ...o, points: pts(o.points) };
393
+ return { ...o, points: pts(o.points), ...o.holes ? { holes: o.holes.map(pts) } : {} };
281
394
  case "section":
282
- return { ...o, outline: pts(o.outline) };
395
+ return {
396
+ ...o,
397
+ outline: pts(o.outline),
398
+ ...o.outlinePath ? { outlinePath: translateSectionOutlinePath(o.outlinePath, dx, dy) } : {},
399
+ ...o.holes ? { holes: o.holes.map(pts) } : {}
400
+ };
283
401
  case "text":
284
402
  return { ...o, position: p(o.position) };
285
403
  case "shape":
@@ -402,12 +520,33 @@ function objectSeatLabels(o) {
402
520
  function isSeatObject(o) {
403
521
  return o.type === "row" || o.type === "table" || o.type === "booth" || o.type === "gaArea";
404
522
  }
523
+ function samePoints(left, right) {
524
+ return left.length === right.length && left.every((point, index) => point.x === right[index].x && point.y === right[index].y);
525
+ }
526
+ function sameGASurfaceAsSection(object, section) {
527
+ if (object.type !== "gaArea" || !samePoints(object.points, section.outline)) return false;
528
+ const objectHoles = object.holes ?? [];
529
+ const sectionHoles = section.holes ?? [];
530
+ return objectHoles.length === sectionHoles.length && objectHoles.every((hole, index) => samePoints(hole, sectionHoles[index]));
531
+ }
405
532
  function computeSections(doc) {
406
533
  const objs = allObjects(doc);
407
534
  const sectionObjs = objs.filter((o) => o.type === "section");
408
535
  const nodes = /* @__PURE__ */ new Map();
409
536
  for (const s of sectionObjs) {
410
- nodes.set(s.id, { id: s.id, label: s.label || "Section", zone: s.zone, seatCount: 0, objectIds: [], seatLabels: [] });
537
+ const logicalId = s.logicalSectionId ?? s.id;
538
+ const existing = nodes.get(logicalId);
539
+ if (existing) {
540
+ continue;
541
+ }
542
+ nodes.set(logicalId, {
543
+ id: logicalId,
544
+ label: s.label || "Section",
545
+ zone: s.zone,
546
+ seatCount: 0,
547
+ objectIds: [],
548
+ seatLabels: []
549
+ });
411
550
  }
412
551
  const ungrouped = { id: UNGROUPED_ID, label: "Other seats", seatCount: 0, objectIds: [], seatLabels: [] };
413
552
  const objectToSection = /* @__PURE__ */ new Map();
@@ -415,22 +554,24 @@ function computeSections(doc) {
415
554
  if (!isSeatObject(obj)) continue;
416
555
  const labels = objectSeatLabels(obj);
417
556
  if (labels.length === 0) continue;
557
+ const referencedLogicalId = obj.referenceInventorySource?.logicalSectionId;
418
558
  const c = objectCenter(obj);
419
- const owner = sectionObjs.find((s) => pointInPolygon(c, s.outline));
420
- const node = owner ? nodes.get(owner.id) : ungrouped;
559
+ const referencedOwner = referencedLogicalId ? sectionObjs.find((section) => (section.logicalSectionId ?? section.id) === referencedLogicalId && (sameGASurfaceAsSection(obj, section) || pointInPolygonWithHoles(c, section.outline, section.holes))) : void 0;
560
+ const owner = referencedOwner ?? sectionObjs.find((s) => pointInPolygonWithHoles(c, s.outline, s.holes));
561
+ const node = owner ? nodes.get(owner.logicalSectionId ?? owner.id) : ungrouped;
421
562
  node.seatCount += labels.length;
422
563
  node.objectIds.push(obj.id);
423
564
  node.seatLabels.push(...labels);
424
565
  objectToSection.set(obj.id, node.id);
425
566
  }
426
567
  return {
427
- sections: sectionObjs.map((s) => nodes.get(s.id)),
568
+ sections: [...nodes.values()],
428
569
  ungrouped: ungrouped.objectIds.length ? ungrouped : null,
429
570
  objectToSection
430
571
  };
431
572
  }
432
573
  function isSectionHidden(s, hidden) {
433
- return hidden.has(s.id) || !!s.zone && hidden.has(s.zone);
574
+ return hidden.has(s.id) || !!s.logicalSectionId && hidden.has(s.logicalSectionId) || !!s.zone && hidden.has(s.zone);
434
575
  }
435
576
  function hiddenObjectIds(doc, hidden) {
436
577
  const out = /* @__PURE__ */ new Set();
@@ -483,6 +624,61 @@ import { Ellipse } from "konva/lib/shapes/Ellipse";
483
624
  import { Line } from "konva/lib/shapes/Line";
484
625
  import { Text } from "konva/lib/shapes/Text";
485
626
  import { Image as KImage } from "konva/lib/shapes/Image";
627
+ import { Shape } from "konva/lib/Shape";
628
+
629
+ // src/core/chartRenderRules.ts
630
+ var SEAT_LABEL_FONT_SIZE = 7;
631
+ var BOOTH_LABEL_FONT_SIZE = 10;
632
+ var GA_LABEL_FONT_SIZE = 15;
633
+ var GA_CAPACITY_LABEL_FONT_SIZE = 11;
634
+ var GA_FILL_OPACITY = 0.85;
635
+ var MIN_VISIBLE_BOOKABLE_LABEL_PX = 12;
636
+ var SMALL_TEXT_CONTRAST = 4.5;
637
+ var DARK_BOOKABLE_LABEL_INK = "#000000";
638
+ var LIGHT_BOOKABLE_LABEL_INK = "#ffffff";
639
+ function isBookableLabelLegibleAtScale(fontSize, effectiveScale) {
640
+ return fontSize * effectiveScale >= MIN_VISIBLE_BOOKABLE_LABEL_PX;
641
+ }
642
+ function bookableMarkerLabel(publicLabel) {
643
+ return /-(\d{1,5})$/.exec(publicLabel)?.[1] ?? publicLabel;
644
+ }
645
+ function luminance(value) {
646
+ const match = /^#([0-9a-f]{6})$/i.exec(value.trim());
647
+ if (!match) return null;
648
+ const channel = (offset) => {
649
+ const encoded = Number.parseInt(match[1].slice(offset, offset + 2), 16) / 255;
650
+ return encoded <= 0.04045 ? encoded / 12.92 : ((encoded + 0.055) / 1.055) ** 2.4;
651
+ };
652
+ return 0.2126 * channel(0) + 0.7152 * channel(2) + 0.0722 * channel(4);
653
+ }
654
+ function renderedTextContrast(ink, fill) {
655
+ const inkLuminance = luminance(ink);
656
+ const fillLuminance = luminance(fill);
657
+ if (inkLuminance == null || fillLuminance == null) return null;
658
+ return (Math.max(inkLuminance, fillLuminance) + 0.05) / (Math.min(inkLuminance, fillLuminance) + 0.05);
659
+ }
660
+ function rgb(value) {
661
+ const match = /^#([0-9a-f]{6})$/i.exec(value.trim());
662
+ if (!match) return null;
663
+ const packed = Number.parseInt(match[1], 16);
664
+ return [packed >> 16 & 255, packed >> 8 & 255, packed & 255];
665
+ }
666
+ function compositeHexOver(foreground, background, opacity) {
667
+ const front = rgb(foreground);
668
+ const back = rgb(background);
669
+ if (!front || !back) return background;
670
+ const alpha = Math.max(0, Math.min(1, opacity));
671
+ const channels = front.map((value, index) => Math.round(value * alpha + back[index] * (1 - alpha)));
672
+ return `#${channels.map((value) => value.toString(16).padStart(2, "0")).join("")}`;
673
+ }
674
+ function stateAwareBookableLabelInk(fill, preferred) {
675
+ const preferredContrast = renderedTextContrast(preferred, fill);
676
+ if (preferredContrast != null && preferredContrast >= SMALL_TEXT_CONTRAST) return preferred;
677
+ const darkContrast = renderedTextContrast(DARK_BOOKABLE_LABEL_INK, fill) ?? 0;
678
+ const lightContrast = renderedTextContrast(LIGHT_BOOKABLE_LABEL_INK, fill) ?? 0;
679
+ if (darkContrast === 0 && lightContrast === 0) return preferred;
680
+ return darkContrast >= lightContrast ? DARK_BOOKABLE_LABEL_INK : LIGHT_BOOKABLE_LABEL_INK;
681
+ }
486
682
 
487
683
  // src/lib/money.ts
488
684
  var DEFAULT_CURRENCY = "USD";
@@ -542,6 +738,8 @@ var en = {
542
738
  // buyer picker page (src/pages/PickerPage.tsx)
543
739
  "picker.language": "Language",
544
740
  "picker.zoomToFit": "Zoom to fit",
741
+ "picker.seatCountLabel": "seats",
742
+ "picker.capacity": "capacity",
545
743
  "picker.viewMode": "View mode",
546
744
  "picker.floor": "Floor",
547
745
  "picker.zoomLevel": "Zoom level",
@@ -631,7 +829,8 @@ function formatDate(value, opts) {
631
829
  var SEAT_RADIUS = 9;
632
830
  var SEAT_LEGIBLE_SCALE = 0.9;
633
831
  var CACHE_THRESHOLD = 0.55 * SEAT_LEGIBLE_SCALE;
634
- var LABEL_SCALE = 1;
832
+ var LABEL_SCALE = MIN_VISIBLE_BOOKABLE_LABEL_PX / SEAT_LABEL_FONT_SIZE;
833
+ var MIN_FITTED_SEAT_LABEL_FONT_SIZE = 4;
635
834
  var SEAT_TAP_SLOP_PX = 14;
636
835
  var SECTION_PROMINENT_SCALE = 0.45 * SEAT_LEGIBLE_SCALE;
637
836
  var BLOCK_MELT_TOP = 0.9 * SEAT_LEGIBLE_SCALE;
@@ -645,21 +844,29 @@ var ISO_SQUASH = 0.58;
645
844
  var LIFT_PER_STEP = 58;
646
845
  var ISO_TWEEN_MS = 320;
647
846
  var CAMERA_GLIDE_MS = 650;
648
- var BLOCK_FILL_ALPHA = 0.85;
649
- var SOLD_DARKEN = 0.5;
847
+ var BLOCK_FILL_ALPHA = 1;
848
+ var SECTION_STROKE_PX = 2;
849
+ var LIGHT_OVERVIEW_SECTION_FILL = "#e5e7eb";
850
+ var LIGHT_OVERVIEW_SECTION_STROKE = "#c7cbd1";
851
+ var LIGHT_OVERVIEW_SECTION_INK = "#595f69";
852
+ var LIGHT_OVERVIEW_FOCAL_FILL = "#d1d5db";
853
+ var LIGHT_OVERVIEW_FOCAL_STROKE = "#b8bdc4";
854
+ var DARK_OVERVIEW_SECTION_FILL = "#273142";
855
+ var DARK_OVERVIEW_SECTION_STROKE = "#526078";
856
+ var DARK_OVERVIEW_SECTION_INK = "#f1f5f9";
857
+ var DARK_OVERVIEW_FOCAL_FILL = "#374151";
858
+ var DARK_OVERVIEW_FOCAL_STROKE = "#64748b";
650
859
  var SECTION_LABEL_PX = 20;
651
- var SECTION_SUB_PX = 12.5;
652
- var ZONE_LABEL_PX = 30;
860
+ var MIN_SECTION_LABEL_PX = 12;
861
+ var ZONE_LABEL_PX = 18;
653
862
  var ZONE_SUB_PX = 12;
863
+ var HIERARCHY_PILL_BACKGROUND = "#111827";
654
864
  var HELD_FILL = "#6b7280";
655
865
  var TAKEN_FILL = "#374151";
656
866
  var NFS_STROKE = "#4b5563";
657
- var CLOSED_SECTION_FILL = "#586070";
658
867
  var CLOSED_SEAT_FILL = "#4b5563";
659
868
  var CLOSED_SEAT_OPACITY = 0.4;
660
869
  var FOCUS_DIM_OPACITY = 0.16;
661
- var FOCUS_DESATURATE = 0.72;
662
- var FOCUS_NEUTRAL = "#6b7280";
663
870
  var FOCUS_BACKDROP_FILL = "rgba(244,246,248,0.06)";
664
871
  var CB_PALETTE = ["#E69F00", "#56B4E9", "#009E73", "#F0E442", "#0072B2", "#D55E00", "#CC79A7"];
665
872
  var ACCESS_RING = {
@@ -680,6 +887,7 @@ var DEF_SELECTION = "#ffffff";
680
887
  var DEF_SELECTION_ON_LIGHT = "#0b1220";
681
888
  var DEF_DECOR_FILL = "#232c40";
682
889
  var DEF_TEXT = "#8b93a7";
890
+ var DEF_CANVAS_BACKGROUND = "#0e1117";
683
891
  function colorLuminance(color) {
684
892
  const s = color.trim();
685
893
  let r = NaN;
@@ -692,11 +900,11 @@ function colorLuminance(color) {
692
900
  g = parseInt(h.slice(2, 4), 16);
693
901
  b = parseInt(h.slice(4, 6), 16);
694
902
  } 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];
903
+ const rgb2 = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/.exec(s);
904
+ if (rgb2) {
905
+ r = +rgb2[1];
906
+ g = +rgb2[2];
907
+ b = +rgb2[3];
700
908
  }
701
909
  }
702
910
  if (Number.isNaN(r)) return NaN;
@@ -706,6 +914,34 @@ function isLightColor(color) {
706
914
  const lum = colorLuminance(color);
707
915
  return !Number.isNaN(lum) && lum > 0.6;
708
916
  }
917
+ function opaqueColorHex(color) {
918
+ const value = color.trim();
919
+ const hex = /^#([\da-f]{3}|[\da-f]{6})$/i.exec(value);
920
+ if (hex) {
921
+ const expanded = hex[1].length === 3 ? hex[1].split("").map((channel) => channel + channel).join("") : hex[1];
922
+ return `#${expanded.toLowerCase()}`;
923
+ }
924
+ const rgb2 = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([\d.]+))?\s*\)$/i.exec(value);
925
+ if (!rgb2 || rgb2[4] != null && Number(rgb2[4]) < 0.999) return null;
926
+ const channels = [Number(rgb2[1]), Number(rgb2[2]), Number(rgb2[3])];
927
+ if (channels.some((channel) => !Number.isInteger(channel) || channel < 0 || channel > 255)) return null;
928
+ return `#${channels.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;
929
+ }
930
+ function overviewPalette(canvasBackground) {
931
+ return isLightColor(canvasBackground) ? {
932
+ sectionFill: LIGHT_OVERVIEW_SECTION_FILL,
933
+ sectionStroke: LIGHT_OVERVIEW_SECTION_STROKE,
934
+ sectionInk: LIGHT_OVERVIEW_SECTION_INK,
935
+ focalFill: LIGHT_OVERVIEW_FOCAL_FILL,
936
+ focalStroke: LIGHT_OVERVIEW_FOCAL_STROKE
937
+ } : {
938
+ sectionFill: DARK_OVERVIEW_SECTION_FILL,
939
+ sectionStroke: DARK_OVERVIEW_SECTION_STROKE,
940
+ sectionInk: DARK_OVERVIEW_SECTION_INK,
941
+ focalFill: DARK_OVERVIEW_FOCAL_FILL,
942
+ focalStroke: DARK_OVERVIEW_FOCAL_STROKE
943
+ };
944
+ }
709
945
  var clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
710
946
  function seatIdOf(target) {
711
947
  const n = target;
@@ -741,6 +977,108 @@ function polyBounds(pts) {
741
977
  }
742
978
  return { x: minX, y: minY, width: Math.max(1, maxX - minX), height: Math.max(1, maxY - minY) };
743
979
  }
980
+ function rotatedRectPoints(center, width, height, rotation) {
981
+ const radians = rotation * Math.PI / 180;
982
+ const cos = Math.cos(radians);
983
+ const sin = Math.sin(radians);
984
+ return [
985
+ { x: -width / 2, y: -height / 2 },
986
+ { x: width / 2, y: -height / 2 },
987
+ { x: width / 2, y: height / 2 },
988
+ { x: -width / 2, y: height / 2 }
989
+ ].map((point) => ({
990
+ x: center.x + point.x * cos - point.y * sin,
991
+ y: center.y + point.x * sin + point.y * cos
992
+ }));
993
+ }
994
+ function pointsBounds(points) {
995
+ const bounds = polyBounds(points);
996
+ return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height };
997
+ }
998
+ function rotatedRectFitsPolygon(center, width, height, rotation, outer, holes) {
999
+ const radians = rotation * Math.PI / 180;
1000
+ const cos = Math.cos(radians);
1001
+ const sin = Math.sin(radians);
1002
+ for (let yStep = 0; yStep <= 4; yStep++) {
1003
+ for (let xStep = 0; xStep <= 6; xStep++) {
1004
+ const localX = width * (xStep / 6 - 0.5);
1005
+ const localY = height * (yStep / 4 - 0.5);
1006
+ const point = {
1007
+ x: center.x + localX * cos - localY * sin,
1008
+ y: center.y + localX * sin + localY * cos
1009
+ };
1010
+ if (!pointInPolygonWithHoles(point, outer, holes)) return false;
1011
+ }
1012
+ }
1013
+ return true;
1014
+ }
1015
+ function polygonLabelCandidates(outer, holes, preferred) {
1016
+ const bounds = polyBounds(outer);
1017
+ const centre = { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 };
1018
+ const points = [preferred];
1019
+ for (let row = 1; row < 12; row += 1) {
1020
+ for (let column = 1; column < 12; column += 1) {
1021
+ const point = {
1022
+ x: bounds.x + bounds.width * column / 12,
1023
+ y: bounds.y + bounds.height * row / 12
1024
+ };
1025
+ if (pointInPolygonWithHoles(point, outer, holes)) points.push(point);
1026
+ }
1027
+ }
1028
+ 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));
1029
+ }
1030
+ function polygonWithHolesShape(outer, holes, attrs, outerPath) {
1031
+ const signedArea = (points) => points.reduce((sum, point, index) => {
1032
+ const next = points[(index + 1) % points.length];
1033
+ return sum + point.x * next.y - next.x * point.y;
1034
+ }, 0);
1035
+ const outerClockwise = signedArea(outer) > 0;
1036
+ return new Shape({
1037
+ ...attrs,
1038
+ sceneFunc(context, shape) {
1039
+ context.beginPath();
1040
+ const polygonPath = (points) => {
1041
+ if (!points.length) return;
1042
+ context.moveTo(points[0].x, points[0].y);
1043
+ for (let index = 1; index < points.length; index += 1) context.lineTo(points[index].x, points[index].y);
1044
+ context.closePath();
1045
+ };
1046
+ const vectorPath = (path) => {
1047
+ context.moveTo(path.start.x, path.start.y);
1048
+ let current = path.start;
1049
+ for (const segment of path.segments) {
1050
+ if (segment.kind === "line") context.lineTo(segment.end.x, segment.end.y);
1051
+ else if (segment.kind === "arc") context.arc(
1052
+ segment.center.x,
1053
+ segment.center.y,
1054
+ segment.radius,
1055
+ Math.atan2(current.y - segment.center.y, current.x - segment.center.x),
1056
+ Math.atan2(segment.end.y - segment.center.y, segment.end.x - segment.center.x),
1057
+ !segment.clockwise
1058
+ );
1059
+ else context.bezierCurveTo(
1060
+ segment.control1.x,
1061
+ segment.control1.y,
1062
+ segment.control2.x,
1063
+ segment.control2.y,
1064
+ segment.end.x,
1065
+ segment.end.y
1066
+ );
1067
+ current = segment.end;
1068
+ }
1069
+ context.closePath();
1070
+ };
1071
+ if (outerPath) vectorPath(outerPath);
1072
+ else polygonPath(outer);
1073
+ for (const hole of holes ?? []) {
1074
+ const holeClockwise = signedArea(hole) > 0;
1075
+ polygonPath(holeClockwise === outerClockwise ? [...hole].reverse() : hole);
1076
+ }
1077
+ context.fillStrokeShape(shape);
1078
+ },
1079
+ perfectDrawEnabled: false
1080
+ });
1081
+ }
744
1082
  function rgba(hex, a) {
745
1083
  const m = /^#?([\da-f]{6})$/i.exec(hex.trim());
746
1084
  if (!m) return hex;
@@ -760,11 +1098,11 @@ function mixColors(parts, fallback) {
760
1098
  let b = 0;
761
1099
  let tw = 0;
762
1100
  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;
1101
+ const rgb2 = hexToRgb(p.hex);
1102
+ if (!rgb2 || p.w <= 0) continue;
1103
+ r += rgb2[0] * p.w;
1104
+ g += rgb2[1] * p.w;
1105
+ b += rgb2[2] * p.w;
768
1106
  tw += p.w;
769
1107
  }
770
1108
  return tw > 0 ? toHex(r / tw, g / tw, b / tw) : fallback;
@@ -788,11 +1126,21 @@ var _SeatmapRenderer = class _SeatmapRenderer {
788
1126
  this.circleById = /* @__PURE__ */ new Map();
789
1127
  /** Booth block geometry, keyed by booth id (= the unit's rowId). */
790
1128
  this.boothDims = /* @__PURE__ */ new Map();
791
- /** Booth label node so status changes can say HELD/SOLD on the full block. */
1129
+ /** Booth labels live with the booth shape but obey the shared rendered-size LOD. */
792
1130
  this.boothLabelById = /* @__PURE__ */ new Map();
1131
+ /** Viewport seat labels are rebuilt after each settled camera change. */
1132
+ this.seatLabelById = /* @__PURE__ */ new Map();
1133
+ /** Authored free-text nodes obey the same rendered-size visibility floor. */
1134
+ this.freeTextById = /* @__PURE__ */ new Map();
1135
+ /** Stage/rink landmarks retain a readable screen-space caption at overview. */
1136
+ this.primaryFocalLabels = /* @__PURE__ */ new Map();
1137
+ /** GA paint and text share price/highlight filter state. */
1138
+ this.gaById = /* @__PURE__ */ new Map();
793
1139
  this.statusById = /* @__PURE__ */ new Map();
794
1140
  this.catColor = /* @__PURE__ */ new Map();
795
1141
  this.theme = {};
1142
+ /** Opaque paint actually visible behind transparent Konva canvases. */
1143
+ this.canvasBackground = DEF_CANVAS_BACKGROUND;
796
1144
  /** Effective selection/hover ring color — resolved per chart in setChart(). */
797
1145
  this.effSelection = DEF_SELECTION;
798
1146
  /** Colorblind-safe mode (Okabe-Ito hues + hollow booked seats). */
@@ -1090,6 +1438,11 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1090
1438
  this.circleById.clear();
1091
1439
  this.boothDims.clear();
1092
1440
  this.boothLabelById.clear();
1441
+ this.seatLabelById.clear();
1442
+ this.freeTextById.clear();
1443
+ this.primaryFocalLabels.clear();
1444
+ this.gaById.clear();
1445
+ for (const marker of this.selectionMarkers.values()) marker.destroy();
1093
1446
  this.selectionMarkers.clear();
1094
1447
  this.ownedHold.clear();
1095
1448
  this.selectionFocusId = null;
@@ -1101,6 +1454,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1101
1454
  this.sections = [];
1102
1455
  this.zones = [];
1103
1456
  this.seatSection.clear();
1457
+ this.focusedSectionId = null;
1458
+ this.focusBackdrop = null;
1104
1459
  this.catPrice.clear();
1105
1460
  this.zoneColor.clear();
1106
1461
  this.lodScale = 0;
@@ -1118,7 +1473,9 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1118
1473
  this.hoverRing.visible(false);
1119
1474
  this.theme = doc.theme ?? {};
1120
1475
  this.seatR = clamp(this.theme.seatScale ?? 1, 0.7, 1.6) * SEAT_RADIUS;
1121
- this.container.style.background = this.theme.background ?? "";
1476
+ this.container.style.background = "";
1477
+ this.canvasBackground = this.resolveCanvasBackground();
1478
+ this.container.style.background = this.canvasBackground;
1122
1479
  this.effSelection = this.resolveSelectionColor();
1123
1480
  this.hoverRing.stroke(this.effSelection);
1124
1481
  this.hoverRing.radius(this.seatR + 2);
@@ -1347,12 +1704,21 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1347
1704
  }
1348
1705
  return this.selectMany(ids);
1349
1706
  }
1707
+ /** Exact SDK capture helper. MCP never accepts this id; the SDK derives it
1708
+ * from the persisted floor and uses the normal selected paint/ring path. */
1709
+ setEvidenceSelection(seatId) {
1710
+ if (!this.seatById.has(seatId) || !this.isSelectable(seatId)) return false;
1711
+ if (this.selection.size) this.clearSelection();
1712
+ this.setSelected(seatId, true);
1713
+ this.overlayLayer.batchDraw();
1714
+ return this.selection.has(seatId);
1715
+ }
1350
1716
  /** Selectable seats in a section OR zone id — pure read (no selection change). */
1351
1717
  getSelectableInSection(sectionId) {
1352
1718
  const out = [];
1353
1719
  const seen = /* @__PURE__ */ new Set();
1354
1720
  for (const sec of this.sections) {
1355
- if (sec.id !== sectionId && sec.zone !== sectionId) continue;
1721
+ if (sec.id !== sectionId && sec.logicalId !== sectionId && sec.zone !== sectionId) continue;
1356
1722
  for (const id of sec.memberIds) {
1357
1723
  if (seen.has(id)) continue;
1358
1724
  seen.add(id);
@@ -1495,6 +1861,65 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1495
1861
  };
1496
1862
  requestAnimationFrame(step);
1497
1863
  }
1864
+ /**
1865
+ * Pulse a section outline without moving the camera or mutating the authored
1866
+ * geometry. The temporary halo is drawn in the non-listening overlay layer,
1867
+ * so the apparent 4% lift never changes hit testing or selection bounds.
1868
+ */
1869
+ flashSection(sectionId, color = "#22a06b") {
1870
+ const matches = this.sections.filter((section) => section.id === sectionId || section.zone === sectionId);
1871
+ if (!matches.length) return;
1872
+ for (const section of matches) {
1873
+ const centre = section.outline.reduce(
1874
+ (sum, point) => ({ x: sum.x + point.x, y: sum.y + point.y }),
1875
+ { x: 0, y: 0 }
1876
+ );
1877
+ centre.x /= section.outline.length;
1878
+ centre.y /= section.outline.length;
1879
+ const lift = section.elevation > 0 ? this.isoLiftLocal(section.elevation) : { x: 0, y: 0 };
1880
+ const halo = new Line({
1881
+ x: centre.x + lift.x,
1882
+ y: centre.y + lift.y,
1883
+ points: section.outline.flatMap((point) => [point.x - centre.x, point.y - centre.y]),
1884
+ closed: true,
1885
+ stroke: color,
1886
+ strokeWidth: 3,
1887
+ strokeScaleEnabled: false,
1888
+ opacity: 0.92,
1889
+ listening: false,
1890
+ perfectDrawEnabled: false,
1891
+ shadowForStrokeEnabled: true,
1892
+ shadowColor: color,
1893
+ shadowBlur: 14,
1894
+ shadowOpacity: 0.7
1895
+ });
1896
+ this.overlayLayer.add(halo);
1897
+ this.overlayLayer.batchDraw();
1898
+ const remove = () => {
1899
+ if (!halo.getLayer()) return;
1900
+ halo.destroy();
1901
+ this.overlayLayer.batchDraw();
1902
+ };
1903
+ if (this.reducedMotion || typeof document !== "undefined" && document.hidden) {
1904
+ setTimeout(remove, 520);
1905
+ continue;
1906
+ }
1907
+ const start = performance.now();
1908
+ const duration = 820;
1909
+ const step = (now) => {
1910
+ if (this.destroyed || !halo.getLayer()) return;
1911
+ const t2 = Math.min(1, (now - start) / duration);
1912
+ const eased = 1 - Math.pow(1 - t2, 3);
1913
+ const scale = 1 + eased * 0.04;
1914
+ halo.scale({ x: scale, y: scale });
1915
+ halo.opacity(0.92 * (1 - t2));
1916
+ this.overlayLayer.batchDraw();
1917
+ if (t2 < 1) requestAnimationFrame(step);
1918
+ else remove();
1919
+ };
1920
+ requestAnimationFrame(step);
1921
+ }
1922
+ }
1498
1923
  /** Nearest seat from `fromId` in a cardinal direction (aligned + close wins). */
1499
1924
  nearestSeat(fromId, dir) {
1500
1925
  const from = this.seatById.get(fromId);
@@ -1568,6 +1993,11 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1568
1993
  seatCount() {
1569
1994
  return this.seats.length;
1570
1995
  }
1996
+ bookableCount() {
1997
+ let total = this.seats.length;
1998
+ for (const area of this.gaById.values()) total += area.capacity;
1999
+ return total;
2000
+ }
1571
2001
  worldToScreen(point) {
1572
2002
  const s = this.stage.scaleX();
1573
2003
  const p = this.isoT === 0 ? point : this.isoForward(point);
@@ -1584,6 +2014,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1584
2014
  const c = this.circleById.get(seat.id);
1585
2015
  if (c) this.paintSeat(c, seat.id);
1586
2016
  }
2017
+ this.updateLabels();
1587
2018
  if (this.cached) {
1588
2019
  this.seatLayer.clearCache();
1589
2020
  this.cacheSeatLayer();
@@ -1610,12 +2041,33 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1610
2041
  const c = this.circleById.get(seat.id);
1611
2042
  if (c) this.paintSeat(c, seat.id);
1612
2043
  }
2044
+ this.updateLabels();
1613
2045
  if (this.cached) {
1614
2046
  this.seatLayer.clearCache();
1615
2047
  this.cacheSeatLayer();
1616
2048
  } else {
1617
2049
  this.seatLayer.batchDraw();
1618
2050
  }
2051
+ this.applyGAFilterState();
2052
+ }
2053
+ gaCategoryDimmed(categoryKey) {
2054
+ return Boolean(
2055
+ this.categoryHighlight && categoryKey !== this.categoryHighlight || this.categoryFilter && !this.categoryFilter.has(categoryKey)
2056
+ );
2057
+ }
2058
+ /** Keep GA paint and its two labels in the same legend/price-filter state. */
2059
+ applyGAFilterState() {
2060
+ this.paintGAStateForView();
2061
+ this.updateFreeTextVisibility();
2062
+ this.bgLayer.batchDraw();
2063
+ }
2064
+ paintGAStateForView() {
2065
+ for (const ga of this.gaById.values()) {
2066
+ const filteredOut = Boolean(this.categoryFilter && !this.categoryFilter.has(ga.categoryKey));
2067
+ const overviewHidden = ga.sectionId != null && this.effScale() < CACHE_THRESHOLD;
2068
+ ga.polygon.opacity(overviewHidden ? 0 : this.gaCategoryDimmed(ga.categoryKey) ? GA_FILL_OPACITY * 0.08 : GA_FILL_OPACITY);
2069
+ ga.polygon.listening(!overviewHidden && !filteredOut);
2070
+ }
1619
2071
  }
1620
2072
  /** Frame the currently available inventory that survived a buyer price
1621
2073
  * filter. Clearing the filter glides back to the full venue. */
@@ -1910,13 +2362,12 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1910
2362
  });
1911
2363
  rect.setAttr("seatId", seat.id);
1912
2364
  this.circleById.set(seat.id, rect);
1913
- this.paintSeat(rect, seat.id);
1914
2365
  target.add(rect);
1915
2366
  const t2 = new Text({
1916
2367
  x: seat.x,
1917
2368
  y: seat.y,
1918
2369
  text: seat.label,
1919
- fontSize: 10,
2370
+ fontSize: BOOTH_LABEL_FONT_SIZE,
1920
2371
  fontStyle: "600",
1921
2372
  fontFamily: this.labelFont(),
1922
2373
  fill: this.theme.seatLabelColor ?? DEF_SEAT_LABEL,
@@ -1925,9 +2376,12 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1925
2376
  });
1926
2377
  t2.offsetX(t2.width() / 2);
1927
2378
  t2.offsetY(t2.height() / 2);
2379
+ t2.visible(false);
2380
+ this.boothLabelById.set(seat.id, t2);
1928
2381
  this.hasBoothText = true;
1929
2382
  this.boothLabelById.set(seat.id, t2);
1930
2383
  target.add(t2);
2384
+ this.paintSeat(rect, seat.id);
1931
2385
  }
1932
2386
  /** The category's display color — Okabe-Ito hue when colorblind-safe is on. */
1933
2387
  seatBaseColor(categoryKey) {
@@ -1935,6 +2389,15 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1935
2389
  const idx = this.catOrder.indexOf(categoryKey);
1936
2390
  return CB_PALETTE[(idx >= 0 ? idx : 0) % CB_PALETTE.length];
1937
2391
  }
2392
+ /** Authored free fills retain the chart's validated ink. Renderer-owned
2393
+ * transient fills choose an ink against the paint that is actually visible. */
2394
+ renderedBookableLabelInk(id, shape) {
2395
+ const preferred = this.theme.seatLabelColor ?? DEF_SEAT_LABEL;
2396
+ const status = this.statusById.get(id) ?? "free";
2397
+ if (status === "free" && !this.selection.has(id)) return preferred;
2398
+ const fill = shape.fill();
2399
+ return stateAwareBookableLabelInk(typeof fill === "string" ? fill : "", preferred);
2400
+ }
1938
2401
  /** Apply fill/stroke/opacity for a seat's current status + selection. */
1939
2402
  paintSeat(c, id) {
1940
2403
  const seat = this.seatById.get(id);
@@ -1998,7 +2461,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1998
2461
  }
1999
2462
  if (this.dimmedSections.size) {
2000
2463
  const sec = this.seatSection.get(id);
2001
- if (sec && (this.dimmedSections.has(sec.id) || sec.zone != null && this.dimmedSections.has(sec.zone))) {
2464
+ if (sec && (this.dimmedSections.has(sec.id) || this.dimmedSections.has(sec.logicalId) || sec.zone != null && this.dimmedSections.has(sec.zone))) {
2002
2465
  c.opacity(0.18);
2003
2466
  }
2004
2467
  }
@@ -2011,16 +2474,23 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2011
2474
  }
2012
2475
  if (this.focusedSectionId) {
2013
2476
  const sec = this.seatSection.get(id);
2014
- const inFocus = !!sec && (sec.id === this.focusedSectionId || sec.zone === this.focusedSectionId);
2477
+ const inFocus = !!sec && (sec.id === this.focusedSectionId || sec.logicalId === this.focusedSectionId || sec.zone === this.focusedSectionId);
2015
2478
  if (!inFocus) c.opacity(FOCUS_DIM_OPACITY);
2016
2479
  }
2017
2480
  if (this.selectionFocusId && id !== this.selectionFocusId) c.opacity(Math.min(c.opacity(), 0.16));
2481
+ const bookableLabel = this.boothLabelById.get(id) ?? this.seatLabelById.get(id);
2482
+ if (bookableLabel) {
2483
+ bookableLabel.fill(this.renderedBookableLabelInk(id, c));
2484
+ bookableLabel.visible(
2485
+ isBookableLabelLegibleAtScale(bookableLabel.fontSize(), this.effScale()) && c.opacity() >= 0.5
2486
+ );
2487
+ }
2018
2488
  }
2019
2489
  /** True when a seat sits in a section/zone currently marked `closed`. */
2020
2490
  seatInClosedSection(id) {
2021
2491
  if (!this.closedSections.size) return false;
2022
2492
  const sec = this.seatSection.get(id);
2023
- return !!sec && (this.closedSections.has(sec.id) || sec.zone != null && this.closedSections.has(sec.zone));
2493
+ return !!sec && (this.closedSections.has(sec.id) || this.closedSections.has(sec.logicalId) || sec.zone != null && this.closedSections.has(sec.zone));
2024
2494
  }
2025
2495
  /**
2026
2496
  * Colorblind-safe mode: swap category hues for the Okabe-Ito palette and
@@ -2033,6 +2503,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2033
2503
  const c = this.circleById.get(seat.id);
2034
2504
  if (c) this.paintSeat(c, seat.id);
2035
2505
  }
2506
+ this.updateLabels();
2036
2507
  if (this.cached) {
2037
2508
  this.seatLayer.clearCache();
2038
2509
  this.cacheSeatLayer();
@@ -2077,7 +2548,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2077
2548
  * seat-pick gate below only lets buyers pick once seats are ≥ LABEL_SCALE big).
2078
2549
  */
2079
2550
  focusSection(id) {
2080
- if (!this.sections.some((s) => s.id === id)) return;
2551
+ if (!this.sections.some((section) => section.id === id || section.logicalId === id)) return;
2081
2552
  this.focusedSectionId = id;
2082
2553
  this.drawFocusBackdrop(id);
2083
2554
  this.repaintSectionsAndSeats();
@@ -2105,20 +2576,20 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2105
2576
  this.focusBackdrop.destroy();
2106
2577
  this.focusBackdrop = null;
2107
2578
  }
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;
2579
+ const sections = this.sections.filter((section) => section.id === id || section.logicalId === id);
2580
+ if (!sections.length) return;
2581
+ const backdrop = new Group({ listening: false });
2582
+ for (const section of sections) {
2583
+ backdrop.add(polygonWithHolesShape(section.outline, section.holes, {
2584
+ fill: FOCUS_BACKDROP_FILL,
2585
+ stroke: rgba("#ffffff", 0.1),
2586
+ strokeWidth: 1,
2587
+ listening: false
2588
+ }, section.outlinePath));
2589
+ }
2590
+ this.bgLayer.add(backdrop);
2591
+ backdrop.moveToTop();
2592
+ this.focusBackdrop = backdrop;
2122
2593
  }
2123
2594
  /** Repaint every seat + section block to reflect closed/focus state, then redraw. */
2124
2595
  repaintSectionsAndSeats() {
@@ -2146,7 +2617,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2146
2617
  height: Math.abs(br.y - tl.y)
2147
2618
  };
2148
2619
  }
2149
- /** Axis-aligned world bounds of all seats + section outlines (minimap F3 frame). */
2620
+ /** Axis-aligned world bounds of seats, section outlines, and GA polygons (minimap F3 frame). */
2150
2621
  getWorldBounds() {
2151
2622
  let minX = Infinity;
2152
2623
  let minY = Infinity;
@@ -2160,6 +2631,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2160
2631
  };
2161
2632
  for (const s of this.seats) grow(s.x, s.y);
2162
2633
  for (const sec of this.sections) for (const p of sec.outline) grow(p.x, p.y);
2634
+ for (const area of this.gaById.values()) for (const p of area.points) grow(p.x, p.y);
2163
2635
  if (!Number.isFinite(minX)) return { x: 0, y: 0, width: 1, height: 1 };
2164
2636
  return { x: minX, y: minY, width: Math.max(1, maxX - minX), height: Math.max(1, maxY - minY) };
2165
2637
  }
@@ -2171,12 +2643,14 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2171
2643
  const c = this.circleById.get(seat.id);
2172
2644
  if (c) this.paintSeat(c, seat.id);
2173
2645
  }
2646
+ this.updateLabels();
2174
2647
  if (this.cached) {
2175
2648
  this.seatLayer.clearCache();
2176
2649
  this.cacheSeatLayer();
2177
2650
  } else {
2178
2651
  this.seatLayer.batchDraw();
2179
2652
  }
2653
+ this.applyGAFilterState();
2180
2654
  }
2181
2655
  renderBackground(doc) {
2182
2656
  if (doc.backgroundImage) this.renderBackgroundImage(doc.backgroundImage);
@@ -2194,32 +2668,40 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2194
2668
  this.renderText(obj);
2195
2669
  }
2196
2670
  }
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
2671
  }
2209
2672
  /** Organizer floor-plan photo, dimmed, at the very bottom of the bg layer. */
2210
2673
  renderBackgroundImage(bg) {
2674
+ if (!bg.url || bg.visible === false) return;
2211
2675
  const img = new window.Image();
2212
2676
  img.onload = () => {
2213
2677
  const natW = img.naturalWidth || 4;
2214
2678
  const natH = img.naturalHeight || 3;
2679
+ const rawCrop = bg.crop ?? { x: 0, y: 0, width: 1, height: 1 };
2680
+ const cropX = Math.max(0, Math.min(0.99, rawCrop.x));
2681
+ const cropY = Math.max(0, Math.min(0.99, rawCrop.y));
2682
+ const crop = {
2683
+ x: cropX,
2684
+ y: cropY,
2685
+ width: Math.max(0.01, Math.min(1 - cropX, rawCrop.width)),
2686
+ height: Math.max(0.01, Math.min(1 - cropY, rawCrop.height))
2687
+ };
2215
2688
  const w = bg.width;
2216
- const h = w * (natH / natW);
2689
+ const h = w * (natH * crop.height / (natW * crop.width));
2217
2690
  const node = new KImage({
2218
2691
  image: img,
2219
- x: bg.center.x - w / 2,
2220
- y: bg.center.y - h / 2,
2692
+ x: bg.center.x,
2693
+ y: bg.center.y,
2694
+ offsetX: w / 2,
2695
+ offsetY: h / 2,
2221
2696
  width: w,
2222
2697
  height: h,
2698
+ rotation: bg.rotation ?? 0,
2699
+ crop: {
2700
+ x: crop.x * natW,
2701
+ y: crop.y * natH,
2702
+ width: crop.width * natW,
2703
+ height: crop.height * natH
2704
+ },
2223
2705
  opacity: bg.opacity,
2224
2706
  listening: false
2225
2707
  });
@@ -2291,28 +2773,42 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2291
2773
  })
2292
2774
  );
2293
2775
  }
2294
- this.addCentredLabel(this.bgLayer, obj.label, obj.center.x, obj.center.y, "#cbd5e1", 12, true);
2776
+ const label = this.addCentredLabel(this.bgLayer, obj.label, obj.center.x, obj.center.y, "#cbd5e1", 12, true);
2777
+ this.freeTextById.set(obj.id, { node: label, background: "#232c40", kind: "table" });
2295
2778
  }
2296
2779
  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
- );
2780
+ const background = this.canvasBackground;
2781
+ const preferredInk = obj.color ?? this.theme.textColor ?? DEF_TEXT;
2782
+ const node = new Text({
2783
+ x: obj.position.x,
2784
+ y: obj.position.y,
2785
+ text: obj.text,
2786
+ fontSize: obj.fontSize,
2787
+ rotation: obj.rotation,
2788
+ // Authored ink remains preferred, but an embed/theme surface can change
2789
+ // the actual canvas. Fail over to readable black/white instead of
2790
+ // painting an otherwise valid caption invisibly on that active surface.
2791
+ fill: stateAwareBookableLabelInk(background, preferredInk),
2792
+ fontFamily: this.labelFont(),
2793
+ listening: false,
2794
+ perfectDrawEnabled: false
2795
+ });
2796
+ this.freeTextById.set(obj.id, {
2797
+ node,
2798
+ background,
2799
+ kind: "free-text"
2800
+ });
2801
+ this.bgLayer.add(node);
2310
2802
  }
2311
2803
  renderShape(obj) {
2312
- const fill = obj.fill ?? this.theme.decorFill ?? DEF_DECOR_FILL;
2804
+ const authoredFill = obj.fill ?? this.theme.decorFill ?? DEF_DECOR_FILL;
2313
2805
  const isStage = obj.role === "stage";
2806
+ const referenceFocal = obj.role === "reference-focal";
2314
2807
  const isDecor = !!obj.role && !isStage;
2315
- const stroke = isStage ? lighten(fill, 0.28) : void 0;
2808
+ const palette = overviewPalette(this.canvasBackground);
2809
+ const fill = referenceFocal ? palette.focalFill : authoredFill;
2810
+ const stroke = isStage ? lighten(fill, 0.28) : referenceFocal ? palette.focalStroke : void 0;
2811
+ const strokeWidth = isStage ? 1 : referenceFocal ? 2 : 0;
2316
2812
  let cx = 0;
2317
2813
  let cy = 0;
2318
2814
  if (obj.kind === "rect" && obj.x != null && obj.y != null && obj.width != null && obj.height != null) {
@@ -2334,7 +2830,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2334
2830
  height: obj.height,
2335
2831
  ...grad,
2336
2832
  stroke,
2337
- strokeWidth: isStage ? 1 : 0,
2833
+ strokeWidth,
2338
2834
  cornerRadius: 4,
2339
2835
  listening: false
2340
2836
  })
@@ -2348,7 +2844,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2348
2844
  fillLinearGradientColorStops: [0, darken(fill, 0.3), 1, lighten(fill, 0.12)]
2349
2845
  } : { fill };
2350
2846
  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 })
2847
+ new Ellipse({ x: cx, y: cy, rotation: obj.rotation ?? 0, radiusX: obj.width / 2, radiusY: obj.height / 2, ...grad, stroke, strokeWidth, listening: false })
2352
2848
  );
2353
2849
  } else if (obj.kind === "polygon" && obj.points && obj.points.length) {
2354
2850
  const pts = obj.points.flatMap((p) => [p.x, p.y]);
@@ -2363,17 +2859,35 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2363
2859
  fillLinearGradientColorStops: [0, darken(fill, 0.3), 1, lighten(fill, 0.12)]
2364
2860
  } : { fill };
2365
2861
  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 })
2862
+ new Line({ points: pts, closed: true, x: cx, y: cy, offsetX: cx, offsetY: cy, rotation: obj.rotation ?? 0, ...grad, stroke, strokeWidth, listening: false })
2367
2863
  );
2368
2864
  }
2369
2865
  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);
2866
+ if (isStage) {
2867
+ const node = this.addStageLabel(cx, cy, obj.label, fill);
2868
+ this.primaryFocalLabels.set(node, 22);
2869
+ this.freeTextById.set(obj.id, { node, background: fill, kind: "stage" });
2870
+ } else if (isDecor) {
2871
+ const node = this.addCentredLabel(
2872
+ this.bgLayer,
2873
+ obj.label,
2874
+ cx,
2875
+ cy,
2876
+ referenceFocal ? stateAwareBookableLabelInk(fill, "#e6e9f0") : "#9aa3b5",
2877
+ referenceFocal ? 18 : 12,
2878
+ false
2879
+ );
2880
+ if (referenceFocal) this.primaryFocalLabels.set(node, 18);
2881
+ this.freeTextById.set(obj.id, { node, background: fill, kind: "decor" });
2882
+ } else {
2883
+ const node = this.addCentredLabel(this.bgLayer, obj.label, cx, cy, "#cbd5e1", 16, true);
2884
+ this.freeTextById.set(obj.id, { node, background: fill, kind: "decor" });
2885
+ }
2373
2886
  }
2374
2887
  }
2375
2888
  /** Prominent stage caption: uppercase, letter-spaced, larger, softly dimmed. */
2376
- addStageLabel(x, y, text) {
2889
+ addStageLabel(x, y, text, background) {
2890
+ const ink = stateAwareBookableLabelInk(background, "#e6e9f0");
2377
2891
  const t2 = new Text({
2378
2892
  x,
2379
2893
  y,
@@ -2382,22 +2896,24 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2382
2896
  fontStyle: "700",
2383
2897
  letterSpacing: 4,
2384
2898
  fontFamily: this.labelFont(),
2385
- fill: rgba("#e6e9f0", 0.62),
2899
+ fill: ink,
2386
2900
  listening: false,
2387
2901
  perfectDrawEnabled: false
2388
2902
  });
2389
2903
  t2.offsetX(t2.width() / 2);
2390
2904
  t2.offsetY(t2.height() / 2);
2391
2905
  this.bgLayer.add(t2);
2906
+ return t2;
2392
2907
  }
2393
2908
  renderGA(obj) {
2394
2909
  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,
2910
+ const canvas = this.canvasBackground;
2911
+ const effectiveBackground = compositeHexOver(color, canvas, GA_FILL_OPACITY);
2912
+ const preferredInk = this.theme.textColor ?? "#e6e9f0";
2913
+ const ink = stateAwareBookableLabelInk(effectiveBackground, preferredInk);
2914
+ const poly = polygonWithHolesShape(obj.points, obj.holes, {
2399
2915
  fill: color,
2400
- opacity: 0.22,
2916
+ opacity: GA_FILL_OPACITY,
2401
2917
  stroke: color,
2402
2918
  strokeWidth: 1.5
2403
2919
  });
@@ -2410,31 +2926,51 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2410
2926
  this.container.style.cursor = "default";
2411
2927
  });
2412
2928
  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);
2929
+ const labelPoint = polygonLabelPoint(obj.points, obj.holes);
2930
+ const containingSection = this.sections.find((section) => pointInPolygonWithHoles(labelPoint, section.outline, section.holes));
2931
+ const label = this.addCentredLabel(this.bgLayer, obj.label, labelPoint.x, labelPoint.y - 8, ink, GA_LABEL_FONT_SIZE, false);
2932
+ const capacity = this.addCentredLabel(this.bgLayer, `cap ${obj.capacity}`, labelPoint.x, labelPoint.y + 10, ink, GA_CAPACITY_LABEL_FONT_SIZE, false);
2933
+ this.freeTextById.set(`${obj.id}:label`, {
2934
+ objectId: obj.id,
2935
+ node: label,
2936
+ background: effectiveBackground,
2937
+ kind: "ga-label",
2938
+ categoryKey: obj.categoryKey
2939
+ });
2940
+ this.freeTextById.set(`${obj.id}:capacity`, {
2941
+ objectId: obj.id,
2942
+ node: capacity,
2943
+ background: effectiveBackground,
2944
+ kind: "ga-capacity",
2945
+ categoryKey: obj.categoryKey
2946
+ });
2947
+ this.gaById.set(obj.id, {
2948
+ label: obj.label,
2949
+ capacity: obj.capacity,
2950
+ categoryKey: obj.categoryKey,
2951
+ points: obj.points,
2952
+ polygon: poly,
2953
+ effectiveBackground,
2954
+ ...containingSection ? { sectionId: containingSection.logicalId } : {}
2955
+ });
2417
2956
  }
2418
2957
  /**
2419
2958
  * A section renders in three coordinated layers driven by the LOD melt:
2420
2959
  * • 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.
2960
+ * • a neutral solid shell that fades in at the overview rung, and
2961
+ * • one readable, contained section name.
2962
+ * Category, row, seat, and availability detail belongs to section focus/zoom.
2963
+ * Membership and category mix are still precomputed for the detailed state.
2425
2964
  */
2426
2965
  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
- };
2966
+ const centroid = polygonLabelPoint(obj.outline, obj.holes);
2967
+ const palette = overviewPalette(this.canvasBackground);
2432
2968
  const memberIds = [];
2433
2969
  const catCounts = /* @__PURE__ */ new Map();
2434
2970
  let free = 0;
2435
2971
  for (const seat of this.seats) {
2436
2972
  if (this.seatSection.has(seat.id)) continue;
2437
- if (!pointInPolygon(seat, obj.outline)) continue;
2973
+ if (!pointInPolygonWithHoles(seat, obj.outline, obj.holes)) continue;
2438
2974
  memberIds.push(seat.id);
2439
2975
  catCounts.set(seat.categoryKey, (catCounts.get(seat.categoryKey) ?? 0) + 1);
2440
2976
  if ((this.statusById.get(seat.id) ?? "free") === "free") free++;
@@ -2470,52 +3006,21 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2470
3006
  }
2471
3007
  const bgTarget = liftGroupBg ?? this.bgLayer;
2472
3008
  const outlineTint = obj.color ?? this.zoneColor.get(obj.zone ?? "") ?? "#3a4358";
2473
- const outlinePoly = new Line({
2474
- points: pts,
2475
- closed: true,
3009
+ const outlinePoly = polygonWithHolesShape(obj.outline, obj.holes, {
2476
3010
  stroke: rgba(outlineTint, 0.5),
2477
3011
  strokeWidth: 1.75,
2478
3012
  fill: rgba(outlineTint, 0.08),
2479
- lineJoin: "round",
2480
- listening: false,
2481
- perfectDrawEnabled: false
2482
- });
3013
+ listening: false
3014
+ }, obj.outlinePath);
2483
3015
  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,
3016
+ const blockPoly = polygonWithHolesShape(obj.outline, obj.holes, {
3017
+ fill: palette.sectionFill,
3018
+ stroke: palette.sectionStroke,
3019
+ strokeWidth: SECTION_STROKE_PX,
2490
3020
  opacity: 0,
2491
- listening: false,
2492
- perfectDrawEnabled: false
2493
- });
3021
+ listening: false
3022
+ }, obj.outlinePath);
2494
3023
  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
- }
2519
3024
  const nameLabel = new Text({
2520
3025
  x: centroid.x,
2521
3026
  y: centroid.y,
@@ -2523,12 +3028,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2523
3028
  fontSize: 22,
2524
3029
  fontStyle: "700",
2525
3030
  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,
3031
+ fill: palette.sectionInk,
2532
3032
  listening: false,
2533
3033
  perfectDrawEnabled: false
2534
3034
  });
@@ -2542,11 +3042,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2542
3042
  fontSize: 12,
2543
3043
  fontStyle: "700",
2544
3044
  fontFamily: "JetBrains Mono, ui-monospace, monospace",
2545
- fill: "#f4f6fb",
2546
- shadowColor: "#05070c",
2547
- shadowBlur: 5,
2548
- shadowOpacity: 0.9,
2549
- shadowForStrokeEnabled: false,
3045
+ fill: palette.sectionInk,
2550
3046
  opacity: 0,
2551
3047
  listening: false,
2552
3048
  perfectDrawEnabled: false
@@ -2555,9 +3051,13 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2555
3051
  bgTarget.add(subLabel);
2556
3052
  const sec = {
2557
3053
  id: obj.id,
3054
+ logicalId: obj.logicalSectionId ?? obj.id,
2558
3055
  label: obj.label,
2559
3056
  outline: obj.outline,
3057
+ ...obj.outlinePath ? { outlinePath: obj.outlinePath } : {},
3058
+ holes: obj.holes ?? [],
2560
3059
  centroid,
3060
+ labelAnchors: polygonLabelCandidates(obj.outline, obj.holes ?? [], centroid),
2561
3061
  zone: obj.zone,
2562
3062
  memberIds,
2563
3063
  total: memberIds.length,
@@ -2566,9 +3066,10 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2566
3066
  outlineTint,
2567
3067
  outlinePoly,
2568
3068
  blockPoly,
2569
- rowLines,
2570
3069
  nameLabel,
2571
3070
  subLabel,
3071
+ nameLabelFits: true,
3072
+ subLabelFits: true,
2572
3073
  elevation,
2573
3074
  liftGroupBg,
2574
3075
  liftGroupSeat,
@@ -2580,7 +3081,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2580
3081
  this.sections.push(sec);
2581
3082
  }
2582
3083
  refreshSectionHeat(sec) {
2583
- const raw = this.sectionHeat.get(sec.id) ?? (sec.zone ? this.sectionHeat.get(sec.zone) : void 0);
3084
+ const raw = this.sectionHeat.get(sec.id) ?? this.sectionHeat.get(sec.logicalId) ?? (sec.zone ? this.sectionHeat.get(sec.zone) : void 0);
2584
3085
  if (raw == null || raw <= 0) {
2585
3086
  sec.outlinePoly.stroke(rgba(sec.outlineTint, 0.5));
2586
3087
  sec.outlinePoly.fill(rgba(sec.outlineTint, 0.08));
@@ -2596,7 +3097,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2596
3097
  sec.outlinePoly.shadowBlur(4 + raw * 12);
2597
3098
  sec.outlinePoly.shadowOpacity(0.25 + raw * 0.45);
2598
3099
  }
2599
- /** Recompute a section's availability-tinted fill + "N LEFT" (cheap; on status change). */
3100
+ /** Recompute a section's neutral overview state and retained detail count. */
2600
3101
  refreshSectionFill(sec) {
2601
3102
  sec.blockPoly.fill(this.sectionBlockFill(sec));
2602
3103
  sec.subLabel.text(t("map.seatsLeft", { count: sec.free }));
@@ -2604,25 +3105,12 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2604
3105
  }
2605
3106
  /** True when a section/zone is currently in the `closed` event-state. */
2606
3107
  isSectionClosed(sec) {
2607
- return this.closedSections.has(sec.id) || sec.zone != null && this.closedSections.has(sec.zone);
3108
+ return this.closedSections.has(sec.id) || this.closedSections.has(sec.logicalId) || sec.zone != null && this.closedSections.has(sec.zone);
2608
3109
  }
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
- */
3110
+ /** Clean overview shells never leak category, price, or live availability paint. */
2614
3111
  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;
3112
+ const fill = overviewPalette(this.canvasBackground).sectionFill;
3113
+ return this.isSectionClosed(sec) ? darken(fill, 0.12) : fill;
2626
3114
  }
2627
3115
  /**
2628
3116
  * Zone rung: one giant screen-constant label per zone (+ optional "FROM $n"),
@@ -2651,19 +3139,31 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2651
3139
  if (typeof p === "number" && p < minPrice) minPrice = p;
2652
3140
  }
2653
3141
  }
3142
+ const back = new Rect({
3143
+ x: cx,
3144
+ y: cy,
3145
+ width: 1,
3146
+ height: 1,
3147
+ offsetX: 0.5,
3148
+ offsetY: 0.5,
3149
+ cornerRadius: 1,
3150
+ fill: HIERARCHY_PILL_BACKGROUND,
3151
+ stroke: z.color ?? anchor.outlineTint,
3152
+ strokeWidth: 1,
3153
+ opacity: 0,
3154
+ listening: false,
3155
+ perfectDrawEnabled: false
3156
+ });
3157
+ this.bgLayer.add(back);
2654
3158
  const label = new Text({
2655
3159
  x: cx,
2656
3160
  y: cy,
2657
3161
  text: z.label.toUpperCase(),
2658
- fontSize: 34,
3162
+ fontSize: ZONE_LABEL_PX,
2659
3163
  fontStyle: "800",
2660
- letterSpacing: 2,
3164
+ letterSpacing: 0.5,
2661
3165
  fontFamily: this.labelFont(),
2662
- fill: z.color ?? "#f2f4f8",
2663
- shadowColor: "#05070c",
2664
- shadowBlur: 10,
2665
- shadowOpacity: 0.95,
2666
- shadowForStrokeEnabled: false,
3166
+ fill: "#f4f6fb",
2667
3167
  opacity: 0,
2668
3168
  listening: false,
2669
3169
  perfectDrawEnabled: false
@@ -2680,7 +3180,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2680
3180
  fontSize: 14,
2681
3181
  fontStyle: "600",
2682
3182
  fontFamily: "JetBrains Mono, ui-monospace, monospace",
2683
- fill: rgba("#e6e9f0", 0.75),
3183
+ fill: "#cbd5e1",
2684
3184
  opacity: 0,
2685
3185
  listening: false,
2686
3186
  perfectDrawEnabled: false
@@ -2688,7 +3188,14 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2688
3188
  sub.offsetX(sub.width() / 2);
2689
3189
  this.bgLayer.add(sub);
2690
3190
  }
2691
- this.zones.push({ id: z.id, label, sub });
3191
+ this.zones.push({
3192
+ id: z.id,
3193
+ anchor: { x: cx, y: cy },
3194
+ back,
3195
+ background: HIERARCHY_PILL_BACKGROUND,
3196
+ label,
3197
+ sub
3198
+ });
2692
3199
  }
2693
3200
  }
2694
3201
  /**
@@ -2710,74 +3217,106 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2710
3217
  blockT = clamp((BLOCK_MELT_TOP - scale) / (BLOCK_MELT_TOP - SECTION_PROMINENT_SCALE), 0, 1);
2711
3218
  zoneT = clamp((SECTION_PROMINENT_SCALE - scale) / (SECTION_PROMINENT_SCALE - ZONE_PROMINENT_SCALE), 0, 1);
2712
3219
  }
3220
+ const sectionOverview = scale < CACHE_THRESHOLD;
3221
+ if (sectionOverview) blockT = 1;
2713
3222
  if (!this.zones.length) zoneT = 0;
2714
- this.seatLayer.opacity(1 - blockT);
3223
+ this.seatLayer.opacity(sectionOverview ? 0 : 1 - blockT);
2715
3224
  const sx = this.stage.scaleX();
2716
3225
  const rescale = this.lodScale === 0 || Math.abs(scale - this.lodScale) / (this.lodScale || 1) > 0.02;
2717
3226
  if (rescale) this.lodScale = scale;
2718
3227
  const focus = this.focusedSectionId;
3228
+ const palette = overviewPalette(this.canvasBackground);
3229
+ const sectionLabelT = clamp((blockT - 0.2) / 0.8, 0, 1);
2719
3230
  for (const sec of this.sections) {
2720
- const dim = focus && sec.id !== focus && sec.zone !== focus ? FOCUS_DIM_OPACITY : 1;
3231
+ const dim = focus && sec.id !== focus && sec.logicalId !== focus && sec.zone !== focus ? FOCUS_DIM_OPACITY : 1;
3232
+ sec.outlinePoly.opacity(sectionOverview ? 0 : (1 - blockT) * dim);
2721
3233
  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
- }
3234
+ sec.blockPoly.stroke(palette.sectionStroke);
3235
+ sec.blockPoly.strokeWidth(SECTION_STROKE_PX / Math.max(sx, 1e-4));
3236
+ const sectionFill = sec.blockPoly.fill();
3237
+ const sectionInk = stateAwareBookableLabelInk(
3238
+ typeof sectionFill === "string" ? sectionFill : sec.baseFill,
3239
+ palette.sectionInk
3240
+ );
3241
+ sec.nameLabel.fill(sectionInk);
3242
+ sec.subLabel.fill(sectionInk);
3243
+ if (rescale) this.fitSectionRungLabels(sec, sx);
3244
+ const labelOpacity = sectionLabelT * (1 - zoneT) * dim;
3245
+ sec.nameLabel.opacity(sec.nameLabelFits ? labelOpacity : 0);
3246
+ sec.subLabel.opacity(0);
2730
3247
  }
2731
3248
  const zoneOpacity = zoneT * (1 - this.isoT);
2732
3249
  for (const zone of this.zones) {
3250
+ zone.back.opacity(zoneOpacity);
2733
3251
  zone.label.opacity(zoneOpacity);
2734
3252
  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
- }
3253
+ if (rescale) this.sizeZonePill(zone, sx);
2740
3254
  }
2741
3255
  this.decollideRungLabels(sx);
3256
+ this.dedupeLogicalSectionLabels();
3257
+ for (const zone of this.zones) {
3258
+ const opacity = zone.label.opacity();
3259
+ zone.back.opacity(opacity);
3260
+ if (zone.sub) zone.sub.opacity(opacity);
3261
+ }
2742
3262
  this.bgLayer.batchDraw();
2743
3263
  }
3264
+ /** One semantic section gets one overview label, even across split contours. */
3265
+ dedupeLogicalSectionLabels() {
3266
+ const byLogical = /* @__PURE__ */ new Map();
3267
+ for (const section of this.sections) {
3268
+ (byLogical.get(section.logicalId) ?? byLogical.set(section.logicalId, []).get(section.logicalId)).push(section);
3269
+ }
3270
+ for (const components of byLogical.values()) {
3271
+ if (components.length < 2) continue;
3272
+ const visible = components.filter((component) => component.nameLabel.opacity() > 0.05).sort((left, right) => {
3273
+ const leftBounds = polyBounds(left.outline);
3274
+ const rightBounds = polyBounds(right.outline);
3275
+ return rightBounds.width * rightBounds.height - leftBounds.width * leftBounds.height;
3276
+ });
3277
+ for (const component of visible.slice(1)) component.nameLabel.opacity(0);
3278
+ }
3279
+ }
2744
3280
  /**
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).
3281
+ * Keep transitional zone pills from covering section names. Section names
3282
+ * are already proven inside disjoint shells, so they must not cull each other.
2751
3283
  */
2752
3284
  decollideRungLabels(sx) {
2753
3285
  const GAP = 4;
2754
3286
  const cands = [];
2755
3287
  const boxOf = (t2) => {
2756
3288
  const p = this.worldToScreen({ x: t2.x(), y: t2.y() });
2757
- const w = t2.width() * sx;
2758
- const h = t2.height() * sx;
3289
+ const rotated = pointsBounds(rotatedRectPoints(
3290
+ { x: 0, y: 0 },
3291
+ t2.width() * sx,
3292
+ t2.height() * sx,
3293
+ t2.rotation()
3294
+ ));
3295
+ const w = rotated.width;
3296
+ const h = rotated.height;
2759
3297
  return { x: p.x - w / 2, y: p.y - h / 2, w, h };
2760
3298
  };
2761
3299
  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) });
3300
+ if (zone.label.opacity() > 0.05) {
3301
+ const p = this.worldToScreen(zone.anchor);
3302
+ const w = zone.back.width() * sx;
3303
+ const h = zone.back.height() * sx;
3304
+ cands.push({ node: zone.label, tier: 0, section: false, box: { x: p.x - w / 2, y: p.y - h / 2, w, h } });
3305
+ }
2764
3306
  }
2765
3307
  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) });
3308
+ if (sec.nameLabel.opacity() > 0.05) cands.push({ node: sec.nameLabel, tier: 1, section: true, box: boxOf(sec.nameLabel) });
2768
3309
  }
2769
3310
  if (cands.length < 2) return;
2770
3311
  cands.sort((a, b) => a.tier - b.tier || a.box.y - b.box.y || a.box.x - b.box.x);
2771
3312
  const kept = [];
2772
- const culled = /* @__PURE__ */ new Set();
2773
3313
  const collides = (b) => kept.some(
2774
3314
  (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
3315
  );
2776
3316
  for (const c of cands) {
2777
- if (c.owner && culled.has(c.owner) || collides(c.box)) {
3317
+ if (collides(c.box)) {
2778
3318
  c.node.opacity(0);
2779
- culled.add(c.node);
2780
- } else {
3319
+ } else if (!c.section) {
2781
3320
  kept.push(c.box);
2782
3321
  }
2783
3322
  }
@@ -2789,6 +3328,52 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2789
3328
  t2.offsetY(t2.height() / 2);
2790
3329
  t2.y(y);
2791
3330
  }
3331
+ /** Fit one centred section name, rotating narrow shells like the target chart. */
3332
+ fitSectionRungLabels(sec, sx) {
3333
+ const paddingPx = 8;
3334
+ sec.subLabelFits = false;
3335
+ for (let fontPx = SECTION_LABEL_PX; fontPx >= MIN_SECTION_LABEL_PX; fontPx -= 1) {
3336
+ for (const rotation of [0, -90]) {
3337
+ sec.nameLabel.rotation(rotation);
3338
+ this.sizeLabel(sec.nameLabel, fontPx / sx, sec.nameLabel.y());
3339
+ for (const anchor of sec.labelAnchors) {
3340
+ sec.nameLabel.position(anchor);
3341
+ const paddingWorld = paddingPx / sx;
3342
+ if (rotatedRectFitsPolygon(
3343
+ anchor,
3344
+ sec.nameLabel.width() + paddingWorld,
3345
+ sec.nameLabel.height() + paddingWorld,
3346
+ rotation,
3347
+ sec.outline,
3348
+ sec.holes
3349
+ )) {
3350
+ sec.nameLabelFits = true;
3351
+ return;
3352
+ }
3353
+ }
3354
+ }
3355
+ }
3356
+ sec.nameLabel.position(sec.centroid);
3357
+ sec.nameLabel.rotation(0);
3358
+ sec.nameLabelFits = false;
3359
+ }
3360
+ /** Size one screen-constant zone name/price pill around its shared anchor. */
3361
+ sizeZonePill(zone, sx) {
3362
+ const padX = 10 / sx;
3363
+ const padY = 6 / sx;
3364
+ const gap = zone.sub ? 3 / sx : 0;
3365
+ this.sizeLabel(zone.label, ZONE_LABEL_PX / sx, zone.anchor.y);
3366
+ if (zone.sub) this.sizeLabel(zone.sub, ZONE_SUB_PX / sx, zone.anchor.y);
3367
+ const width = Math.max(zone.label.width(), zone.sub?.width() ?? 0) + padX * 2;
3368
+ const height = zone.label.height() + (zone.sub ? gap + zone.sub.height() : 0) + padY * 2;
3369
+ zone.label.y(zone.anchor.y - (zone.sub ? (gap + zone.sub.height()) / 2 : 0));
3370
+ if (zone.sub) zone.sub.y(zone.anchor.y + (zone.label.height() + gap) / 2);
3371
+ zone.back.position(zone.anchor);
3372
+ zone.back.size({ width, height });
3373
+ zone.back.offset({ x: width / 2, y: height / 2 });
3374
+ zone.back.cornerRadius(7 / sx);
3375
+ zone.back.strokeWidth(1 / sx);
3376
+ }
2792
3377
  /**
2793
3378
  * Map a container-relative screen point back to world coords. Inverts the
2794
3379
  * stage (scale/pos) and, in iso view, the iso affine — so screen-space taps
@@ -2803,12 +3388,12 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2803
3388
  sectionAt(clientPoint) {
2804
3389
  if (!this.sections.length) return null;
2805
3390
  const world = this.screenToWorld(clientPoint);
2806
- const hit = this.sections.find((sec) => pointInPolygon(world, sec.outline));
2807
- return hit ? hit.id : null;
3391
+ const hit = this.sections.find((sec) => pointInPolygonWithHoles(world, sec.outline, sec.holes));
3392
+ return hit ? hit.logicalId : null;
2808
3393
  }
2809
3394
  /** Seat ids belonging to a section (Slice 5 section-summary card). */
2810
3395
  sectionMembers(id) {
2811
- return this.sections.find((s) => s.id === id)?.memberIds.slice() ?? [];
3396
+ return [...new Set(this.sections.filter((section) => section.id === id || section.logicalId === id || section.zone === id).flatMap((section) => section.memberIds))];
2812
3397
  }
2813
3398
  addCentredLabel(layer, text, x, y, fill, fontSize, bold) {
2814
3399
  const t2 = new Text({
@@ -2825,6 +3410,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2825
3410
  t2.offsetX(t2.width() / 2);
2826
3411
  t2.offsetY(t2.height() / 2);
2827
3412
  layer.add(t2);
3413
+ return t2;
2828
3414
  }
2829
3415
  // ---- selection ------------------------------------------------------------
2830
3416
  isSelectable(id) {
@@ -2858,21 +3444,22 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2858
3444
  * the container's computed CSS background (walking up past transparent
2859
3445
  * ancestors). Unknown/unparseable backgrounds keep the dark default.
2860
3446
  */
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;
3447
+ resolveCanvasBackground() {
3448
+ const themed = this.theme.background ? opaqueColorHex(this.theme.background) : null;
3449
+ if (themed) return themed;
3450
+ if (typeof getComputedStyle === "function") {
3451
+ let element = this.container;
3452
+ while (element) {
3453
+ const resolved = opaqueColorHex(getComputedStyle(element).backgroundColor);
3454
+ if (resolved) return resolved;
3455
+ element = element.parentElement;
2873
3456
  }
2874
3457
  }
2875
- return isLightColor(bg) ? DEF_SELECTION_ON_LIGHT : DEF_SELECTION;
3458
+ return DEF_CANVAS_BACKGROUND;
3459
+ }
3460
+ resolveSelectionColor() {
3461
+ if (this.theme.selectionColor) return this.theme.selectionColor;
3462
+ return isLightColor(this.canvasBackground) ? DEF_SELECTION_ON_LIGHT : DEF_SELECTION;
2876
3463
  }
2877
3464
  setSelected(id, on, silent = false) {
2878
3465
  const c = this.circleById.get(id);
@@ -2898,6 +3485,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2898
3485
  const candidate = this.selectionFocusId === id;
2899
3486
  const dims = this.boothDims.get(seat.rowId);
2900
3487
  const marker = new Group({
3488
+ name: "selection-ring",
2901
3489
  x: seat.x,
2902
3490
  y: seat.y,
2903
3491
  rotation: dims?.rotation ?? 0,
@@ -2905,6 +3493,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2905
3493
  perfectDrawEnabled: false,
2906
3494
  opacity: this.selectionFocusId && !candidate ? 0.2 : 1
2907
3495
  });
3496
+ marker.setAttr("seatId", id);
2908
3497
  const common = {
2909
3498
  stroke: this.effSelection,
2910
3499
  listening: false,
@@ -2981,10 +3570,18 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2981
3570
  * whether the section already fills the viewport (small container) so the tap
2982
3571
  * must fall through and pick.
2983
3572
  */
3573
+ sectionBounds(id) {
3574
+ const bounds = this.sections.filter((section) => section.id === id || section.logicalId === id).map((section) => polyBounds(section.outline));
3575
+ if (!bounds.length) return null;
3576
+ const left = Math.min(...bounds.map((box) => box.x));
3577
+ const top = Math.min(...bounds.map((box) => box.y));
3578
+ const right = Math.max(...bounds.map((box) => box.x + box.width));
3579
+ const bottom = Math.max(...bounds.map((box) => box.y + box.height));
3580
+ return { x: left, y: top, width: right - left, height: bottom - top };
3581
+ }
2984
3582
  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);
3583
+ const b = this.sectionBounds(id);
3584
+ if (!b) return this.stage.scaleX();
2988
3585
  const w = this.stage.width();
2989
3586
  const h = this.stage.height();
2990
3587
  const { min, max } = this.zoomBounds();
@@ -3014,11 +3611,11 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3014
3611
  if (this.effScale() < LABEL_SCALE && this.sections.length) {
3015
3612
  const sec = this.seatSection.get(id);
3016
3613
  if (sec) {
3017
- const alreadyFocused = this.focusedSectionId === sec.id;
3018
- const canZoomInFurther = this.sectionFrameScale(sec.id) > this.stage.scaleX() * 1.02;
3614
+ const alreadyFocused = this.focusedSectionId === sec.logicalId;
3615
+ const canZoomInFurther = this.sectionFrameScale(sec.logicalId) > this.stage.scaleX() * 1.02;
3019
3616
  if (!alreadyFocused && canZoomInFurther) {
3020
- if (this.opts.onSectionTap) this.opts.onSectionTap(sec.id);
3021
- else this.focusSection(sec.id);
3617
+ if (this.opts.onSectionTap) this.opts.onSectionTap(sec.logicalId);
3618
+ else this.focusSection(sec.logicalId);
3022
3619
  return;
3023
3620
  }
3024
3621
  }
@@ -3097,10 +3694,10 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3097
3694
  }
3098
3695
  if (this.sections.length) {
3099
3696
  const world = this.screenToWorld(pointer);
3100
- const hit = this.sections.find((sn) => pointInPolygon(world, sn.outline));
3697
+ const hit = this.sections.find((sn) => pointInPolygonWithHoles(world, sn.outline, sn.holes));
3101
3698
  if (hit) {
3102
- if (this.opts.onSectionTap) this.opts.onSectionTap(hit.id);
3103
- else this.focusRegion(hit.id);
3699
+ if (this.opts.onSectionTap) this.opts.onSectionTap(hit.logicalId);
3700
+ else this.focusRegion(hit.logicalId);
3104
3701
  return;
3105
3702
  }
3106
3703
  }
@@ -3186,10 +3783,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3186
3783
  * newer glide cancels an in-flight one.
3187
3784
  */
3188
3785
  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;
3786
+ const b = typeof target === "string" ? this.sectionBounds(target) : target;
3193
3787
  if (!b) return;
3194
3788
  this.cancelGlide();
3195
3789
  if (opts?.animate === false || this.reducedMotion) {
@@ -3249,25 +3843,343 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3249
3843
  if (this.zones.length && scale < ZONE_PROMINENT_SCALE) return "zones";
3250
3844
  return "sections";
3251
3845
  }
3252
- /** Jump the camera to a rung's zoom band, centred on the chart (glided). */
3846
+ getRenderedQualityEvidence() {
3847
+ const effectiveScale = this.effScale();
3848
+ const stageScale = this.stage.scaleX();
3849
+ const viewport = { width: this.stage.width(), height: this.stage.height() };
3850
+ const rounded = (value) => Math.round(value * 100) / 100;
3851
+ const labels = this.seats.map((seat) => {
3852
+ const shape = this.circleById.get(seat.id);
3853
+ const label = this.boothLabelById.get(seat.id) ?? this.seatLabelById.get(seat.id);
3854
+ const authoredFontSize = seat.kind === "booth" ? BOOTH_LABEL_FONT_SIZE : SEAT_LABEL_FONT_SIZE;
3855
+ const renderedFontPx = rounded((label?.fontSize() ?? authoredFontSize) * effectiveScale);
3856
+ const screen = this.worldToScreen(seat);
3857
+ const outside = screen.x < 0 || screen.x > viewport.width || screen.y < 0 || screen.y > viewport.height;
3858
+ const opacity = shape?.opacity() ?? 0;
3859
+ const section = this.seatSection.get(seat.id);
3860
+ const visible = Boolean(label?.isVisible()) && opacity >= 0.5 && !outside;
3861
+ let hiddenReason;
3862
+ if (!visible) {
3863
+ if (opacity < 0.5) hiddenReason = "dimmed-or-unavailable";
3864
+ else if (renderedFontPx < MIN_VISIBLE_BOOKABLE_LABEL_PX) hiddenReason = "below-minimum-size";
3865
+ else if (outside) hiddenReason = "outside-viewport";
3866
+ else if (!label) hiddenReason = "clutter-or-fit";
3867
+ else hiddenReason = "renderer-hidden";
3868
+ }
3869
+ const labelWidth = label ? label.width() * stageScale : 0;
3870
+ const labelHeight = label ? label.height() * effectiveScale : 0;
3871
+ const directWidthPx = shape instanceof Rect ? shape.width() * stageScale : this.seatR * 2 * effectiveScale;
3872
+ const directHeightPx = shape instanceof Rect ? shape.height() * stageScale : this.seatR * 2 * effectiveScale;
3873
+ const assistedDiameterPx = 2 * (this.seatR * effectiveScale + SEAT_TAP_SLOP_PX);
3874
+ const fill = shape?.fill();
3875
+ const ink = label?.fill();
3876
+ return {
3877
+ seatId: seat.id,
3878
+ label: seat.label,
3879
+ kind: seat.kind === "booth" ? "booth" : "seat",
3880
+ categoryKey: seat.categoryKey,
3881
+ ...section ? { sectionId: section.id } : {},
3882
+ ...section?.zone ? { zoneId: section.zone } : {},
3883
+ status: this.statusById.get(seat.id) ?? "free",
3884
+ selected: this.selection.has(seat.id),
3885
+ visible,
3886
+ renderedFontPx,
3887
+ fill: typeof fill === "string" ? fill : "",
3888
+ ink: typeof ink === "string" ? ink : this.theme.seatLabelColor ?? DEF_SEAT_LABEL,
3889
+ opacity: rounded(opacity),
3890
+ pointerTarget: {
3891
+ active: !this.cached && this.isSelectable(seat.id),
3892
+ directWidthPx: rounded(directWidthPx),
3893
+ directHeightPx: rounded(directHeightPx),
3894
+ effectiveMinimumPx: rounded(Math.max(
3895
+ Math.min(directWidthPx, directHeightPx),
3896
+ assistedDiameterPx
3897
+ ))
3898
+ },
3899
+ screenCenter: { x: rounded(screen.x), y: rounded(screen.y) },
3900
+ ...visible ? {
3901
+ screenBox: {
3902
+ x: rounded(screen.x - labelWidth / 2),
3903
+ y: rounded(screen.y - labelHeight / 2),
3904
+ width: rounded(labelWidth),
3905
+ height: rounded(labelHeight)
3906
+ }
3907
+ } : {},
3908
+ ...hiddenReason ? { hiddenReason } : {}
3909
+ };
3910
+ });
3911
+ const visibleLabels = labels.filter((label) => label.visible).length;
3912
+ const hierarchyEvidence = (id, kind, role, node, backgroundFill, section) => {
3913
+ const worldCorners = rotatedRectPoints(
3914
+ { x: node.x(), y: node.y() },
3915
+ node.width(),
3916
+ node.height(),
3917
+ node.rotation()
3918
+ );
3919
+ const screenBounds = pointsBounds(worldCorners.map((corner) => this.worldToScreen(corner)));
3920
+ const opacity = rounded(node.opacity());
3921
+ const ink = node.fill();
3922
+ const outside = screenBounds.x + screenBounds.width < 0 || screenBounds.x > viewport.width || screenBounds.y + screenBounds.height < 0 || screenBounds.y > viewport.height;
3923
+ const visible = node.isVisible() && opacity > 0.05 && !outside;
3924
+ const fitsContainer = section ? rotatedRectFitsPolygon(
3925
+ { x: node.x(), y: node.y() },
3926
+ node.width(),
3927
+ node.height(),
3928
+ node.rotation(),
3929
+ section.outline,
3930
+ section.holes
3931
+ ) : void 0;
3932
+ return {
3933
+ id,
3934
+ kind,
3935
+ role,
3936
+ label: node.text(),
3937
+ visible,
3938
+ renderedFontPx: rounded(node.fontSize() * stageScale),
3939
+ opacity,
3940
+ fill: backgroundFill,
3941
+ ink: typeof ink === "string" ? ink : "",
3942
+ ...fitsContainer == null ? {} : { fitsContainer },
3943
+ ...visible ? {
3944
+ screenBox: {
3945
+ x: rounded(screenBounds.x),
3946
+ y: rounded(screenBounds.y),
3947
+ width: rounded(screenBounds.width),
3948
+ height: rounded(screenBounds.height)
3949
+ }
3950
+ } : {}
3951
+ };
3952
+ };
3953
+ const hierarchyLabels = [
3954
+ ...this.sections.map((section) => {
3955
+ const fill = section.blockPoly.fill();
3956
+ return hierarchyEvidence(
3957
+ section.id,
3958
+ "section",
3959
+ "name",
3960
+ section.nameLabel,
3961
+ typeof fill === "string" ? fill : section.baseFill,
3962
+ section
3963
+ );
3964
+ }),
3965
+ ...this.sections.map((section) => {
3966
+ const fill = section.blockPoly.fill();
3967
+ return hierarchyEvidence(
3968
+ `${section.id}:availability`,
3969
+ "section",
3970
+ "availability",
3971
+ section.subLabel,
3972
+ typeof fill === "string" ? fill : section.baseFill,
3973
+ section
3974
+ );
3975
+ }),
3976
+ ...this.zones.flatMap((zone) => [
3977
+ hierarchyEvidence(zone.id, "zone", "name", zone.label, zone.background),
3978
+ ...zone.sub ? [hierarchyEvidence(`${zone.id}:price`, "zone", "price", zone.sub, zone.background)] : []
3979
+ ])
3980
+ ];
3981
+ const gaAreas = [...this.gaById].map(([areaId, ga]) => {
3982
+ const screenPoints = ga.points.map((point) => this.worldToScreen(point));
3983
+ const left = Math.min(...screenPoints.map((point) => point.x));
3984
+ const top = Math.min(...screenPoints.map((point) => point.y));
3985
+ const right = Math.max(...screenPoints.map((point) => point.x));
3986
+ const bottom = Math.max(...screenPoints.map((point) => point.y));
3987
+ const outside = right < 0 || left > viewport.width || bottom < 0 || top > viewport.height;
3988
+ const opacity = rounded(ga.polygon.opacity());
3989
+ const visible = opacity >= 0.1 && !outside;
3990
+ const fill = ga.polygon.fill();
3991
+ return {
3992
+ areaId,
3993
+ label: ga.label,
3994
+ capacity: ga.capacity,
3995
+ categoryKey: ga.categoryKey,
3996
+ ...ga.sectionId ? { sectionId: ga.sectionId } : {},
3997
+ visible,
3998
+ interactive: ga.polygon.listening(),
3999
+ opacity,
4000
+ fill: typeof fill === "string" ? fill : "",
4001
+ effectiveBackground: ga.effectiveBackground,
4002
+ ...visible ? {
4003
+ screenBox: {
4004
+ x: rounded(left),
4005
+ y: rounded(top),
4006
+ width: rounded(right - left),
4007
+ height: rounded(bottom - top)
4008
+ }
4009
+ } : {}
4010
+ };
4011
+ });
4012
+ const freeTextLabels = [...this.freeTextById].map(([recordKey, record]) => {
4013
+ const { node, background, kind } = record;
4014
+ const point = this.worldToScreen({ x: node.x(), y: node.y() });
4015
+ const width = node.width() * stageScale;
4016
+ const height = node.height() * effectiveScale;
4017
+ const left = point.x - node.offsetX() * stageScale;
4018
+ const top = point.y - node.offsetY() * effectiveScale;
4019
+ const renderedFontPx = rounded(node.fontSize() * effectiveScale);
4020
+ const outside = left + width < 0 || left > viewport.width || top + height < 0 || top > viewport.height;
4021
+ const visible = node.isVisible() && !outside;
4022
+ const ink = node.fill();
4023
+ const opacity = rounded(node.getAbsoluteOpacity());
4024
+ let hiddenReason;
4025
+ if (!visible) {
4026
+ if (renderedFontPx < MIN_VISIBLE_BOOKABLE_LABEL_PX) hiddenReason = "below-minimum-size";
4027
+ else if (outside) hiddenReason = "outside-viewport";
4028
+ else hiddenReason = "renderer-hidden";
4029
+ }
4030
+ return {
4031
+ objectId: record.objectId ?? recordKey,
4032
+ kind,
4033
+ text: node.text(),
4034
+ visible,
4035
+ renderedFontPx,
4036
+ ink: typeof ink === "string" ? ink : "",
4037
+ background,
4038
+ opacity,
4039
+ ...visible ? {
4040
+ screenBox: {
4041
+ x: rounded(left),
4042
+ y: rounded(top),
4043
+ width: rounded(width),
4044
+ height: rounded(height)
4045
+ }
4046
+ } : {},
4047
+ ...hiddenReason ? { hiddenReason } : {}
4048
+ };
4049
+ });
4050
+ const palette = overviewPalette(this.canvasBackground);
4051
+ const neutralSectionFills = /* @__PURE__ */ new Set([
4052
+ palette.sectionFill.toLowerCase(),
4053
+ darken(palette.sectionFill, 0.12).toLowerCase()
4054
+ ]);
4055
+ const visibleSectionShells = this.sections.filter((section) => section.blockPoly.opacity() > 0.05);
4056
+ return {
4057
+ viewport,
4058
+ canvasBackground: this.canvasBackground,
4059
+ effectiveScale: rounded(effectiveScale),
4060
+ rung: this.getRung(),
4061
+ minimumVisibleLabelPx: MIN_VISIBLE_BOOKABLE_LABEL_PX,
4062
+ totalLabelledBookableUnits: labels.length,
4063
+ visibleLabels,
4064
+ hiddenLabels: labels.length - visibleLabels,
4065
+ totalBookableUnits: labels.length + gaAreas.reduce((sum, area) => sum + area.capacity, 0),
4066
+ selectionRingSeatIds: this.overlayLayer.find(".selection-ring").map((node) => String(node.getAttr("seatId") ?? "")).filter(Boolean),
4067
+ selectionRingColor: this.effSelection,
4068
+ focusedSectionId: this.focusedSectionId,
4069
+ focusBackdropVisible: Boolean(this.focusBackdrop?.isVisible()),
4070
+ categoryFilterKeys: this.categoryFilter ? [...this.categoryFilter].sort() : null,
4071
+ overviewStyle: {
4072
+ visibleSectionShells: visibleSectionShells.length,
4073
+ categoryPaintedSectionShells: visibleSectionShells.filter((section) => {
4074
+ const fill = section.blockPoly.fill();
4075
+ return typeof fill !== "string" || !neutralSectionFills.has(fill.toLowerCase());
4076
+ }).length,
4077
+ visibleCategoryDetailOutlines: this.sections.filter((section) => section.outlinePoly.opacity() > 0.05).length,
4078
+ // Row-hint nodes no longer exist in the production overview scene.
4079
+ visibleSectionRowHints: 0,
4080
+ visibleSectionAvailabilityLabels: this.sections.filter((section) => section.subLabel.opacity() > 0.05).length,
4081
+ visibleSectionGADetails: [...this.gaById.values()].filter((area) => area.sectionId != null && area.polygon.opacity() > 0.05).length
4082
+ },
4083
+ labels,
4084
+ gaAreas,
4085
+ hierarchyLabels,
4086
+ freeTextLabels
4087
+ };
4088
+ }
4089
+ /** Jump the camera to a rung's zoom band (glided). */
3253
4090
  setRung(rung) {
3254
4091
  if (rung === "zones") {
3255
4092
  this.cancelGlide();
3256
4093
  this.zoomToFit();
3257
4094
  return;
3258
4095
  }
3259
- const target = rung === "sections" ? (SECTION_PROMINENT_SCALE + CACHE_THRESHOLD) / 2 : Math.max(SEAT_FOCUS_SCALE, CACHE_THRESHOLD * 1.3);
4096
+ const target = rung === "sections" ? (SECTION_PROMINENT_SCALE + CACHE_THRESHOLD) / 2 : Math.max(
4097
+ this.seatLabelTargetScale() * 1.05,
4098
+ SEAT_FOCUS_SCALE,
4099
+ CACHE_THRESHOLD * 1.3
4100
+ );
3260
4101
  const w = this.stage.width();
3261
4102
  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;
4103
+ const visible = this.getVisibleWorldRect();
4104
+ const viewCentre = {
4105
+ x: visible.x + visible.width / 2,
4106
+ y: visible.y + visible.height / 2
4107
+ };
4108
+ let cx = viewCentre.x;
4109
+ let cy = viewCentre.y;
4110
+ if (rung === "sections" && this.sections.length > 0) {
4111
+ const sectionCentres = this.sections.map((section) => {
4112
+ const bounds = polyBounds(section.outline);
4113
+ return {
4114
+ x: bounds.x + bounds.width / 2,
4115
+ y: bounds.y + bounds.height / 2
4116
+ };
4117
+ });
4118
+ const halfWidth = w / (target * 2);
4119
+ const halfHeight = h / (target * 2);
4120
+ const hierarchyWillBeVisible = sectionCentres.some((point) => Math.abs(point.x - viewCentre.x) <= halfWidth && Math.abs(point.y - viewCentre.y) <= halfHeight);
4121
+ if (!hierarchyWillBeVisible) {
4122
+ const nearest = sectionCentres.reduce((best, point) => {
4123
+ const distance = (point.x - viewCentre.x) ** 2 + (point.y - viewCentre.y) ** 2;
4124
+ return distance < best.distance ? { point, distance } : best;
4125
+ }, { point: sectionCentres[0], distance: Infinity });
4126
+ cx = nearest.point.x;
4127
+ cy = nearest.point.y;
4128
+ }
4129
+ }
4130
+ const seatAnchors = rung === "seats" ? this.seats.filter((seat) => seat.kind !== "booth") : [];
4131
+ if (seatAnchors.length > 0) {
4132
+ let nearest = seatAnchors[0];
4133
+ let nearestDistance = Infinity;
4134
+ for (const seat of seatAnchors) {
4135
+ const dx = seat.x - viewCentre.x;
4136
+ const dy = seat.y - viewCentre.y;
4137
+ const distance = dx * dx + dy * dy;
4138
+ if (distance < nearestDistance) {
4139
+ nearest = seat;
4140
+ nearestDistance = distance;
4141
+ }
4142
+ }
4143
+ cx = nearest.x;
4144
+ cy = nearest.y;
4145
+ }
3264
4146
  const bw = w / (target * 1.12);
3265
4147
  const bh = h / (target * 1.12);
3266
4148
  this.focusRegion({ x: cx - bw / 2, y: cy - bh / 2, width: bw, height: bh });
3267
4149
  }
4150
+ /**
4151
+ * The seat rung must account for labels that auto-fit inside a seat circle.
4152
+ * A short `A-1` remains at the normal 7u target; a table label such as
4153
+ * `T13-10` may fit at 4u and therefore needs a deeper camera target to reach
4154
+ * the same 12 CSS-pixel floor. Measurement happens only on explicit rung
4155
+ * navigation, never during pan/zoom frames.
4156
+ */
4157
+ seatLabelTargetScale() {
4158
+ let minimumFont = BOOTH_LABEL_FONT_SIZE;
4159
+ const measure = new Text({
4160
+ fontSize: SEAT_LABEL_FONT_SIZE,
4161
+ fontStyle: "600",
4162
+ fontFamily: this.labelFont(),
4163
+ listening: false
4164
+ });
4165
+ const maxWidth = this.seatR * 2 - 3;
4166
+ for (const seat of this.seats) {
4167
+ if (seat.kind === "booth") {
4168
+ minimumFont = Math.min(minimumFont, BOOTH_LABEL_FONT_SIZE);
4169
+ continue;
4170
+ }
4171
+ measure.fontSize(SEAT_LABEL_FONT_SIZE);
4172
+ measure.text(bookableMarkerLabel(seat.label));
4173
+ const fitted = measure.width() > maxWidth ? Math.max(MIN_FITTED_SEAT_LABEL_FONT_SIZE, SEAT_LABEL_FONT_SIZE * maxWidth / measure.width()) : SEAT_LABEL_FONT_SIZE;
4174
+ minimumFont = Math.min(minimumFont, fitted);
4175
+ }
4176
+ measure.destroy();
4177
+ return MIN_VISIBLE_BOOKABLE_LABEL_PX / Math.max(MIN_FITTED_SEAT_LABEL_FONT_SIZE, minimumFont);
4178
+ }
3268
4179
  /** Recompute LOD (cache/labels) after any pan/zoom settles. */
3269
4180
  afterViewChange() {
3270
4181
  this.updateLOD();
4182
+ this.updateFreeTextVisibility();
3271
4183
  this.updateLabels();
3272
4184
  this.scheduleViewChange();
3273
4185
  }
@@ -3281,7 +4193,13 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3281
4193
  }
3282
4194
  updateLOD() {
3283
4195
  const scale = this.effScale();
4196
+ const focalScale = Math.max(scale, 1e-4);
4197
+ for (const [label, targetPx] of this.primaryFocalLabels) {
4198
+ this.sizeLabel(label, targetPx / focalScale, label.y());
4199
+ }
3284
4200
  if (this.hasSections) this.applySectionLod(scale);
4201
+ else if (this.primaryFocalLabels.size) this.bgLayer.batchDraw();
4202
+ this.paintGAStateForView();
3285
4203
  const shouldCache = scale < CACHE_THRESHOLD;
3286
4204
  if (shouldCache && !this.cached) {
3287
4205
  this.cacheSeatLayer();
@@ -3320,15 +4238,37 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3320
4238
  if (this.recacheTimer) {
3321
4239
  clearTimeout(this.recacheTimer);
3322
4240
  this.recacheTimer = null;
3323
- this.rebuildSeatCache();
4241
+ if (this.effScale() < CACHE_THRESHOLD) {
4242
+ this.rebuildSeatCache();
4243
+ } else if (this.cached) {
4244
+ this.seatLayer.clearCache();
4245
+ this.seatLayer.listening(true);
4246
+ this.cached = false;
4247
+ }
3324
4248
  }
3325
4249
  this.bgLayer.draw();
3326
4250
  this.seatLayer.draw();
3327
4251
  this.overlayLayer.draw();
3328
4252
  }
4253
+ updateFreeTextVisibility() {
4254
+ const effectiveScale = this.effScale();
4255
+ for (const { objectId, node, categoryKey, kind } of this.freeTextById.values()) {
4256
+ const gaDimmed = categoryKey != null && (kind === "ga-label" || kind === "ga-capacity") && this.gaCategoryDimmed(categoryKey);
4257
+ const gaOverviewHidden = objectId != null && (kind === "ga-label" || kind === "ga-capacity") && this.gaById.get(objectId)?.sectionId != null && effectiveScale < CACHE_THRESHOLD;
4258
+ node.visible(!gaDimmed && !gaOverviewHidden && isBookableLabelLegibleAtScale(node.fontSize(), effectiveScale));
4259
+ }
4260
+ }
3329
4261
  updateLabels() {
3330
- const show = this.effScale() > LABEL_SCALE;
4262
+ const effectiveScale = this.effScale();
4263
+ const show = effectiveScale >= LABEL_SCALE;
4264
+ for (const [id, label] of this.boothLabelById) {
4265
+ const shape = this.circleById.get(id);
4266
+ label.visible(
4267
+ isBookableLabelLegibleAtScale(label.fontSize(), effectiveScale) && (shape?.opacity() ?? 1) >= 0.5
4268
+ );
4269
+ }
3331
4270
  this.labelGroup.destroyChildren();
4271
+ this.seatLabelById.clear();
3332
4272
  if (!show) {
3333
4273
  this.overlayLayer.batchDraw();
3334
4274
  return;
@@ -3342,8 +4282,10 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3342
4282
  for (const seat of this.seats) {
3343
4283
  if (seat.kind === "booth") continue;
3344
4284
  if (seat.x < x0 || seat.x > x1 || seat.y < y0 || seat.y > y1) continue;
4285
+ const shape = this.circleById.get(seat.id);
4286
+ if ((shape?.opacity() ?? 1) < 0.5) continue;
3345
4287
  const status = this.statusById.get(seat.id) ?? "free";
3346
- const unavailable = status === "booked" || status === "held" && !this.ownedHold.has(seat.id);
4288
+ const unavailable = status === "booked" || status === "held" && !this.ownedHold.has(seat.id) && !this.opts.manageMode;
3347
4289
  if (unavailable) {
3348
4290
  const cue = new Group({ x: seat.x, y: seat.y, listening: false });
3349
4291
  if (status === "held") {
@@ -3381,23 +4323,28 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3381
4323
  const t2 = new Text({
3382
4324
  x: seat.x,
3383
4325
  y: seat.y,
3384
- text: seat.label,
3385
- fontSize: 7,
4326
+ text: bookableMarkerLabel(seat.label),
4327
+ fontSize: SEAT_LABEL_FONT_SIZE,
3386
4328
  fontStyle: "600",
3387
4329
  fontFamily: this.labelFont(),
3388
- fill: this.theme.seatLabelColor ?? DEF_SEAT_LABEL,
4330
+ fill: shape ? this.renderedBookableLabelInk(seat.id, shape) : this.theme.seatLabelColor ?? DEF_SEAT_LABEL,
3389
4331
  listening: false,
3390
4332
  perfectDrawEnabled: false
3391
4333
  });
3392
4334
  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) {
4335
+ if (t2.width() > maxW) t2.fontSize(Math.max(MIN_FITTED_SEAT_LABEL_FONT_SIZE, SEAT_LABEL_FONT_SIZE * maxW / t2.width()));
4336
+ if (t2.width() > maxW + 0.01) {
4337
+ t2.destroy();
4338
+ continue;
4339
+ }
4340
+ if (!isBookableLabelLegibleAtScale(t2.fontSize(), effectiveScale)) {
3395
4341
  t2.destroy();
3396
4342
  continue;
3397
4343
  }
3398
4344
  t2.offsetX(t2.width() / 2);
3399
4345
  t2.offsetY(t2.height() / 2);
3400
4346
  this.labelGroup.add(t2);
4347
+ this.seatLabelById.set(seat.id, t2);
3401
4348
  if (++count >= MAX_LABELS) break;
3402
4349
  }
3403
4350
  if (this.isoT > 0) this.applyUprightLabels();
@@ -4649,9 +5596,9 @@ function generateSeatThumb(seat, focalPoint, _neighborSeats) {
4649
5596
 
4650
5597
  // src/i18n/bundles.ts
4651
5598
  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 }))
5599
+ es: () => import("./es-NLD3NL3W.js").then((m) => ({ default: m.es })),
5600
+ de: () => import("./de-ZTKJOFHT.js").then((m) => ({ default: m.de })),
5601
+ fr: () => import("./fr-KSFKWSAT.js").then((m) => ({ default: m.fr }))
4655
5602
  };
4656
5603
  var loaded = /* @__PURE__ */ new Set(["en"]);
4657
5604
  async function loadLocale(code) {
@@ -4709,6 +5656,8 @@ export {
4709
5656
  loadLocale,
4710
5657
  objectCenter,
4711
5658
  pointInPolygon,
5659
+ pointInPolygonWithHoles,
5660
+ polygonLabelPoint,
4712
5661
  resolveLocale,
4713
5662
  setLocale,
4714
5663
  setMoneyLocale,