@seatlayer/core 0.16.1 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.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",
@@ -307,6 +313,8 @@ __export(index_exports, {
307
313
  loadLocale: () => loadLocale,
308
314
  objectCenter: () => objectCenter,
309
315
  pointInPolygon: () => pointInPolygon,
316
+ pointInPolygonWithHoles: () => pointInPolygonWithHoles,
317
+ polygonLabelPoint: () => polygonLabelPoint,
310
318
  resolveLocale: () => resolveLocale,
311
319
  setLocale: () => setLocale,
312
320
  setMoneyLocale: () => setMoneyLocale,
@@ -350,6 +358,71 @@ function layerOf(obj) {
350
358
  }
351
359
  var CHART_STORAGE_KEY = "seatmap.chart";
352
360
 
361
+ // src/core/complexGeometry.ts
362
+ function cubicPoint(path, t2) {
363
+ const u = 1 - t2;
364
+ const a = u * u * u;
365
+ const b = 3 * u * u * t2;
366
+ const c = 3 * u * t2 * t2;
367
+ const d = t2 * t2 * t2;
368
+ return {
369
+ x: a * path.start.x + b * path.control1.x + c * path.control2.x + d * path.end.x,
370
+ y: a * path.start.y + b * path.control1.y + c * path.control2.y + d * path.end.y
371
+ };
372
+ }
373
+ function distributeAlongCubic(path, count, resolution = 192) {
374
+ if (!Number.isInteger(count) || count < 1) throw new Error("Path point count must be a positive integer");
375
+ if (count === 1) return [cubicPoint(path, 0.5)];
376
+ const samples = Array.from({ length: resolution + 1 }, (_, index) => cubicPoint(path, index / resolution));
377
+ const lengths = new Float64Array(samples.length);
378
+ for (let index = 1; index < samples.length; index += 1) {
379
+ const dx = samples[index].x - samples[index - 1].x;
380
+ const dy = samples[index].y - samples[index - 1].y;
381
+ lengths[index] = lengths[index - 1] + Math.hypot(dx, dy);
382
+ }
383
+ const total = lengths[lengths.length - 1];
384
+ if (total <= 1e-9) return Array.from({ length: count }, () => ({ ...path.start }));
385
+ const output = [];
386
+ let segment = 1;
387
+ for (let index = 0; index < count; index += 1) {
388
+ const target = total * index / (count - 1);
389
+ while (segment < lengths.length - 1 && lengths[segment] < target) segment += 1;
390
+ const before = lengths[segment - 1];
391
+ const after = lengths[segment];
392
+ const ratio = after === before ? 0 : (target - before) / (after - before);
393
+ output.push({
394
+ x: samples[segment - 1].x + (samples[segment].x - samples[segment - 1].x) * ratio,
395
+ y: samples[segment - 1].y + (samples[segment].y - samples[segment - 1].y) * ratio
396
+ });
397
+ }
398
+ return output;
399
+ }
400
+
401
+ // src/core/sectionPath.ts
402
+ var TAU = Math.PI * 2;
403
+ function translateSectionOutlinePath(path, dx, dy) {
404
+ const translate = (point) => ({ x: point.x + dx, y: point.y + dy });
405
+ return transformSectionOutlinePath(path, translate);
406
+ }
407
+ function transformSectionOutlinePath(path, transform, radiusScale = 1, reflected = false) {
408
+ return {
409
+ ...path,
410
+ start: transform(path.start),
411
+ segments: path.segments.map((segment) => segment.kind === "line" ? { ...segment, end: transform(segment.end) } : segment.kind === "arc" ? {
412
+ ...segment,
413
+ center: transform(segment.center),
414
+ radius: segment.radius * Math.abs(radiusScale),
415
+ clockwise: reflected ? !segment.clockwise : segment.clockwise,
416
+ end: transform(segment.end)
417
+ } : {
418
+ ...segment,
419
+ control1: transform(segment.control1),
420
+ control2: transform(segment.control2),
421
+ end: transform(segment.end)
422
+ })
423
+ };
424
+ }
425
+
353
426
  // src/core/layout.ts
354
427
  function overrideAccessibility(o) {
355
428
  if (!o) return [];
@@ -371,6 +444,7 @@ function place(lx, ly, deg, origin) {
371
444
  function rowSeatPositions(row) {
372
445
  const { seatCount, seatSpacing, curve, rotation, origin } = row;
373
446
  const out = [];
447
+ if (row.path) return distributeAlongCubic(row.path, seatCount);
374
448
  if (seatCount <= 1) {
375
449
  if (seatCount === 1) out.push({ x: origin.x, y: origin.y });
376
450
  return out;
@@ -533,6 +607,53 @@ function pointInPolygon(p, poly) {
533
607
  }
534
608
  return inside;
535
609
  }
610
+ function pointOnPolygonBoundary(p, poly) {
611
+ return poly.some((start, index) => {
612
+ const end = poly[(index + 1) % poly.length];
613
+ const cross = (p.y - start.y) * (end.x - start.x) - (p.x - start.x) * (end.y - start.y);
614
+ if (Math.abs(cross) > 1e-7) return false;
615
+ const dot = (p.x - start.x) * (end.x - start.x) + (p.y - start.y) * (end.y - start.y);
616
+ const lengthSquared = (end.x - start.x) ** 2 + (end.y - start.y) ** 2;
617
+ return dot >= -1e-7 && dot <= lengthSquared + 1e-7;
618
+ });
619
+ }
620
+ function pointInPolygonWithHoles(p, outer, holes) {
621
+ return pointInPolygon(p, outer) && !(holes ?? []).some((hole) => pointInPolygon(p, hole) || pointOnPolygonBoundary(p, hole));
622
+ }
623
+ function polygonLabelPoint(outer, holes) {
624
+ if (!outer.length) return { x: 0, y: 0 };
625
+ const xs = outer.map((point) => point.x);
626
+ const ys = outer.map((point) => point.y);
627
+ const bounds = { minX: Math.min(...xs), maxX: Math.max(...xs), minY: Math.min(...ys), maxY: Math.max(...ys) };
628
+ const centroid = polygonCentroid(outer);
629
+ if (pointInPolygonWithHoles(centroid, outer, holes)) return centroid;
630
+ let best = outer[0];
631
+ let bestScore = -Infinity;
632
+ const rings = [outer, ...holes ?? []];
633
+ for (let row = 1; row < 24; row += 1) {
634
+ for (let column = 1; column < 24; column += 1) {
635
+ const point = {
636
+ x: bounds.minX + (bounds.maxX - bounds.minX) * column / 24,
637
+ y: bounds.minY + (bounds.maxY - bounds.minY) * row / 24
638
+ };
639
+ if (!pointInPolygonWithHoles(point, outer, holes)) continue;
640
+ const score = Math.min(...rings.flatMap((ring) => ring.map((start, index) => {
641
+ const end = ring[(index + 1) % ring.length];
642
+ const dx = end.x - start.x;
643
+ const dy = end.y - start.y;
644
+ const denominator = dx * dx + dy * dy;
645
+ const projection = denominator ? ((point.x - start.x) * dx + (point.y - start.y) * dy) / denominator : 0;
646
+ const t2 = Math.max(0, Math.min(1, projection));
647
+ return Math.hypot(point.x - (start.x + t2 * dx), point.y - (start.y + t2 * dy));
648
+ })));
649
+ if (score > bestScore) {
650
+ best = point;
651
+ bestScore = score;
652
+ }
653
+ }
654
+ }
655
+ return best;
656
+ }
536
657
  function polygonCentroid(pts) {
537
658
  if (!pts.length) return { x: 0, y: 0 };
538
659
  let x = 0;
@@ -596,9 +717,14 @@ function translateObject(o, dx, dy) {
596
717
  case "booth":
597
718
  return { ...o, center: p(o.center) };
598
719
  case "gaArea":
599
- return { ...o, points: pts(o.points) };
720
+ return { ...o, points: pts(o.points), ...o.holes ? { holes: o.holes.map(pts) } : {} };
600
721
  case "section":
601
- return { ...o, outline: pts(o.outline) };
722
+ return {
723
+ ...o,
724
+ outline: pts(o.outline),
725
+ ...o.outlinePath ? { outlinePath: translateSectionOutlinePath(o.outlinePath, dx, dy) } : {},
726
+ ...o.holes ? { holes: o.holes.map(pts) } : {}
727
+ };
602
728
  case "text":
603
729
  return { ...o, position: p(o.position) };
604
730
  case "shape":
@@ -721,12 +847,33 @@ function objectSeatLabels(o) {
721
847
  function isSeatObject(o) {
722
848
  return o.type === "row" || o.type === "table" || o.type === "booth" || o.type === "gaArea";
723
849
  }
850
+ function samePoints(left, right) {
851
+ return left.length === right.length && left.every((point, index) => point.x === right[index].x && point.y === right[index].y);
852
+ }
853
+ function sameGASurfaceAsSection(object, section) {
854
+ if (object.type !== "gaArea" || !samePoints(object.points, section.outline)) return false;
855
+ const objectHoles = object.holes ?? [];
856
+ const sectionHoles = section.holes ?? [];
857
+ return objectHoles.length === sectionHoles.length && objectHoles.every((hole, index) => samePoints(hole, sectionHoles[index]));
858
+ }
724
859
  function computeSections(doc) {
725
860
  const objs = allObjects(doc);
726
861
  const sectionObjs = objs.filter((o) => o.type === "section");
727
862
  const nodes = /* @__PURE__ */ new Map();
728
863
  for (const s of sectionObjs) {
729
- nodes.set(s.id, { id: s.id, label: s.label || "Section", zone: s.zone, seatCount: 0, objectIds: [], seatLabels: [] });
864
+ const logicalId = s.logicalSectionId ?? s.id;
865
+ const existing = nodes.get(logicalId);
866
+ if (existing) {
867
+ continue;
868
+ }
869
+ nodes.set(logicalId, {
870
+ id: logicalId,
871
+ label: s.label || "Section",
872
+ zone: s.zone,
873
+ seatCount: 0,
874
+ objectIds: [],
875
+ seatLabels: []
876
+ });
730
877
  }
731
878
  const ungrouped = { id: UNGROUPED_ID, label: "Other seats", seatCount: 0, objectIds: [], seatLabels: [] };
732
879
  const objectToSection = /* @__PURE__ */ new Map();
@@ -734,22 +881,24 @@ function computeSections(doc) {
734
881
  if (!isSeatObject(obj)) continue;
735
882
  const labels = objectSeatLabels(obj);
736
883
  if (labels.length === 0) continue;
884
+ const referencedLogicalId = obj.referenceInventorySource?.logicalSectionId;
737
885
  const c = objectCenter(obj);
738
- const owner = sectionObjs.find((s) => pointInPolygon(c, s.outline));
739
- const node = owner ? nodes.get(owner.id) : ungrouped;
886
+ const referencedOwner = referencedLogicalId ? sectionObjs.find((section) => (section.logicalSectionId ?? section.id) === referencedLogicalId && (sameGASurfaceAsSection(obj, section) || pointInPolygonWithHoles(c, section.outline, section.holes))) : void 0;
887
+ const owner = referencedOwner ?? sectionObjs.find((s) => pointInPolygonWithHoles(c, s.outline, s.holes));
888
+ const node = owner ? nodes.get(owner.logicalSectionId ?? owner.id) : ungrouped;
740
889
  node.seatCount += labels.length;
741
890
  node.objectIds.push(obj.id);
742
891
  node.seatLabels.push(...labels);
743
892
  objectToSection.set(obj.id, node.id);
744
893
  }
745
894
  return {
746
- sections: sectionObjs.map((s) => nodes.get(s.id)),
895
+ sections: [...nodes.values()],
747
896
  ungrouped: ungrouped.objectIds.length ? ungrouped : null,
748
897
  objectToSection
749
898
  };
750
899
  }
751
900
  function isSectionHidden(s, hidden) {
752
- return hidden.has(s.id) || !!s.zone && hidden.has(s.zone);
901
+ return hidden.has(s.id) || !!s.logicalSectionId && hidden.has(s.logicalSectionId) || !!s.zone && hidden.has(s.zone);
753
902
  }
754
903
  function hiddenObjectIds(doc, hidden) {
755
904
  const out = /* @__PURE__ */ new Set();
@@ -802,6 +951,61 @@ var import_Ellipse = require("konva/lib/shapes/Ellipse");
802
951
  var import_Line = require("konva/lib/shapes/Line");
803
952
  var import_Text = require("konva/lib/shapes/Text");
804
953
  var import_Image = require("konva/lib/shapes/Image");
954
+ var import_Shape = require("konva/lib/Shape");
955
+
956
+ // src/core/chartRenderRules.ts
957
+ var SEAT_LABEL_FONT_SIZE = 7;
958
+ var BOOTH_LABEL_FONT_SIZE = 10;
959
+ var GA_LABEL_FONT_SIZE = 15;
960
+ var GA_CAPACITY_LABEL_FONT_SIZE = 11;
961
+ var GA_FILL_OPACITY = 0.85;
962
+ var MIN_VISIBLE_BOOKABLE_LABEL_PX = 12;
963
+ var SMALL_TEXT_CONTRAST = 4.5;
964
+ var DARK_BOOKABLE_LABEL_INK = "#000000";
965
+ var LIGHT_BOOKABLE_LABEL_INK = "#ffffff";
966
+ function isBookableLabelLegibleAtScale(fontSize, effectiveScale) {
967
+ return fontSize * effectiveScale >= MIN_VISIBLE_BOOKABLE_LABEL_PX;
968
+ }
969
+ function bookableMarkerLabel(publicLabel) {
970
+ return /-(\d{1,5})$/.exec(publicLabel)?.[1] ?? publicLabel;
971
+ }
972
+ function luminance(value) {
973
+ const match = /^#([0-9a-f]{6})$/i.exec(value.trim());
974
+ if (!match) return null;
975
+ const channel = (offset) => {
976
+ const encoded = Number.parseInt(match[1].slice(offset, offset + 2), 16) / 255;
977
+ return encoded <= 0.04045 ? encoded / 12.92 : ((encoded + 0.055) / 1.055) ** 2.4;
978
+ };
979
+ return 0.2126 * channel(0) + 0.7152 * channel(2) + 0.0722 * channel(4);
980
+ }
981
+ function renderedTextContrast(ink, fill) {
982
+ const inkLuminance = luminance(ink);
983
+ const fillLuminance = luminance(fill);
984
+ if (inkLuminance == null || fillLuminance == null) return null;
985
+ return (Math.max(inkLuminance, fillLuminance) + 0.05) / (Math.min(inkLuminance, fillLuminance) + 0.05);
986
+ }
987
+ function rgb(value) {
988
+ const match = /^#([0-9a-f]{6})$/i.exec(value.trim());
989
+ if (!match) return null;
990
+ const packed = Number.parseInt(match[1], 16);
991
+ return [packed >> 16 & 255, packed >> 8 & 255, packed & 255];
992
+ }
993
+ function compositeHexOver(foreground, background, opacity) {
994
+ const front = rgb(foreground);
995
+ const back = rgb(background);
996
+ if (!front || !back) return background;
997
+ const alpha = Math.max(0, Math.min(1, opacity));
998
+ const channels = front.map((value, index) => Math.round(value * alpha + back[index] * (1 - alpha)));
999
+ return `#${channels.map((value) => value.toString(16).padStart(2, "0")).join("")}`;
1000
+ }
1001
+ function stateAwareBookableLabelInk(fill, preferred) {
1002
+ const preferredContrast = renderedTextContrast(preferred, fill);
1003
+ if (preferredContrast != null && preferredContrast >= SMALL_TEXT_CONTRAST) return preferred;
1004
+ const darkContrast = renderedTextContrast(DARK_BOOKABLE_LABEL_INK, fill) ?? 0;
1005
+ const lightContrast = renderedTextContrast(LIGHT_BOOKABLE_LABEL_INK, fill) ?? 0;
1006
+ if (darkContrast === 0 && lightContrast === 0) return preferred;
1007
+ return darkContrast >= lightContrast ? DARK_BOOKABLE_LABEL_INK : LIGHT_BOOKABLE_LABEL_INK;
1008
+ }
805
1009
 
806
1010
  // src/lib/money.ts
807
1011
  var DEFAULT_CURRENCY = "USD";
@@ -861,6 +1065,8 @@ var en = {
861
1065
  // buyer picker page (src/pages/PickerPage.tsx)
862
1066
  "picker.language": "Language",
863
1067
  "picker.zoomToFit": "Zoom to fit",
1068
+ "picker.seatCountLabel": "seats",
1069
+ "picker.capacity": "capacity",
864
1070
  "picker.viewMode": "View mode",
865
1071
  "picker.floor": "Floor",
866
1072
  "picker.zoomLevel": "Zoom level",
@@ -950,7 +1156,8 @@ function formatDate(value, opts) {
950
1156
  var SEAT_RADIUS = 9;
951
1157
  var SEAT_LEGIBLE_SCALE = 0.9;
952
1158
  var CACHE_THRESHOLD = 0.55 * SEAT_LEGIBLE_SCALE;
953
- var LABEL_SCALE = 1;
1159
+ var LABEL_SCALE = MIN_VISIBLE_BOOKABLE_LABEL_PX / SEAT_LABEL_FONT_SIZE;
1160
+ var MIN_FITTED_SEAT_LABEL_FONT_SIZE = 4;
954
1161
  var SEAT_TAP_SLOP_PX = 14;
955
1162
  var SECTION_PROMINENT_SCALE = 0.45 * SEAT_LEGIBLE_SCALE;
956
1163
  var BLOCK_MELT_TOP = 0.9 * SEAT_LEGIBLE_SCALE;
@@ -964,21 +1171,29 @@ var ISO_SQUASH = 0.58;
964
1171
  var LIFT_PER_STEP = 58;
965
1172
  var ISO_TWEEN_MS = 320;
966
1173
  var CAMERA_GLIDE_MS = 650;
967
- var BLOCK_FILL_ALPHA = 0.85;
968
- var SOLD_DARKEN = 0.5;
1174
+ var BLOCK_FILL_ALPHA = 1;
1175
+ var SECTION_STROKE_PX = 2;
1176
+ var LIGHT_OVERVIEW_SECTION_FILL = "#e5e7eb";
1177
+ var LIGHT_OVERVIEW_SECTION_STROKE = "#c7cbd1";
1178
+ var LIGHT_OVERVIEW_SECTION_INK = "#595f69";
1179
+ var LIGHT_OVERVIEW_FOCAL_FILL = "#d1d5db";
1180
+ var LIGHT_OVERVIEW_FOCAL_STROKE = "#b8bdc4";
1181
+ var DARK_OVERVIEW_SECTION_FILL = "#273142";
1182
+ var DARK_OVERVIEW_SECTION_STROKE = "#526078";
1183
+ var DARK_OVERVIEW_SECTION_INK = "#f1f5f9";
1184
+ var DARK_OVERVIEW_FOCAL_FILL = "#374151";
1185
+ var DARK_OVERVIEW_FOCAL_STROKE = "#64748b";
969
1186
  var SECTION_LABEL_PX = 20;
970
- var SECTION_SUB_PX = 12.5;
971
- var ZONE_LABEL_PX = 30;
1187
+ var MIN_SECTION_LABEL_PX = 12;
1188
+ var ZONE_LABEL_PX = 18;
972
1189
  var ZONE_SUB_PX = 12;
1190
+ var HIERARCHY_PILL_BACKGROUND = "#111827";
973
1191
  var HELD_FILL = "#6b7280";
974
1192
  var TAKEN_FILL = "#374151";
975
1193
  var NFS_STROKE = "#4b5563";
976
- var CLOSED_SECTION_FILL = "#586070";
977
1194
  var CLOSED_SEAT_FILL = "#4b5563";
978
1195
  var CLOSED_SEAT_OPACITY = 0.4;
979
1196
  var FOCUS_DIM_OPACITY = 0.16;
980
- var FOCUS_DESATURATE = 0.72;
981
- var FOCUS_NEUTRAL = "#6b7280";
982
1197
  var FOCUS_BACKDROP_FILL = "rgba(244,246,248,0.06)";
983
1198
  var CB_PALETTE = ["#E69F00", "#56B4E9", "#009E73", "#F0E442", "#0072B2", "#D55E00", "#CC79A7"];
984
1199
  var ACCESS_RING = {
@@ -999,6 +1214,7 @@ var DEF_SELECTION = "#ffffff";
999
1214
  var DEF_SELECTION_ON_LIGHT = "#0b1220";
1000
1215
  var DEF_DECOR_FILL = "#232c40";
1001
1216
  var DEF_TEXT = "#8b93a7";
1217
+ var DEF_CANVAS_BACKGROUND = "#0e1117";
1002
1218
  function colorLuminance(color) {
1003
1219
  const s = color.trim();
1004
1220
  let r = NaN;
@@ -1011,11 +1227,11 @@ function colorLuminance(color) {
1011
1227
  g = parseInt(h.slice(2, 4), 16);
1012
1228
  b = parseInt(h.slice(4, 6), 16);
1013
1229
  } 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];
1230
+ const rgb2 = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/.exec(s);
1231
+ if (rgb2) {
1232
+ r = +rgb2[1];
1233
+ g = +rgb2[2];
1234
+ b = +rgb2[3];
1019
1235
  }
1020
1236
  }
1021
1237
  if (Number.isNaN(r)) return NaN;
@@ -1025,6 +1241,34 @@ function isLightColor(color) {
1025
1241
  const lum = colorLuminance(color);
1026
1242
  return !Number.isNaN(lum) && lum > 0.6;
1027
1243
  }
1244
+ function opaqueColorHex(color) {
1245
+ const value = color.trim();
1246
+ const hex = /^#([\da-f]{3}|[\da-f]{6})$/i.exec(value);
1247
+ if (hex) {
1248
+ const expanded = hex[1].length === 3 ? hex[1].split("").map((channel) => channel + channel).join("") : hex[1];
1249
+ return `#${expanded.toLowerCase()}`;
1250
+ }
1251
+ const rgb2 = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([\d.]+))?\s*\)$/i.exec(value);
1252
+ if (!rgb2 || rgb2[4] != null && Number(rgb2[4]) < 0.999) return null;
1253
+ const channels = [Number(rgb2[1]), Number(rgb2[2]), Number(rgb2[3])];
1254
+ if (channels.some((channel) => !Number.isInteger(channel) || channel < 0 || channel > 255)) return null;
1255
+ return `#${channels.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;
1256
+ }
1257
+ function overviewPalette(canvasBackground) {
1258
+ return isLightColor(canvasBackground) ? {
1259
+ sectionFill: LIGHT_OVERVIEW_SECTION_FILL,
1260
+ sectionStroke: LIGHT_OVERVIEW_SECTION_STROKE,
1261
+ sectionInk: LIGHT_OVERVIEW_SECTION_INK,
1262
+ focalFill: LIGHT_OVERVIEW_FOCAL_FILL,
1263
+ focalStroke: LIGHT_OVERVIEW_FOCAL_STROKE
1264
+ } : {
1265
+ sectionFill: DARK_OVERVIEW_SECTION_FILL,
1266
+ sectionStroke: DARK_OVERVIEW_SECTION_STROKE,
1267
+ sectionInk: DARK_OVERVIEW_SECTION_INK,
1268
+ focalFill: DARK_OVERVIEW_FOCAL_FILL,
1269
+ focalStroke: DARK_OVERVIEW_FOCAL_STROKE
1270
+ };
1271
+ }
1028
1272
  var clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
1029
1273
  function seatIdOf(target) {
1030
1274
  const n = target;
@@ -1060,6 +1304,108 @@ function polyBounds(pts) {
1060
1304
  }
1061
1305
  return { x: minX, y: minY, width: Math.max(1, maxX - minX), height: Math.max(1, maxY - minY) };
1062
1306
  }
1307
+ function rotatedRectPoints(center, width, height, rotation) {
1308
+ const radians = rotation * Math.PI / 180;
1309
+ const cos = Math.cos(radians);
1310
+ const sin = Math.sin(radians);
1311
+ return [
1312
+ { x: -width / 2, y: -height / 2 },
1313
+ { x: width / 2, y: -height / 2 },
1314
+ { x: width / 2, y: height / 2 },
1315
+ { x: -width / 2, y: height / 2 }
1316
+ ].map((point) => ({
1317
+ x: center.x + point.x * cos - point.y * sin,
1318
+ y: center.y + point.x * sin + point.y * cos
1319
+ }));
1320
+ }
1321
+ function pointsBounds(points) {
1322
+ const bounds = polyBounds(points);
1323
+ return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height };
1324
+ }
1325
+ function rotatedRectFitsPolygon(center, width, height, rotation, outer, holes) {
1326
+ const radians = rotation * Math.PI / 180;
1327
+ const cos = Math.cos(radians);
1328
+ const sin = Math.sin(radians);
1329
+ for (let yStep = 0; yStep <= 4; yStep++) {
1330
+ for (let xStep = 0; xStep <= 6; xStep++) {
1331
+ const localX = width * (xStep / 6 - 0.5);
1332
+ const localY = height * (yStep / 4 - 0.5);
1333
+ const point = {
1334
+ x: center.x + localX * cos - localY * sin,
1335
+ y: center.y + localX * sin + localY * cos
1336
+ };
1337
+ if (!pointInPolygonWithHoles(point, outer, holes)) return false;
1338
+ }
1339
+ }
1340
+ return true;
1341
+ }
1342
+ function polygonLabelCandidates(outer, holes, preferred) {
1343
+ const bounds = polyBounds(outer);
1344
+ const centre = { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 };
1345
+ const points = [preferred];
1346
+ for (let row = 1; row < 12; row += 1) {
1347
+ for (let column = 1; column < 12; column += 1) {
1348
+ const point = {
1349
+ x: bounds.x + bounds.width * column / 12,
1350
+ y: bounds.y + bounds.height * row / 12
1351
+ };
1352
+ if (pointInPolygonWithHoles(point, outer, holes)) points.push(point);
1353
+ }
1354
+ }
1355
+ 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));
1356
+ }
1357
+ function polygonWithHolesShape(outer, holes, attrs, outerPath) {
1358
+ const signedArea = (points) => points.reduce((sum, point, index) => {
1359
+ const next = points[(index + 1) % points.length];
1360
+ return sum + point.x * next.y - next.x * point.y;
1361
+ }, 0);
1362
+ const outerClockwise = signedArea(outer) > 0;
1363
+ return new import_Shape.Shape({
1364
+ ...attrs,
1365
+ sceneFunc(context, shape) {
1366
+ context.beginPath();
1367
+ const polygonPath = (points) => {
1368
+ if (!points.length) return;
1369
+ context.moveTo(points[0].x, points[0].y);
1370
+ for (let index = 1; index < points.length; index += 1) context.lineTo(points[index].x, points[index].y);
1371
+ context.closePath();
1372
+ };
1373
+ const vectorPath = (path) => {
1374
+ context.moveTo(path.start.x, path.start.y);
1375
+ let current = path.start;
1376
+ for (const segment of path.segments) {
1377
+ if (segment.kind === "line") context.lineTo(segment.end.x, segment.end.y);
1378
+ else if (segment.kind === "arc") context.arc(
1379
+ segment.center.x,
1380
+ segment.center.y,
1381
+ segment.radius,
1382
+ Math.atan2(current.y - segment.center.y, current.x - segment.center.x),
1383
+ Math.atan2(segment.end.y - segment.center.y, segment.end.x - segment.center.x),
1384
+ !segment.clockwise
1385
+ );
1386
+ else context.bezierCurveTo(
1387
+ segment.control1.x,
1388
+ segment.control1.y,
1389
+ segment.control2.x,
1390
+ segment.control2.y,
1391
+ segment.end.x,
1392
+ segment.end.y
1393
+ );
1394
+ current = segment.end;
1395
+ }
1396
+ context.closePath();
1397
+ };
1398
+ if (outerPath) vectorPath(outerPath);
1399
+ else polygonPath(outer);
1400
+ for (const hole of holes ?? []) {
1401
+ const holeClockwise = signedArea(hole) > 0;
1402
+ polygonPath(holeClockwise === outerClockwise ? [...hole].reverse() : hole);
1403
+ }
1404
+ context.fillStrokeShape(shape);
1405
+ },
1406
+ perfectDrawEnabled: false
1407
+ });
1408
+ }
1063
1409
  function rgba(hex, a) {
1064
1410
  const m = /^#?([\da-f]{6})$/i.exec(hex.trim());
1065
1411
  if (!m) return hex;
@@ -1079,11 +1425,11 @@ function mixColors(parts, fallback) {
1079
1425
  let b = 0;
1080
1426
  let tw = 0;
1081
1427
  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;
1428
+ const rgb2 = hexToRgb(p.hex);
1429
+ if (!rgb2 || p.w <= 0) continue;
1430
+ r += rgb2[0] * p.w;
1431
+ g += rgb2[1] * p.w;
1432
+ b += rgb2[2] * p.w;
1087
1433
  tw += p.w;
1088
1434
  }
1089
1435
  return tw > 0 ? toHex(r / tw, g / tw, b / tw) : fallback;
@@ -1107,11 +1453,21 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1107
1453
  this.circleById = /* @__PURE__ */ new Map();
1108
1454
  /** Booth block geometry, keyed by booth id (= the unit's rowId). */
1109
1455
  this.boothDims = /* @__PURE__ */ new Map();
1110
- /** Booth label node so status changes can say HELD/SOLD on the full block. */
1456
+ /** Booth labels live with the booth shape but obey the shared rendered-size LOD. */
1111
1457
  this.boothLabelById = /* @__PURE__ */ new Map();
1458
+ /** Viewport seat labels are rebuilt after each settled camera change. */
1459
+ this.seatLabelById = /* @__PURE__ */ new Map();
1460
+ /** Authored free-text nodes obey the same rendered-size visibility floor. */
1461
+ this.freeTextById = /* @__PURE__ */ new Map();
1462
+ /** Stage/rink landmarks retain a readable screen-space caption at overview. */
1463
+ this.primaryFocalLabels = /* @__PURE__ */ new Map();
1464
+ /** GA paint and text share price/highlight filter state. */
1465
+ this.gaById = /* @__PURE__ */ new Map();
1112
1466
  this.statusById = /* @__PURE__ */ new Map();
1113
1467
  this.catColor = /* @__PURE__ */ new Map();
1114
1468
  this.theme = {};
1469
+ /** Opaque paint actually visible behind transparent Konva canvases. */
1470
+ this.canvasBackground = DEF_CANVAS_BACKGROUND;
1115
1471
  /** Effective selection/hover ring color — resolved per chart in setChart(). */
1116
1472
  this.effSelection = DEF_SELECTION;
1117
1473
  /** Colorblind-safe mode (Okabe-Ito hues + hollow booked seats). */
@@ -1409,6 +1765,11 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1409
1765
  this.circleById.clear();
1410
1766
  this.boothDims.clear();
1411
1767
  this.boothLabelById.clear();
1768
+ this.seatLabelById.clear();
1769
+ this.freeTextById.clear();
1770
+ this.primaryFocalLabels.clear();
1771
+ this.gaById.clear();
1772
+ for (const marker of this.selectionMarkers.values()) marker.destroy();
1412
1773
  this.selectionMarkers.clear();
1413
1774
  this.ownedHold.clear();
1414
1775
  this.selectionFocusId = null;
@@ -1420,6 +1781,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1420
1781
  this.sections = [];
1421
1782
  this.zones = [];
1422
1783
  this.seatSection.clear();
1784
+ this.focusedSectionId = null;
1785
+ this.focusBackdrop = null;
1423
1786
  this.catPrice.clear();
1424
1787
  this.zoneColor.clear();
1425
1788
  this.lodScale = 0;
@@ -1437,7 +1800,9 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1437
1800
  this.hoverRing.visible(false);
1438
1801
  this.theme = doc.theme ?? {};
1439
1802
  this.seatR = clamp(this.theme.seatScale ?? 1, 0.7, 1.6) * SEAT_RADIUS;
1440
- this.container.style.background = this.theme.background ?? "";
1803
+ this.container.style.background = "";
1804
+ this.canvasBackground = this.resolveCanvasBackground();
1805
+ this.container.style.background = this.canvasBackground;
1441
1806
  this.effSelection = this.resolveSelectionColor();
1442
1807
  this.hoverRing.stroke(this.effSelection);
1443
1808
  this.hoverRing.radius(this.seatR + 2);
@@ -1666,12 +2031,21 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1666
2031
  }
1667
2032
  return this.selectMany(ids);
1668
2033
  }
2034
+ /** Exact SDK capture helper. MCP never accepts this id; the SDK derives it
2035
+ * from the persisted floor and uses the normal selected paint/ring path. */
2036
+ setEvidenceSelection(seatId) {
2037
+ if (!this.seatById.has(seatId) || !this.isSelectable(seatId)) return false;
2038
+ if (this.selection.size) this.clearSelection();
2039
+ this.setSelected(seatId, true);
2040
+ this.overlayLayer.batchDraw();
2041
+ return this.selection.has(seatId);
2042
+ }
1669
2043
  /** Selectable seats in a section OR zone id — pure read (no selection change). */
1670
2044
  getSelectableInSection(sectionId) {
1671
2045
  const out = [];
1672
2046
  const seen = /* @__PURE__ */ new Set();
1673
2047
  for (const sec of this.sections) {
1674
- if (sec.id !== sectionId && sec.zone !== sectionId) continue;
2048
+ if (sec.id !== sectionId && sec.logicalId !== sectionId && sec.zone !== sectionId) continue;
1675
2049
  for (const id of sec.memberIds) {
1676
2050
  if (seen.has(id)) continue;
1677
2051
  seen.add(id);
@@ -1814,6 +2188,65 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1814
2188
  };
1815
2189
  requestAnimationFrame(step);
1816
2190
  }
2191
+ /**
2192
+ * Pulse a section outline without moving the camera or mutating the authored
2193
+ * geometry. The temporary halo is drawn in the non-listening overlay layer,
2194
+ * so the apparent 4% lift never changes hit testing or selection bounds.
2195
+ */
2196
+ flashSection(sectionId, color = "#22a06b") {
2197
+ const matches = this.sections.filter((section) => section.id === sectionId || section.zone === sectionId);
2198
+ if (!matches.length) return;
2199
+ for (const section of matches) {
2200
+ const centre = section.outline.reduce(
2201
+ (sum, point) => ({ x: sum.x + point.x, y: sum.y + point.y }),
2202
+ { x: 0, y: 0 }
2203
+ );
2204
+ centre.x /= section.outline.length;
2205
+ centre.y /= section.outline.length;
2206
+ const lift = section.elevation > 0 ? this.isoLiftLocal(section.elevation) : { x: 0, y: 0 };
2207
+ const halo = new import_Line.Line({
2208
+ x: centre.x + lift.x,
2209
+ y: centre.y + lift.y,
2210
+ points: section.outline.flatMap((point) => [point.x - centre.x, point.y - centre.y]),
2211
+ closed: true,
2212
+ stroke: color,
2213
+ strokeWidth: 3,
2214
+ strokeScaleEnabled: false,
2215
+ opacity: 0.92,
2216
+ listening: false,
2217
+ perfectDrawEnabled: false,
2218
+ shadowForStrokeEnabled: true,
2219
+ shadowColor: color,
2220
+ shadowBlur: 14,
2221
+ shadowOpacity: 0.7
2222
+ });
2223
+ this.overlayLayer.add(halo);
2224
+ this.overlayLayer.batchDraw();
2225
+ const remove = () => {
2226
+ if (!halo.getLayer()) return;
2227
+ halo.destroy();
2228
+ this.overlayLayer.batchDraw();
2229
+ };
2230
+ if (this.reducedMotion || typeof document !== "undefined" && document.hidden) {
2231
+ setTimeout(remove, 520);
2232
+ continue;
2233
+ }
2234
+ const start = performance.now();
2235
+ const duration = 820;
2236
+ const step = (now) => {
2237
+ if (this.destroyed || !halo.getLayer()) return;
2238
+ const t2 = Math.min(1, (now - start) / duration);
2239
+ const eased = 1 - Math.pow(1 - t2, 3);
2240
+ const scale = 1 + eased * 0.04;
2241
+ halo.scale({ x: scale, y: scale });
2242
+ halo.opacity(0.92 * (1 - t2));
2243
+ this.overlayLayer.batchDraw();
2244
+ if (t2 < 1) requestAnimationFrame(step);
2245
+ else remove();
2246
+ };
2247
+ requestAnimationFrame(step);
2248
+ }
2249
+ }
1817
2250
  /** Nearest seat from `fromId` in a cardinal direction (aligned + close wins). */
1818
2251
  nearestSeat(fromId, dir) {
1819
2252
  const from = this.seatById.get(fromId);
@@ -1887,6 +2320,11 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1887
2320
  seatCount() {
1888
2321
  return this.seats.length;
1889
2322
  }
2323
+ bookableCount() {
2324
+ let total = this.seats.length;
2325
+ for (const area of this.gaById.values()) total += area.capacity;
2326
+ return total;
2327
+ }
1890
2328
  worldToScreen(point) {
1891
2329
  const s = this.stage.scaleX();
1892
2330
  const p = this.isoT === 0 ? point : this.isoForward(point);
@@ -1903,6 +2341,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1903
2341
  const c = this.circleById.get(seat.id);
1904
2342
  if (c) this.paintSeat(c, seat.id);
1905
2343
  }
2344
+ this.updateLabels();
1906
2345
  if (this.cached) {
1907
2346
  this.seatLayer.clearCache();
1908
2347
  this.cacheSeatLayer();
@@ -1929,12 +2368,33 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1929
2368
  const c = this.circleById.get(seat.id);
1930
2369
  if (c) this.paintSeat(c, seat.id);
1931
2370
  }
2371
+ this.updateLabels();
1932
2372
  if (this.cached) {
1933
2373
  this.seatLayer.clearCache();
1934
2374
  this.cacheSeatLayer();
1935
2375
  } else {
1936
2376
  this.seatLayer.batchDraw();
1937
2377
  }
2378
+ this.applyGAFilterState();
2379
+ }
2380
+ gaCategoryDimmed(categoryKey) {
2381
+ return Boolean(
2382
+ this.categoryHighlight && categoryKey !== this.categoryHighlight || this.categoryFilter && !this.categoryFilter.has(categoryKey)
2383
+ );
2384
+ }
2385
+ /** Keep GA paint and its two labels in the same legend/price-filter state. */
2386
+ applyGAFilterState() {
2387
+ this.paintGAStateForView();
2388
+ this.updateFreeTextVisibility();
2389
+ this.bgLayer.batchDraw();
2390
+ }
2391
+ paintGAStateForView() {
2392
+ for (const ga of this.gaById.values()) {
2393
+ const filteredOut = Boolean(this.categoryFilter && !this.categoryFilter.has(ga.categoryKey));
2394
+ const overviewHidden = ga.sectionId != null && this.effScale() < CACHE_THRESHOLD;
2395
+ ga.polygon.opacity(overviewHidden ? 0 : this.gaCategoryDimmed(ga.categoryKey) ? GA_FILL_OPACITY * 0.08 : GA_FILL_OPACITY);
2396
+ ga.polygon.listening(!overviewHidden && !filteredOut);
2397
+ }
1938
2398
  }
1939
2399
  /** Frame the currently available inventory that survived a buyer price
1940
2400
  * filter. Clearing the filter glides back to the full venue. */
@@ -2229,13 +2689,12 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2229
2689
  });
2230
2690
  rect.setAttr("seatId", seat.id);
2231
2691
  this.circleById.set(seat.id, rect);
2232
- this.paintSeat(rect, seat.id);
2233
2692
  target.add(rect);
2234
2693
  const t2 = new import_Text.Text({
2235
2694
  x: seat.x,
2236
2695
  y: seat.y,
2237
2696
  text: seat.label,
2238
- fontSize: 10,
2697
+ fontSize: BOOTH_LABEL_FONT_SIZE,
2239
2698
  fontStyle: "600",
2240
2699
  fontFamily: this.labelFont(),
2241
2700
  fill: this.theme.seatLabelColor ?? DEF_SEAT_LABEL,
@@ -2244,9 +2703,12 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2244
2703
  });
2245
2704
  t2.offsetX(t2.width() / 2);
2246
2705
  t2.offsetY(t2.height() / 2);
2706
+ t2.visible(false);
2707
+ this.boothLabelById.set(seat.id, t2);
2247
2708
  this.hasBoothText = true;
2248
2709
  this.boothLabelById.set(seat.id, t2);
2249
2710
  target.add(t2);
2711
+ this.paintSeat(rect, seat.id);
2250
2712
  }
2251
2713
  /** The category's display color — Okabe-Ito hue when colorblind-safe is on. */
2252
2714
  seatBaseColor(categoryKey) {
@@ -2254,6 +2716,15 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2254
2716
  const idx = this.catOrder.indexOf(categoryKey);
2255
2717
  return CB_PALETTE[(idx >= 0 ? idx : 0) % CB_PALETTE.length];
2256
2718
  }
2719
+ /** Authored free fills retain the chart's validated ink. Renderer-owned
2720
+ * transient fills choose an ink against the paint that is actually visible. */
2721
+ renderedBookableLabelInk(id, shape) {
2722
+ const preferred = this.theme.seatLabelColor ?? DEF_SEAT_LABEL;
2723
+ const status = this.statusById.get(id) ?? "free";
2724
+ if (status === "free" && !this.selection.has(id)) return preferred;
2725
+ const fill = shape.fill();
2726
+ return stateAwareBookableLabelInk(typeof fill === "string" ? fill : "", preferred);
2727
+ }
2257
2728
  /** Apply fill/stroke/opacity for a seat's current status + selection. */
2258
2729
  paintSeat(c, id) {
2259
2730
  const seat = this.seatById.get(id);
@@ -2317,7 +2788,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2317
2788
  }
2318
2789
  if (this.dimmedSections.size) {
2319
2790
  const sec = this.seatSection.get(id);
2320
- if (sec && (this.dimmedSections.has(sec.id) || sec.zone != null && this.dimmedSections.has(sec.zone))) {
2791
+ if (sec && (this.dimmedSections.has(sec.id) || this.dimmedSections.has(sec.logicalId) || sec.zone != null && this.dimmedSections.has(sec.zone))) {
2321
2792
  c.opacity(0.18);
2322
2793
  }
2323
2794
  }
@@ -2330,16 +2801,23 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2330
2801
  }
2331
2802
  if (this.focusedSectionId) {
2332
2803
  const sec = this.seatSection.get(id);
2333
- const inFocus = !!sec && (sec.id === this.focusedSectionId || sec.zone === this.focusedSectionId);
2804
+ const inFocus = !!sec && (sec.id === this.focusedSectionId || sec.logicalId === this.focusedSectionId || sec.zone === this.focusedSectionId);
2334
2805
  if (!inFocus) c.opacity(FOCUS_DIM_OPACITY);
2335
2806
  }
2336
2807
  if (this.selectionFocusId && id !== this.selectionFocusId) c.opacity(Math.min(c.opacity(), 0.16));
2808
+ const bookableLabel = this.boothLabelById.get(id) ?? this.seatLabelById.get(id);
2809
+ if (bookableLabel) {
2810
+ bookableLabel.fill(this.renderedBookableLabelInk(id, c));
2811
+ bookableLabel.visible(
2812
+ isBookableLabelLegibleAtScale(bookableLabel.fontSize(), this.effScale()) && c.opacity() >= 0.5
2813
+ );
2814
+ }
2337
2815
  }
2338
2816
  /** True when a seat sits in a section/zone currently marked `closed`. */
2339
2817
  seatInClosedSection(id) {
2340
2818
  if (!this.closedSections.size) return false;
2341
2819
  const sec = this.seatSection.get(id);
2342
- return !!sec && (this.closedSections.has(sec.id) || sec.zone != null && this.closedSections.has(sec.zone));
2820
+ return !!sec && (this.closedSections.has(sec.id) || this.closedSections.has(sec.logicalId) || sec.zone != null && this.closedSections.has(sec.zone));
2343
2821
  }
2344
2822
  /**
2345
2823
  * Colorblind-safe mode: swap category hues for the Okabe-Ito palette and
@@ -2352,6 +2830,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2352
2830
  const c = this.circleById.get(seat.id);
2353
2831
  if (c) this.paintSeat(c, seat.id);
2354
2832
  }
2833
+ this.updateLabels();
2355
2834
  if (this.cached) {
2356
2835
  this.seatLayer.clearCache();
2357
2836
  this.cacheSeatLayer();
@@ -2396,7 +2875,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2396
2875
  * seat-pick gate below only lets buyers pick once seats are ≥ LABEL_SCALE big).
2397
2876
  */
2398
2877
  focusSection(id) {
2399
- if (!this.sections.some((s) => s.id === id)) return;
2878
+ if (!this.sections.some((section) => section.id === id || section.logicalId === id)) return;
2400
2879
  this.focusedSectionId = id;
2401
2880
  this.drawFocusBackdrop(id);
2402
2881
  this.repaintSectionsAndSeats();
@@ -2424,20 +2903,20 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2424
2903
  this.focusBackdrop.destroy();
2425
2904
  this.focusBackdrop = null;
2426
2905
  }
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;
2906
+ const sections = this.sections.filter((section) => section.id === id || section.logicalId === id);
2907
+ if (!sections.length) return;
2908
+ const backdrop = new import_Group.Group({ listening: false });
2909
+ for (const section of sections) {
2910
+ backdrop.add(polygonWithHolesShape(section.outline, section.holes, {
2911
+ fill: FOCUS_BACKDROP_FILL,
2912
+ stroke: rgba("#ffffff", 0.1),
2913
+ strokeWidth: 1,
2914
+ listening: false
2915
+ }, section.outlinePath));
2916
+ }
2917
+ this.bgLayer.add(backdrop);
2918
+ backdrop.moveToTop();
2919
+ this.focusBackdrop = backdrop;
2441
2920
  }
2442
2921
  /** Repaint every seat + section block to reflect closed/focus state, then redraw. */
2443
2922
  repaintSectionsAndSeats() {
@@ -2465,7 +2944,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2465
2944
  height: Math.abs(br.y - tl.y)
2466
2945
  };
2467
2946
  }
2468
- /** Axis-aligned world bounds of all seats + section outlines (minimap F3 frame). */
2947
+ /** Axis-aligned world bounds of seats, section outlines, and GA polygons (minimap F3 frame). */
2469
2948
  getWorldBounds() {
2470
2949
  let minX = Infinity;
2471
2950
  let minY = Infinity;
@@ -2479,6 +2958,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2479
2958
  };
2480
2959
  for (const s of this.seats) grow(s.x, s.y);
2481
2960
  for (const sec of this.sections) for (const p of sec.outline) grow(p.x, p.y);
2961
+ for (const area of this.gaById.values()) for (const p of area.points) grow(p.x, p.y);
2482
2962
  if (!Number.isFinite(minX)) return { x: 0, y: 0, width: 1, height: 1 };
2483
2963
  return { x: minX, y: minY, width: Math.max(1, maxX - minX), height: Math.max(1, maxY - minY) };
2484
2964
  }
@@ -2490,12 +2970,14 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2490
2970
  const c = this.circleById.get(seat.id);
2491
2971
  if (c) this.paintSeat(c, seat.id);
2492
2972
  }
2973
+ this.updateLabels();
2493
2974
  if (this.cached) {
2494
2975
  this.seatLayer.clearCache();
2495
2976
  this.cacheSeatLayer();
2496
2977
  } else {
2497
2978
  this.seatLayer.batchDraw();
2498
2979
  }
2980
+ this.applyGAFilterState();
2499
2981
  }
2500
2982
  renderBackground(doc) {
2501
2983
  if (doc.backgroundImage) this.renderBackgroundImage(doc.backgroundImage);
@@ -2513,32 +2995,40 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2513
2995
  this.renderText(obj);
2514
2996
  }
2515
2997
  }
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
2998
  }
2528
2999
  /** Organizer floor-plan photo, dimmed, at the very bottom of the bg layer. */
2529
3000
  renderBackgroundImage(bg) {
3001
+ if (!bg.url || bg.visible === false) return;
2530
3002
  const img = new window.Image();
2531
3003
  img.onload = () => {
2532
3004
  const natW = img.naturalWidth || 4;
2533
3005
  const natH = img.naturalHeight || 3;
3006
+ const rawCrop = bg.crop ?? { x: 0, y: 0, width: 1, height: 1 };
3007
+ const cropX = Math.max(0, Math.min(0.99, rawCrop.x));
3008
+ const cropY = Math.max(0, Math.min(0.99, rawCrop.y));
3009
+ const crop = {
3010
+ x: cropX,
3011
+ y: cropY,
3012
+ width: Math.max(0.01, Math.min(1 - cropX, rawCrop.width)),
3013
+ height: Math.max(0.01, Math.min(1 - cropY, rawCrop.height))
3014
+ };
2534
3015
  const w = bg.width;
2535
- const h = w * (natH / natW);
3016
+ const h = w * (natH * crop.height / (natW * crop.width));
2536
3017
  const node = new import_Image.Image({
2537
3018
  image: img,
2538
- x: bg.center.x - w / 2,
2539
- y: bg.center.y - h / 2,
3019
+ x: bg.center.x,
3020
+ y: bg.center.y,
3021
+ offsetX: w / 2,
3022
+ offsetY: h / 2,
2540
3023
  width: w,
2541
3024
  height: h,
3025
+ rotation: bg.rotation ?? 0,
3026
+ crop: {
3027
+ x: crop.x * natW,
3028
+ y: crop.y * natH,
3029
+ width: crop.width * natW,
3030
+ height: crop.height * natH
3031
+ },
2542
3032
  opacity: bg.opacity,
2543
3033
  listening: false
2544
3034
  });
@@ -2610,28 +3100,42 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2610
3100
  })
2611
3101
  );
2612
3102
  }
2613
- this.addCentredLabel(this.bgLayer, obj.label, obj.center.x, obj.center.y, "#cbd5e1", 12, true);
3103
+ const label = this.addCentredLabel(this.bgLayer, obj.label, obj.center.x, obj.center.y, "#cbd5e1", 12, true);
3104
+ this.freeTextById.set(obj.id, { node: label, background: "#232c40", kind: "table" });
2614
3105
  }
2615
3106
  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
- );
3107
+ const background = this.canvasBackground;
3108
+ const preferredInk = obj.color ?? this.theme.textColor ?? DEF_TEXT;
3109
+ const node = new import_Text.Text({
3110
+ x: obj.position.x,
3111
+ y: obj.position.y,
3112
+ text: obj.text,
3113
+ fontSize: obj.fontSize,
3114
+ rotation: obj.rotation,
3115
+ // Authored ink remains preferred, but an embed/theme surface can change
3116
+ // the actual canvas. Fail over to readable black/white instead of
3117
+ // painting an otherwise valid caption invisibly on that active surface.
3118
+ fill: stateAwareBookableLabelInk(background, preferredInk),
3119
+ fontFamily: this.labelFont(),
3120
+ listening: false,
3121
+ perfectDrawEnabled: false
3122
+ });
3123
+ this.freeTextById.set(obj.id, {
3124
+ node,
3125
+ background,
3126
+ kind: "free-text"
3127
+ });
3128
+ this.bgLayer.add(node);
2629
3129
  }
2630
3130
  renderShape(obj) {
2631
- const fill = obj.fill ?? this.theme.decorFill ?? DEF_DECOR_FILL;
3131
+ const authoredFill = obj.fill ?? this.theme.decorFill ?? DEF_DECOR_FILL;
2632
3132
  const isStage = obj.role === "stage";
3133
+ const referenceFocal = obj.role === "reference-focal";
2633
3134
  const isDecor = !!obj.role && !isStage;
2634
- const stroke = isStage ? lighten(fill, 0.28) : void 0;
3135
+ const palette = overviewPalette(this.canvasBackground);
3136
+ const fill = referenceFocal ? palette.focalFill : authoredFill;
3137
+ const stroke = isStage ? lighten(fill, 0.28) : referenceFocal ? palette.focalStroke : void 0;
3138
+ const strokeWidth = isStage ? 1 : referenceFocal ? 2 : 0;
2635
3139
  let cx = 0;
2636
3140
  let cy = 0;
2637
3141
  if (obj.kind === "rect" && obj.x != null && obj.y != null && obj.width != null && obj.height != null) {
@@ -2653,7 +3157,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2653
3157
  height: obj.height,
2654
3158
  ...grad,
2655
3159
  stroke,
2656
- strokeWidth: isStage ? 1 : 0,
3160
+ strokeWidth,
2657
3161
  cornerRadius: 4,
2658
3162
  listening: false
2659
3163
  })
@@ -2667,7 +3171,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2667
3171
  fillLinearGradientColorStops: [0, darken(fill, 0.3), 1, lighten(fill, 0.12)]
2668
3172
  } : { fill };
2669
3173
  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 })
3174
+ 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
3175
  );
2672
3176
  } else if (obj.kind === "polygon" && obj.points && obj.points.length) {
2673
3177
  const pts = obj.points.flatMap((p) => [p.x, p.y]);
@@ -2682,17 +3186,35 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2682
3186
  fillLinearGradientColorStops: [0, darken(fill, 0.3), 1, lighten(fill, 0.12)]
2683
3187
  } : { fill };
2684
3188
  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 })
3189
+ 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
3190
  );
2687
3191
  }
2688
3192
  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);
3193
+ if (isStage) {
3194
+ const node = this.addStageLabel(cx, cy, obj.label, fill);
3195
+ this.primaryFocalLabels.set(node, 22);
3196
+ this.freeTextById.set(obj.id, { node, background: fill, kind: "stage" });
3197
+ } else if (isDecor) {
3198
+ const node = this.addCentredLabel(
3199
+ this.bgLayer,
3200
+ obj.label,
3201
+ cx,
3202
+ cy,
3203
+ referenceFocal ? stateAwareBookableLabelInk(fill, "#e6e9f0") : "#9aa3b5",
3204
+ referenceFocal ? 18 : 12,
3205
+ false
3206
+ );
3207
+ if (referenceFocal) this.primaryFocalLabels.set(node, 18);
3208
+ this.freeTextById.set(obj.id, { node, background: fill, kind: "decor" });
3209
+ } else {
3210
+ const node = this.addCentredLabel(this.bgLayer, obj.label, cx, cy, "#cbd5e1", 16, true);
3211
+ this.freeTextById.set(obj.id, { node, background: fill, kind: "decor" });
3212
+ }
2692
3213
  }
2693
3214
  }
2694
3215
  /** Prominent stage caption: uppercase, letter-spaced, larger, softly dimmed. */
2695
- addStageLabel(x, y, text) {
3216
+ addStageLabel(x, y, text, background) {
3217
+ const ink = stateAwareBookableLabelInk(background, "#e6e9f0");
2696
3218
  const t2 = new import_Text.Text({
2697
3219
  x,
2698
3220
  y,
@@ -2701,22 +3223,24 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2701
3223
  fontStyle: "700",
2702
3224
  letterSpacing: 4,
2703
3225
  fontFamily: this.labelFont(),
2704
- fill: rgba("#e6e9f0", 0.62),
3226
+ fill: ink,
2705
3227
  listening: false,
2706
3228
  perfectDrawEnabled: false
2707
3229
  });
2708
3230
  t2.offsetX(t2.width() / 2);
2709
3231
  t2.offsetY(t2.height() / 2);
2710
3232
  this.bgLayer.add(t2);
3233
+ return t2;
2711
3234
  }
2712
3235
  renderGA(obj) {
2713
3236
  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,
3237
+ const canvas = this.canvasBackground;
3238
+ const effectiveBackground = compositeHexOver(color, canvas, GA_FILL_OPACITY);
3239
+ const preferredInk = this.theme.textColor ?? "#e6e9f0";
3240
+ const ink = stateAwareBookableLabelInk(effectiveBackground, preferredInk);
3241
+ const poly = polygonWithHolesShape(obj.points, obj.holes, {
2718
3242
  fill: color,
2719
- opacity: 0.22,
3243
+ opacity: GA_FILL_OPACITY,
2720
3244
  stroke: color,
2721
3245
  strokeWidth: 1.5
2722
3246
  });
@@ -2729,31 +3253,51 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2729
3253
  this.container.style.cursor = "default";
2730
3254
  });
2731
3255
  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);
3256
+ const labelPoint = polygonLabelPoint(obj.points, obj.holes);
3257
+ const containingSection = this.sections.find((section) => pointInPolygonWithHoles(labelPoint, section.outline, section.holes));
3258
+ const label = this.addCentredLabel(this.bgLayer, obj.label, labelPoint.x, labelPoint.y - 8, ink, GA_LABEL_FONT_SIZE, false);
3259
+ const capacity = this.addCentredLabel(this.bgLayer, `cap ${obj.capacity}`, labelPoint.x, labelPoint.y + 10, ink, GA_CAPACITY_LABEL_FONT_SIZE, false);
3260
+ this.freeTextById.set(`${obj.id}:label`, {
3261
+ objectId: obj.id,
3262
+ node: label,
3263
+ background: effectiveBackground,
3264
+ kind: "ga-label",
3265
+ categoryKey: obj.categoryKey
3266
+ });
3267
+ this.freeTextById.set(`${obj.id}:capacity`, {
3268
+ objectId: obj.id,
3269
+ node: capacity,
3270
+ background: effectiveBackground,
3271
+ kind: "ga-capacity",
3272
+ categoryKey: obj.categoryKey
3273
+ });
3274
+ this.gaById.set(obj.id, {
3275
+ label: obj.label,
3276
+ capacity: obj.capacity,
3277
+ categoryKey: obj.categoryKey,
3278
+ points: obj.points,
3279
+ polygon: poly,
3280
+ effectiveBackground,
3281
+ ...containingSection ? { sectionId: containingSection.logicalId } : {}
3282
+ });
2736
3283
  }
2737
3284
  /**
2738
3285
  * A section renders in three coordinated layers driven by the LOD melt:
2739
3286
  * • 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.
3287
+ * • a neutral solid shell that fades in at the overview rung, and
3288
+ * • one readable, contained section name.
3289
+ * Category, row, seat, and availability detail belongs to section focus/zoom.
3290
+ * Membership and category mix are still precomputed for the detailed state.
2744
3291
  */
2745
3292
  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
- };
3293
+ const centroid = polygonLabelPoint(obj.outline, obj.holes);
3294
+ const palette = overviewPalette(this.canvasBackground);
2751
3295
  const memberIds = [];
2752
3296
  const catCounts = /* @__PURE__ */ new Map();
2753
3297
  let free = 0;
2754
3298
  for (const seat of this.seats) {
2755
3299
  if (this.seatSection.has(seat.id)) continue;
2756
- if (!pointInPolygon(seat, obj.outline)) continue;
3300
+ if (!pointInPolygonWithHoles(seat, obj.outline, obj.holes)) continue;
2757
3301
  memberIds.push(seat.id);
2758
3302
  catCounts.set(seat.categoryKey, (catCounts.get(seat.categoryKey) ?? 0) + 1);
2759
3303
  if ((this.statusById.get(seat.id) ?? "free") === "free") free++;
@@ -2789,52 +3333,21 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2789
3333
  }
2790
3334
  const bgTarget = liftGroupBg ?? this.bgLayer;
2791
3335
  const outlineTint = obj.color ?? this.zoneColor.get(obj.zone ?? "") ?? "#3a4358";
2792
- const outlinePoly = new import_Line.Line({
2793
- points: pts,
2794
- closed: true,
3336
+ const outlinePoly = polygonWithHolesShape(obj.outline, obj.holes, {
2795
3337
  stroke: rgba(outlineTint, 0.5),
2796
3338
  strokeWidth: 1.75,
2797
3339
  fill: rgba(outlineTint, 0.08),
2798
- lineJoin: "round",
2799
- listening: false,
2800
- perfectDrawEnabled: false
2801
- });
3340
+ listening: false
3341
+ }, obj.outlinePath);
2802
3342
  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,
3343
+ const blockPoly = polygonWithHolesShape(obj.outline, obj.holes, {
3344
+ fill: palette.sectionFill,
3345
+ stroke: palette.sectionStroke,
3346
+ strokeWidth: SECTION_STROKE_PX,
2809
3347
  opacity: 0,
2810
- listening: false,
2811
- perfectDrawEnabled: false
2812
- });
3348
+ listening: false
3349
+ }, obj.outlinePath);
2813
3350
  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
- }
2838
3351
  const nameLabel = new import_Text.Text({
2839
3352
  x: centroid.x,
2840
3353
  y: centroid.y,
@@ -2842,12 +3355,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2842
3355
  fontSize: 22,
2843
3356
  fontStyle: "700",
2844
3357
  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,
3358
+ fill: palette.sectionInk,
2851
3359
  listening: false,
2852
3360
  perfectDrawEnabled: false
2853
3361
  });
@@ -2861,11 +3369,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2861
3369
  fontSize: 12,
2862
3370
  fontStyle: "700",
2863
3371
  fontFamily: "JetBrains Mono, ui-monospace, monospace",
2864
- fill: "#f4f6fb",
2865
- shadowColor: "#05070c",
2866
- shadowBlur: 5,
2867
- shadowOpacity: 0.9,
2868
- shadowForStrokeEnabled: false,
3372
+ fill: palette.sectionInk,
2869
3373
  opacity: 0,
2870
3374
  listening: false,
2871
3375
  perfectDrawEnabled: false
@@ -2874,9 +3378,13 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2874
3378
  bgTarget.add(subLabel);
2875
3379
  const sec = {
2876
3380
  id: obj.id,
3381
+ logicalId: obj.logicalSectionId ?? obj.id,
2877
3382
  label: obj.label,
2878
3383
  outline: obj.outline,
3384
+ ...obj.outlinePath ? { outlinePath: obj.outlinePath } : {},
3385
+ holes: obj.holes ?? [],
2879
3386
  centroid,
3387
+ labelAnchors: polygonLabelCandidates(obj.outline, obj.holes ?? [], centroid),
2880
3388
  zone: obj.zone,
2881
3389
  memberIds,
2882
3390
  total: memberIds.length,
@@ -2885,9 +3393,10 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2885
3393
  outlineTint,
2886
3394
  outlinePoly,
2887
3395
  blockPoly,
2888
- rowLines,
2889
3396
  nameLabel,
2890
3397
  subLabel,
3398
+ nameLabelFits: true,
3399
+ subLabelFits: true,
2891
3400
  elevation,
2892
3401
  liftGroupBg,
2893
3402
  liftGroupSeat,
@@ -2899,7 +3408,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2899
3408
  this.sections.push(sec);
2900
3409
  }
2901
3410
  refreshSectionHeat(sec) {
2902
- const raw = this.sectionHeat.get(sec.id) ?? (sec.zone ? this.sectionHeat.get(sec.zone) : void 0);
3411
+ const raw = this.sectionHeat.get(sec.id) ?? this.sectionHeat.get(sec.logicalId) ?? (sec.zone ? this.sectionHeat.get(sec.zone) : void 0);
2903
3412
  if (raw == null || raw <= 0) {
2904
3413
  sec.outlinePoly.stroke(rgba(sec.outlineTint, 0.5));
2905
3414
  sec.outlinePoly.fill(rgba(sec.outlineTint, 0.08));
@@ -2915,7 +3424,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2915
3424
  sec.outlinePoly.shadowBlur(4 + raw * 12);
2916
3425
  sec.outlinePoly.shadowOpacity(0.25 + raw * 0.45);
2917
3426
  }
2918
- /** Recompute a section's availability-tinted fill + "N LEFT" (cheap; on status change). */
3427
+ /** Recompute a section's neutral overview state and retained detail count. */
2919
3428
  refreshSectionFill(sec) {
2920
3429
  sec.blockPoly.fill(this.sectionBlockFill(sec));
2921
3430
  sec.subLabel.text(t("map.seatsLeft", { count: sec.free }));
@@ -2923,25 +3432,12 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2923
3432
  }
2924
3433
  /** True when a section/zone is currently in the `closed` event-state. */
2925
3434
  isSectionClosed(sec) {
2926
- return this.closedSections.has(sec.id) || sec.zone != null && this.closedSections.has(sec.zone);
3435
+ return this.closedSections.has(sec.id) || this.closedSections.has(sec.logicalId) || sec.zone != null && this.closedSections.has(sec.zone);
2927
3436
  }
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
- */
3437
+ /** Clean overview shells never leak category, price, or live availability paint. */
2933
3438
  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;
3439
+ const fill = overviewPalette(this.canvasBackground).sectionFill;
3440
+ return this.isSectionClosed(sec) ? darken(fill, 0.12) : fill;
2945
3441
  }
2946
3442
  /**
2947
3443
  * Zone rung: one giant screen-constant label per zone (+ optional "FROM $n"),
@@ -2970,19 +3466,31 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2970
3466
  if (typeof p === "number" && p < minPrice) minPrice = p;
2971
3467
  }
2972
3468
  }
3469
+ const back = new import_Rect.Rect({
3470
+ x: cx,
3471
+ y: cy,
3472
+ width: 1,
3473
+ height: 1,
3474
+ offsetX: 0.5,
3475
+ offsetY: 0.5,
3476
+ cornerRadius: 1,
3477
+ fill: HIERARCHY_PILL_BACKGROUND,
3478
+ stroke: z.color ?? anchor.outlineTint,
3479
+ strokeWidth: 1,
3480
+ opacity: 0,
3481
+ listening: false,
3482
+ perfectDrawEnabled: false
3483
+ });
3484
+ this.bgLayer.add(back);
2973
3485
  const label = new import_Text.Text({
2974
3486
  x: cx,
2975
3487
  y: cy,
2976
3488
  text: z.label.toUpperCase(),
2977
- fontSize: 34,
3489
+ fontSize: ZONE_LABEL_PX,
2978
3490
  fontStyle: "800",
2979
- letterSpacing: 2,
3491
+ letterSpacing: 0.5,
2980
3492
  fontFamily: this.labelFont(),
2981
- fill: z.color ?? "#f2f4f8",
2982
- shadowColor: "#05070c",
2983
- shadowBlur: 10,
2984
- shadowOpacity: 0.95,
2985
- shadowForStrokeEnabled: false,
3493
+ fill: "#f4f6fb",
2986
3494
  opacity: 0,
2987
3495
  listening: false,
2988
3496
  perfectDrawEnabled: false
@@ -2999,7 +3507,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2999
3507
  fontSize: 14,
3000
3508
  fontStyle: "600",
3001
3509
  fontFamily: "JetBrains Mono, ui-monospace, monospace",
3002
- fill: rgba("#e6e9f0", 0.75),
3510
+ fill: "#cbd5e1",
3003
3511
  opacity: 0,
3004
3512
  listening: false,
3005
3513
  perfectDrawEnabled: false
@@ -3007,7 +3515,14 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3007
3515
  sub.offsetX(sub.width() / 2);
3008
3516
  this.bgLayer.add(sub);
3009
3517
  }
3010
- this.zones.push({ id: z.id, label, sub });
3518
+ this.zones.push({
3519
+ id: z.id,
3520
+ anchor: { x: cx, y: cy },
3521
+ back,
3522
+ background: HIERARCHY_PILL_BACKGROUND,
3523
+ label,
3524
+ sub
3525
+ });
3011
3526
  }
3012
3527
  }
3013
3528
  /**
@@ -3029,74 +3544,106 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3029
3544
  blockT = clamp((BLOCK_MELT_TOP - scale) / (BLOCK_MELT_TOP - SECTION_PROMINENT_SCALE), 0, 1);
3030
3545
  zoneT = clamp((SECTION_PROMINENT_SCALE - scale) / (SECTION_PROMINENT_SCALE - ZONE_PROMINENT_SCALE), 0, 1);
3031
3546
  }
3547
+ const sectionOverview = scale < CACHE_THRESHOLD;
3548
+ if (sectionOverview) blockT = 1;
3032
3549
  if (!this.zones.length) zoneT = 0;
3033
- this.seatLayer.opacity(1 - blockT);
3550
+ this.seatLayer.opacity(sectionOverview ? 0 : 1 - blockT);
3034
3551
  const sx = this.stage.scaleX();
3035
3552
  const rescale = this.lodScale === 0 || Math.abs(scale - this.lodScale) / (this.lodScale || 1) > 0.02;
3036
3553
  if (rescale) this.lodScale = scale;
3037
3554
  const focus = this.focusedSectionId;
3555
+ const palette = overviewPalette(this.canvasBackground);
3556
+ const sectionLabelT = clamp((blockT - 0.2) / 0.8, 0, 1);
3038
3557
  for (const sec of this.sections) {
3039
- const dim = focus && sec.id !== focus && sec.zone !== focus ? FOCUS_DIM_OPACITY : 1;
3558
+ const dim = focus && sec.id !== focus && sec.logicalId !== focus && sec.zone !== focus ? FOCUS_DIM_OPACITY : 1;
3559
+ sec.outlinePoly.opacity(sectionOverview ? 0 : (1 - blockT) * dim);
3040
3560
  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
- }
3561
+ sec.blockPoly.stroke(palette.sectionStroke);
3562
+ sec.blockPoly.strokeWidth(SECTION_STROKE_PX / Math.max(sx, 1e-4));
3563
+ const sectionFill = sec.blockPoly.fill();
3564
+ const sectionInk = stateAwareBookableLabelInk(
3565
+ typeof sectionFill === "string" ? sectionFill : sec.baseFill,
3566
+ palette.sectionInk
3567
+ );
3568
+ sec.nameLabel.fill(sectionInk);
3569
+ sec.subLabel.fill(sectionInk);
3570
+ if (rescale) this.fitSectionRungLabels(sec, sx);
3571
+ const labelOpacity = sectionLabelT * (1 - zoneT) * dim;
3572
+ sec.nameLabel.opacity(sec.nameLabelFits ? labelOpacity : 0);
3573
+ sec.subLabel.opacity(0);
3049
3574
  }
3050
3575
  const zoneOpacity = zoneT * (1 - this.isoT);
3051
3576
  for (const zone of this.zones) {
3577
+ zone.back.opacity(zoneOpacity);
3052
3578
  zone.label.opacity(zoneOpacity);
3053
3579
  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
- }
3580
+ if (rescale) this.sizeZonePill(zone, sx);
3059
3581
  }
3060
3582
  this.decollideRungLabels(sx);
3583
+ this.dedupeLogicalSectionLabels();
3584
+ for (const zone of this.zones) {
3585
+ const opacity = zone.label.opacity();
3586
+ zone.back.opacity(opacity);
3587
+ if (zone.sub) zone.sub.opacity(opacity);
3588
+ }
3061
3589
  this.bgLayer.batchDraw();
3062
3590
  }
3591
+ /** One semantic section gets one overview label, even across split contours. */
3592
+ dedupeLogicalSectionLabels() {
3593
+ const byLogical = /* @__PURE__ */ new Map();
3594
+ for (const section of this.sections) {
3595
+ (byLogical.get(section.logicalId) ?? byLogical.set(section.logicalId, []).get(section.logicalId)).push(section);
3596
+ }
3597
+ for (const components of byLogical.values()) {
3598
+ if (components.length < 2) continue;
3599
+ const visible = components.filter((component) => component.nameLabel.opacity() > 0.05).sort((left, right) => {
3600
+ const leftBounds = polyBounds(left.outline);
3601
+ const rightBounds = polyBounds(right.outline);
3602
+ return rightBounds.width * rightBounds.height - leftBounds.width * leftBounds.height;
3603
+ });
3604
+ for (const component of visible.slice(1)) component.nameLabel.opacity(0);
3605
+ }
3606
+ }
3063
3607
  /**
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).
3608
+ * Keep transitional zone pills from covering section names. Section names
3609
+ * are already proven inside disjoint shells, so they must not cull each other.
3070
3610
  */
3071
3611
  decollideRungLabels(sx) {
3072
3612
  const GAP = 4;
3073
3613
  const cands = [];
3074
3614
  const boxOf = (t2) => {
3075
3615
  const p = this.worldToScreen({ x: t2.x(), y: t2.y() });
3076
- const w = t2.width() * sx;
3077
- const h = t2.height() * sx;
3616
+ const rotated = pointsBounds(rotatedRectPoints(
3617
+ { x: 0, y: 0 },
3618
+ t2.width() * sx,
3619
+ t2.height() * sx,
3620
+ t2.rotation()
3621
+ ));
3622
+ const w = rotated.width;
3623
+ const h = rotated.height;
3078
3624
  return { x: p.x - w / 2, y: p.y - h / 2, w, h };
3079
3625
  };
3080
3626
  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) });
3627
+ if (zone.label.opacity() > 0.05) {
3628
+ const p = this.worldToScreen(zone.anchor);
3629
+ const w = zone.back.width() * sx;
3630
+ const h = zone.back.height() * sx;
3631
+ cands.push({ node: zone.label, tier: 0, section: false, box: { x: p.x - w / 2, y: p.y - h / 2, w, h } });
3632
+ }
3083
3633
  }
3084
3634
  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) });
3635
+ if (sec.nameLabel.opacity() > 0.05) cands.push({ node: sec.nameLabel, tier: 1, section: true, box: boxOf(sec.nameLabel) });
3087
3636
  }
3088
3637
  if (cands.length < 2) return;
3089
3638
  cands.sort((a, b) => a.tier - b.tier || a.box.y - b.box.y || a.box.x - b.box.x);
3090
3639
  const kept = [];
3091
- const culled = /* @__PURE__ */ new Set();
3092
3640
  const collides = (b) => kept.some(
3093
3641
  (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
3642
  );
3095
3643
  for (const c of cands) {
3096
- if (c.owner && culled.has(c.owner) || collides(c.box)) {
3644
+ if (collides(c.box)) {
3097
3645
  c.node.opacity(0);
3098
- culled.add(c.node);
3099
- } else {
3646
+ } else if (!c.section) {
3100
3647
  kept.push(c.box);
3101
3648
  }
3102
3649
  }
@@ -3108,6 +3655,52 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3108
3655
  t2.offsetY(t2.height() / 2);
3109
3656
  t2.y(y);
3110
3657
  }
3658
+ /** Fit one centred section name, rotating narrow shells like the target chart. */
3659
+ fitSectionRungLabels(sec, sx) {
3660
+ const paddingPx = 8;
3661
+ sec.subLabelFits = false;
3662
+ for (let fontPx = SECTION_LABEL_PX; fontPx >= MIN_SECTION_LABEL_PX; fontPx -= 1) {
3663
+ for (const rotation of [0, -90]) {
3664
+ sec.nameLabel.rotation(rotation);
3665
+ this.sizeLabel(sec.nameLabel, fontPx / sx, sec.nameLabel.y());
3666
+ for (const anchor of sec.labelAnchors) {
3667
+ sec.nameLabel.position(anchor);
3668
+ const paddingWorld = paddingPx / sx;
3669
+ if (rotatedRectFitsPolygon(
3670
+ anchor,
3671
+ sec.nameLabel.width() + paddingWorld,
3672
+ sec.nameLabel.height() + paddingWorld,
3673
+ rotation,
3674
+ sec.outline,
3675
+ sec.holes
3676
+ )) {
3677
+ sec.nameLabelFits = true;
3678
+ return;
3679
+ }
3680
+ }
3681
+ }
3682
+ }
3683
+ sec.nameLabel.position(sec.centroid);
3684
+ sec.nameLabel.rotation(0);
3685
+ sec.nameLabelFits = false;
3686
+ }
3687
+ /** Size one screen-constant zone name/price pill around its shared anchor. */
3688
+ sizeZonePill(zone, sx) {
3689
+ const padX = 10 / sx;
3690
+ const padY = 6 / sx;
3691
+ const gap = zone.sub ? 3 / sx : 0;
3692
+ this.sizeLabel(zone.label, ZONE_LABEL_PX / sx, zone.anchor.y);
3693
+ if (zone.sub) this.sizeLabel(zone.sub, ZONE_SUB_PX / sx, zone.anchor.y);
3694
+ const width = Math.max(zone.label.width(), zone.sub?.width() ?? 0) + padX * 2;
3695
+ const height = zone.label.height() + (zone.sub ? gap + zone.sub.height() : 0) + padY * 2;
3696
+ zone.label.y(zone.anchor.y - (zone.sub ? (gap + zone.sub.height()) / 2 : 0));
3697
+ if (zone.sub) zone.sub.y(zone.anchor.y + (zone.label.height() + gap) / 2);
3698
+ zone.back.position(zone.anchor);
3699
+ zone.back.size({ width, height });
3700
+ zone.back.offset({ x: width / 2, y: height / 2 });
3701
+ zone.back.cornerRadius(7 / sx);
3702
+ zone.back.strokeWidth(1 / sx);
3703
+ }
3111
3704
  /**
3112
3705
  * Map a container-relative screen point back to world coords. Inverts the
3113
3706
  * stage (scale/pos) and, in iso view, the iso affine — so screen-space taps
@@ -3122,12 +3715,12 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3122
3715
  sectionAt(clientPoint) {
3123
3716
  if (!this.sections.length) return null;
3124
3717
  const world = this.screenToWorld(clientPoint);
3125
- const hit = this.sections.find((sec) => pointInPolygon(world, sec.outline));
3126
- return hit ? hit.id : null;
3718
+ const hit = this.sections.find((sec) => pointInPolygonWithHoles(world, sec.outline, sec.holes));
3719
+ return hit ? hit.logicalId : null;
3127
3720
  }
3128
3721
  /** Seat ids belonging to a section (Slice 5 section-summary card). */
3129
3722
  sectionMembers(id) {
3130
- return this.sections.find((s) => s.id === id)?.memberIds.slice() ?? [];
3723
+ return [...new Set(this.sections.filter((section) => section.id === id || section.logicalId === id || section.zone === id).flatMap((section) => section.memberIds))];
3131
3724
  }
3132
3725
  addCentredLabel(layer, text, x, y, fill, fontSize, bold) {
3133
3726
  const t2 = new import_Text.Text({
@@ -3144,6 +3737,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3144
3737
  t2.offsetX(t2.width() / 2);
3145
3738
  t2.offsetY(t2.height() / 2);
3146
3739
  layer.add(t2);
3740
+ return t2;
3147
3741
  }
3148
3742
  // ---- selection ------------------------------------------------------------
3149
3743
  isSelectable(id) {
@@ -3177,21 +3771,22 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3177
3771
  * the container's computed CSS background (walking up past transparent
3178
3772
  * ancestors). Unknown/unparseable backgrounds keep the dark default.
3179
3773
  */
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;
3774
+ resolveCanvasBackground() {
3775
+ const themed = this.theme.background ? opaqueColorHex(this.theme.background) : null;
3776
+ if (themed) return themed;
3777
+ if (typeof getComputedStyle === "function") {
3778
+ let element = this.container;
3779
+ while (element) {
3780
+ const resolved = opaqueColorHex(getComputedStyle(element).backgroundColor);
3781
+ if (resolved) return resolved;
3782
+ element = element.parentElement;
3192
3783
  }
3193
3784
  }
3194
- return isLightColor(bg) ? DEF_SELECTION_ON_LIGHT : DEF_SELECTION;
3785
+ return DEF_CANVAS_BACKGROUND;
3786
+ }
3787
+ resolveSelectionColor() {
3788
+ if (this.theme.selectionColor) return this.theme.selectionColor;
3789
+ return isLightColor(this.canvasBackground) ? DEF_SELECTION_ON_LIGHT : DEF_SELECTION;
3195
3790
  }
3196
3791
  setSelected(id, on, silent = false) {
3197
3792
  const c = this.circleById.get(id);
@@ -3217,6 +3812,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3217
3812
  const candidate = this.selectionFocusId === id;
3218
3813
  const dims = this.boothDims.get(seat.rowId);
3219
3814
  const marker = new import_Group.Group({
3815
+ name: "selection-ring",
3220
3816
  x: seat.x,
3221
3817
  y: seat.y,
3222
3818
  rotation: dims?.rotation ?? 0,
@@ -3224,6 +3820,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3224
3820
  perfectDrawEnabled: false,
3225
3821
  opacity: this.selectionFocusId && !candidate ? 0.2 : 1
3226
3822
  });
3823
+ marker.setAttr("seatId", id);
3227
3824
  const common = {
3228
3825
  stroke: this.effSelection,
3229
3826
  listening: false,
@@ -3300,10 +3897,18 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3300
3897
  * whether the section already fills the viewport (small container) so the tap
3301
3898
  * must fall through and pick.
3302
3899
  */
3900
+ sectionBounds(id) {
3901
+ const bounds = this.sections.filter((section) => section.id === id || section.logicalId === id).map((section) => polyBounds(section.outline));
3902
+ if (!bounds.length) return null;
3903
+ const left = Math.min(...bounds.map((box) => box.x));
3904
+ const top = Math.min(...bounds.map((box) => box.y));
3905
+ const right = Math.max(...bounds.map((box) => box.x + box.width));
3906
+ const bottom = Math.max(...bounds.map((box) => box.y + box.height));
3907
+ return { x: left, y: top, width: right - left, height: bottom - top };
3908
+ }
3303
3909
  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);
3910
+ const b = this.sectionBounds(id);
3911
+ if (!b) return this.stage.scaleX();
3307
3912
  const w = this.stage.width();
3308
3913
  const h = this.stage.height();
3309
3914
  const { min, max } = this.zoomBounds();
@@ -3333,11 +3938,11 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3333
3938
  if (this.effScale() < LABEL_SCALE && this.sections.length) {
3334
3939
  const sec = this.seatSection.get(id);
3335
3940
  if (sec) {
3336
- const alreadyFocused = this.focusedSectionId === sec.id;
3337
- const canZoomInFurther = this.sectionFrameScale(sec.id) > this.stage.scaleX() * 1.02;
3941
+ const alreadyFocused = this.focusedSectionId === sec.logicalId;
3942
+ const canZoomInFurther = this.sectionFrameScale(sec.logicalId) > this.stage.scaleX() * 1.02;
3338
3943
  if (!alreadyFocused && canZoomInFurther) {
3339
- if (this.opts.onSectionTap) this.opts.onSectionTap(sec.id);
3340
- else this.focusSection(sec.id);
3944
+ if (this.opts.onSectionTap) this.opts.onSectionTap(sec.logicalId);
3945
+ else this.focusSection(sec.logicalId);
3341
3946
  return;
3342
3947
  }
3343
3948
  }
@@ -3416,10 +4021,10 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3416
4021
  }
3417
4022
  if (this.sections.length) {
3418
4023
  const world = this.screenToWorld(pointer);
3419
- const hit = this.sections.find((sn) => pointInPolygon(world, sn.outline));
4024
+ const hit = this.sections.find((sn) => pointInPolygonWithHoles(world, sn.outline, sn.holes));
3420
4025
  if (hit) {
3421
- if (this.opts.onSectionTap) this.opts.onSectionTap(hit.id);
3422
- else this.focusRegion(hit.id);
4026
+ if (this.opts.onSectionTap) this.opts.onSectionTap(hit.logicalId);
4027
+ else this.focusRegion(hit.logicalId);
3423
4028
  return;
3424
4029
  }
3425
4030
  }
@@ -3505,10 +4110,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3505
4110
  * newer glide cancels an in-flight one.
3506
4111
  */
3507
4112
  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;
4113
+ const b = typeof target === "string" ? this.sectionBounds(target) : target;
3512
4114
  if (!b) return;
3513
4115
  this.cancelGlide();
3514
4116
  if (opts?.animate === false || this.reducedMotion) {
@@ -3568,25 +4170,343 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3568
4170
  if (this.zones.length && scale < ZONE_PROMINENT_SCALE) return "zones";
3569
4171
  return "sections";
3570
4172
  }
3571
- /** Jump the camera to a rung's zoom band, centred on the chart (glided). */
4173
+ getRenderedQualityEvidence() {
4174
+ const effectiveScale = this.effScale();
4175
+ const stageScale = this.stage.scaleX();
4176
+ const viewport = { width: this.stage.width(), height: this.stage.height() };
4177
+ const rounded = (value) => Math.round(value * 100) / 100;
4178
+ const labels = this.seats.map((seat) => {
4179
+ const shape = this.circleById.get(seat.id);
4180
+ const label = this.boothLabelById.get(seat.id) ?? this.seatLabelById.get(seat.id);
4181
+ const authoredFontSize = seat.kind === "booth" ? BOOTH_LABEL_FONT_SIZE : SEAT_LABEL_FONT_SIZE;
4182
+ const renderedFontPx = rounded((label?.fontSize() ?? authoredFontSize) * effectiveScale);
4183
+ const screen = this.worldToScreen(seat);
4184
+ const outside = screen.x < 0 || screen.x > viewport.width || screen.y < 0 || screen.y > viewport.height;
4185
+ const opacity = shape?.opacity() ?? 0;
4186
+ const section = this.seatSection.get(seat.id);
4187
+ const visible = Boolean(label?.isVisible()) && opacity >= 0.5 && !outside;
4188
+ let hiddenReason;
4189
+ if (!visible) {
4190
+ if (opacity < 0.5) hiddenReason = "dimmed-or-unavailable";
4191
+ else if (renderedFontPx < MIN_VISIBLE_BOOKABLE_LABEL_PX) hiddenReason = "below-minimum-size";
4192
+ else if (outside) hiddenReason = "outside-viewport";
4193
+ else if (!label) hiddenReason = "clutter-or-fit";
4194
+ else hiddenReason = "renderer-hidden";
4195
+ }
4196
+ const labelWidth = label ? label.width() * stageScale : 0;
4197
+ const labelHeight = label ? label.height() * effectiveScale : 0;
4198
+ const directWidthPx = shape instanceof import_Rect.Rect ? shape.width() * stageScale : this.seatR * 2 * effectiveScale;
4199
+ const directHeightPx = shape instanceof import_Rect.Rect ? shape.height() * stageScale : this.seatR * 2 * effectiveScale;
4200
+ const assistedDiameterPx = 2 * (this.seatR * effectiveScale + SEAT_TAP_SLOP_PX);
4201
+ const fill = shape?.fill();
4202
+ const ink = label?.fill();
4203
+ return {
4204
+ seatId: seat.id,
4205
+ label: seat.label,
4206
+ kind: seat.kind === "booth" ? "booth" : "seat",
4207
+ categoryKey: seat.categoryKey,
4208
+ ...section ? { sectionId: section.id } : {},
4209
+ ...section?.zone ? { zoneId: section.zone } : {},
4210
+ status: this.statusById.get(seat.id) ?? "free",
4211
+ selected: this.selection.has(seat.id),
4212
+ visible,
4213
+ renderedFontPx,
4214
+ fill: typeof fill === "string" ? fill : "",
4215
+ ink: typeof ink === "string" ? ink : this.theme.seatLabelColor ?? DEF_SEAT_LABEL,
4216
+ opacity: rounded(opacity),
4217
+ pointerTarget: {
4218
+ active: !this.cached && this.isSelectable(seat.id),
4219
+ directWidthPx: rounded(directWidthPx),
4220
+ directHeightPx: rounded(directHeightPx),
4221
+ effectiveMinimumPx: rounded(Math.max(
4222
+ Math.min(directWidthPx, directHeightPx),
4223
+ assistedDiameterPx
4224
+ ))
4225
+ },
4226
+ screenCenter: { x: rounded(screen.x), y: rounded(screen.y) },
4227
+ ...visible ? {
4228
+ screenBox: {
4229
+ x: rounded(screen.x - labelWidth / 2),
4230
+ y: rounded(screen.y - labelHeight / 2),
4231
+ width: rounded(labelWidth),
4232
+ height: rounded(labelHeight)
4233
+ }
4234
+ } : {},
4235
+ ...hiddenReason ? { hiddenReason } : {}
4236
+ };
4237
+ });
4238
+ const visibleLabels = labels.filter((label) => label.visible).length;
4239
+ const hierarchyEvidence = (id, kind, role, node, backgroundFill, section) => {
4240
+ const worldCorners = rotatedRectPoints(
4241
+ { x: node.x(), y: node.y() },
4242
+ node.width(),
4243
+ node.height(),
4244
+ node.rotation()
4245
+ );
4246
+ const screenBounds = pointsBounds(worldCorners.map((corner) => this.worldToScreen(corner)));
4247
+ const opacity = rounded(node.opacity());
4248
+ const ink = node.fill();
4249
+ const outside = screenBounds.x + screenBounds.width < 0 || screenBounds.x > viewport.width || screenBounds.y + screenBounds.height < 0 || screenBounds.y > viewport.height;
4250
+ const visible = node.isVisible() && opacity > 0.05 && !outside;
4251
+ const fitsContainer = section ? rotatedRectFitsPolygon(
4252
+ { x: node.x(), y: node.y() },
4253
+ node.width(),
4254
+ node.height(),
4255
+ node.rotation(),
4256
+ section.outline,
4257
+ section.holes
4258
+ ) : void 0;
4259
+ return {
4260
+ id,
4261
+ kind,
4262
+ role,
4263
+ label: node.text(),
4264
+ visible,
4265
+ renderedFontPx: rounded(node.fontSize() * stageScale),
4266
+ opacity,
4267
+ fill: backgroundFill,
4268
+ ink: typeof ink === "string" ? ink : "",
4269
+ ...fitsContainer == null ? {} : { fitsContainer },
4270
+ ...visible ? {
4271
+ screenBox: {
4272
+ x: rounded(screenBounds.x),
4273
+ y: rounded(screenBounds.y),
4274
+ width: rounded(screenBounds.width),
4275
+ height: rounded(screenBounds.height)
4276
+ }
4277
+ } : {}
4278
+ };
4279
+ };
4280
+ const hierarchyLabels = [
4281
+ ...this.sections.map((section) => {
4282
+ const fill = section.blockPoly.fill();
4283
+ return hierarchyEvidence(
4284
+ section.id,
4285
+ "section",
4286
+ "name",
4287
+ section.nameLabel,
4288
+ typeof fill === "string" ? fill : section.baseFill,
4289
+ section
4290
+ );
4291
+ }),
4292
+ ...this.sections.map((section) => {
4293
+ const fill = section.blockPoly.fill();
4294
+ return hierarchyEvidence(
4295
+ `${section.id}:availability`,
4296
+ "section",
4297
+ "availability",
4298
+ section.subLabel,
4299
+ typeof fill === "string" ? fill : section.baseFill,
4300
+ section
4301
+ );
4302
+ }),
4303
+ ...this.zones.flatMap((zone) => [
4304
+ hierarchyEvidence(zone.id, "zone", "name", zone.label, zone.background),
4305
+ ...zone.sub ? [hierarchyEvidence(`${zone.id}:price`, "zone", "price", zone.sub, zone.background)] : []
4306
+ ])
4307
+ ];
4308
+ const gaAreas = [...this.gaById].map(([areaId, ga]) => {
4309
+ const screenPoints = ga.points.map((point) => this.worldToScreen(point));
4310
+ const left = Math.min(...screenPoints.map((point) => point.x));
4311
+ const top = Math.min(...screenPoints.map((point) => point.y));
4312
+ const right = Math.max(...screenPoints.map((point) => point.x));
4313
+ const bottom = Math.max(...screenPoints.map((point) => point.y));
4314
+ const outside = right < 0 || left > viewport.width || bottom < 0 || top > viewport.height;
4315
+ const opacity = rounded(ga.polygon.opacity());
4316
+ const visible = opacity >= 0.1 && !outside;
4317
+ const fill = ga.polygon.fill();
4318
+ return {
4319
+ areaId,
4320
+ label: ga.label,
4321
+ capacity: ga.capacity,
4322
+ categoryKey: ga.categoryKey,
4323
+ ...ga.sectionId ? { sectionId: ga.sectionId } : {},
4324
+ visible,
4325
+ interactive: ga.polygon.listening(),
4326
+ opacity,
4327
+ fill: typeof fill === "string" ? fill : "",
4328
+ effectiveBackground: ga.effectiveBackground,
4329
+ ...visible ? {
4330
+ screenBox: {
4331
+ x: rounded(left),
4332
+ y: rounded(top),
4333
+ width: rounded(right - left),
4334
+ height: rounded(bottom - top)
4335
+ }
4336
+ } : {}
4337
+ };
4338
+ });
4339
+ const freeTextLabels = [...this.freeTextById].map(([recordKey, record]) => {
4340
+ const { node, background, kind } = record;
4341
+ const point = this.worldToScreen({ x: node.x(), y: node.y() });
4342
+ const width = node.width() * stageScale;
4343
+ const height = node.height() * effectiveScale;
4344
+ const left = point.x - node.offsetX() * stageScale;
4345
+ const top = point.y - node.offsetY() * effectiveScale;
4346
+ const renderedFontPx = rounded(node.fontSize() * effectiveScale);
4347
+ const outside = left + width < 0 || left > viewport.width || top + height < 0 || top > viewport.height;
4348
+ const visible = node.isVisible() && !outside;
4349
+ const ink = node.fill();
4350
+ const opacity = rounded(node.getAbsoluteOpacity());
4351
+ let hiddenReason;
4352
+ if (!visible) {
4353
+ if (renderedFontPx < MIN_VISIBLE_BOOKABLE_LABEL_PX) hiddenReason = "below-minimum-size";
4354
+ else if (outside) hiddenReason = "outside-viewport";
4355
+ else hiddenReason = "renderer-hidden";
4356
+ }
4357
+ return {
4358
+ objectId: record.objectId ?? recordKey,
4359
+ kind,
4360
+ text: node.text(),
4361
+ visible,
4362
+ renderedFontPx,
4363
+ ink: typeof ink === "string" ? ink : "",
4364
+ background,
4365
+ opacity,
4366
+ ...visible ? {
4367
+ screenBox: {
4368
+ x: rounded(left),
4369
+ y: rounded(top),
4370
+ width: rounded(width),
4371
+ height: rounded(height)
4372
+ }
4373
+ } : {},
4374
+ ...hiddenReason ? { hiddenReason } : {}
4375
+ };
4376
+ });
4377
+ const palette = overviewPalette(this.canvasBackground);
4378
+ const neutralSectionFills = /* @__PURE__ */ new Set([
4379
+ palette.sectionFill.toLowerCase(),
4380
+ darken(palette.sectionFill, 0.12).toLowerCase()
4381
+ ]);
4382
+ const visibleSectionShells = this.sections.filter((section) => section.blockPoly.opacity() > 0.05);
4383
+ return {
4384
+ viewport,
4385
+ canvasBackground: this.canvasBackground,
4386
+ effectiveScale: rounded(effectiveScale),
4387
+ rung: this.getRung(),
4388
+ minimumVisibleLabelPx: MIN_VISIBLE_BOOKABLE_LABEL_PX,
4389
+ totalLabelledBookableUnits: labels.length,
4390
+ visibleLabels,
4391
+ hiddenLabels: labels.length - visibleLabels,
4392
+ totalBookableUnits: labels.length + gaAreas.reduce((sum, area) => sum + area.capacity, 0),
4393
+ selectionRingSeatIds: this.overlayLayer.find(".selection-ring").map((node) => String(node.getAttr("seatId") ?? "")).filter(Boolean),
4394
+ selectionRingColor: this.effSelection,
4395
+ focusedSectionId: this.focusedSectionId,
4396
+ focusBackdropVisible: Boolean(this.focusBackdrop?.isVisible()),
4397
+ categoryFilterKeys: this.categoryFilter ? [...this.categoryFilter].sort() : null,
4398
+ overviewStyle: {
4399
+ visibleSectionShells: visibleSectionShells.length,
4400
+ categoryPaintedSectionShells: visibleSectionShells.filter((section) => {
4401
+ const fill = section.blockPoly.fill();
4402
+ return typeof fill !== "string" || !neutralSectionFills.has(fill.toLowerCase());
4403
+ }).length,
4404
+ visibleCategoryDetailOutlines: this.sections.filter((section) => section.outlinePoly.opacity() > 0.05).length,
4405
+ // Row-hint nodes no longer exist in the production overview scene.
4406
+ visibleSectionRowHints: 0,
4407
+ visibleSectionAvailabilityLabels: this.sections.filter((section) => section.subLabel.opacity() > 0.05).length,
4408
+ visibleSectionGADetails: [...this.gaById.values()].filter((area) => area.sectionId != null && area.polygon.opacity() > 0.05).length
4409
+ },
4410
+ labels,
4411
+ gaAreas,
4412
+ hierarchyLabels,
4413
+ freeTextLabels
4414
+ };
4415
+ }
4416
+ /** Jump the camera to a rung's zoom band (glided). */
3572
4417
  setRung(rung) {
3573
4418
  if (rung === "zones") {
3574
4419
  this.cancelGlide();
3575
4420
  this.zoomToFit();
3576
4421
  return;
3577
4422
  }
3578
- const target = rung === "sections" ? (SECTION_PROMINENT_SCALE + CACHE_THRESHOLD) / 2 : Math.max(SEAT_FOCUS_SCALE, CACHE_THRESHOLD * 1.3);
4423
+ const target = rung === "sections" ? (SECTION_PROMINENT_SCALE + CACHE_THRESHOLD) / 2 : Math.max(
4424
+ this.seatLabelTargetScale() * 1.05,
4425
+ SEAT_FOCUS_SCALE,
4426
+ CACHE_THRESHOLD * 1.3
4427
+ );
3579
4428
  const w = this.stage.width();
3580
4429
  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;
4430
+ const visible = this.getVisibleWorldRect();
4431
+ const viewCentre = {
4432
+ x: visible.x + visible.width / 2,
4433
+ y: visible.y + visible.height / 2
4434
+ };
4435
+ let cx = viewCentre.x;
4436
+ let cy = viewCentre.y;
4437
+ if (rung === "sections" && this.sections.length > 0) {
4438
+ const sectionCentres = this.sections.map((section) => {
4439
+ const bounds = polyBounds(section.outline);
4440
+ return {
4441
+ x: bounds.x + bounds.width / 2,
4442
+ y: bounds.y + bounds.height / 2
4443
+ };
4444
+ });
4445
+ const halfWidth = w / (target * 2);
4446
+ const halfHeight = h / (target * 2);
4447
+ const hierarchyWillBeVisible = sectionCentres.some((point) => Math.abs(point.x - viewCentre.x) <= halfWidth && Math.abs(point.y - viewCentre.y) <= halfHeight);
4448
+ if (!hierarchyWillBeVisible) {
4449
+ const nearest = sectionCentres.reduce((best, point) => {
4450
+ const distance = (point.x - viewCentre.x) ** 2 + (point.y - viewCentre.y) ** 2;
4451
+ return distance < best.distance ? { point, distance } : best;
4452
+ }, { point: sectionCentres[0], distance: Infinity });
4453
+ cx = nearest.point.x;
4454
+ cy = nearest.point.y;
4455
+ }
4456
+ }
4457
+ const seatAnchors = rung === "seats" ? this.seats.filter((seat) => seat.kind !== "booth") : [];
4458
+ if (seatAnchors.length > 0) {
4459
+ let nearest = seatAnchors[0];
4460
+ let nearestDistance = Infinity;
4461
+ for (const seat of seatAnchors) {
4462
+ const dx = seat.x - viewCentre.x;
4463
+ const dy = seat.y - viewCentre.y;
4464
+ const distance = dx * dx + dy * dy;
4465
+ if (distance < nearestDistance) {
4466
+ nearest = seat;
4467
+ nearestDistance = distance;
4468
+ }
4469
+ }
4470
+ cx = nearest.x;
4471
+ cy = nearest.y;
4472
+ }
3583
4473
  const bw = w / (target * 1.12);
3584
4474
  const bh = h / (target * 1.12);
3585
4475
  this.focusRegion({ x: cx - bw / 2, y: cy - bh / 2, width: bw, height: bh });
3586
4476
  }
4477
+ /**
4478
+ * The seat rung must account for labels that auto-fit inside a seat circle.
4479
+ * A short `A-1` remains at the normal 7u target; a table label such as
4480
+ * `T13-10` may fit at 4u and therefore needs a deeper camera target to reach
4481
+ * the same 12 CSS-pixel floor. Measurement happens only on explicit rung
4482
+ * navigation, never during pan/zoom frames.
4483
+ */
4484
+ seatLabelTargetScale() {
4485
+ let minimumFont = BOOTH_LABEL_FONT_SIZE;
4486
+ const measure = new import_Text.Text({
4487
+ fontSize: SEAT_LABEL_FONT_SIZE,
4488
+ fontStyle: "600",
4489
+ fontFamily: this.labelFont(),
4490
+ listening: false
4491
+ });
4492
+ const maxWidth = this.seatR * 2 - 3;
4493
+ for (const seat of this.seats) {
4494
+ if (seat.kind === "booth") {
4495
+ minimumFont = Math.min(minimumFont, BOOTH_LABEL_FONT_SIZE);
4496
+ continue;
4497
+ }
4498
+ measure.fontSize(SEAT_LABEL_FONT_SIZE);
4499
+ measure.text(bookableMarkerLabel(seat.label));
4500
+ const fitted = measure.width() > maxWidth ? Math.max(MIN_FITTED_SEAT_LABEL_FONT_SIZE, SEAT_LABEL_FONT_SIZE * maxWidth / measure.width()) : SEAT_LABEL_FONT_SIZE;
4501
+ minimumFont = Math.min(minimumFont, fitted);
4502
+ }
4503
+ measure.destroy();
4504
+ return MIN_VISIBLE_BOOKABLE_LABEL_PX / Math.max(MIN_FITTED_SEAT_LABEL_FONT_SIZE, minimumFont);
4505
+ }
3587
4506
  /** Recompute LOD (cache/labels) after any pan/zoom settles. */
3588
4507
  afterViewChange() {
3589
4508
  this.updateLOD();
4509
+ this.updateFreeTextVisibility();
3590
4510
  this.updateLabels();
3591
4511
  this.scheduleViewChange();
3592
4512
  }
@@ -3600,7 +4520,13 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3600
4520
  }
3601
4521
  updateLOD() {
3602
4522
  const scale = this.effScale();
4523
+ const focalScale = Math.max(scale, 1e-4);
4524
+ for (const [label, targetPx] of this.primaryFocalLabels) {
4525
+ this.sizeLabel(label, targetPx / focalScale, label.y());
4526
+ }
3603
4527
  if (this.hasSections) this.applySectionLod(scale);
4528
+ else if (this.primaryFocalLabels.size) this.bgLayer.batchDraw();
4529
+ this.paintGAStateForView();
3604
4530
  const shouldCache = scale < CACHE_THRESHOLD;
3605
4531
  if (shouldCache && !this.cached) {
3606
4532
  this.cacheSeatLayer();
@@ -3639,15 +4565,37 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3639
4565
  if (this.recacheTimer) {
3640
4566
  clearTimeout(this.recacheTimer);
3641
4567
  this.recacheTimer = null;
3642
- this.rebuildSeatCache();
4568
+ if (this.effScale() < CACHE_THRESHOLD) {
4569
+ this.rebuildSeatCache();
4570
+ } else if (this.cached) {
4571
+ this.seatLayer.clearCache();
4572
+ this.seatLayer.listening(true);
4573
+ this.cached = false;
4574
+ }
3643
4575
  }
3644
4576
  this.bgLayer.draw();
3645
4577
  this.seatLayer.draw();
3646
4578
  this.overlayLayer.draw();
3647
4579
  }
4580
+ updateFreeTextVisibility() {
4581
+ const effectiveScale = this.effScale();
4582
+ for (const { objectId, node, categoryKey, kind } of this.freeTextById.values()) {
4583
+ const gaDimmed = categoryKey != null && (kind === "ga-label" || kind === "ga-capacity") && this.gaCategoryDimmed(categoryKey);
4584
+ const gaOverviewHidden = objectId != null && (kind === "ga-label" || kind === "ga-capacity") && this.gaById.get(objectId)?.sectionId != null && effectiveScale < CACHE_THRESHOLD;
4585
+ node.visible(!gaDimmed && !gaOverviewHidden && isBookableLabelLegibleAtScale(node.fontSize(), effectiveScale));
4586
+ }
4587
+ }
3648
4588
  updateLabels() {
3649
- const show = this.effScale() > LABEL_SCALE;
4589
+ const effectiveScale = this.effScale();
4590
+ const show = effectiveScale >= LABEL_SCALE;
4591
+ for (const [id, label] of this.boothLabelById) {
4592
+ const shape = this.circleById.get(id);
4593
+ label.visible(
4594
+ isBookableLabelLegibleAtScale(label.fontSize(), effectiveScale) && (shape?.opacity() ?? 1) >= 0.5
4595
+ );
4596
+ }
3650
4597
  this.labelGroup.destroyChildren();
4598
+ this.seatLabelById.clear();
3651
4599
  if (!show) {
3652
4600
  this.overlayLayer.batchDraw();
3653
4601
  return;
@@ -3661,8 +4609,10 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3661
4609
  for (const seat of this.seats) {
3662
4610
  if (seat.kind === "booth") continue;
3663
4611
  if (seat.x < x0 || seat.x > x1 || seat.y < y0 || seat.y > y1) continue;
4612
+ const shape = this.circleById.get(seat.id);
4613
+ if ((shape?.opacity() ?? 1) < 0.5) continue;
3664
4614
  const status = this.statusById.get(seat.id) ?? "free";
3665
- const unavailable = status === "booked" || status === "held" && !this.ownedHold.has(seat.id);
4615
+ const unavailable = status === "booked" || status === "held" && !this.ownedHold.has(seat.id) && !this.opts.manageMode;
3666
4616
  if (unavailable) {
3667
4617
  const cue = new import_Group.Group({ x: seat.x, y: seat.y, listening: false });
3668
4618
  if (status === "held") {
@@ -3700,23 +4650,28 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3700
4650
  const t2 = new import_Text.Text({
3701
4651
  x: seat.x,
3702
4652
  y: seat.y,
3703
- text: seat.label,
3704
- fontSize: 7,
4653
+ text: bookableMarkerLabel(seat.label),
4654
+ fontSize: SEAT_LABEL_FONT_SIZE,
3705
4655
  fontStyle: "600",
3706
4656
  fontFamily: this.labelFont(),
3707
- fill: this.theme.seatLabelColor ?? DEF_SEAT_LABEL,
4657
+ fill: shape ? this.renderedBookableLabelInk(seat.id, shape) : this.theme.seatLabelColor ?? DEF_SEAT_LABEL,
3708
4658
  listening: false,
3709
4659
  perfectDrawEnabled: false
3710
4660
  });
3711
4661
  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) {
4662
+ if (t2.width() > maxW) t2.fontSize(Math.max(MIN_FITTED_SEAT_LABEL_FONT_SIZE, SEAT_LABEL_FONT_SIZE * maxW / t2.width()));
4663
+ if (t2.width() > maxW + 0.01) {
4664
+ t2.destroy();
4665
+ continue;
4666
+ }
4667
+ if (!isBookableLabelLegibleAtScale(t2.fontSize(), effectiveScale)) {
3714
4668
  t2.destroy();
3715
4669
  continue;
3716
4670
  }
3717
4671
  t2.offsetX(t2.width() / 2);
3718
4672
  t2.offsetY(t2.height() / 2);
3719
4673
  this.labelGroup.add(t2);
4674
+ this.seatLabelById.set(seat.id, t2);
3720
4675
  if (++count >= MAX_LABELS) break;
3721
4676
  }
3722
4677
  if (this.isoT > 0) this.applyUprightLabels();
@@ -5029,6 +5984,8 @@ async function loadLocale(code) {
5029
5984
  loadLocale,
5030
5985
  objectCenter,
5031
5986
  pointInPolygon,
5987
+ pointInPolygonWithHoles,
5988
+ polygonLabelPoint,
5032
5989
  resolveLocale,
5033
5990
  setLocale,
5034
5991
  setMoneyLocale,