@seatlayer/core 0.16.1 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -56,6 +56,8 @@ var init_es = __esm({
56
56
  // buyer picker page (src/pages/PickerPage.tsx)
57
57
  "picker.language": "Idioma",
58
58
  "picker.zoomToFit": "Ajustar zoom",
59
+ "picker.seatCountLabel": "asientos",
60
+ "picker.capacity": "capacidad",
59
61
  "picker.viewMode": "Modo de vista",
60
62
  "picker.floor": "Piso",
61
63
  "picker.zoomLevel": "Nivel de zoom",
@@ -138,6 +140,8 @@ var init_de = __esm({
138
140
  // buyer picker page (src/pages/PickerPage.tsx)
139
141
  "picker.language": "Sprache",
140
142
  "picker.zoomToFit": "Auf Gr\xF6\xDFe anpassen",
143
+ "picker.seatCountLabel": "Pl\xE4tze",
144
+ "picker.capacity": "Kapazit\xE4t",
141
145
  "picker.viewMode": "Ansichtsmodus",
142
146
  "picker.floor": "Etage",
143
147
  "picker.zoomLevel": "Zoomstufe",
@@ -220,6 +224,8 @@ var init_fr = __esm({
220
224
  // buyer picker page (src/pages/PickerPage.tsx)
221
225
  "picker.language": "Langue",
222
226
  "picker.zoomToFit": "Ajuster le zoom",
227
+ "picker.seatCountLabel": "si\xE8ges",
228
+ "picker.capacity": "capacit\xE9",
223
229
  "picker.viewMode": "Mode d'affichage",
224
230
  "picker.floor": "\xC9tage",
225
231
  "picker.zoomLevel": "Niveau de zoom",
@@ -269,9 +275,12 @@ var init_fr = __esm({
269
275
  // src/index.ts
270
276
  var index_exports = {};
271
277
  __export(index_exports, {
278
+ ACCESSIBILITY_RING_COLOR: () => ACCESSIBILITY_RING_COLOR,
272
279
  ACCESSIBILITY_TYPES: () => ACCESSIBILITY_TYPES,
273
280
  CHART_STORAGE_KEY: () => CHART_STORAGE_KEY,
274
281
  DEFAULT_CURRENCY: () => DEFAULT_CURRENCY,
282
+ LABEL_STYLE_MAX_SIZE: () => LABEL_STYLE_MAX_SIZE,
283
+ LABEL_STYLE_MIN_SIZE: () => LABEL_STYLE_MIN_SIZE,
275
284
  MAX_EVENT_INVENTORY: () => MAX_EVENT_INVENTORY,
276
285
  MAX_GA_CAPACITY: () => MAX_GA_CAPACITY,
277
286
  PickerController: () => PickerController,
@@ -279,6 +288,7 @@ __export(index_exports, {
279
288
  SeatmapRenderer: () => SeatmapRenderer,
280
289
  UNGROUPED_ID: () => UNGROUPED_ID,
281
290
  accessibilityMeta: () => accessibilityMeta,
291
+ accessibilityRingColor: () => accessibilityRingColor,
282
292
  allObjects: () => allObjects,
283
293
  applyHidden: () => applyHidden,
284
294
  chartBounds: () => chartBounds,
@@ -307,6 +317,8 @@ __export(index_exports, {
307
317
  loadLocale: () => loadLocale,
308
318
  objectCenter: () => objectCenter,
309
319
  pointInPolygon: () => pointInPolygon,
320
+ pointInPolygonWithHoles: () => pointInPolygonWithHoles,
321
+ polygonLabelPoint: () => polygonLabelPoint,
310
322
  resolveLocale: () => resolveLocale,
311
323
  setLocale: () => setLocale,
312
324
  setMoneyLocale: () => setMoneyLocale,
@@ -331,6 +343,21 @@ var ACCESSIBILITY_LABEL = new Map(ACCESSIBILITY_TYPES.map((a) => [a.key, a]));
331
343
  function accessibilityMeta(key) {
332
344
  return ACCESSIBILITY_LABEL.get(key);
333
345
  }
346
+ var ACCESSIBILITY_RING_COLOR = {
347
+ wheelchair: "#3b82f6",
348
+ companion: "#8b5cf6",
349
+ "semi-ambulatory": "#0ea5e9",
350
+ hearing: "#14b8a6",
351
+ "sign-language": "#f59e0b",
352
+ "plus-size": "#ec4899",
353
+ "lift-armrest": "#22c55e"
354
+ };
355
+ function accessibilityRingColor(types) {
356
+ const primary = types?.[0];
357
+ return primary && ACCESSIBILITY_RING_COLOR[primary] || "#3b82f6";
358
+ }
359
+ var LABEL_STYLE_MIN_SIZE = 8;
360
+ var LABEL_STYLE_MAX_SIZE = 24;
334
361
  function layerOf(obj) {
335
362
  switch (obj.type) {
336
363
  case "row":
@@ -350,6 +377,71 @@ function layerOf(obj) {
350
377
  }
351
378
  var CHART_STORAGE_KEY = "seatmap.chart";
352
379
 
380
+ // src/core/complexGeometry.ts
381
+ function cubicPoint(path, t2) {
382
+ const u = 1 - t2;
383
+ const a = u * u * u;
384
+ const b = 3 * u * u * t2;
385
+ const c = 3 * u * t2 * t2;
386
+ const d = t2 * t2 * t2;
387
+ return {
388
+ x: a * path.start.x + b * path.control1.x + c * path.control2.x + d * path.end.x,
389
+ y: a * path.start.y + b * path.control1.y + c * path.control2.y + d * path.end.y
390
+ };
391
+ }
392
+ function distributeAlongCubic(path, count, resolution = 192) {
393
+ if (!Number.isInteger(count) || count < 1) throw new Error("Path point count must be a positive integer");
394
+ if (count === 1) return [cubicPoint(path, 0.5)];
395
+ const samples = Array.from({ length: resolution + 1 }, (_, index) => cubicPoint(path, index / resolution));
396
+ const lengths = new Float64Array(samples.length);
397
+ for (let index = 1; index < samples.length; index += 1) {
398
+ const dx = samples[index].x - samples[index - 1].x;
399
+ const dy = samples[index].y - samples[index - 1].y;
400
+ lengths[index] = lengths[index - 1] + Math.hypot(dx, dy);
401
+ }
402
+ const total = lengths[lengths.length - 1];
403
+ if (total <= 1e-9) return Array.from({ length: count }, () => ({ ...path.start }));
404
+ const output = [];
405
+ let segment = 1;
406
+ for (let index = 0; index < count; index += 1) {
407
+ const target = total * index / (count - 1);
408
+ while (segment < lengths.length - 1 && lengths[segment] < target) segment += 1;
409
+ const before = lengths[segment - 1];
410
+ const after = lengths[segment];
411
+ const ratio = after === before ? 0 : (target - before) / (after - before);
412
+ output.push({
413
+ x: samples[segment - 1].x + (samples[segment].x - samples[segment - 1].x) * ratio,
414
+ y: samples[segment - 1].y + (samples[segment].y - samples[segment - 1].y) * ratio
415
+ });
416
+ }
417
+ return output;
418
+ }
419
+
420
+ // src/core/sectionPath.ts
421
+ var TAU = Math.PI * 2;
422
+ function translateSectionOutlinePath(path, dx, dy) {
423
+ const translate = (point) => ({ x: point.x + dx, y: point.y + dy });
424
+ return transformSectionOutlinePath(path, translate);
425
+ }
426
+ function transformSectionOutlinePath(path, transform, radiusScale = 1, reflected = false) {
427
+ return {
428
+ ...path,
429
+ start: transform(path.start),
430
+ segments: path.segments.map((segment) => segment.kind === "line" ? { ...segment, end: transform(segment.end) } : segment.kind === "arc" ? {
431
+ ...segment,
432
+ center: transform(segment.center),
433
+ radius: segment.radius * Math.abs(radiusScale),
434
+ clockwise: reflected ? !segment.clockwise : segment.clockwise,
435
+ end: transform(segment.end)
436
+ } : {
437
+ ...segment,
438
+ control1: transform(segment.control1),
439
+ control2: transform(segment.control2),
440
+ end: transform(segment.end)
441
+ })
442
+ };
443
+ }
444
+
353
445
  // src/core/layout.ts
354
446
  function overrideAccessibility(o) {
355
447
  if (!o) return [];
@@ -371,6 +463,7 @@ function place(lx, ly, deg, origin) {
371
463
  function rowSeatPositions(row) {
372
464
  const { seatCount, seatSpacing, curve, rotation, origin } = row;
373
465
  const out = [];
466
+ if (row.path) return distributeAlongCubic(row.path, seatCount);
374
467
  if (seatCount <= 1) {
375
468
  if (seatCount === 1) out.push({ x: origin.x, y: origin.y });
376
469
  return out;
@@ -411,15 +504,21 @@ function expandRowSlots(row) {
411
504
  return rowSeatPositions(row).map((p, i) => {
412
505
  const o = ov.get(i);
413
506
  const accessibility = overrideAccessibility(o);
507
+ const inventoryLabel = o?.label ?? `${row.label}-${seatNumber(i)}`;
508
+ const displayPrefix = row.displayLabel ?? row.label;
509
+ const commercial = { ...row.commercial, ...o?.commercial };
414
510
  return {
415
511
  index: i,
416
512
  x: p.x + (o?.dx ?? 0),
417
513
  y: p.y + (o?.dy ?? 0),
418
- label: o?.label ?? `${row.label}-${seatNumber(i)}`,
514
+ label: inventoryLabel,
515
+ displayLabel: o?.displayLabel ?? `${displayPrefix}-${seatNumber(i)}`,
419
516
  categoryKey: o?.categoryKey ?? row.categoryKey,
420
517
  skipped: !!o?.skip,
421
518
  accessible: accessibility.length > 0,
422
- accessibility
519
+ accessibility,
520
+ commercial: Object.values(commercial).some((value) => value !== void 0 && value !== false && value !== "") ? commercial : void 0,
521
+ viewUrl: o?.viewFromSeatUrl ?? row.viewFromSeatUrl
423
522
  };
424
523
  });
425
524
  }
@@ -430,13 +529,15 @@ function expandRow(row) {
430
529
  seats.push({
431
530
  id: `${row.id}:${slot.index}`,
432
531
  label: slot.label,
532
+ displayLabel: slot.displayLabel === slot.label ? void 0 : slot.displayLabel,
433
533
  x: slot.x,
434
534
  y: slot.y,
435
535
  rowId: row.id,
436
536
  categoryKey: slot.categoryKey,
437
537
  accessible: slot.accessible || void 0,
438
538
  accessibility: slot.accessibility.length ? slot.accessibility : void 0,
439
- viewUrl: row.viewFromSeatUrl
539
+ commercial: slot.commercial,
540
+ viewUrl: slot.viewUrl
440
541
  });
441
542
  }
442
543
  return seats;
@@ -533,6 +634,53 @@ function pointInPolygon(p, poly) {
533
634
  }
534
635
  return inside;
535
636
  }
637
+ function pointOnPolygonBoundary(p, poly) {
638
+ return poly.some((start, index) => {
639
+ const end = poly[(index + 1) % poly.length];
640
+ const cross = (p.y - start.y) * (end.x - start.x) - (p.x - start.x) * (end.y - start.y);
641
+ if (Math.abs(cross) > 1e-7) return false;
642
+ const dot = (p.x - start.x) * (end.x - start.x) + (p.y - start.y) * (end.y - start.y);
643
+ const lengthSquared = (end.x - start.x) ** 2 + (end.y - start.y) ** 2;
644
+ return dot >= -1e-7 && dot <= lengthSquared + 1e-7;
645
+ });
646
+ }
647
+ function pointInPolygonWithHoles(p, outer, holes) {
648
+ return pointInPolygon(p, outer) && !(holes ?? []).some((hole) => pointInPolygon(p, hole) || pointOnPolygonBoundary(p, hole));
649
+ }
650
+ function polygonLabelPoint(outer, holes) {
651
+ if (!outer.length) return { x: 0, y: 0 };
652
+ const xs = outer.map((point) => point.x);
653
+ const ys = outer.map((point) => point.y);
654
+ const bounds = { minX: Math.min(...xs), maxX: Math.max(...xs), minY: Math.min(...ys), maxY: Math.max(...ys) };
655
+ const centroid = polygonCentroid(outer);
656
+ if (pointInPolygonWithHoles(centroid, outer, holes)) return centroid;
657
+ let best = outer[0];
658
+ let bestScore = -Infinity;
659
+ const rings = [outer, ...holes ?? []];
660
+ for (let row = 1; row < 24; row += 1) {
661
+ for (let column = 1; column < 24; column += 1) {
662
+ const point = {
663
+ x: bounds.minX + (bounds.maxX - bounds.minX) * column / 24,
664
+ y: bounds.minY + (bounds.maxY - bounds.minY) * row / 24
665
+ };
666
+ if (!pointInPolygonWithHoles(point, outer, holes)) continue;
667
+ const score = Math.min(...rings.flatMap((ring) => ring.map((start, index) => {
668
+ const end = ring[(index + 1) % ring.length];
669
+ const dx = end.x - start.x;
670
+ const dy = end.y - start.y;
671
+ const denominator = dx * dx + dy * dy;
672
+ const projection = denominator ? ((point.x - start.x) * dx + (point.y - start.y) * dy) / denominator : 0;
673
+ const t2 = Math.max(0, Math.min(1, projection));
674
+ return Math.hypot(point.x - (start.x + t2 * dx), point.y - (start.y + t2 * dy));
675
+ })));
676
+ if (score > bestScore) {
677
+ best = point;
678
+ bestScore = score;
679
+ }
680
+ }
681
+ }
682
+ return best;
683
+ }
536
684
  function polygonCentroid(pts) {
537
685
  if (!pts.length) return { x: 0, y: 0 };
538
686
  let x = 0;
@@ -596,9 +744,14 @@ function translateObject(o, dx, dy) {
596
744
  case "booth":
597
745
  return { ...o, center: p(o.center) };
598
746
  case "gaArea":
599
- return { ...o, points: pts(o.points) };
747
+ return { ...o, points: pts(o.points), ...o.holes ? { holes: o.holes.map(pts) } : {} };
600
748
  case "section":
601
- return { ...o, outline: pts(o.outline) };
749
+ return {
750
+ ...o,
751
+ outline: pts(o.outline),
752
+ ...o.outlinePath ? { outlinePath: translateSectionOutlinePath(o.outlinePath, dx, dy) } : {},
753
+ ...o.holes ? { holes: o.holes.map(pts) } : {}
754
+ };
602
755
  case "text":
603
756
  return { ...o, position: p(o.position) };
604
757
  case "shape":
@@ -721,12 +874,33 @@ function objectSeatLabels(o) {
721
874
  function isSeatObject(o) {
722
875
  return o.type === "row" || o.type === "table" || o.type === "booth" || o.type === "gaArea";
723
876
  }
877
+ function samePoints(left, right) {
878
+ return left.length === right.length && left.every((point, index) => point.x === right[index].x && point.y === right[index].y);
879
+ }
880
+ function sameGASurfaceAsSection(object, section) {
881
+ if (object.type !== "gaArea" || !samePoints(object.points, section.outline)) return false;
882
+ const objectHoles = object.holes ?? [];
883
+ const sectionHoles = section.holes ?? [];
884
+ return objectHoles.length === sectionHoles.length && objectHoles.every((hole, index) => samePoints(hole, sectionHoles[index]));
885
+ }
724
886
  function computeSections(doc) {
725
887
  const objs = allObjects(doc);
726
888
  const sectionObjs = objs.filter((o) => o.type === "section");
727
889
  const nodes = /* @__PURE__ */ new Map();
728
890
  for (const s of sectionObjs) {
729
- nodes.set(s.id, { id: s.id, label: s.label || "Section", zone: s.zone, seatCount: 0, objectIds: [], seatLabels: [] });
891
+ const logicalId = s.logicalSectionId ?? s.id;
892
+ const existing = nodes.get(logicalId);
893
+ if (existing) {
894
+ continue;
895
+ }
896
+ nodes.set(logicalId, {
897
+ id: logicalId,
898
+ label: s.displayLabel || s.label || "Section",
899
+ zone: s.zone,
900
+ seatCount: 0,
901
+ objectIds: [],
902
+ seatLabels: []
903
+ });
730
904
  }
731
905
  const ungrouped = { id: UNGROUPED_ID, label: "Other seats", seatCount: 0, objectIds: [], seatLabels: [] };
732
906
  const objectToSection = /* @__PURE__ */ new Map();
@@ -734,22 +908,24 @@ function computeSections(doc) {
734
908
  if (!isSeatObject(obj)) continue;
735
909
  const labels = objectSeatLabels(obj);
736
910
  if (labels.length === 0) continue;
911
+ const referencedLogicalId = obj.referenceInventorySource?.logicalSectionId;
737
912
  const c = objectCenter(obj);
738
- const owner = sectionObjs.find((s) => pointInPolygon(c, s.outline));
739
- const node = owner ? nodes.get(owner.id) : ungrouped;
913
+ const referencedOwner = referencedLogicalId ? sectionObjs.find((section) => (section.logicalSectionId ?? section.id) === referencedLogicalId && (sameGASurfaceAsSection(obj, section) || pointInPolygonWithHoles(c, section.outline, section.holes))) : void 0;
914
+ const owner = referencedOwner ?? sectionObjs.find((s) => pointInPolygonWithHoles(c, s.outline, s.holes));
915
+ const node = owner ? nodes.get(owner.logicalSectionId ?? owner.id) : ungrouped;
740
916
  node.seatCount += labels.length;
741
917
  node.objectIds.push(obj.id);
742
918
  node.seatLabels.push(...labels);
743
919
  objectToSection.set(obj.id, node.id);
744
920
  }
745
921
  return {
746
- sections: sectionObjs.map((s) => nodes.get(s.id)),
922
+ sections: [...nodes.values()],
747
923
  ungrouped: ungrouped.objectIds.length ? ungrouped : null,
748
924
  objectToSection
749
925
  };
750
926
  }
751
927
  function isSectionHidden(s, hidden) {
752
- return hidden.has(s.id) || !!s.zone && hidden.has(s.zone);
928
+ return hidden.has(s.id) || !!s.logicalSectionId && hidden.has(s.logicalSectionId) || !!s.zone && hidden.has(s.zone);
753
929
  }
754
930
  function hiddenObjectIds(doc, hidden) {
755
931
  const out = /* @__PURE__ */ new Set();
@@ -802,6 +978,61 @@ var import_Ellipse = require("konva/lib/shapes/Ellipse");
802
978
  var import_Line = require("konva/lib/shapes/Line");
803
979
  var import_Text = require("konva/lib/shapes/Text");
804
980
  var import_Image = require("konva/lib/shapes/Image");
981
+ var import_Shape = require("konva/lib/Shape");
982
+
983
+ // src/core/chartRenderRules.ts
984
+ var SEAT_LABEL_FONT_SIZE = 7;
985
+ var BOOTH_LABEL_FONT_SIZE = 10;
986
+ var GA_LABEL_FONT_SIZE = 15;
987
+ var GA_CAPACITY_LABEL_FONT_SIZE = 11;
988
+ var GA_FILL_OPACITY = 0.85;
989
+ var MIN_VISIBLE_BOOKABLE_LABEL_PX = 12;
990
+ var SMALL_TEXT_CONTRAST = 4.5;
991
+ var DARK_BOOKABLE_LABEL_INK = "#000000";
992
+ var LIGHT_BOOKABLE_LABEL_INK = "#ffffff";
993
+ function isBookableLabelLegibleAtScale(fontSize, effectiveScale) {
994
+ return fontSize * effectiveScale >= MIN_VISIBLE_BOOKABLE_LABEL_PX;
995
+ }
996
+ function bookableMarkerLabel(publicLabel) {
997
+ return /-(\d{1,5})$/.exec(publicLabel)?.[1] ?? publicLabel;
998
+ }
999
+ function luminance(value) {
1000
+ const match = /^#([0-9a-f]{6})$/i.exec(value.trim());
1001
+ if (!match) return null;
1002
+ const channel = (offset) => {
1003
+ const encoded = Number.parseInt(match[1].slice(offset, offset + 2), 16) / 255;
1004
+ return encoded <= 0.04045 ? encoded / 12.92 : ((encoded + 0.055) / 1.055) ** 2.4;
1005
+ };
1006
+ return 0.2126 * channel(0) + 0.7152 * channel(2) + 0.0722 * channel(4);
1007
+ }
1008
+ function renderedTextContrast(ink, fill) {
1009
+ const inkLuminance = luminance(ink);
1010
+ const fillLuminance = luminance(fill);
1011
+ if (inkLuminance == null || fillLuminance == null) return null;
1012
+ return (Math.max(inkLuminance, fillLuminance) + 0.05) / (Math.min(inkLuminance, fillLuminance) + 0.05);
1013
+ }
1014
+ function rgb(value) {
1015
+ const match = /^#([0-9a-f]{6})$/i.exec(value.trim());
1016
+ if (!match) return null;
1017
+ const packed = Number.parseInt(match[1], 16);
1018
+ return [packed >> 16 & 255, packed >> 8 & 255, packed & 255];
1019
+ }
1020
+ function compositeHexOver(foreground, background, opacity) {
1021
+ const front = rgb(foreground);
1022
+ const back = rgb(background);
1023
+ if (!front || !back) return background;
1024
+ const alpha = Math.max(0, Math.min(1, opacity));
1025
+ const channels = front.map((value, index) => Math.round(value * alpha + back[index] * (1 - alpha)));
1026
+ return `#${channels.map((value) => value.toString(16).padStart(2, "0")).join("")}`;
1027
+ }
1028
+ function stateAwareBookableLabelInk(fill, preferred) {
1029
+ const preferredContrast = renderedTextContrast(preferred, fill);
1030
+ if (preferredContrast != null && preferredContrast >= SMALL_TEXT_CONTRAST) return preferred;
1031
+ const darkContrast = renderedTextContrast(DARK_BOOKABLE_LABEL_INK, fill) ?? 0;
1032
+ const lightContrast = renderedTextContrast(LIGHT_BOOKABLE_LABEL_INK, fill) ?? 0;
1033
+ if (darkContrast === 0 && lightContrast === 0) return preferred;
1034
+ return darkContrast >= lightContrast ? DARK_BOOKABLE_LABEL_INK : LIGHT_BOOKABLE_LABEL_INK;
1035
+ }
805
1036
 
806
1037
  // src/lib/money.ts
807
1038
  var DEFAULT_CURRENCY = "USD";
@@ -861,6 +1092,8 @@ var en = {
861
1092
  // buyer picker page (src/pages/PickerPage.tsx)
862
1093
  "picker.language": "Language",
863
1094
  "picker.zoomToFit": "Zoom to fit",
1095
+ "picker.seatCountLabel": "seats",
1096
+ "picker.capacity": "capacity",
864
1097
  "picker.viewMode": "View mode",
865
1098
  "picker.floor": "Floor",
866
1099
  "picker.zoomLevel": "Zoom level",
@@ -950,7 +1183,8 @@ function formatDate(value, opts) {
950
1183
  var SEAT_RADIUS = 9;
951
1184
  var SEAT_LEGIBLE_SCALE = 0.9;
952
1185
  var CACHE_THRESHOLD = 0.55 * SEAT_LEGIBLE_SCALE;
953
- var LABEL_SCALE = 1;
1186
+ var LABEL_SCALE = MIN_VISIBLE_BOOKABLE_LABEL_PX / SEAT_LABEL_FONT_SIZE;
1187
+ var MIN_FITTED_SEAT_LABEL_FONT_SIZE = 4;
954
1188
  var SEAT_TAP_SLOP_PX = 14;
955
1189
  var SECTION_PROMINENT_SCALE = 0.45 * SEAT_LEGIBLE_SCALE;
956
1190
  var BLOCK_MELT_TOP = 0.9 * SEAT_LEGIBLE_SCALE;
@@ -964,32 +1198,31 @@ var ISO_SQUASH = 0.58;
964
1198
  var LIFT_PER_STEP = 58;
965
1199
  var ISO_TWEEN_MS = 320;
966
1200
  var CAMERA_GLIDE_MS = 650;
967
- var BLOCK_FILL_ALPHA = 0.85;
968
- var SOLD_DARKEN = 0.5;
1201
+ var BLOCK_FILL_ALPHA = 1;
1202
+ var SECTION_STROKE_PX = 2;
1203
+ var LIGHT_OVERVIEW_SECTION_FILL = "#e5e7eb";
1204
+ var LIGHT_OVERVIEW_SECTION_STROKE = "#c7cbd1";
1205
+ var LIGHT_OVERVIEW_SECTION_INK = "#595f69";
1206
+ var LIGHT_OVERVIEW_FOCAL_FILL = "#d1d5db";
1207
+ var LIGHT_OVERVIEW_FOCAL_STROKE = "#b8bdc4";
1208
+ var DARK_OVERVIEW_SECTION_FILL = "#273142";
1209
+ var DARK_OVERVIEW_SECTION_STROKE = "#526078";
1210
+ var DARK_OVERVIEW_SECTION_INK = "#f1f5f9";
1211
+ var DARK_OVERVIEW_FOCAL_FILL = "#374151";
1212
+ var DARK_OVERVIEW_FOCAL_STROKE = "#64748b";
969
1213
  var SECTION_LABEL_PX = 20;
970
- var SECTION_SUB_PX = 12.5;
971
- var ZONE_LABEL_PX = 30;
1214
+ var MIN_SECTION_LABEL_PX = 12;
1215
+ var ZONE_LABEL_PX = 18;
972
1216
  var ZONE_SUB_PX = 12;
1217
+ var HIERARCHY_PILL_BACKGROUND = "#111827";
973
1218
  var HELD_FILL = "#6b7280";
974
1219
  var TAKEN_FILL = "#374151";
975
1220
  var NFS_STROKE = "#4b5563";
976
- var CLOSED_SECTION_FILL = "#586070";
977
1221
  var CLOSED_SEAT_FILL = "#4b5563";
978
1222
  var CLOSED_SEAT_OPACITY = 0.4;
979
1223
  var FOCUS_DIM_OPACITY = 0.16;
980
- var FOCUS_DESATURATE = 0.72;
981
- var FOCUS_NEUTRAL = "#6b7280";
982
1224
  var FOCUS_BACKDROP_FILL = "rgba(244,246,248,0.06)";
983
1225
  var CB_PALETTE = ["#E69F00", "#56B4E9", "#009E73", "#F0E442", "#0072B2", "#D55E00", "#CC79A7"];
984
- var ACCESS_RING = {
985
- wheelchair: "#3b82f6",
986
- companion: "#8b5cf6",
987
- "semi-ambulatory": "#0ea5e9",
988
- hearing: "#14b8a6",
989
- "sign-language": "#f59e0b",
990
- "plus-size": "#ec4899",
991
- "lift-armrest": "#22c55e"
992
- };
993
1226
  function seatMatchesAccess(seat, filter) {
994
1227
  if (filter.length === 0) return !!seat.accessible;
995
1228
  return !!seat.accessibility?.some((t2) => filter.includes(t2));
@@ -999,6 +1232,7 @@ var DEF_SELECTION = "#ffffff";
999
1232
  var DEF_SELECTION_ON_LIGHT = "#0b1220";
1000
1233
  var DEF_DECOR_FILL = "#232c40";
1001
1234
  var DEF_TEXT = "#8b93a7";
1235
+ var DEF_CANVAS_BACKGROUND = "#0e1117";
1002
1236
  function colorLuminance(color) {
1003
1237
  const s = color.trim();
1004
1238
  let r = NaN;
@@ -1011,11 +1245,11 @@ function colorLuminance(color) {
1011
1245
  g = parseInt(h.slice(2, 4), 16);
1012
1246
  b = parseInt(h.slice(4, 6), 16);
1013
1247
  } else {
1014
- const rgb = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/.exec(s);
1015
- if (rgb) {
1016
- r = +rgb[1];
1017
- g = +rgb[2];
1018
- b = +rgb[3];
1248
+ const rgb2 = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/.exec(s);
1249
+ if (rgb2) {
1250
+ r = +rgb2[1];
1251
+ g = +rgb2[2];
1252
+ b = +rgb2[3];
1019
1253
  }
1020
1254
  }
1021
1255
  if (Number.isNaN(r)) return NaN;
@@ -1025,6 +1259,34 @@ function isLightColor(color) {
1025
1259
  const lum = colorLuminance(color);
1026
1260
  return !Number.isNaN(lum) && lum > 0.6;
1027
1261
  }
1262
+ function opaqueColorHex(color) {
1263
+ const value = color.trim();
1264
+ const hex = /^#([\da-f]{3}|[\da-f]{6})$/i.exec(value);
1265
+ if (hex) {
1266
+ const expanded = hex[1].length === 3 ? hex[1].split("").map((channel) => channel + channel).join("") : hex[1];
1267
+ return `#${expanded.toLowerCase()}`;
1268
+ }
1269
+ const rgb2 = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([\d.]+))?\s*\)$/i.exec(value);
1270
+ if (!rgb2 || rgb2[4] != null && Number(rgb2[4]) < 0.999) return null;
1271
+ const channels = [Number(rgb2[1]), Number(rgb2[2]), Number(rgb2[3])];
1272
+ if (channels.some((channel) => !Number.isInteger(channel) || channel < 0 || channel > 255)) return null;
1273
+ return `#${channels.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;
1274
+ }
1275
+ function overviewPalette(canvasBackground) {
1276
+ return isLightColor(canvasBackground) ? {
1277
+ sectionFill: LIGHT_OVERVIEW_SECTION_FILL,
1278
+ sectionStroke: LIGHT_OVERVIEW_SECTION_STROKE,
1279
+ sectionInk: LIGHT_OVERVIEW_SECTION_INK,
1280
+ focalFill: LIGHT_OVERVIEW_FOCAL_FILL,
1281
+ focalStroke: LIGHT_OVERVIEW_FOCAL_STROKE
1282
+ } : {
1283
+ sectionFill: DARK_OVERVIEW_SECTION_FILL,
1284
+ sectionStroke: DARK_OVERVIEW_SECTION_STROKE,
1285
+ sectionInk: DARK_OVERVIEW_SECTION_INK,
1286
+ focalFill: DARK_OVERVIEW_FOCAL_FILL,
1287
+ focalStroke: DARK_OVERVIEW_FOCAL_STROKE
1288
+ };
1289
+ }
1028
1290
  var clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
1029
1291
  function seatIdOf(target) {
1030
1292
  const n = target;
@@ -1060,6 +1322,108 @@ function polyBounds(pts) {
1060
1322
  }
1061
1323
  return { x: minX, y: minY, width: Math.max(1, maxX - minX), height: Math.max(1, maxY - minY) };
1062
1324
  }
1325
+ function rotatedRectPoints(center, width, height, rotation) {
1326
+ const radians = rotation * Math.PI / 180;
1327
+ const cos = Math.cos(radians);
1328
+ const sin = Math.sin(radians);
1329
+ return [
1330
+ { x: -width / 2, y: -height / 2 },
1331
+ { x: width / 2, y: -height / 2 },
1332
+ { x: width / 2, y: height / 2 },
1333
+ { x: -width / 2, y: height / 2 }
1334
+ ].map((point) => ({
1335
+ x: center.x + point.x * cos - point.y * sin,
1336
+ y: center.y + point.x * sin + point.y * cos
1337
+ }));
1338
+ }
1339
+ function pointsBounds(points) {
1340
+ const bounds = polyBounds(points);
1341
+ return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height };
1342
+ }
1343
+ function rotatedRectFitsPolygon(center, width, height, rotation, outer, holes) {
1344
+ const radians = rotation * Math.PI / 180;
1345
+ const cos = Math.cos(radians);
1346
+ const sin = Math.sin(radians);
1347
+ for (let yStep = 0; yStep <= 4; yStep++) {
1348
+ for (let xStep = 0; xStep <= 6; xStep++) {
1349
+ const localX = width * (xStep / 6 - 0.5);
1350
+ const localY = height * (yStep / 4 - 0.5);
1351
+ const point = {
1352
+ x: center.x + localX * cos - localY * sin,
1353
+ y: center.y + localX * sin + localY * cos
1354
+ };
1355
+ if (!pointInPolygonWithHoles(point, outer, holes)) return false;
1356
+ }
1357
+ }
1358
+ return true;
1359
+ }
1360
+ function polygonLabelCandidates(outer, holes, preferred) {
1361
+ const bounds = polyBounds(outer);
1362
+ const centre = { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 };
1363
+ const points = [preferred];
1364
+ for (let row = 1; row < 12; row += 1) {
1365
+ for (let column = 1; column < 12; column += 1) {
1366
+ const point = {
1367
+ x: bounds.x + bounds.width * column / 12,
1368
+ y: bounds.y + bounds.height * row / 12
1369
+ };
1370
+ if (pointInPolygonWithHoles(point, outer, holes)) points.push(point);
1371
+ }
1372
+ }
1373
+ 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));
1374
+ }
1375
+ function polygonWithHolesShape(outer, holes, attrs, outerPath) {
1376
+ const signedArea = (points) => points.reduce((sum, point, index) => {
1377
+ const next = points[(index + 1) % points.length];
1378
+ return sum + point.x * next.y - next.x * point.y;
1379
+ }, 0);
1380
+ const outerClockwise = signedArea(outer) > 0;
1381
+ return new import_Shape.Shape({
1382
+ ...attrs,
1383
+ sceneFunc(context, shape) {
1384
+ context.beginPath();
1385
+ const polygonPath = (points) => {
1386
+ if (!points.length) return;
1387
+ context.moveTo(points[0].x, points[0].y);
1388
+ for (let index = 1; index < points.length; index += 1) context.lineTo(points[index].x, points[index].y);
1389
+ context.closePath();
1390
+ };
1391
+ const vectorPath = (path) => {
1392
+ context.moveTo(path.start.x, path.start.y);
1393
+ let current = path.start;
1394
+ for (const segment of path.segments) {
1395
+ if (segment.kind === "line") context.lineTo(segment.end.x, segment.end.y);
1396
+ else if (segment.kind === "arc") context.arc(
1397
+ segment.center.x,
1398
+ segment.center.y,
1399
+ segment.radius,
1400
+ Math.atan2(current.y - segment.center.y, current.x - segment.center.x),
1401
+ Math.atan2(segment.end.y - segment.center.y, segment.end.x - segment.center.x),
1402
+ !segment.clockwise
1403
+ );
1404
+ else context.bezierCurveTo(
1405
+ segment.control1.x,
1406
+ segment.control1.y,
1407
+ segment.control2.x,
1408
+ segment.control2.y,
1409
+ segment.end.x,
1410
+ segment.end.y
1411
+ );
1412
+ current = segment.end;
1413
+ }
1414
+ context.closePath();
1415
+ };
1416
+ if (outerPath) vectorPath(outerPath);
1417
+ else polygonPath(outer);
1418
+ for (const hole of holes ?? []) {
1419
+ const holeClockwise = signedArea(hole) > 0;
1420
+ polygonPath(holeClockwise === outerClockwise ? [...hole].reverse() : hole);
1421
+ }
1422
+ context.fillStrokeShape(shape);
1423
+ },
1424
+ perfectDrawEnabled: false
1425
+ });
1426
+ }
1063
1427
  function rgba(hex, a) {
1064
1428
  const m = /^#?([\da-f]{6})$/i.exec(hex.trim());
1065
1429
  if (!m) return hex;
@@ -1079,11 +1443,11 @@ function mixColors(parts, fallback) {
1079
1443
  let b = 0;
1080
1444
  let tw = 0;
1081
1445
  for (const p of parts) {
1082
- const rgb = hexToRgb(p.hex);
1083
- if (!rgb || p.w <= 0) continue;
1084
- r += rgb[0] * p.w;
1085
- g += rgb[1] * p.w;
1086
- b += rgb[2] * p.w;
1446
+ const rgb2 = hexToRgb(p.hex);
1447
+ if (!rgb2 || p.w <= 0) continue;
1448
+ r += rgb2[0] * p.w;
1449
+ g += rgb2[1] * p.w;
1450
+ b += rgb2[2] * p.w;
1087
1451
  tw += p.w;
1088
1452
  }
1089
1453
  return tw > 0 ? toHex(r / tw, g / tw, b / tw) : fallback;
@@ -1107,11 +1471,21 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1107
1471
  this.circleById = /* @__PURE__ */ new Map();
1108
1472
  /** Booth block geometry, keyed by booth id (= the unit's rowId). */
1109
1473
  this.boothDims = /* @__PURE__ */ new Map();
1110
- /** Booth label node so status changes can say HELD/SOLD on the full block. */
1474
+ /** Booth labels live with the booth shape but obey the shared rendered-size LOD. */
1111
1475
  this.boothLabelById = /* @__PURE__ */ new Map();
1476
+ /** Viewport seat labels are rebuilt after each settled camera change. */
1477
+ this.seatLabelById = /* @__PURE__ */ new Map();
1478
+ /** Authored free-text nodes obey the same rendered-size visibility floor. */
1479
+ this.freeTextById = /* @__PURE__ */ new Map();
1480
+ /** Stage/rink landmarks retain a readable screen-space caption at overview. */
1481
+ this.primaryFocalLabels = /* @__PURE__ */ new Map();
1482
+ /** GA paint and text share price/highlight filter state. */
1483
+ this.gaById = /* @__PURE__ */ new Map();
1112
1484
  this.statusById = /* @__PURE__ */ new Map();
1113
1485
  this.catColor = /* @__PURE__ */ new Map();
1114
1486
  this.theme = {};
1487
+ /** Opaque paint actually visible behind transparent Konva canvases. */
1488
+ this.canvasBackground = DEF_CANVAS_BACKGROUND;
1115
1489
  /** Effective selection/hover ring color — resolved per chart in setChart(). */
1116
1490
  this.effSelection = DEF_SELECTION;
1117
1491
  /** Colorblind-safe mode (Okabe-Ito hues + hollow booked seats). */
@@ -1409,6 +1783,11 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1409
1783
  this.circleById.clear();
1410
1784
  this.boothDims.clear();
1411
1785
  this.boothLabelById.clear();
1786
+ this.seatLabelById.clear();
1787
+ this.freeTextById.clear();
1788
+ this.primaryFocalLabels.clear();
1789
+ this.gaById.clear();
1790
+ for (const marker of this.selectionMarkers.values()) marker.destroy();
1412
1791
  this.selectionMarkers.clear();
1413
1792
  this.ownedHold.clear();
1414
1793
  this.selectionFocusId = null;
@@ -1420,6 +1799,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1420
1799
  this.sections = [];
1421
1800
  this.zones = [];
1422
1801
  this.seatSection.clear();
1802
+ this.focusedSectionId = null;
1803
+ this.focusBackdrop = null;
1423
1804
  this.catPrice.clear();
1424
1805
  this.zoneColor.clear();
1425
1806
  this.lodScale = 0;
@@ -1437,7 +1818,9 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1437
1818
  this.hoverRing.visible(false);
1438
1819
  this.theme = doc.theme ?? {};
1439
1820
  this.seatR = clamp(this.theme.seatScale ?? 1, 0.7, 1.6) * SEAT_RADIUS;
1440
- this.container.style.background = this.theme.background ?? "";
1821
+ this.container.style.background = "";
1822
+ this.canvasBackground = this.resolveCanvasBackground();
1823
+ this.container.style.background = this.canvasBackground;
1441
1824
  this.effSelection = this.resolveSelectionColor();
1442
1825
  this.hoverRing.stroke(this.effSelection);
1443
1826
  this.hoverRing.radius(this.seatR + 2);
@@ -1666,12 +2049,21 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1666
2049
  }
1667
2050
  return this.selectMany(ids);
1668
2051
  }
2052
+ /** Exact SDK capture helper. MCP never accepts this id; the SDK derives it
2053
+ * from the persisted floor and uses the normal selected paint/ring path. */
2054
+ setEvidenceSelection(seatId) {
2055
+ if (!this.seatById.has(seatId) || !this.isSelectable(seatId)) return false;
2056
+ if (this.selection.size) this.clearSelection();
2057
+ this.setSelected(seatId, true);
2058
+ this.overlayLayer.batchDraw();
2059
+ return this.selection.has(seatId);
2060
+ }
1669
2061
  /** Selectable seats in a section OR zone id — pure read (no selection change). */
1670
2062
  getSelectableInSection(sectionId) {
1671
2063
  const out = [];
1672
2064
  const seen = /* @__PURE__ */ new Set();
1673
2065
  for (const sec of this.sections) {
1674
- if (sec.id !== sectionId && sec.zone !== sectionId) continue;
2066
+ if (sec.id !== sectionId && sec.logicalId !== sectionId && sec.zone !== sectionId) continue;
1675
2067
  for (const id of sec.memberIds) {
1676
2068
  if (seen.has(id)) continue;
1677
2069
  seen.add(id);
@@ -1814,6 +2206,65 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1814
2206
  };
1815
2207
  requestAnimationFrame(step);
1816
2208
  }
2209
+ /**
2210
+ * Pulse a section outline without moving the camera or mutating the authored
2211
+ * geometry. The temporary halo is drawn in the non-listening overlay layer,
2212
+ * so the apparent 4% lift never changes hit testing or selection bounds.
2213
+ */
2214
+ flashSection(sectionId, color = "#22a06b") {
2215
+ const matches = this.sections.filter((section) => section.id === sectionId || section.zone === sectionId);
2216
+ if (!matches.length) return;
2217
+ for (const section of matches) {
2218
+ const centre = section.outline.reduce(
2219
+ (sum, point) => ({ x: sum.x + point.x, y: sum.y + point.y }),
2220
+ { x: 0, y: 0 }
2221
+ );
2222
+ centre.x /= section.outline.length;
2223
+ centre.y /= section.outline.length;
2224
+ const lift = section.elevation > 0 ? this.isoLiftLocal(section.elevation) : { x: 0, y: 0 };
2225
+ const halo = new import_Line.Line({
2226
+ x: centre.x + lift.x,
2227
+ y: centre.y + lift.y,
2228
+ points: section.outline.flatMap((point) => [point.x - centre.x, point.y - centre.y]),
2229
+ closed: true,
2230
+ stroke: color,
2231
+ strokeWidth: 3,
2232
+ strokeScaleEnabled: false,
2233
+ opacity: 0.92,
2234
+ listening: false,
2235
+ perfectDrawEnabled: false,
2236
+ shadowForStrokeEnabled: true,
2237
+ shadowColor: color,
2238
+ shadowBlur: 14,
2239
+ shadowOpacity: 0.7
2240
+ });
2241
+ this.overlayLayer.add(halo);
2242
+ this.overlayLayer.batchDraw();
2243
+ const remove = () => {
2244
+ if (!halo.getLayer()) return;
2245
+ halo.destroy();
2246
+ this.overlayLayer.batchDraw();
2247
+ };
2248
+ if (this.reducedMotion || typeof document !== "undefined" && document.hidden) {
2249
+ setTimeout(remove, 520);
2250
+ continue;
2251
+ }
2252
+ const start = performance.now();
2253
+ const duration = 820;
2254
+ const step = (now) => {
2255
+ if (this.destroyed || !halo.getLayer()) return;
2256
+ const t2 = Math.min(1, (now - start) / duration);
2257
+ const eased = 1 - Math.pow(1 - t2, 3);
2258
+ const scale = 1 + eased * 0.04;
2259
+ halo.scale({ x: scale, y: scale });
2260
+ halo.opacity(0.92 * (1 - t2));
2261
+ this.overlayLayer.batchDraw();
2262
+ if (t2 < 1) requestAnimationFrame(step);
2263
+ else remove();
2264
+ };
2265
+ requestAnimationFrame(step);
2266
+ }
2267
+ }
1817
2268
  /** Nearest seat from `fromId` in a cardinal direction (aligned + close wins). */
1818
2269
  nearestSeat(fromId, dir) {
1819
2270
  const from = this.seatById.get(fromId);
@@ -1887,6 +2338,11 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1887
2338
  seatCount() {
1888
2339
  return this.seats.length;
1889
2340
  }
2341
+ bookableCount() {
2342
+ let total = this.seats.length;
2343
+ for (const area of this.gaById.values()) total += area.capacity;
2344
+ return total;
2345
+ }
1890
2346
  worldToScreen(point) {
1891
2347
  const s = this.stage.scaleX();
1892
2348
  const p = this.isoT === 0 ? point : this.isoForward(point);
@@ -1903,6 +2359,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1903
2359
  const c = this.circleById.get(seat.id);
1904
2360
  if (c) this.paintSeat(c, seat.id);
1905
2361
  }
2362
+ this.updateLabels();
1906
2363
  if (this.cached) {
1907
2364
  this.seatLayer.clearCache();
1908
2365
  this.cacheSeatLayer();
@@ -1929,12 +2386,33 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1929
2386
  const c = this.circleById.get(seat.id);
1930
2387
  if (c) this.paintSeat(c, seat.id);
1931
2388
  }
2389
+ this.updateLabels();
1932
2390
  if (this.cached) {
1933
2391
  this.seatLayer.clearCache();
1934
2392
  this.cacheSeatLayer();
1935
2393
  } else {
1936
2394
  this.seatLayer.batchDraw();
1937
2395
  }
2396
+ this.applyGAFilterState();
2397
+ }
2398
+ gaCategoryDimmed(categoryKey) {
2399
+ return Boolean(
2400
+ this.categoryHighlight && categoryKey !== this.categoryHighlight || this.categoryFilter && !this.categoryFilter.has(categoryKey)
2401
+ );
2402
+ }
2403
+ /** Keep GA paint and its two labels in the same legend/price-filter state. */
2404
+ applyGAFilterState() {
2405
+ this.paintGAStateForView();
2406
+ this.updateFreeTextVisibility();
2407
+ this.bgLayer.batchDraw();
2408
+ }
2409
+ paintGAStateForView() {
2410
+ for (const ga of this.gaById.values()) {
2411
+ const filteredOut = Boolean(this.categoryFilter && !this.categoryFilter.has(ga.categoryKey));
2412
+ const overviewHidden = ga.sectionId != null && this.effScale() < CACHE_THRESHOLD;
2413
+ ga.polygon.opacity(overviewHidden ? 0 : this.gaCategoryDimmed(ga.categoryKey) ? GA_FILL_OPACITY * 0.08 : GA_FILL_OPACITY);
2414
+ ga.polygon.listening(!overviewHidden && !filteredOut);
2415
+ }
1938
2416
  }
1939
2417
  /** Frame the currently available inventory that survived a buyer price
1940
2418
  * filter. Clearing the filter glides back to the full venue. */
@@ -2194,13 +2672,12 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2194
2672
  this.paintSeat(c, seat.id);
2195
2673
  target.add(c);
2196
2674
  if (seat.accessible) {
2197
- const primary = seat.accessibility?.[0];
2198
2675
  target.add(
2199
2676
  new import_Circle.Circle({
2200
2677
  x: seat.x,
2201
2678
  y: seat.y,
2202
2679
  radius: this.seatR + 1,
2203
- stroke: primary && ACCESS_RING[primary] || "#3b82f6",
2680
+ stroke: accessibilityRingColor(seat.accessibility),
2204
2681
  strokeWidth: 2,
2205
2682
  listening: false,
2206
2683
  perfectDrawEnabled: false,
@@ -2229,13 +2706,12 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2229
2706
  });
2230
2707
  rect.setAttr("seatId", seat.id);
2231
2708
  this.circleById.set(seat.id, rect);
2232
- this.paintSeat(rect, seat.id);
2233
2709
  target.add(rect);
2234
2710
  const t2 = new import_Text.Text({
2235
2711
  x: seat.x,
2236
2712
  y: seat.y,
2237
- text: seat.label,
2238
- fontSize: 10,
2713
+ text: seat.displayLabel ?? seat.label,
2714
+ fontSize: BOOTH_LABEL_FONT_SIZE,
2239
2715
  fontStyle: "600",
2240
2716
  fontFamily: this.labelFont(),
2241
2717
  fill: this.theme.seatLabelColor ?? DEF_SEAT_LABEL,
@@ -2244,9 +2720,12 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2244
2720
  });
2245
2721
  t2.offsetX(t2.width() / 2);
2246
2722
  t2.offsetY(t2.height() / 2);
2723
+ t2.visible(false);
2724
+ this.boothLabelById.set(seat.id, t2);
2247
2725
  this.hasBoothText = true;
2248
2726
  this.boothLabelById.set(seat.id, t2);
2249
2727
  target.add(t2);
2728
+ this.paintSeat(rect, seat.id);
2250
2729
  }
2251
2730
  /** The category's display color — Okabe-Ito hue when colorblind-safe is on. */
2252
2731
  seatBaseColor(categoryKey) {
@@ -2254,6 +2733,15 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2254
2733
  const idx = this.catOrder.indexOf(categoryKey);
2255
2734
  return CB_PALETTE[(idx >= 0 ? idx : 0) % CB_PALETTE.length];
2256
2735
  }
2736
+ /** Resolve label ink against the paint that is actually visible. The theme
2737
+ * ink remains preferred, but mixed category palettes cannot always share one
2738
+ * accessible text colour, so every free and transient state gets the same
2739
+ * deterministic dark/light fallback used by Designer. */
2740
+ renderedBookableLabelInk(shape) {
2741
+ const preferred = this.theme.seatLabelColor ?? DEF_SEAT_LABEL;
2742
+ const fill = shape.fill();
2743
+ return stateAwareBookableLabelInk(typeof fill === "string" ? fill : "", preferred);
2744
+ }
2257
2745
  /** Apply fill/stroke/opacity for a seat's current status + selection. */
2258
2746
  paintSeat(c, id) {
2259
2747
  const seat = this.seatById.get(id);
@@ -2317,7 +2805,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2317
2805
  }
2318
2806
  if (this.dimmedSections.size) {
2319
2807
  const sec = this.seatSection.get(id);
2320
- if (sec && (this.dimmedSections.has(sec.id) || sec.zone != null && this.dimmedSections.has(sec.zone))) {
2808
+ if (sec && (this.dimmedSections.has(sec.id) || this.dimmedSections.has(sec.logicalId) || sec.zone != null && this.dimmedSections.has(sec.zone))) {
2321
2809
  c.opacity(0.18);
2322
2810
  }
2323
2811
  }
@@ -2330,16 +2818,23 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2330
2818
  }
2331
2819
  if (this.focusedSectionId) {
2332
2820
  const sec = this.seatSection.get(id);
2333
- const inFocus = !!sec && (sec.id === this.focusedSectionId || sec.zone === this.focusedSectionId);
2821
+ const inFocus = !!sec && (sec.id === this.focusedSectionId || sec.logicalId === this.focusedSectionId || sec.zone === this.focusedSectionId);
2334
2822
  if (!inFocus) c.opacity(FOCUS_DIM_OPACITY);
2335
2823
  }
2336
2824
  if (this.selectionFocusId && id !== this.selectionFocusId) c.opacity(Math.min(c.opacity(), 0.16));
2825
+ const bookableLabel = this.boothLabelById.get(id) ?? this.seatLabelById.get(id);
2826
+ if (bookableLabel) {
2827
+ bookableLabel.fill(this.renderedBookableLabelInk(c));
2828
+ bookableLabel.visible(
2829
+ isBookableLabelLegibleAtScale(bookableLabel.fontSize(), this.effScale()) && c.opacity() >= 0.5
2830
+ );
2831
+ }
2337
2832
  }
2338
2833
  /** True when a seat sits in a section/zone currently marked `closed`. */
2339
2834
  seatInClosedSection(id) {
2340
2835
  if (!this.closedSections.size) return false;
2341
2836
  const sec = this.seatSection.get(id);
2342
- return !!sec && (this.closedSections.has(sec.id) || sec.zone != null && this.closedSections.has(sec.zone));
2837
+ return !!sec && (this.closedSections.has(sec.id) || this.closedSections.has(sec.logicalId) || sec.zone != null && this.closedSections.has(sec.zone));
2343
2838
  }
2344
2839
  /**
2345
2840
  * Colorblind-safe mode: swap category hues for the Okabe-Ito palette and
@@ -2352,6 +2847,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2352
2847
  const c = this.circleById.get(seat.id);
2353
2848
  if (c) this.paintSeat(c, seat.id);
2354
2849
  }
2850
+ this.updateLabels();
2355
2851
  if (this.cached) {
2356
2852
  this.seatLayer.clearCache();
2357
2853
  this.cacheSeatLayer();
@@ -2396,7 +2892,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2396
2892
  * seat-pick gate below only lets buyers pick once seats are ≥ LABEL_SCALE big).
2397
2893
  */
2398
2894
  focusSection(id) {
2399
- if (!this.sections.some((s) => s.id === id)) return;
2895
+ if (!this.sections.some((section) => section.id === id || section.logicalId === id)) return;
2400
2896
  this.focusedSectionId = id;
2401
2897
  this.drawFocusBackdrop(id);
2402
2898
  this.repaintSectionsAndSeats();
@@ -2424,20 +2920,20 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2424
2920
  this.focusBackdrop.destroy();
2425
2921
  this.focusBackdrop = null;
2426
2922
  }
2427
- const sec = this.sections.find((s) => s.id === id);
2428
- if (!sec) return;
2429
- const panel = new import_Line.Line({
2430
- points: sec.outline.flatMap((p) => [p.x, p.y]),
2431
- closed: true,
2432
- fill: FOCUS_BACKDROP_FILL,
2433
- stroke: rgba("#ffffff", 0.1),
2434
- strokeWidth: 1,
2435
- listening: false,
2436
- perfectDrawEnabled: false
2437
- });
2438
- this.bgLayer.add(panel);
2439
- panel.moveToTop();
2440
- this.focusBackdrop = panel;
2923
+ const sections = this.sections.filter((section) => section.id === id || section.logicalId === id);
2924
+ if (!sections.length) return;
2925
+ const backdrop = new import_Group.Group({ listening: false });
2926
+ for (const section of sections) {
2927
+ backdrop.add(polygonWithHolesShape(section.outline, section.holes, {
2928
+ fill: FOCUS_BACKDROP_FILL,
2929
+ stroke: rgba("#ffffff", 0.1),
2930
+ strokeWidth: 1,
2931
+ listening: false
2932
+ }, section.outlinePath));
2933
+ }
2934
+ this.bgLayer.add(backdrop);
2935
+ backdrop.moveToTop();
2936
+ this.focusBackdrop = backdrop;
2441
2937
  }
2442
2938
  /** Repaint every seat + section block to reflect closed/focus state, then redraw. */
2443
2939
  repaintSectionsAndSeats() {
@@ -2465,7 +2961,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2465
2961
  height: Math.abs(br.y - tl.y)
2466
2962
  };
2467
2963
  }
2468
- /** Axis-aligned world bounds of all seats + section outlines (minimap F3 frame). */
2964
+ /** Axis-aligned world bounds of seats, section outlines, and GA polygons (minimap F3 frame). */
2469
2965
  getWorldBounds() {
2470
2966
  let minX = Infinity;
2471
2967
  let minY = Infinity;
@@ -2479,6 +2975,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2479
2975
  };
2480
2976
  for (const s of this.seats) grow(s.x, s.y);
2481
2977
  for (const sec of this.sections) for (const p of sec.outline) grow(p.x, p.y);
2978
+ for (const area of this.gaById.values()) for (const p of area.points) grow(p.x, p.y);
2482
2979
  if (!Number.isFinite(minX)) return { x: 0, y: 0, width: 1, height: 1 };
2483
2980
  return { x: minX, y: minY, width: Math.max(1, maxX - minX), height: Math.max(1, maxY - minY) };
2484
2981
  }
@@ -2490,12 +2987,14 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2490
2987
  const c = this.circleById.get(seat.id);
2491
2988
  if (c) this.paintSeat(c, seat.id);
2492
2989
  }
2990
+ this.updateLabels();
2493
2991
  if (this.cached) {
2494
2992
  this.seatLayer.clearCache();
2495
2993
  this.cacheSeatLayer();
2496
2994
  } else {
2497
2995
  this.seatLayer.batchDraw();
2498
2996
  }
2997
+ this.applyGAFilterState();
2499
2998
  }
2500
2999
  renderBackground(doc) {
2501
3000
  if (doc.backgroundImage) this.renderBackgroundImage(doc.backgroundImage);
@@ -2513,32 +3012,40 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2513
3012
  this.renderText(obj);
2514
3013
  }
2515
3014
  }
2516
- const f = doc.focalPoint;
2517
- if (f) {
2518
- const size = 14;
2519
- const cross = new import_Group.Group({ listening: false });
2520
- cross.add(
2521
- new import_Line.Line({ points: [f.x - size, f.y, f.x + size, f.y], stroke: "#4b5563", strokeWidth: 1.5 }),
2522
- new import_Line.Line({ points: [f.x, f.y - size, f.x, f.y + size], stroke: "#4b5563", strokeWidth: 1.5 }),
2523
- new import_Circle.Circle({ x: f.x, y: f.y, radius: 3, fill: "#4b5563" })
2524
- );
2525
- this.bgLayer.add(cross);
2526
- }
2527
3015
  }
2528
3016
  /** Organizer floor-plan photo, dimmed, at the very bottom of the bg layer. */
2529
3017
  renderBackgroundImage(bg) {
3018
+ if (!bg.url || bg.visible === false) return;
2530
3019
  const img = new window.Image();
2531
3020
  img.onload = () => {
2532
3021
  const natW = img.naturalWidth || 4;
2533
3022
  const natH = img.naturalHeight || 3;
3023
+ const rawCrop = bg.crop ?? { x: 0, y: 0, width: 1, height: 1 };
3024
+ const cropX = Math.max(0, Math.min(0.99, rawCrop.x));
3025
+ const cropY = Math.max(0, Math.min(0.99, rawCrop.y));
3026
+ const crop = {
3027
+ x: cropX,
3028
+ y: cropY,
3029
+ width: Math.max(0.01, Math.min(1 - cropX, rawCrop.width)),
3030
+ height: Math.max(0.01, Math.min(1 - cropY, rawCrop.height))
3031
+ };
2534
3032
  const w = bg.width;
2535
- const h = w * (natH / natW);
3033
+ const h = w * (natH * crop.height / (natW * crop.width));
2536
3034
  const node = new import_Image.Image({
2537
3035
  image: img,
2538
- x: bg.center.x - w / 2,
2539
- y: bg.center.y - h / 2,
3036
+ x: bg.center.x,
3037
+ y: bg.center.y,
3038
+ offsetX: w / 2,
3039
+ offsetY: h / 2,
2540
3040
  width: w,
2541
3041
  height: h,
3042
+ rotation: bg.rotation ?? 0,
3043
+ crop: {
3044
+ x: crop.x * natW,
3045
+ y: crop.y * natH,
3046
+ width: crop.width * natW,
3047
+ height: crop.height * natH
3048
+ },
2542
3049
  opacity: bg.opacity,
2543
3050
  listening: false
2544
3051
  });
@@ -2610,28 +3117,42 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2610
3117
  })
2611
3118
  );
2612
3119
  }
2613
- this.addCentredLabel(this.bgLayer, obj.label, obj.center.x, obj.center.y, "#cbd5e1", 12, true);
3120
+ const label = this.addCentredLabel(this.bgLayer, obj.label, obj.center.x, obj.center.y, "#cbd5e1", 12, true);
3121
+ this.freeTextById.set(obj.id, { node: label, background: "#232c40", kind: "table" });
2614
3122
  }
2615
3123
  renderText(obj) {
2616
- this.bgLayer.add(
2617
- new import_Text.Text({
2618
- x: obj.position.x,
2619
- y: obj.position.y,
2620
- text: obj.text,
2621
- fontSize: obj.fontSize,
2622
- rotation: obj.rotation,
2623
- fill: obj.color ?? this.theme.textColor ?? DEF_TEXT,
2624
- fontFamily: this.labelFont(),
2625
- listening: false,
2626
- perfectDrawEnabled: false
2627
- })
2628
- );
3124
+ const background = this.canvasBackground;
3125
+ const preferredInk = obj.color ?? this.theme.textColor ?? DEF_TEXT;
3126
+ const node = new import_Text.Text({
3127
+ x: obj.position.x,
3128
+ y: obj.position.y,
3129
+ text: obj.text,
3130
+ fontSize: obj.fontSize,
3131
+ rotation: obj.rotation,
3132
+ // Authored ink remains preferred, but an embed/theme surface can change
3133
+ // the actual canvas. Fail over to readable black/white instead of
3134
+ // painting an otherwise valid caption invisibly on that active surface.
3135
+ fill: stateAwareBookableLabelInk(background, preferredInk),
3136
+ fontFamily: this.labelFont(),
3137
+ listening: false,
3138
+ perfectDrawEnabled: false
3139
+ });
3140
+ this.freeTextById.set(obj.id, {
3141
+ node,
3142
+ background,
3143
+ kind: "free-text"
3144
+ });
3145
+ this.bgLayer.add(node);
2629
3146
  }
2630
3147
  renderShape(obj) {
2631
- const fill = obj.fill ?? this.theme.decorFill ?? DEF_DECOR_FILL;
3148
+ const authoredFill = obj.fill ?? this.theme.decorFill ?? DEF_DECOR_FILL;
2632
3149
  const isStage = obj.role === "stage";
3150
+ const referenceFocal = obj.role === "reference-focal";
2633
3151
  const isDecor = !!obj.role && !isStage;
2634
- const stroke = isStage ? lighten(fill, 0.28) : void 0;
3152
+ const palette = overviewPalette(this.canvasBackground);
3153
+ const fill = referenceFocal ? palette.focalFill : authoredFill;
3154
+ const stroke = isStage ? lighten(fill, 0.28) : referenceFocal ? palette.focalStroke : void 0;
3155
+ const strokeWidth = isStage ? 1 : referenceFocal ? 2 : 0;
2635
3156
  let cx = 0;
2636
3157
  let cy = 0;
2637
3158
  if (obj.kind === "rect" && obj.x != null && obj.y != null && obj.width != null && obj.height != null) {
@@ -2653,7 +3174,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2653
3174
  height: obj.height,
2654
3175
  ...grad,
2655
3176
  stroke,
2656
- strokeWidth: isStage ? 1 : 0,
3177
+ strokeWidth,
2657
3178
  cornerRadius: 4,
2658
3179
  listening: false
2659
3180
  })
@@ -2667,7 +3188,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2667
3188
  fillLinearGradientColorStops: [0, darken(fill, 0.3), 1, lighten(fill, 0.12)]
2668
3189
  } : { fill };
2669
3190
  this.bgLayer.add(
2670
- new import_Ellipse.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 })
3191
+ new import_Ellipse.Ellipse({ x: cx, y: cy, rotation: obj.rotation ?? 0, radiusX: obj.width / 2, radiusY: obj.height / 2, ...grad, stroke, strokeWidth, listening: false })
2671
3192
  );
2672
3193
  } else if (obj.kind === "polygon" && obj.points && obj.points.length) {
2673
3194
  const pts = obj.points.flatMap((p) => [p.x, p.y]);
@@ -2682,17 +3203,35 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2682
3203
  fillLinearGradientColorStops: [0, darken(fill, 0.3), 1, lighten(fill, 0.12)]
2683
3204
  } : { fill };
2684
3205
  this.bgLayer.add(
2685
- new import_Line.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 })
3206
+ new import_Line.Line({ points: pts, closed: true, x: cx, y: cy, offsetX: cx, offsetY: cy, rotation: obj.rotation ?? 0, ...grad, stroke, strokeWidth, listening: false })
2686
3207
  );
2687
3208
  }
2688
3209
  if (obj.label) {
2689
- if (isStage) this.addStageLabel(cx, cy, obj.label);
2690
- else if (isDecor) this.addCentredLabel(this.bgLayer, obj.label, cx, cy, "#9aa3b5", 12, false);
2691
- else this.addCentredLabel(this.bgLayer, obj.label, cx, cy, "#cbd5e1", 16, true);
3210
+ if (isStage) {
3211
+ const node = this.addStageLabel(cx, cy, obj.label, fill);
3212
+ this.primaryFocalLabels.set(node, 22);
3213
+ this.freeTextById.set(obj.id, { node, background: fill, kind: "stage" });
3214
+ } else if (isDecor) {
3215
+ const node = this.addCentredLabel(
3216
+ this.bgLayer,
3217
+ obj.label,
3218
+ cx,
3219
+ cy,
3220
+ referenceFocal ? stateAwareBookableLabelInk(fill, "#e6e9f0") : "#9aa3b5",
3221
+ referenceFocal ? 18 : 12,
3222
+ false
3223
+ );
3224
+ if (referenceFocal) this.primaryFocalLabels.set(node, 18);
3225
+ this.freeTextById.set(obj.id, { node, background: fill, kind: "decor" });
3226
+ } else {
3227
+ const node = this.addCentredLabel(this.bgLayer, obj.label, cx, cy, "#cbd5e1", 16, true);
3228
+ this.freeTextById.set(obj.id, { node, background: fill, kind: "decor" });
3229
+ }
2692
3230
  }
2693
3231
  }
2694
3232
  /** Prominent stage caption: uppercase, letter-spaced, larger, softly dimmed. */
2695
- addStageLabel(x, y, text) {
3233
+ addStageLabel(x, y, text, background) {
3234
+ const ink = stateAwareBookableLabelInk(background, "#e6e9f0");
2696
3235
  const t2 = new import_Text.Text({
2697
3236
  x,
2698
3237
  y,
@@ -2701,22 +3240,24 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2701
3240
  fontStyle: "700",
2702
3241
  letterSpacing: 4,
2703
3242
  fontFamily: this.labelFont(),
2704
- fill: rgba("#e6e9f0", 0.62),
3243
+ fill: ink,
2705
3244
  listening: false,
2706
3245
  perfectDrawEnabled: false
2707
3246
  });
2708
3247
  t2.offsetX(t2.width() / 2);
2709
3248
  t2.offsetY(t2.height() / 2);
2710
3249
  this.bgLayer.add(t2);
3250
+ return t2;
2711
3251
  }
2712
3252
  renderGA(obj) {
2713
3253
  const color = this.catColor.get(obj.categoryKey) ?? "#6e7bff";
2714
- const pts = obj.points.flatMap((p) => [p.x, p.y]);
2715
- const poly = new import_Line.Line({
2716
- points: pts,
2717
- closed: true,
3254
+ const canvas = this.canvasBackground;
3255
+ const effectiveBackground = compositeHexOver(color, canvas, GA_FILL_OPACITY);
3256
+ const preferredInk = this.theme.textColor ?? "#e6e9f0";
3257
+ const ink = stateAwareBookableLabelInk(effectiveBackground, preferredInk);
3258
+ const poly = polygonWithHolesShape(obj.points, obj.holes, {
2718
3259
  fill: color,
2719
- opacity: 0.22,
3260
+ opacity: GA_FILL_OPACITY,
2720
3261
  stroke: color,
2721
3262
  strokeWidth: 1.5
2722
3263
  });
@@ -2729,31 +3270,51 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2729
3270
  this.container.style.cursor = "default";
2730
3271
  });
2731
3272
  this.bgLayer.add(poly);
2732
- const cx = obj.points.reduce((a, p) => a + p.x, 0) / obj.points.length;
2733
- const cy = obj.points.reduce((a, p) => a + p.y, 0) / obj.points.length;
2734
- this.addCentredLabel(this.bgLayer, obj.label, cx, cy - 8, "#e6e9f0", 15, false);
2735
- this.addCentredLabel(this.bgLayer, `cap ${obj.capacity}`, cx, cy + 10, "#8b93a7", 11, false);
3273
+ const labelPoint = polygonLabelPoint(obj.points, obj.holes);
3274
+ const containingSection = this.sections.find((section) => pointInPolygonWithHoles(labelPoint, section.outline, section.holes));
3275
+ const label = this.addCentredLabel(this.bgLayer, obj.label, labelPoint.x, labelPoint.y - 8, ink, GA_LABEL_FONT_SIZE, false);
3276
+ const capacity = this.addCentredLabel(this.bgLayer, `cap ${obj.capacity}`, labelPoint.x, labelPoint.y + 10, ink, GA_CAPACITY_LABEL_FONT_SIZE, false);
3277
+ this.freeTextById.set(`${obj.id}:label`, {
3278
+ objectId: obj.id,
3279
+ node: label,
3280
+ background: effectiveBackground,
3281
+ kind: "ga-label",
3282
+ categoryKey: obj.categoryKey
3283
+ });
3284
+ this.freeTextById.set(`${obj.id}:capacity`, {
3285
+ objectId: obj.id,
3286
+ node: capacity,
3287
+ background: effectiveBackground,
3288
+ kind: "ga-capacity",
3289
+ categoryKey: obj.categoryKey
3290
+ });
3291
+ this.gaById.set(obj.id, {
3292
+ label: obj.label,
3293
+ capacity: obj.capacity,
3294
+ categoryKey: obj.categoryKey,
3295
+ points: obj.points,
3296
+ polygon: poly,
3297
+ effectiveBackground,
3298
+ ...containingSection ? { sectionId: containingSection.logicalId } : {}
3299
+ });
2736
3300
  }
2737
3301
  /**
2738
3302
  * A section renders in three coordinated layers driven by the LOD melt:
2739
3303
  * • a faint outline (the existing near-zoom look, untouched),
2740
- * • a solid category-mix block that fades in at the block rung, and
2741
- * • a name + "N LEFT" sublabel.
2742
- * Membership (which seats live inside the outline) + the mix fill + the live
2743
- * availability count are precomputed here (once), not per frame.
3304
+ * • a neutral solid shell that fades in at the overview rung, and
3305
+ * • one readable, contained section name.
3306
+ * Category, row, seat, and availability detail belongs to section focus/zoom.
3307
+ * Membership and category mix are still precomputed for the detailed state.
2744
3308
  */
2745
3309
  renderSection(obj) {
2746
- const pts = obj.outline.flatMap((p) => [p.x, p.y]);
2747
- const centroid = {
2748
- x: obj.outline.reduce((a, p) => a + p.x, 0) / obj.outline.length,
2749
- y: obj.outline.reduce((a, p) => a + p.y, 0) / obj.outline.length
2750
- };
3310
+ const centroid = polygonLabelPoint(obj.outline, obj.holes);
3311
+ const palette = overviewPalette(this.canvasBackground);
2751
3312
  const memberIds = [];
2752
3313
  const catCounts = /* @__PURE__ */ new Map();
2753
3314
  let free = 0;
2754
3315
  for (const seat of this.seats) {
2755
3316
  if (this.seatSection.has(seat.id)) continue;
2756
- if (!pointInPolygon(seat, obj.outline)) continue;
3317
+ if (!pointInPolygonWithHoles(seat, obj.outline, obj.holes)) continue;
2757
3318
  memberIds.push(seat.id);
2758
3319
  catCounts.set(seat.categoryKey, (catCounts.get(seat.categoryKey) ?? 0) + 1);
2759
3320
  if ((this.statusById.get(seat.id) ?? "free") === "free") free++;
@@ -2789,65 +3350,34 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2789
3350
  }
2790
3351
  const bgTarget = liftGroupBg ?? this.bgLayer;
2791
3352
  const outlineTint = obj.color ?? this.zoneColor.get(obj.zone ?? "") ?? "#3a4358";
2792
- const outlinePoly = new import_Line.Line({
2793
- points: pts,
2794
- closed: true,
3353
+ const outlinePoly = polygonWithHolesShape(obj.outline, obj.holes, {
2795
3354
  stroke: rgba(outlineTint, 0.5),
2796
3355
  strokeWidth: 1.75,
2797
3356
  fill: rgba(outlineTint, 0.08),
2798
- lineJoin: "round",
2799
- listening: false,
2800
- perfectDrawEnabled: false
2801
- });
3357
+ listening: false
3358
+ }, obj.outlinePath);
2802
3359
  bgTarget.add(outlinePoly);
2803
- const blockPoly = new import_Line.Line({
2804
- points: pts,
2805
- closed: true,
2806
- fill: baseFill,
2807
- stroke: rgba("#ffffff", 0.12),
2808
- strokeWidth: 1,
3360
+ const blockPoly = polygonWithHolesShape(obj.outline, obj.holes, {
3361
+ fill: palette.sectionFill,
3362
+ stroke: palette.sectionStroke,
3363
+ strokeWidth: SECTION_STROKE_PX,
2809
3364
  opacity: 0,
2810
- listening: false,
2811
- perfectDrawEnabled: false
2812
- });
3365
+ listening: false
3366
+ }, obj.outlinePath);
2813
3367
  bgTarget.add(blockPoly);
2814
- const rowSeats = /* @__PURE__ */ new Map();
2815
- for (const id of memberIds) {
2816
- const s = this.seatById.get(id);
2817
- if (!s) continue;
2818
- const i = Number(id.slice(id.lastIndexOf(":") + 1)) || 0;
2819
- (rowSeats.get(s.rowId) ?? rowSeats.set(s.rowId, []).get(s.rowId)).push({ i, x: s.x, y: s.y });
2820
- }
2821
- const rowLines = [];
2822
- for (const arr of rowSeats.values()) {
2823
- if (arr.length < 2) continue;
2824
- arr.sort((a, b) => a.i - b.i);
2825
- const line = new import_Line.Line({
2826
- points: arr.flatMap((p) => [p.x, p.y]),
2827
- stroke: rgba("#ffffff", 0.34),
2828
- strokeWidth: SEAT_RADIUS * 0.55,
2829
- lineCap: "round",
2830
- lineJoin: "round",
2831
- opacity: 0,
2832
- listening: false,
2833
- perfectDrawEnabled: false
2834
- });
2835
- rowLines.push(line);
2836
- bgTarget.add(line);
2837
- }
3368
+ const labelStyle = obj.labelPresentation?.labelStyle;
3369
+ const preferredInk = labelStyle?.color ?? palette.sectionInk;
3370
+ const labelScale = (labelStyle?.size ?? 18) / 18;
2838
3371
  const nameLabel = new import_Text.Text({
2839
- x: centroid.x,
2840
- y: centroid.y,
2841
- text: obj.label,
2842
- fontSize: 22,
3372
+ x: obj.labelPresentation?.position?.x ?? centroid.x,
3373
+ y: obj.labelPresentation?.position?.y ?? centroid.y,
3374
+ text: obj.displayLabel ?? obj.label,
3375
+ rotation: obj.labelPresentation?.rotation ?? 0,
3376
+ visible: obj.labelPresentation?.visible !== false,
3377
+ fontSize: 22 * labelScale,
2843
3378
  fontStyle: "700",
2844
3379
  fontFamily: this.labelFont(),
2845
- fill: "#8b93a7",
2846
- // Dark halo so the label reads over the seat dots at any zoom.
2847
- shadowColor: "#05070c",
2848
- shadowBlur: 6,
2849
- shadowOpacity: 0.9,
2850
- shadowForStrokeEnabled: false,
3380
+ fill: stateAwareBookableLabelInk(palette.sectionFill, preferredInk),
2851
3381
  listening: false,
2852
3382
  perfectDrawEnabled: false
2853
3383
  });
@@ -2861,11 +3391,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2861
3391
  fontSize: 12,
2862
3392
  fontStyle: "700",
2863
3393
  fontFamily: "JetBrains Mono, ui-monospace, monospace",
2864
- fill: "#f4f6fb",
2865
- shadowColor: "#05070c",
2866
- shadowBlur: 5,
2867
- shadowOpacity: 0.9,
2868
- shadowForStrokeEnabled: false,
3394
+ fill: palette.sectionInk,
2869
3395
  opacity: 0,
2870
3396
  listening: false,
2871
3397
  perfectDrawEnabled: false
@@ -2874,9 +3400,13 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2874
3400
  bgTarget.add(subLabel);
2875
3401
  const sec = {
2876
3402
  id: obj.id,
2877
- label: obj.label,
3403
+ logicalId: obj.logicalSectionId ?? obj.id,
3404
+ label: obj.displayLabel ?? obj.label,
2878
3405
  outline: obj.outline,
3406
+ ...obj.outlinePath ? { outlinePath: obj.outlinePath } : {},
3407
+ holes: obj.holes ?? [],
2879
3408
  centroid,
3409
+ labelAnchors: polygonLabelCandidates(obj.outline, obj.holes ?? [], centroid),
2880
3410
  zone: obj.zone,
2881
3411
  memberIds,
2882
3412
  total: memberIds.length,
@@ -2885,9 +3415,12 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2885
3415
  outlineTint,
2886
3416
  outlinePoly,
2887
3417
  blockPoly,
2888
- rowLines,
2889
3418
  nameLabel,
2890
3419
  subLabel,
3420
+ preferredInk,
3421
+ labelScale,
3422
+ nameLabelFits: true,
3423
+ subLabelFits: true,
2891
3424
  elevation,
2892
3425
  liftGroupBg,
2893
3426
  liftGroupSeat,
@@ -2899,7 +3432,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2899
3432
  this.sections.push(sec);
2900
3433
  }
2901
3434
  refreshSectionHeat(sec) {
2902
- const raw = this.sectionHeat.get(sec.id) ?? (sec.zone ? this.sectionHeat.get(sec.zone) : void 0);
3435
+ const raw = this.sectionHeat.get(sec.id) ?? this.sectionHeat.get(sec.logicalId) ?? (sec.zone ? this.sectionHeat.get(sec.zone) : void 0);
2903
3436
  if (raw == null || raw <= 0) {
2904
3437
  sec.outlinePoly.stroke(rgba(sec.outlineTint, 0.5));
2905
3438
  sec.outlinePoly.fill(rgba(sec.outlineTint, 0.08));
@@ -2915,7 +3448,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2915
3448
  sec.outlinePoly.shadowBlur(4 + raw * 12);
2916
3449
  sec.outlinePoly.shadowOpacity(0.25 + raw * 0.45);
2917
3450
  }
2918
- /** Recompute a section's availability-tinted fill + "N LEFT" (cheap; on status change). */
3451
+ /** Recompute a section's neutral overview state and retained detail count. */
2919
3452
  refreshSectionFill(sec) {
2920
3453
  sec.blockPoly.fill(this.sectionBlockFill(sec));
2921
3454
  sec.subLabel.text(t("map.seatsLeft", { count: sec.free }));
@@ -2923,25 +3456,12 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2923
3456
  }
2924
3457
  /** True when a section/zone is currently in the `closed` event-state. */
2925
3458
  isSectionClosed(sec) {
2926
- return this.closedSections.has(sec.id) || sec.zone != null && this.closedSections.has(sec.zone);
3459
+ return this.closedSections.has(sec.id) || this.closedSections.has(sec.logicalId) || sec.zone != null && this.closedSections.has(sec.zone);
2927
3460
  }
2928
- /**
2929
- * The block-fill colour for a section: flat desaturated grey when `closed`,
2930
- * else the availability-darkened category mix; then desaturated toward neutral
2931
- * when another section holds focus (AXS dim treatment).
2932
- */
3461
+ /** Clean overview shells never leak category, price, or live availability paint. */
2933
3462
  sectionBlockFill(sec) {
2934
- let fill;
2935
- if (this.isSectionClosed(sec)) {
2936
- fill = CLOSED_SECTION_FILL;
2937
- } else {
2938
- const sold = sec.total > 0 ? (sec.total - sec.free) / sec.total : 0;
2939
- fill = darken(sec.baseFill, sold * SOLD_DARKEN);
2940
- }
2941
- if (this.focusedSectionId && sec.id !== this.focusedSectionId && sec.zone !== this.focusedSectionId) {
2942
- fill = lerpColor(fill, FOCUS_NEUTRAL, FOCUS_DESATURATE);
2943
- }
2944
- return fill;
3463
+ const fill = overviewPalette(this.canvasBackground).sectionFill;
3464
+ return this.isSectionClosed(sec) ? darken(fill, 0.12) : fill;
2945
3465
  }
2946
3466
  /**
2947
3467
  * Zone rung: one giant screen-constant label per zone (+ optional "FROM $n"),
@@ -2970,19 +3490,31 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2970
3490
  if (typeof p === "number" && p < minPrice) minPrice = p;
2971
3491
  }
2972
3492
  }
3493
+ const back = new import_Rect.Rect({
3494
+ x: cx,
3495
+ y: cy,
3496
+ width: 1,
3497
+ height: 1,
3498
+ offsetX: 0.5,
3499
+ offsetY: 0.5,
3500
+ cornerRadius: 1,
3501
+ fill: HIERARCHY_PILL_BACKGROUND,
3502
+ stroke: z.color ?? anchor.outlineTint,
3503
+ strokeWidth: 1,
3504
+ opacity: 0,
3505
+ listening: false,
3506
+ perfectDrawEnabled: false
3507
+ });
3508
+ this.bgLayer.add(back);
2973
3509
  const label = new import_Text.Text({
2974
3510
  x: cx,
2975
3511
  y: cy,
2976
3512
  text: z.label.toUpperCase(),
2977
- fontSize: 34,
3513
+ fontSize: ZONE_LABEL_PX,
2978
3514
  fontStyle: "800",
2979
- letterSpacing: 2,
3515
+ letterSpacing: 0.5,
2980
3516
  fontFamily: this.labelFont(),
2981
- fill: z.color ?? "#f2f4f8",
2982
- shadowColor: "#05070c",
2983
- shadowBlur: 10,
2984
- shadowOpacity: 0.95,
2985
- shadowForStrokeEnabled: false,
3517
+ fill: "#f4f6fb",
2986
3518
  opacity: 0,
2987
3519
  listening: false,
2988
3520
  perfectDrawEnabled: false
@@ -2999,7 +3531,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2999
3531
  fontSize: 14,
3000
3532
  fontStyle: "600",
3001
3533
  fontFamily: "JetBrains Mono, ui-monospace, monospace",
3002
- fill: rgba("#e6e9f0", 0.75),
3534
+ fill: "#cbd5e1",
3003
3535
  opacity: 0,
3004
3536
  listening: false,
3005
3537
  perfectDrawEnabled: false
@@ -3007,7 +3539,14 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3007
3539
  sub.offsetX(sub.width() / 2);
3008
3540
  this.bgLayer.add(sub);
3009
3541
  }
3010
- this.zones.push({ id: z.id, label, sub });
3542
+ this.zones.push({
3543
+ id: z.id,
3544
+ anchor: { x: cx, y: cy },
3545
+ back,
3546
+ background: HIERARCHY_PILL_BACKGROUND,
3547
+ label,
3548
+ sub
3549
+ });
3011
3550
  }
3012
3551
  }
3013
3552
  /**
@@ -3029,74 +3568,106 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3029
3568
  blockT = clamp((BLOCK_MELT_TOP - scale) / (BLOCK_MELT_TOP - SECTION_PROMINENT_SCALE), 0, 1);
3030
3569
  zoneT = clamp((SECTION_PROMINENT_SCALE - scale) / (SECTION_PROMINENT_SCALE - ZONE_PROMINENT_SCALE), 0, 1);
3031
3570
  }
3571
+ const sectionOverview = scale < CACHE_THRESHOLD;
3572
+ if (sectionOverview) blockT = 1;
3032
3573
  if (!this.zones.length) zoneT = 0;
3033
- this.seatLayer.opacity(1 - blockT);
3574
+ this.seatLayer.opacity(sectionOverview ? 0 : 1 - blockT);
3034
3575
  const sx = this.stage.scaleX();
3035
3576
  const rescale = this.lodScale === 0 || Math.abs(scale - this.lodScale) / (this.lodScale || 1) > 0.02;
3036
3577
  if (rescale) this.lodScale = scale;
3037
3578
  const focus = this.focusedSectionId;
3579
+ const palette = overviewPalette(this.canvasBackground);
3580
+ const sectionLabelT = clamp((blockT - 0.2) / 0.8, 0, 1);
3038
3581
  for (const sec of this.sections) {
3039
- const dim = focus && sec.id !== focus && sec.zone !== focus ? FOCUS_DIM_OPACITY : 1;
3582
+ const dim = focus && sec.id !== focus && sec.logicalId !== focus && sec.zone !== focus ? FOCUS_DIM_OPACITY : 1;
3583
+ sec.outlinePoly.opacity(sectionOverview ? 0 : (1 - blockT) * dim);
3040
3584
  sec.blockPoly.opacity(BLOCK_FILL_ALPHA * blockT * dim);
3041
- for (const line of sec.rowLines) line.opacity(blockT * (1 - zoneT) * dim);
3042
- sec.nameLabel.fill(lerpColor("#aab3c5", "#ffffff", blockT));
3043
- sec.nameLabel.opacity((1 - zoneT) * dim);
3044
- sec.subLabel.opacity(blockT * (1 - zoneT) * dim);
3045
- if (rescale) {
3046
- this.sizeLabel(sec.nameLabel, SECTION_LABEL_PX / sx, sec.centroid.y - SECTION_SUB_PX / sx);
3047
- this.sizeLabel(sec.subLabel, SECTION_SUB_PX / sx, sec.centroid.y + SECTION_LABEL_PX / sx);
3048
- }
3585
+ sec.blockPoly.stroke(palette.sectionStroke);
3586
+ sec.blockPoly.strokeWidth(SECTION_STROKE_PX / Math.max(sx, 1e-4));
3587
+ const sectionFill = sec.blockPoly.fill();
3588
+ const sectionInk = stateAwareBookableLabelInk(
3589
+ typeof sectionFill === "string" ? sectionFill : sec.baseFill,
3590
+ sec.preferredInk
3591
+ );
3592
+ sec.nameLabel.fill(sectionInk);
3593
+ sec.subLabel.fill(sectionInk);
3594
+ if (rescale) this.fitSectionRungLabels(sec, sx);
3595
+ const labelOpacity = sectionLabelT * (1 - zoneT) * dim;
3596
+ sec.nameLabel.opacity(sec.nameLabelFits ? labelOpacity : 0);
3597
+ sec.subLabel.opacity(0);
3049
3598
  }
3050
3599
  const zoneOpacity = zoneT * (1 - this.isoT);
3051
3600
  for (const zone of this.zones) {
3601
+ zone.back.opacity(zoneOpacity);
3052
3602
  zone.label.opacity(zoneOpacity);
3053
3603
  if (zone.sub) zone.sub.opacity(zoneOpacity);
3054
- if (rescale) {
3055
- const cy = zone.label.y();
3056
- this.sizeLabel(zone.label, ZONE_LABEL_PX / sx, cy);
3057
- if (zone.sub) this.sizeLabel(zone.sub, ZONE_SUB_PX / sx, cy + ZONE_LABEL_PX / sx);
3058
- }
3604
+ if (rescale) this.sizeZonePill(zone, sx);
3059
3605
  }
3060
3606
  this.decollideRungLabels(sx);
3607
+ this.dedupeLogicalSectionLabels();
3608
+ for (const zone of this.zones) {
3609
+ const opacity = zone.label.opacity();
3610
+ zone.back.opacity(opacity);
3611
+ if (zone.sub) zone.sub.opacity(opacity);
3612
+ }
3061
3613
  this.bgLayer.batchDraw();
3062
3614
  }
3615
+ /** One semantic section gets one overview label, even across split contours. */
3616
+ dedupeLogicalSectionLabels() {
3617
+ const byLogical = /* @__PURE__ */ new Map();
3618
+ for (const section of this.sections) {
3619
+ (byLogical.get(section.logicalId) ?? byLogical.set(section.logicalId, []).get(section.logicalId)).push(section);
3620
+ }
3621
+ for (const components of byLogical.values()) {
3622
+ if (components.length < 2) continue;
3623
+ const visible = components.filter((component) => component.nameLabel.opacity() > 0.05).sort((left, right) => {
3624
+ const leftBounds = polyBounds(left.outline);
3625
+ const rightBounds = polyBounds(right.outline);
3626
+ return rightBounds.width * rightBounds.height - leftBounds.width * leftBounds.height;
3627
+ });
3628
+ for (const component of visible.slice(1)) component.nameLabel.opacity(0);
3629
+ }
3630
+ }
3063
3631
  /**
3064
- * Greedy label de-collision for the zone/section rungs (same approach as the
3065
- * designer's cullRowLabels): price/"N LEFT" sublabels are lowest priority and
3066
- * drop first; name labels keep top-to-bottom, left-to-right; anything whose
3067
- * on-screen box (+4px gap) overlaps an already-kept box hides. Recomputed on
3068
- * every LOD pass so hidden labels reappear as zoom spreads them apart.
3069
- * Culling multiplies the opacity applySectionLod just assigned (never raises).
3632
+ * Keep transitional zone pills from covering section names. Section names
3633
+ * are already proven inside disjoint shells, so they must not cull each other.
3070
3634
  */
3071
3635
  decollideRungLabels(sx) {
3072
3636
  const GAP = 4;
3073
3637
  const cands = [];
3074
3638
  const boxOf = (t2) => {
3075
3639
  const p = this.worldToScreen({ x: t2.x(), y: t2.y() });
3076
- const w = t2.width() * sx;
3077
- const h = t2.height() * sx;
3640
+ const rotated = pointsBounds(rotatedRectPoints(
3641
+ { x: 0, y: 0 },
3642
+ t2.width() * sx,
3643
+ t2.height() * sx,
3644
+ t2.rotation()
3645
+ ));
3646
+ const w = rotated.width;
3647
+ const h = rotated.height;
3078
3648
  return { x: p.x - w / 2, y: p.y - h / 2, w, h };
3079
3649
  };
3080
3650
  for (const zone of this.zones) {
3081
- if (zone.label.opacity() > 0.05) cands.push({ node: zone.label, tier: 0, box: boxOf(zone.label) });
3082
- if (zone.sub && zone.sub.opacity() > 0.05) cands.push({ node: zone.sub, tier: 2, owner: zone.label, box: boxOf(zone.sub) });
3651
+ if (zone.label.opacity() > 0.05) {
3652
+ const p = this.worldToScreen(zone.anchor);
3653
+ const w = zone.back.width() * sx;
3654
+ const h = zone.back.height() * sx;
3655
+ cands.push({ node: zone.label, tier: 0, section: false, box: { x: p.x - w / 2, y: p.y - h / 2, w, h } });
3656
+ }
3083
3657
  }
3084
3658
  for (const sec of this.sections) {
3085
- if (sec.nameLabel.opacity() > 0.05) cands.push({ node: sec.nameLabel, tier: 1, box: boxOf(sec.nameLabel) });
3086
- if (sec.subLabel.opacity() > 0.05) cands.push({ node: sec.subLabel, tier: 3, owner: sec.nameLabel, box: boxOf(sec.subLabel) });
3659
+ if (sec.nameLabel.opacity() > 0.05) cands.push({ node: sec.nameLabel, tier: 1, section: true, box: boxOf(sec.nameLabel) });
3087
3660
  }
3088
3661
  if (cands.length < 2) return;
3089
3662
  cands.sort((a, b) => a.tier - b.tier || a.box.y - b.box.y || a.box.x - b.box.x);
3090
3663
  const kept = [];
3091
- const culled = /* @__PURE__ */ new Set();
3092
3664
  const collides = (b) => kept.some(
3093
3665
  (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
3094
3666
  );
3095
3667
  for (const c of cands) {
3096
- if (c.owner && culled.has(c.owner) || collides(c.box)) {
3668
+ if (collides(c.box)) {
3097
3669
  c.node.opacity(0);
3098
- culled.add(c.node);
3099
- } else {
3670
+ } else if (!c.section) {
3100
3671
  kept.push(c.box);
3101
3672
  }
3102
3673
  }
@@ -3108,6 +3679,54 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3108
3679
  t2.offsetY(t2.height() / 2);
3109
3680
  t2.y(y);
3110
3681
  }
3682
+ /** Fit one centred section name, rotating narrow shells like the target chart. */
3683
+ fitSectionRungLabels(sec, sx) {
3684
+ const paddingPx = 8;
3685
+ sec.subLabelFits = false;
3686
+ const maxPx = SECTION_LABEL_PX * sec.labelScale;
3687
+ const minPx = MIN_SECTION_LABEL_PX * sec.labelScale;
3688
+ for (let fontPx = maxPx; fontPx >= minPx; fontPx -= 1) {
3689
+ for (const rotation of [0, -90]) {
3690
+ sec.nameLabel.rotation(rotation);
3691
+ this.sizeLabel(sec.nameLabel, fontPx / sx, sec.nameLabel.y());
3692
+ for (const anchor of sec.labelAnchors) {
3693
+ sec.nameLabel.position(anchor);
3694
+ const paddingWorld = paddingPx / sx;
3695
+ if (rotatedRectFitsPolygon(
3696
+ anchor,
3697
+ sec.nameLabel.width() + paddingWorld,
3698
+ sec.nameLabel.height() + paddingWorld,
3699
+ rotation,
3700
+ sec.outline,
3701
+ sec.holes
3702
+ )) {
3703
+ sec.nameLabelFits = true;
3704
+ return;
3705
+ }
3706
+ }
3707
+ }
3708
+ }
3709
+ sec.nameLabel.position(sec.centroid);
3710
+ sec.nameLabel.rotation(0);
3711
+ sec.nameLabelFits = false;
3712
+ }
3713
+ /** Size one screen-constant zone name/price pill around its shared anchor. */
3714
+ sizeZonePill(zone, sx) {
3715
+ const padX = 10 / sx;
3716
+ const padY = 6 / sx;
3717
+ const gap = zone.sub ? 3 / sx : 0;
3718
+ this.sizeLabel(zone.label, ZONE_LABEL_PX / sx, zone.anchor.y);
3719
+ if (zone.sub) this.sizeLabel(zone.sub, ZONE_SUB_PX / sx, zone.anchor.y);
3720
+ const width = Math.max(zone.label.width(), zone.sub?.width() ?? 0) + padX * 2;
3721
+ const height = zone.label.height() + (zone.sub ? gap + zone.sub.height() : 0) + padY * 2;
3722
+ zone.label.y(zone.anchor.y - (zone.sub ? (gap + zone.sub.height()) / 2 : 0));
3723
+ if (zone.sub) zone.sub.y(zone.anchor.y + (zone.label.height() + gap) / 2);
3724
+ zone.back.position(zone.anchor);
3725
+ zone.back.size({ width, height });
3726
+ zone.back.offset({ x: width / 2, y: height / 2 });
3727
+ zone.back.cornerRadius(7 / sx);
3728
+ zone.back.strokeWidth(1 / sx);
3729
+ }
3111
3730
  /**
3112
3731
  * Map a container-relative screen point back to world coords. Inverts the
3113
3732
  * stage (scale/pos) and, in iso view, the iso affine — so screen-space taps
@@ -3122,12 +3741,12 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3122
3741
  sectionAt(clientPoint) {
3123
3742
  if (!this.sections.length) return null;
3124
3743
  const world = this.screenToWorld(clientPoint);
3125
- const hit = this.sections.find((sec) => pointInPolygon(world, sec.outline));
3126
- return hit ? hit.id : null;
3744
+ const hit = this.sections.find((sec) => pointInPolygonWithHoles(world, sec.outline, sec.holes));
3745
+ return hit ? hit.logicalId : null;
3127
3746
  }
3128
3747
  /** Seat ids belonging to a section (Slice 5 section-summary card). */
3129
3748
  sectionMembers(id) {
3130
- return this.sections.find((s) => s.id === id)?.memberIds.slice() ?? [];
3749
+ return [...new Set(this.sections.filter((section) => section.id === id || section.logicalId === id || section.zone === id).flatMap((section) => section.memberIds))];
3131
3750
  }
3132
3751
  addCentredLabel(layer, text, x, y, fill, fontSize, bold) {
3133
3752
  const t2 = new import_Text.Text({
@@ -3144,6 +3763,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3144
3763
  t2.offsetX(t2.width() / 2);
3145
3764
  t2.offsetY(t2.height() / 2);
3146
3765
  layer.add(t2);
3766
+ return t2;
3147
3767
  }
3148
3768
  // ---- selection ------------------------------------------------------------
3149
3769
  isSelectable(id) {
@@ -3177,21 +3797,22 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3177
3797
  * the container's computed CSS background (walking up past transparent
3178
3798
  * ancestors). Unknown/unparseable backgrounds keep the dark default.
3179
3799
  */
3180
- resolveSelectionColor() {
3181
- if (this.theme.selectionColor) return this.theme.selectionColor;
3182
- let bg = this.theme.background ?? "";
3183
- if (!bg && typeof getComputedStyle === "function") {
3184
- let el = this.container;
3185
- while (el) {
3186
- const c = getComputedStyle(el).backgroundColor;
3187
- if (c && c !== "transparent" && !/^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0\s*\)$/.test(c)) {
3188
- bg = c;
3189
- break;
3190
- }
3191
- el = el.parentElement;
3800
+ resolveCanvasBackground() {
3801
+ const themed = this.theme.background ? opaqueColorHex(this.theme.background) : null;
3802
+ if (themed) return themed;
3803
+ if (typeof getComputedStyle === "function") {
3804
+ let element = this.container;
3805
+ while (element) {
3806
+ const resolved = opaqueColorHex(getComputedStyle(element).backgroundColor);
3807
+ if (resolved) return resolved;
3808
+ element = element.parentElement;
3192
3809
  }
3193
3810
  }
3194
- return isLightColor(bg) ? DEF_SELECTION_ON_LIGHT : DEF_SELECTION;
3811
+ return DEF_CANVAS_BACKGROUND;
3812
+ }
3813
+ resolveSelectionColor() {
3814
+ if (this.theme.selectionColor) return this.theme.selectionColor;
3815
+ return isLightColor(this.canvasBackground) ? DEF_SELECTION_ON_LIGHT : DEF_SELECTION;
3195
3816
  }
3196
3817
  setSelected(id, on, silent = false) {
3197
3818
  const c = this.circleById.get(id);
@@ -3217,6 +3838,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3217
3838
  const candidate = this.selectionFocusId === id;
3218
3839
  const dims = this.boothDims.get(seat.rowId);
3219
3840
  const marker = new import_Group.Group({
3841
+ name: "selection-ring",
3220
3842
  x: seat.x,
3221
3843
  y: seat.y,
3222
3844
  rotation: dims?.rotation ?? 0,
@@ -3224,6 +3846,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3224
3846
  perfectDrawEnabled: false,
3225
3847
  opacity: this.selectionFocusId && !candidate ? 0.2 : 1
3226
3848
  });
3849
+ marker.setAttr("seatId", id);
3227
3850
  const common = {
3228
3851
  stroke: this.effSelection,
3229
3852
  listening: false,
@@ -3300,10 +3923,18 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3300
3923
  * whether the section already fills the viewport (small container) so the tap
3301
3924
  * must fall through and pick.
3302
3925
  */
3926
+ sectionBounds(id) {
3927
+ const bounds = this.sections.filter((section) => section.id === id || section.logicalId === id).map((section) => polyBounds(section.outline));
3928
+ if (!bounds.length) return null;
3929
+ const left = Math.min(...bounds.map((box) => box.x));
3930
+ const top = Math.min(...bounds.map((box) => box.y));
3931
+ const right = Math.max(...bounds.map((box) => box.x + box.width));
3932
+ const bottom = Math.max(...bounds.map((box) => box.y + box.height));
3933
+ return { x: left, y: top, width: right - left, height: bottom - top };
3934
+ }
3303
3935
  sectionFrameScale(id) {
3304
- const sec = this.sections.find((s) => s.id === id);
3305
- if (!sec) return this.stage.scaleX();
3306
- const b = polyBounds(sec.outline);
3936
+ const b = this.sectionBounds(id);
3937
+ if (!b) return this.stage.scaleX();
3307
3938
  const w = this.stage.width();
3308
3939
  const h = this.stage.height();
3309
3940
  const { min, max } = this.zoomBounds();
@@ -3333,11 +3964,11 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3333
3964
  if (this.effScale() < LABEL_SCALE && this.sections.length) {
3334
3965
  const sec = this.seatSection.get(id);
3335
3966
  if (sec) {
3336
- const alreadyFocused = this.focusedSectionId === sec.id;
3337
- const canZoomInFurther = this.sectionFrameScale(sec.id) > this.stage.scaleX() * 1.02;
3967
+ const alreadyFocused = this.focusedSectionId === sec.logicalId;
3968
+ const canZoomInFurther = this.sectionFrameScale(sec.logicalId) > this.stage.scaleX() * 1.02;
3338
3969
  if (!alreadyFocused && canZoomInFurther) {
3339
- if (this.opts.onSectionTap) this.opts.onSectionTap(sec.id);
3340
- else this.focusSection(sec.id);
3970
+ if (this.opts.onSectionTap) this.opts.onSectionTap(sec.logicalId);
3971
+ else this.focusSection(sec.logicalId);
3341
3972
  return;
3342
3973
  }
3343
3974
  }
@@ -3416,10 +4047,10 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3416
4047
  }
3417
4048
  if (this.sections.length) {
3418
4049
  const world = this.screenToWorld(pointer);
3419
- const hit = this.sections.find((sn) => pointInPolygon(world, sn.outline));
4050
+ const hit = this.sections.find((sn) => pointInPolygonWithHoles(world, sn.outline, sn.holes));
3420
4051
  if (hit) {
3421
- if (this.opts.onSectionTap) this.opts.onSectionTap(hit.id);
3422
- else this.focusRegion(hit.id);
4052
+ if (this.opts.onSectionTap) this.opts.onSectionTap(hit.logicalId);
4053
+ else this.focusRegion(hit.logicalId);
3423
4054
  return;
3424
4055
  }
3425
4056
  }
@@ -3505,10 +4136,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3505
4136
  * newer glide cancels an in-flight one.
3506
4137
  */
3507
4138
  focusRegion(target, opts) {
3508
- const b = typeof target === "string" ? (() => {
3509
- const sec = this.sections.find((s) => s.id === target);
3510
- return sec ? polyBounds(sec.outline) : null;
3511
- })() : target;
4139
+ const b = typeof target === "string" ? this.sectionBounds(target) : target;
3512
4140
  if (!b) return;
3513
4141
  this.cancelGlide();
3514
4142
  if (opts?.animate === false || this.reducedMotion) {
@@ -3568,25 +4196,343 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3568
4196
  if (this.zones.length && scale < ZONE_PROMINENT_SCALE) return "zones";
3569
4197
  return "sections";
3570
4198
  }
3571
- /** Jump the camera to a rung's zoom band, centred on the chart (glided). */
4199
+ getRenderedQualityEvidence() {
4200
+ const effectiveScale = this.effScale();
4201
+ const stageScale = this.stage.scaleX();
4202
+ const viewport = { width: this.stage.width(), height: this.stage.height() };
4203
+ const rounded = (value) => Math.round(value * 100) / 100;
4204
+ const labels = this.seats.map((seat) => {
4205
+ const shape = this.circleById.get(seat.id);
4206
+ const label = this.boothLabelById.get(seat.id) ?? this.seatLabelById.get(seat.id);
4207
+ const authoredFontSize = seat.kind === "booth" ? BOOTH_LABEL_FONT_SIZE : SEAT_LABEL_FONT_SIZE;
4208
+ const renderedFontPx = rounded((label?.fontSize() ?? authoredFontSize) * effectiveScale);
4209
+ const screen = this.worldToScreen(seat);
4210
+ const outside = screen.x < 0 || screen.x > viewport.width || screen.y < 0 || screen.y > viewport.height;
4211
+ const opacity = shape?.opacity() ?? 0;
4212
+ const section = this.seatSection.get(seat.id);
4213
+ const visible = Boolean(label?.isVisible()) && opacity >= 0.5 && !outside;
4214
+ let hiddenReason;
4215
+ if (!visible) {
4216
+ if (opacity < 0.5) hiddenReason = "dimmed-or-unavailable";
4217
+ else if (renderedFontPx < MIN_VISIBLE_BOOKABLE_LABEL_PX) hiddenReason = "below-minimum-size";
4218
+ else if (outside) hiddenReason = "outside-viewport";
4219
+ else if (!label) hiddenReason = "clutter-or-fit";
4220
+ else hiddenReason = "renderer-hidden";
4221
+ }
4222
+ const labelWidth = label ? label.width() * stageScale : 0;
4223
+ const labelHeight = label ? label.height() * effectiveScale : 0;
4224
+ const directWidthPx = shape instanceof import_Rect.Rect ? shape.width() * stageScale : this.seatR * 2 * effectiveScale;
4225
+ const directHeightPx = shape instanceof import_Rect.Rect ? shape.height() * stageScale : this.seatR * 2 * effectiveScale;
4226
+ const assistedDiameterPx = 2 * (this.seatR * effectiveScale + SEAT_TAP_SLOP_PX);
4227
+ const fill = shape?.fill();
4228
+ const ink = label?.fill();
4229
+ return {
4230
+ seatId: seat.id,
4231
+ label: seat.label,
4232
+ kind: seat.kind === "booth" ? "booth" : "seat",
4233
+ categoryKey: seat.categoryKey,
4234
+ ...section ? { sectionId: section.id } : {},
4235
+ ...section?.zone ? { zoneId: section.zone } : {},
4236
+ status: this.statusById.get(seat.id) ?? "free",
4237
+ selected: this.selection.has(seat.id),
4238
+ visible,
4239
+ renderedFontPx,
4240
+ fill: typeof fill === "string" ? fill : "",
4241
+ ink: typeof ink === "string" ? ink : this.theme.seatLabelColor ?? DEF_SEAT_LABEL,
4242
+ opacity: rounded(opacity),
4243
+ pointerTarget: {
4244
+ active: !this.cached && this.isSelectable(seat.id),
4245
+ directWidthPx: rounded(directWidthPx),
4246
+ directHeightPx: rounded(directHeightPx),
4247
+ effectiveMinimumPx: rounded(Math.max(
4248
+ Math.min(directWidthPx, directHeightPx),
4249
+ assistedDiameterPx
4250
+ ))
4251
+ },
4252
+ screenCenter: { x: rounded(screen.x), y: rounded(screen.y) },
4253
+ ...visible ? {
4254
+ screenBox: {
4255
+ x: rounded(screen.x - labelWidth / 2),
4256
+ y: rounded(screen.y - labelHeight / 2),
4257
+ width: rounded(labelWidth),
4258
+ height: rounded(labelHeight)
4259
+ }
4260
+ } : {},
4261
+ ...hiddenReason ? { hiddenReason } : {}
4262
+ };
4263
+ });
4264
+ const visibleLabels = labels.filter((label) => label.visible).length;
4265
+ const hierarchyEvidence = (id, kind, role, node, backgroundFill, section) => {
4266
+ const worldCorners = rotatedRectPoints(
4267
+ { x: node.x(), y: node.y() },
4268
+ node.width(),
4269
+ node.height(),
4270
+ node.rotation()
4271
+ );
4272
+ const screenBounds = pointsBounds(worldCorners.map((corner) => this.worldToScreen(corner)));
4273
+ const opacity = rounded(node.opacity());
4274
+ const ink = node.fill();
4275
+ const outside = screenBounds.x + screenBounds.width < 0 || screenBounds.x > viewport.width || screenBounds.y + screenBounds.height < 0 || screenBounds.y > viewport.height;
4276
+ const visible = node.isVisible() && opacity > 0.05 && !outside;
4277
+ const fitsContainer = section ? rotatedRectFitsPolygon(
4278
+ { x: node.x(), y: node.y() },
4279
+ node.width(),
4280
+ node.height(),
4281
+ node.rotation(),
4282
+ section.outline,
4283
+ section.holes
4284
+ ) : void 0;
4285
+ return {
4286
+ id,
4287
+ kind,
4288
+ role,
4289
+ label: node.text(),
4290
+ visible,
4291
+ renderedFontPx: rounded(node.fontSize() * stageScale),
4292
+ opacity,
4293
+ fill: backgroundFill,
4294
+ ink: typeof ink === "string" ? ink : "",
4295
+ ...fitsContainer == null ? {} : { fitsContainer },
4296
+ ...visible ? {
4297
+ screenBox: {
4298
+ x: rounded(screenBounds.x),
4299
+ y: rounded(screenBounds.y),
4300
+ width: rounded(screenBounds.width),
4301
+ height: rounded(screenBounds.height)
4302
+ }
4303
+ } : {}
4304
+ };
4305
+ };
4306
+ const hierarchyLabels = [
4307
+ ...this.sections.map((section) => {
4308
+ const fill = section.blockPoly.fill();
4309
+ return hierarchyEvidence(
4310
+ section.id,
4311
+ "section",
4312
+ "name",
4313
+ section.nameLabel,
4314
+ typeof fill === "string" ? fill : section.baseFill,
4315
+ section
4316
+ );
4317
+ }),
4318
+ ...this.sections.map((section) => {
4319
+ const fill = section.blockPoly.fill();
4320
+ return hierarchyEvidence(
4321
+ `${section.id}:availability`,
4322
+ "section",
4323
+ "availability",
4324
+ section.subLabel,
4325
+ typeof fill === "string" ? fill : section.baseFill,
4326
+ section
4327
+ );
4328
+ }),
4329
+ ...this.zones.flatMap((zone) => [
4330
+ hierarchyEvidence(zone.id, "zone", "name", zone.label, zone.background),
4331
+ ...zone.sub ? [hierarchyEvidence(`${zone.id}:price`, "zone", "price", zone.sub, zone.background)] : []
4332
+ ])
4333
+ ];
4334
+ const gaAreas = [...this.gaById].map(([areaId, ga]) => {
4335
+ const screenPoints = ga.points.map((point) => this.worldToScreen(point));
4336
+ const left = Math.min(...screenPoints.map((point) => point.x));
4337
+ const top = Math.min(...screenPoints.map((point) => point.y));
4338
+ const right = Math.max(...screenPoints.map((point) => point.x));
4339
+ const bottom = Math.max(...screenPoints.map((point) => point.y));
4340
+ const outside = right < 0 || left > viewport.width || bottom < 0 || top > viewport.height;
4341
+ const opacity = rounded(ga.polygon.opacity());
4342
+ const visible = opacity >= 0.1 && !outside;
4343
+ const fill = ga.polygon.fill();
4344
+ return {
4345
+ areaId,
4346
+ label: ga.label,
4347
+ capacity: ga.capacity,
4348
+ categoryKey: ga.categoryKey,
4349
+ ...ga.sectionId ? { sectionId: ga.sectionId } : {},
4350
+ visible,
4351
+ interactive: ga.polygon.listening(),
4352
+ opacity,
4353
+ fill: typeof fill === "string" ? fill : "",
4354
+ effectiveBackground: ga.effectiveBackground,
4355
+ ...visible ? {
4356
+ screenBox: {
4357
+ x: rounded(left),
4358
+ y: rounded(top),
4359
+ width: rounded(right - left),
4360
+ height: rounded(bottom - top)
4361
+ }
4362
+ } : {}
4363
+ };
4364
+ });
4365
+ const freeTextLabels = [...this.freeTextById].map(([recordKey, record]) => {
4366
+ const { node, background, kind } = record;
4367
+ const point = this.worldToScreen({ x: node.x(), y: node.y() });
4368
+ const width = node.width() * stageScale;
4369
+ const height = node.height() * effectiveScale;
4370
+ const left = point.x - node.offsetX() * stageScale;
4371
+ const top = point.y - node.offsetY() * effectiveScale;
4372
+ const renderedFontPx = rounded(node.fontSize() * effectiveScale);
4373
+ const outside = left + width < 0 || left > viewport.width || top + height < 0 || top > viewport.height;
4374
+ const visible = node.isVisible() && !outside;
4375
+ const ink = node.fill();
4376
+ const opacity = rounded(node.getAbsoluteOpacity());
4377
+ let hiddenReason;
4378
+ if (!visible) {
4379
+ if (renderedFontPx < MIN_VISIBLE_BOOKABLE_LABEL_PX) hiddenReason = "below-minimum-size";
4380
+ else if (outside) hiddenReason = "outside-viewport";
4381
+ else hiddenReason = "renderer-hidden";
4382
+ }
4383
+ return {
4384
+ objectId: record.objectId ?? recordKey,
4385
+ kind,
4386
+ text: node.text(),
4387
+ visible,
4388
+ renderedFontPx,
4389
+ ink: typeof ink === "string" ? ink : "",
4390
+ background,
4391
+ opacity,
4392
+ ...visible ? {
4393
+ screenBox: {
4394
+ x: rounded(left),
4395
+ y: rounded(top),
4396
+ width: rounded(width),
4397
+ height: rounded(height)
4398
+ }
4399
+ } : {},
4400
+ ...hiddenReason ? { hiddenReason } : {}
4401
+ };
4402
+ });
4403
+ const palette = overviewPalette(this.canvasBackground);
4404
+ const neutralSectionFills = /* @__PURE__ */ new Set([
4405
+ palette.sectionFill.toLowerCase(),
4406
+ darken(palette.sectionFill, 0.12).toLowerCase()
4407
+ ]);
4408
+ const visibleSectionShells = this.sections.filter((section) => section.blockPoly.opacity() > 0.05);
4409
+ return {
4410
+ viewport,
4411
+ canvasBackground: this.canvasBackground,
4412
+ effectiveScale: rounded(effectiveScale),
4413
+ rung: this.getRung(),
4414
+ minimumVisibleLabelPx: MIN_VISIBLE_BOOKABLE_LABEL_PX,
4415
+ totalLabelledBookableUnits: labels.length,
4416
+ visibleLabels,
4417
+ hiddenLabels: labels.length - visibleLabels,
4418
+ totalBookableUnits: labels.length + gaAreas.reduce((sum, area) => sum + area.capacity, 0),
4419
+ selectionRingSeatIds: this.overlayLayer.find(".selection-ring").map((node) => String(node.getAttr("seatId") ?? "")).filter(Boolean),
4420
+ selectionRingColor: this.effSelection,
4421
+ focusedSectionId: this.focusedSectionId,
4422
+ focusBackdropVisible: Boolean(this.focusBackdrop?.isVisible()),
4423
+ categoryFilterKeys: this.categoryFilter ? [...this.categoryFilter].sort() : null,
4424
+ overviewStyle: {
4425
+ visibleSectionShells: visibleSectionShells.length,
4426
+ categoryPaintedSectionShells: visibleSectionShells.filter((section) => {
4427
+ const fill = section.blockPoly.fill();
4428
+ return typeof fill !== "string" || !neutralSectionFills.has(fill.toLowerCase());
4429
+ }).length,
4430
+ visibleCategoryDetailOutlines: this.sections.filter((section) => section.outlinePoly.opacity() > 0.05).length,
4431
+ // Row-hint nodes no longer exist in the production overview scene.
4432
+ visibleSectionRowHints: 0,
4433
+ visibleSectionAvailabilityLabels: this.sections.filter((section) => section.subLabel.opacity() > 0.05).length,
4434
+ visibleSectionGADetails: [...this.gaById.values()].filter((area) => area.sectionId != null && area.polygon.opacity() > 0.05).length
4435
+ },
4436
+ labels,
4437
+ gaAreas,
4438
+ hierarchyLabels,
4439
+ freeTextLabels
4440
+ };
4441
+ }
4442
+ /** Jump the camera to a rung's zoom band (glided). */
3572
4443
  setRung(rung) {
3573
4444
  if (rung === "zones") {
3574
4445
  this.cancelGlide();
3575
4446
  this.zoomToFit();
3576
4447
  return;
3577
4448
  }
3578
- const target = rung === "sections" ? (SECTION_PROMINENT_SCALE + CACHE_THRESHOLD) / 2 : Math.max(SEAT_FOCUS_SCALE, CACHE_THRESHOLD * 1.3);
4449
+ const target = rung === "sections" ? (SECTION_PROMINENT_SCALE + CACHE_THRESHOLD) / 2 : Math.max(
4450
+ this.seatLabelTargetScale() * 1.05,
4451
+ SEAT_FOCUS_SCALE,
4452
+ CACHE_THRESHOLD * 1.3
4453
+ );
3579
4454
  const w = this.stage.width();
3580
4455
  const h = this.stage.height();
3581
- const cx = this.bounds.x + this.bounds.width / 2;
3582
- const cy = this.bounds.y + this.bounds.height / 2;
4456
+ const visible = this.getVisibleWorldRect();
4457
+ const viewCentre = {
4458
+ x: visible.x + visible.width / 2,
4459
+ y: visible.y + visible.height / 2
4460
+ };
4461
+ let cx = viewCentre.x;
4462
+ let cy = viewCentre.y;
4463
+ if (rung === "sections" && this.sections.length > 0) {
4464
+ const sectionCentres = this.sections.map((section) => {
4465
+ const bounds = polyBounds(section.outline);
4466
+ return {
4467
+ x: bounds.x + bounds.width / 2,
4468
+ y: bounds.y + bounds.height / 2
4469
+ };
4470
+ });
4471
+ const halfWidth = w / (target * 2);
4472
+ const halfHeight = h / (target * 2);
4473
+ const hierarchyWillBeVisible = sectionCentres.some((point) => Math.abs(point.x - viewCentre.x) <= halfWidth && Math.abs(point.y - viewCentre.y) <= halfHeight);
4474
+ if (!hierarchyWillBeVisible) {
4475
+ const nearest = sectionCentres.reduce((best, point) => {
4476
+ const distance = (point.x - viewCentre.x) ** 2 + (point.y - viewCentre.y) ** 2;
4477
+ return distance < best.distance ? { point, distance } : best;
4478
+ }, { point: sectionCentres[0], distance: Infinity });
4479
+ cx = nearest.point.x;
4480
+ cy = nearest.point.y;
4481
+ }
4482
+ }
4483
+ const seatAnchors = rung === "seats" ? this.seats.filter((seat) => seat.kind !== "booth") : [];
4484
+ if (seatAnchors.length > 0) {
4485
+ let nearest = seatAnchors[0];
4486
+ let nearestDistance = Infinity;
4487
+ for (const seat of seatAnchors) {
4488
+ const dx = seat.x - viewCentre.x;
4489
+ const dy = seat.y - viewCentre.y;
4490
+ const distance = dx * dx + dy * dy;
4491
+ if (distance < nearestDistance) {
4492
+ nearest = seat;
4493
+ nearestDistance = distance;
4494
+ }
4495
+ }
4496
+ cx = nearest.x;
4497
+ cy = nearest.y;
4498
+ }
3583
4499
  const bw = w / (target * 1.12);
3584
4500
  const bh = h / (target * 1.12);
3585
4501
  this.focusRegion({ x: cx - bw / 2, y: cy - bh / 2, width: bw, height: bh });
3586
4502
  }
4503
+ /**
4504
+ * The seat rung must account for labels that auto-fit inside a seat circle.
4505
+ * A short `A-1` remains at the normal 7u target; a table label such as
4506
+ * `T13-10` may fit at 4u and therefore needs a deeper camera target to reach
4507
+ * the same 12 CSS-pixel floor. Measurement happens only on explicit rung
4508
+ * navigation, never during pan/zoom frames.
4509
+ */
4510
+ seatLabelTargetScale() {
4511
+ let minimumFont = BOOTH_LABEL_FONT_SIZE;
4512
+ const measure = new import_Text.Text({
4513
+ fontSize: SEAT_LABEL_FONT_SIZE,
4514
+ fontStyle: "600",
4515
+ fontFamily: this.labelFont(),
4516
+ listening: false
4517
+ });
4518
+ const maxWidth = this.seatR * 2 - 3;
4519
+ for (const seat of this.seats) {
4520
+ if (seat.kind === "booth") {
4521
+ minimumFont = Math.min(minimumFont, BOOTH_LABEL_FONT_SIZE);
4522
+ continue;
4523
+ }
4524
+ measure.fontSize(SEAT_LABEL_FONT_SIZE);
4525
+ measure.text(bookableMarkerLabel(seat.displayLabel ?? seat.label));
4526
+ const fitted = measure.width() > maxWidth ? Math.max(MIN_FITTED_SEAT_LABEL_FONT_SIZE, SEAT_LABEL_FONT_SIZE * maxWidth / measure.width()) : SEAT_LABEL_FONT_SIZE;
4527
+ minimumFont = Math.min(minimumFont, fitted);
4528
+ }
4529
+ measure.destroy();
4530
+ return MIN_VISIBLE_BOOKABLE_LABEL_PX / Math.max(MIN_FITTED_SEAT_LABEL_FONT_SIZE, minimumFont);
4531
+ }
3587
4532
  /** Recompute LOD (cache/labels) after any pan/zoom settles. */
3588
4533
  afterViewChange() {
3589
4534
  this.updateLOD();
4535
+ this.updateFreeTextVisibility();
3590
4536
  this.updateLabels();
3591
4537
  this.scheduleViewChange();
3592
4538
  }
@@ -3600,7 +4546,13 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3600
4546
  }
3601
4547
  updateLOD() {
3602
4548
  const scale = this.effScale();
4549
+ const focalScale = Math.max(scale, 1e-4);
4550
+ for (const [label, targetPx] of this.primaryFocalLabels) {
4551
+ this.sizeLabel(label, targetPx / focalScale, label.y());
4552
+ }
3603
4553
  if (this.hasSections) this.applySectionLod(scale);
4554
+ else if (this.primaryFocalLabels.size) this.bgLayer.batchDraw();
4555
+ this.paintGAStateForView();
3604
4556
  const shouldCache = scale < CACHE_THRESHOLD;
3605
4557
  if (shouldCache && !this.cached) {
3606
4558
  this.cacheSeatLayer();
@@ -3639,15 +4591,37 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3639
4591
  if (this.recacheTimer) {
3640
4592
  clearTimeout(this.recacheTimer);
3641
4593
  this.recacheTimer = null;
3642
- this.rebuildSeatCache();
4594
+ if (this.effScale() < CACHE_THRESHOLD) {
4595
+ this.rebuildSeatCache();
4596
+ } else if (this.cached) {
4597
+ this.seatLayer.clearCache();
4598
+ this.seatLayer.listening(true);
4599
+ this.cached = false;
4600
+ }
3643
4601
  }
3644
4602
  this.bgLayer.draw();
3645
4603
  this.seatLayer.draw();
3646
4604
  this.overlayLayer.draw();
3647
4605
  }
4606
+ updateFreeTextVisibility() {
4607
+ const effectiveScale = this.effScale();
4608
+ for (const { objectId, node, categoryKey, kind } of this.freeTextById.values()) {
4609
+ const gaDimmed = categoryKey != null && (kind === "ga-label" || kind === "ga-capacity") && this.gaCategoryDimmed(categoryKey);
4610
+ const gaOverviewHidden = objectId != null && (kind === "ga-label" || kind === "ga-capacity") && this.gaById.get(objectId)?.sectionId != null && effectiveScale < CACHE_THRESHOLD;
4611
+ node.visible(!gaDimmed && !gaOverviewHidden && isBookableLabelLegibleAtScale(node.fontSize(), effectiveScale));
4612
+ }
4613
+ }
3648
4614
  updateLabels() {
3649
- const show = this.effScale() > LABEL_SCALE;
4615
+ const effectiveScale = this.effScale();
4616
+ const show = effectiveScale >= LABEL_SCALE;
4617
+ for (const [id, label] of this.boothLabelById) {
4618
+ const shape = this.circleById.get(id);
4619
+ label.visible(
4620
+ isBookableLabelLegibleAtScale(label.fontSize(), effectiveScale) && (shape?.opacity() ?? 1) >= 0.5
4621
+ );
4622
+ }
3650
4623
  this.labelGroup.destroyChildren();
4624
+ this.seatLabelById.clear();
3651
4625
  if (!show) {
3652
4626
  this.overlayLayer.batchDraw();
3653
4627
  return;
@@ -3661,8 +4635,10 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3661
4635
  for (const seat of this.seats) {
3662
4636
  if (seat.kind === "booth") continue;
3663
4637
  if (seat.x < x0 || seat.x > x1 || seat.y < y0 || seat.y > y1) continue;
4638
+ const shape = this.circleById.get(seat.id);
4639
+ if ((shape?.opacity() ?? 1) < 0.5) continue;
3664
4640
  const status = this.statusById.get(seat.id) ?? "free";
3665
- const unavailable = status === "booked" || status === "held" && !this.ownedHold.has(seat.id);
4641
+ const unavailable = status === "booked" || status === "held" && !this.ownedHold.has(seat.id) && !this.opts.manageMode;
3666
4642
  if (unavailable) {
3667
4643
  const cue = new import_Group.Group({ x: seat.x, y: seat.y, listening: false });
3668
4644
  if (status === "held") {
@@ -3700,23 +4676,28 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3700
4676
  const t2 = new import_Text.Text({
3701
4677
  x: seat.x,
3702
4678
  y: seat.y,
3703
- text: seat.label,
3704
- fontSize: 7,
4679
+ text: bookableMarkerLabel(seat.displayLabel ?? seat.label),
4680
+ fontSize: SEAT_LABEL_FONT_SIZE,
3705
4681
  fontStyle: "600",
3706
4682
  fontFamily: this.labelFont(),
3707
- fill: this.theme.seatLabelColor ?? DEF_SEAT_LABEL,
4683
+ fill: shape ? this.renderedBookableLabelInk(shape) : this.theme.seatLabelColor ?? DEF_SEAT_LABEL,
3708
4684
  listening: false,
3709
4685
  perfectDrawEnabled: false
3710
4686
  });
3711
4687
  const maxW = this.seatR * 2 - 3;
3712
- if (t2.width() > maxW) t2.fontSize(Math.max(4, t2.fontSize() * maxW / t2.width()));
3713
- if (t2.fontSize() < 4.2) {
4688
+ if (t2.width() > maxW) t2.fontSize(Math.max(MIN_FITTED_SEAT_LABEL_FONT_SIZE, SEAT_LABEL_FONT_SIZE * maxW / t2.width()));
4689
+ if (t2.width() > maxW + 0.01) {
4690
+ t2.destroy();
4691
+ continue;
4692
+ }
4693
+ if (!isBookableLabelLegibleAtScale(t2.fontSize(), effectiveScale)) {
3714
4694
  t2.destroy();
3715
4695
  continue;
3716
4696
  }
3717
4697
  t2.offsetX(t2.width() / 2);
3718
4698
  t2.offsetY(t2.height() / 2);
3719
4699
  this.labelGroup.add(t2);
4700
+ this.seatLabelById.set(seat.id, t2);
3720
4701
  if (++count >= MAX_LABELS) break;
3721
4702
  }
3722
4703
  if (this.isoT > 0) this.applyUprightLabels();
@@ -4991,9 +5972,12 @@ async function loadLocale(code) {
4991
5972
  }
4992
5973
  // Annotate the CommonJS export names for ESM import in node:
4993
5974
  0 && (module.exports = {
5975
+ ACCESSIBILITY_RING_COLOR,
4994
5976
  ACCESSIBILITY_TYPES,
4995
5977
  CHART_STORAGE_KEY,
4996
5978
  DEFAULT_CURRENCY,
5979
+ LABEL_STYLE_MAX_SIZE,
5980
+ LABEL_STYLE_MIN_SIZE,
4997
5981
  MAX_EVENT_INVENTORY,
4998
5982
  MAX_GA_CAPACITY,
4999
5983
  PickerController,
@@ -5001,6 +5985,7 @@ async function loadLocale(code) {
5001
5985
  SeatmapRenderer,
5002
5986
  UNGROUPED_ID,
5003
5987
  accessibilityMeta,
5988
+ accessibilityRingColor,
5004
5989
  allObjects,
5005
5990
  applyHidden,
5006
5991
  chartBounds,
@@ -5029,6 +6014,8 @@ async function loadLocale(code) {
5029
6014
  loadLocale,
5030
6015
  objectCenter,
5031
6016
  pointInPolygon,
6017
+ pointInPolygonWithHoles,
6018
+ polygonLabelPoint,
5032
6019
  resolveLocale,
5033
6020
  setLocale,
5034
6021
  setMoneyLocale,