@seatlayer/core 0.26.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1547,6 +1547,169 @@ function buyerBackgroundImage(owner) {
1547
1547
  return background?.url && !background.assetId ? background : void 0;
1548
1548
  }
1549
1549
 
1550
+ // src/core/icons.ts
1551
+ var VENUE_ICON_VIEWBOX = 24;
1552
+ var VENUE_ICON_STROKE = 2;
1553
+ var WHEELCHAIR = "M13 4.6a1.6 1.6 0 1 0-3.2 0 1.6 1.6 0 0 0 3.2 0 M11 6.5V13h5l2.4 6 M15 16.4A5 5 0 1 1 9 11.3";
1554
+ var VENUE_ICONS = [
1555
+ // ---- Facilities --------------------------------------------------------
1556
+ {
1557
+ key: "restroom-men",
1558
+ label: "Men's restroom",
1559
+ group: "facilities",
1560
+ path: "M16 7a4 4 0 1 0-8 0 4 4 0 0 0 8 0 M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"
1561
+ },
1562
+ {
1563
+ key: "restroom-women",
1564
+ label: "Women's restroom",
1565
+ group: "facilities",
1566
+ path: "M15 5a3 3 0 1 0-6 0 3 3 0 0 0 6 0 M12 8l-4 10h8l-4-10 M10 18v4 M14 18v4"
1567
+ },
1568
+ {
1569
+ key: "restroom-accessible",
1570
+ label: "Accessible restroom",
1571
+ group: "facilities",
1572
+ path: WHEELCHAIR
1573
+ },
1574
+ {
1575
+ key: "first-aid",
1576
+ label: "First aid",
1577
+ group: "facilities",
1578
+ path: "M5 4h14a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1Z M12 8v8 M8 12h8"
1579
+ },
1580
+ {
1581
+ key: "coat-check",
1582
+ label: "Coat check",
1583
+ group: "facilities",
1584
+ path: "M12 6a2 2 0 0 1 0-4 2 2 0 0 1 1.6 3.2L21 13H3l8.4-7.8 M3 13h18"
1585
+ },
1586
+ {
1587
+ key: "atm",
1588
+ label: "ATM / cash",
1589
+ group: "facilities",
1590
+ path: "M2 6h20v12H2V6Z M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0 M6 9h.01 M18 15h.01"
1591
+ },
1592
+ {
1593
+ key: "info",
1594
+ label: "Info point",
1595
+ group: "facilities",
1596
+ path: "M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18 M12 11v5 M12 8h.01"
1597
+ },
1598
+ {
1599
+ key: "lost-found",
1600
+ label: "Lost & found",
1601
+ group: "facilities",
1602
+ path: "M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14 M16 16l5 5 M9 8.5a2 2 0 1 1 3 1.7c-.8.5-1 .8-1 1.8 M11 15h.01"
1603
+ },
1604
+ {
1605
+ key: "charging",
1606
+ label: "Charging point",
1607
+ group: "facilities",
1608
+ path: "M3 8h13v8H3V8Z M16 10h3v4h-3 M9.6 9l-2 3.5h2.5l-1.5 3"
1609
+ },
1610
+ {
1611
+ key: "smoking",
1612
+ label: "Smoking area",
1613
+ group: "facilities",
1614
+ path: "M2 15h13v3H2v-3Z M18 16h2 M19 8c0 1 1 1.5 1 2.5S19 12 19 13"
1615
+ },
1616
+ {
1617
+ key: "no-smoking",
1618
+ label: "No smoking",
1619
+ group: "facilities",
1620
+ path: "M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18 M5.6 5.6l12.8 12.8 M7 13h6v2H7v-2Z"
1621
+ },
1622
+ // ---- Food & drink ------------------------------------------------------
1623
+ {
1624
+ key: "food",
1625
+ label: "Food",
1626
+ group: "food-drink",
1627
+ path: "M7 3v6a2 2 0 0 0 4 0V3 M9 11v10 M17 3c-1.6 1-2.2 3-2.2 6 0 2 .9 3.2 2.2 3.6V21"
1628
+ },
1629
+ {
1630
+ key: "bar",
1631
+ label: "Bar",
1632
+ group: "food-drink",
1633
+ path: "M4 5h16l-8 8-8-8Z M12 13v6 M8 20h8"
1634
+ },
1635
+ {
1636
+ key: "coffee",
1637
+ label: "Caf\xE9 / coffee",
1638
+ group: "food-drink",
1639
+ path: "M4 8h13v4a5 5 0 0 1-5 5H9a5 5 0 0 1-5-5V8Z M17 9h2a2 2 0 0 1 0 4h-2 M8 2v2 M11 2v2 M4 21h14"
1640
+ },
1641
+ {
1642
+ key: "water",
1643
+ label: "Water / drinks",
1644
+ group: "food-drink",
1645
+ path: "M12 22a7 7 0 0 1-7-7c0-5 7-12 7-12s7 7 7 12a7 7 0 0 1-7 7Z"
1646
+ },
1647
+ {
1648
+ key: "merch",
1649
+ label: "Merch / shop",
1650
+ group: "food-drink",
1651
+ path: "M6 8h12l-1 12H7L6 8Z M9 8V6a3 3 0 0 1 6 0v2"
1652
+ },
1653
+ // ---- Getting around ----------------------------------------------------
1654
+ {
1655
+ key: "entrance",
1656
+ label: "Entrance",
1657
+ group: "navigation",
1658
+ path: "M14 3h5a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1h-5 M3 12h11 M10 8l4 4-4 4"
1659
+ },
1660
+ {
1661
+ key: "exit",
1662
+ label: "Exit",
1663
+ group: "navigation",
1664
+ path: "M10 3H5a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h5 M14 12h7 M17 8l4 4-4 4"
1665
+ },
1666
+ {
1667
+ key: "emergency-exit",
1668
+ label: "Emergency exit",
1669
+ group: "navigation",
1670
+ path: "M14 4.6a1.5 1.5 0 1 0-3 0 1.5 1.5 0 0 0 3 0 M12.5 8l-3.5 2 1.6 3.6-2 4.4 M12.5 10l3.5 1.5 M9 10.5l-3.5 1 M15 12h6 M18 9l3 3-3 3"
1671
+ },
1672
+ {
1673
+ key: "stairs",
1674
+ label: "Stairs",
1675
+ group: "navigation",
1676
+ path: "M3 20v-4h4v-4h4v-4h4V4h5"
1677
+ },
1678
+ {
1679
+ key: "elevator",
1680
+ label: "Elevator",
1681
+ group: "navigation",
1682
+ path: "M5 3h14a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1Z M8 11l1.5-2 1.5 2 M8 14l1.5 2 1.5-2 M15 8v8"
1683
+ },
1684
+ {
1685
+ key: "parking",
1686
+ label: "Parking",
1687
+ group: "navigation",
1688
+ path: "M5 3h14a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1Z M9 17V7h4a3 3 0 0 1 0 6H9"
1689
+ },
1690
+ // ---- Accessibility -----------------------------------------------------
1691
+ {
1692
+ key: "wheelchair",
1693
+ label: "Wheelchair access",
1694
+ group: "accessibility",
1695
+ path: WHEELCHAIR
1696
+ },
1697
+ {
1698
+ key: "hearing",
1699
+ label: "Hearing assistance",
1700
+ group: "accessibility",
1701
+ path: "M8 20a4 4 0 0 1-2-3c0-1 .5-2 .5-3a4.5 4.5 0 1 1 9 0c0 1.4-1 2-2 2.4-1.2.5-1.5 1-1.5 2.2 M17 6a5 5 0 0 1 1 6 M19.5 4a8 8 0 0 1 1.2 9"
1702
+ }
1703
+ ];
1704
+ var BY_KEY = new Map(VENUE_ICONS.map((icon) => [icon.key, icon]));
1705
+ function venueIcon(key) {
1706
+ return key ? BY_KEY.get(key) : void 0;
1707
+ }
1708
+ function venueIconPath(key) {
1709
+ return venueIcon(key)?.path;
1710
+ }
1711
+ var VENUE_ICON_KEYS = VENUE_ICONS.map((icon) => icon.key);
1712
+
1550
1713
  // src/core/spatialIndex.ts
1551
1714
  var DEFAULT_SEAT_RADIUS = 9;
1552
1715
  function halfExtents(shape) {
@@ -1884,6 +2047,7 @@ var en = {
1884
2047
  // renderer (drawn on the Konva map — shared by the embed SDK)
1885
2048
  "map.aria": "Seating map. Use arrow keys to move between seats, Enter to select.",
1886
2049
  "map.seatsLeft": "{count} LEFT",
2050
+ "map.soldOut": "SOLD OUT",
1887
2051
  "map.fromPrice": "FROM {price}",
1888
2052
  "map.statusHeld": "On hold",
1889
2053
  "map.statusTaken": "Taken",
@@ -1891,7 +2055,7 @@ var en = {
1891
2055
  "picker.floor": "Floor",
1892
2056
  "picker.zoomLevel": "Zoom level",
1893
2057
  "picker.rungTip.zones": "Venue overview \u2014 groups of sections such as North Stand or VIP",
1894
- "picker.rungTip.sections": "Section blocks \u2014 category-mix fill + availability tint",
2058
+ "picker.rungTip.sections": "Section blocks \u2014 sold-out and nearly-gone sections shade to show availability at a glance",
1895
2059
  "picker.rungTip.seats": "Individual seats \u2014 blocks melt into dots",
1896
2060
  "picker.rungLabel.zones": "ZONES",
1897
2061
  "picker.rungLabel.sections": "SECTIONS",
@@ -2092,6 +2256,22 @@ function overviewPalette(canvasBackground) {
2092
2256
  focalStroke: DARK_OVERVIEW_FOCAL_STROKE
2093
2257
  };
2094
2258
  }
2259
+ var SECTION_NEARLY_GONE_RATIO = 0.1;
2260
+ function overviewStateFill(palette, state) {
2261
+ const base = palette.sectionFill;
2262
+ const soldOut = desaturate(darken(base, 0.2), 0.85);
2263
+ switch (state) {
2264
+ case "closed":
2265
+ return darken(base, 0.12);
2266
+ case "sold-out":
2267
+ return soldOut;
2268
+ case "nearly-gone":
2269
+ return lerpColor(base, soldOut, 0.4);
2270
+ case "normal":
2271
+ default:
2272
+ return base;
2273
+ }
2274
+ }
2095
2275
  var clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
2096
2276
  function seatIdOf(target) {
2097
2277
  const n = target;
@@ -2114,6 +2294,13 @@ function darken(hex, amt) {
2114
2294
  const mix = (c) => Math.round(c * (1 - amt));
2115
2295
  return `#${(1 << 24 | mix(n >> 16 & 255) << 16 | mix(n >> 8 & 255) << 8 | mix(n & 255)).toString(16).slice(1)}`;
2116
2296
  }
2297
+ function desaturate(hex, amt) {
2298
+ const rgb2 = hexToRgb(hex);
2299
+ if (!rgb2) return hex;
2300
+ const [r, g, b] = rgb2;
2301
+ const grey = 0.299 * r + 0.587 * g + 0.114 * b;
2302
+ return toHex(r + (grey - r) * amt, g + (grey - g) * amt, b + (grey - b) * amt);
2303
+ }
2117
2304
  function polyBounds(pts) {
2118
2305
  let minX = Infinity;
2119
2306
  let minY = Infinity;
@@ -2177,18 +2364,36 @@ function polygonLabelCandidates(outer, holes, preferred) {
2177
2364
  }
2178
2365
  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));
2179
2366
  }
2367
+ function roundedPolygonSubpath(context, points, radius) {
2368
+ const n = points.length;
2369
+ const start = { x: (points[n - 1].x + points[0].x) / 2, y: (points[n - 1].y + points[0].y) / 2 };
2370
+ context.moveTo(start.x, start.y);
2371
+ for (let i = 0; i < n; i += 1) {
2372
+ const curr = points[i];
2373
+ const next = points[(i + 1) % n];
2374
+ const prev = points[(i - 1 + n) % n];
2375
+ const rMax = Math.min(
2376
+ Math.hypot(curr.x - prev.x, curr.y - prev.y) / 2,
2377
+ Math.hypot(next.x - curr.x, next.y - curr.y) / 2
2378
+ );
2379
+ context.arcTo(curr.x, curr.y, next.x, next.y, Math.max(0, Math.min(radius, rMax)));
2380
+ }
2381
+ context.closePath();
2382
+ }
2180
2383
  function polygonWithHolesShape(outer, holes, attrs, outerPath) {
2181
2384
  const signedArea = (points) => points.reduce((sum, point, index) => {
2182
2385
  const next = points[(index + 1) % points.length];
2183
2386
  return sum + point.x * next.y - next.x * point.y;
2184
2387
  }, 0);
2185
2388
  const outerClockwise = signedArea(outer) > 0;
2389
+ const cornerRadius = attrs.cornerRadius && attrs.cornerRadius > 0 ? attrs.cornerRadius : 0;
2186
2390
  return new Shape({
2187
2391
  ...attrs,
2188
2392
  sceneFunc(context, shape) {
2189
2393
  context.beginPath();
2190
2394
  const polygonPath = (points) => {
2191
2395
  if (!points.length) return;
2396
+ if (cornerRadius > 0 && points.length >= 3) return roundedPolygonSubpath(context, points, cornerRadius);
2192
2397
  context.moveTo(points[0].x, points[0].y);
2193
2398
  for (let index = 1; index < points.length; index += 1) context.lineTo(points[index].x, points[index].y);
2194
2399
  context.closePath();
@@ -2323,6 +2528,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2323
2528
  this.accessGlyphById = /* @__PURE__ */ new Map();
2324
2529
  /** Authored free-text nodes obey the same rendered-size visibility floor. */
2325
2530
  this.freeTextById = /* @__PURE__ */ new Map();
2531
+ /** Vector venue-icon Path nodes + authored size; obey the free-text size floor. */
2532
+ this.iconNodeById = /* @__PURE__ */ new Map();
2326
2533
  /** Stage/rink landmarks retain a readable screen-space caption at overview. */
2327
2534
  this.primaryFocalLabels = /* @__PURE__ */ new Map();
2328
2535
  /** GA paint and text share price/highlight filter state. */
@@ -2336,6 +2543,12 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2336
2543
  this.effSelection = DEF_SELECTION;
2337
2544
  /** Colorblind-safe mode (Okabe-Ito hues + hollow booked seats). */
2338
2545
  this.colorblind = false;
2546
+ /** Stable per-category-KEY colorblind hue (OV-46) — order-independent. */
2547
+ this.cbHueByKey = /* @__PURE__ */ new Map();
2548
+ /** OV-45(a): authored row-letter labels for the buyer/preview map, resolved in
2549
+ * world space (matching seat labels) and honouring each row's labelPresentation
2550
+ * (position/rotation/visibility/style). Painted at the seats LOD rung. */
2551
+ this.rowLabelPlan = [];
2339
2552
  /** Category order from the doc — the stable index into the CB palette. */
2340
2553
  this.catOrder = [];
2341
2554
  this.selection = /* @__PURE__ */ new Set();
@@ -2705,6 +2918,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2705
2918
  this.accessRingById.clear();
2706
2919
  this.accessGlyphById.clear();
2707
2920
  this.freeTextById.clear();
2921
+ this.iconNodeById.clear();
2708
2922
  this.primaryFocalLabels.clear();
2709
2923
  this.gaById.clear();
2710
2924
  for (const marker of this.selectionMarkers.values()) marker.destroy();
@@ -2759,13 +2973,22 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2759
2973
  this.catColor.clear();
2760
2974
  this.catPrice.clear();
2761
2975
  this.catOrder = doc.categories.map((c) => c.key);
2976
+ this.cbHueByKey.clear();
2977
+ [...this.catOrder].sort().forEach((key, i) => {
2978
+ this.cbHueByKey.set(key, CB_PALETTE[i % CB_PALETTE.length]);
2979
+ });
2762
2980
  for (const c of doc.categories) {
2763
2981
  this.catColor.set(c.key, c.color);
2764
2982
  if (typeof c.price === "number") this.catPrice.set(c.key, c.price);
2765
2983
  }
2766
2984
  for (const obj of view.objects) {
2767
2985
  if (obj.type === "booth") {
2768
- this.boothDims.set(obj.id, { width: obj.width, height: obj.height, rotation: obj.rotation });
2986
+ this.boothDims.set(obj.id, {
2987
+ width: obj.width,
2988
+ height: obj.height,
2989
+ rotation: obj.rotation,
2990
+ ...obj.points && obj.points.length >= 3 ? { points: obj.points.map((p) => ({ x: p.x, y: p.y })) } : {}
2991
+ });
2769
2992
  }
2770
2993
  }
2771
2994
  this.seats = expandChart(view, {
@@ -2785,6 +3008,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2785
3008
  this.unsectionedSeatGroup = new Group({ listening: true });
2786
3009
  this.seatLayer.add(this.unsectionedSeatGroup);
2787
3010
  this.renderSeats();
3011
+ this.buildRowLabelPlan(view);
2788
3012
  this.overlayLayer.add(this.labelGroup);
2789
3013
  this.overlayLayer.add(this.fgDecorGroup);
2790
3014
  this.overlayLayer.add(this.hoverRing);
@@ -4096,25 +4320,42 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4096
4320
  renderBoothUnit(seat) {
4097
4321
  const target = this.seatContainer(seat.id);
4098
4322
  const dims = this.boothDims.get(seat.rowId) ?? { width: 40, height: 30, rotation: 0 };
4099
- const rect = new Rect({
4100
- x: seat.x,
4101
- y: seat.y,
4102
- width: dims.width,
4103
- height: dims.height,
4104
- offsetX: dims.width / 2,
4105
- offsetY: dims.height / 2,
4106
- rotation: dims.rotation,
4107
- cornerRadius: 4,
4108
- perfectDrawEnabled: false,
4109
- shadowForStrokeEnabled: false,
4110
- hitStrokeWidth: 0
4111
- });
4112
- rect.setAttr("seatId", seat.id);
4113
- this.circleById.set(seat.id, rect);
4114
- target.add(rect);
4323
+ let labelX = seat.x;
4324
+ let labelY = seat.y;
4325
+ let node;
4326
+ if (dims.points && dims.points.length >= 3) {
4327
+ node = new Line({
4328
+ x: seat.x,
4329
+ y: seat.y,
4330
+ points: dims.points.flatMap((p) => [p.x, p.y]),
4331
+ closed: true,
4332
+ perfectDrawEnabled: false,
4333
+ shadowForStrokeEnabled: false,
4334
+ hitStrokeWidth: 0
4335
+ });
4336
+ labelX = seat.x + dims.points.reduce((a, p) => a + p.x, 0) / dims.points.length;
4337
+ labelY = seat.y + dims.points.reduce((a, p) => a + p.y, 0) / dims.points.length;
4338
+ } else {
4339
+ node = new Rect({
4340
+ x: seat.x,
4341
+ y: seat.y,
4342
+ width: dims.width,
4343
+ height: dims.height,
4344
+ offsetX: dims.width / 2,
4345
+ offsetY: dims.height / 2,
4346
+ rotation: dims.rotation,
4347
+ cornerRadius: 4,
4348
+ perfectDrawEnabled: false,
4349
+ shadowForStrokeEnabled: false,
4350
+ hitStrokeWidth: 0
4351
+ });
4352
+ }
4353
+ node.setAttr("seatId", seat.id);
4354
+ this.circleById.set(seat.id, node);
4355
+ target.add(node);
4115
4356
  const t2 = new Text({
4116
- x: seat.x,
4117
- y: seat.y,
4357
+ x: labelX,
4358
+ y: labelY,
4118
4359
  text: seat.displayLabel ?? seat.label,
4119
4360
  fontSize: BOOTH_LABEL_FONT_SIZE,
4120
4361
  fontStyle: "600",
@@ -4129,7 +4370,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4129
4370
  this.hasBoothText = true;
4130
4371
  this.boothLabelById.set(seat.id, t2);
4131
4372
  target.add(t2);
4132
- this.paintSeat(rect, seat.id);
4373
+ this.paintSeat(node, seat.id);
4133
4374
  }
4134
4375
  /**
4135
4376
  * Build the centred accessibility glyph for a seat. Sized relative to the seat
@@ -4197,8 +4438,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4197
4438
  /** The category's display color — Okabe-Ito hue when colorblind-safe is on. */
4198
4439
  seatBaseColor(categoryKey) {
4199
4440
  if (!this.colorblind) return this.catColor.get(categoryKey) ?? "#6e7bff";
4200
- const idx = this.catOrder.indexOf(categoryKey);
4201
- return CB_PALETTE[(idx >= 0 ? idx : 0) % CB_PALETTE.length];
4441
+ return this.cbHueByKey.get(categoryKey) ?? CB_PALETTE[0];
4202
4442
  }
4203
4443
  /** Per-seat authored label size relative to the neutral default (1 = default),
4204
4444
  * mirroring the section convention (labelStyle.size ?? 18) / 18. Only real
@@ -4669,6 +4909,28 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4669
4909
  renderText(obj) {
4670
4910
  const background = this.canvasBackground;
4671
4911
  const preferredInk = obj.color ?? this.theme.textColor ?? DEF_TEXT;
4912
+ const iconPath = obj.semanticKind === "icon" ? venueIconPath(obj.iconKey) : void 0;
4913
+ if (iconPath) {
4914
+ const scale = obj.fontSize / VENUE_ICON_VIEWBOX;
4915
+ const iconNode = new Path({
4916
+ x: obj.position.x,
4917
+ y: obj.position.y,
4918
+ data: iconPath,
4919
+ rotation: obj.rotation,
4920
+ scaleX: scale,
4921
+ scaleY: scale,
4922
+ stroke: stateAwareBookableLabelInk(background, preferredInk),
4923
+ strokeWidth: VENUE_ICON_STROKE,
4924
+ lineCap: "round",
4925
+ lineJoin: "round",
4926
+ fillEnabled: false,
4927
+ listening: false,
4928
+ perfectDrawEnabled: false
4929
+ });
4930
+ this.iconNodeById.set(obj.id, { node: iconNode, fontSize: obj.fontSize });
4931
+ this.bgLayer.add(iconNode);
4932
+ return;
4933
+ }
4672
4934
  const node = new Text({
4673
4935
  x: obj.position.x,
4674
4936
  y: obj.position.y,
@@ -4836,7 +5098,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4836
5098
  fill: color,
4837
5099
  opacity: GA_FILL_OPACITY,
4838
5100
  stroke: color,
4839
- strokeWidth: 1.5
5101
+ strokeWidth: 1.5,
5102
+ cornerRadius: obj.cornerRadius
4840
5103
  });
4841
5104
  poly.setAttr("gaId", obj.id);
4842
5105
  poly.on("click tap", (e) => {
@@ -5048,20 +5311,33 @@ var _SeatmapRenderer = class _SeatmapRenderer {
5048
5311
  sec.outlinePoly.shadowBlur(4 + raw * 12);
5049
5312
  sec.outlinePoly.shadowOpacity(0.25 + raw * 0.45);
5050
5313
  }
5051
- /** Recompute a section's neutral overview state and retained detail count. */
5314
+ /** Recompute a section's availability shading + retained availability text. */
5052
5315
  refreshSectionFill(sec) {
5053
5316
  sec.blockPoly.fill(this.sectionBlockFill(sec));
5054
- sec.subLabel.text(t("map.seatsLeft", { count: sec.free }));
5317
+ sec.subLabel.text(
5318
+ this.sectionAvailabilityState(sec) === "sold-out" ? t("map.soldOut") : t("map.seatsLeft", { count: sec.free })
5319
+ );
5055
5320
  sec.subLabel.offsetX(sec.subLabel.width() / 2);
5056
5321
  }
5057
5322
  /** True when a section/zone is currently in the `closed` event-state. */
5058
5323
  isSectionClosed(sec) {
5059
5324
  return this.closedSections.has(sec.id) || this.closedSections.has(sec.logicalId) || sec.zone != null && this.closedSections.has(sec.zone);
5060
5325
  }
5061
- /** Clean overview shells never leak category, price, or live availability paint. */
5326
+ /** Resolve the four-bucket overview availability state for a section (OV-69).
5327
+ * `closed` (operator) outranks live availability; the availability buckets
5328
+ * only apply to seated sections (GA-only shells have no seat capacity here). */
5329
+ sectionAvailabilityState(sec) {
5330
+ if (this.isSectionClosed(sec)) return "closed";
5331
+ if (sec.total <= 0) return "normal";
5332
+ if (sec.free <= 0) return "sold-out";
5333
+ if (sec.free <= sec.total * SECTION_NEARLY_GONE_RATIO) return "nearly-gone";
5334
+ return "normal";
5335
+ }
5336
+ /** Overview shells stay neutral except for the bounded availability shading
5337
+ * (nearly-gone / sold-out) and the operator `closed` slab — never category
5338
+ * or price paint, which is deferred to section focus/seat detail. */
5062
5339
  sectionBlockFill(sec) {
5063
- const fill = overviewPalette(this.canvasBackground).sectionFill;
5064
- return this.isSectionClosed(sec) ? darken(fill, 0.12) : fill;
5340
+ return overviewStateFill(overviewPalette(this.canvasBackground), this.sectionAvailabilityState(sec));
5065
5341
  }
5066
5342
  /**
5067
5343
  * Zone rung: one giant screen-constant label per zone (+ optional "FROM $n"),
@@ -6067,10 +6343,9 @@ var _SeatmapRenderer = class _SeatmapRenderer {
6067
6343
  };
6068
6344
  });
6069
6345
  const palette = overviewPalette(this.canvasBackground);
6070
- const neutralSectionFills = /* @__PURE__ */ new Set([
6071
- palette.sectionFill.toLowerCase(),
6072
- darken(palette.sectionFill, 0.12).toLowerCase()
6073
- ]);
6346
+ const neutralSectionFills = new Set(
6347
+ ["normal", "nearly-gone", "sold-out", "closed"].map((state) => overviewStateFill(palette, state).toLowerCase())
6348
+ );
6074
6349
  const visibleSectionShells = this.sections.filter((section) => section.blockPoly.opacity() > 0.05);
6075
6350
  return {
6076
6351
  viewport,
@@ -6294,6 +6569,63 @@ var _SeatmapRenderer = class _SeatmapRenderer {
6294
6569
  const gaOverviewHidden = objectId != null && (kind === "ga-label" || kind === "ga-capacity") && this.gaById.get(objectId)?.sectionId != null && effectiveScale < CACHE_THRESHOLD;
6295
6570
  node.visible(!gaDimmed && !gaOverviewHidden && isBookableLabelLegibleAtScale(node.fontSize(), effectiveScale));
6296
6571
  }
6572
+ for (const { node, fontSize } of this.iconNodeById.values()) {
6573
+ node.visible(isBookableLabelLegibleAtScale(fontSize, effectiveScale));
6574
+ }
6575
+ }
6576
+ /** OV-45(a): resolve authored row-letter labels for the buyer/preview map, in
6577
+ * world space, matching the Designer's placement contract (start/end/both/none
6578
+ * preset, a free-drag `position` that wins over the preset, rotation, and
6579
+ * labelStyle colour/size fed through the same auto-contrast guard). Segmented
6580
+ * rows keep their own logical-label handling and are resolved by their owner. */
6581
+ buildRowLabelPlan(view) {
6582
+ this.rowLabelPlan = [];
6583
+ const background = this.theme.background ?? "#0e1117";
6584
+ const seatsByRow = /* @__PURE__ */ new Map();
6585
+ for (const seat of this.seats) {
6586
+ if (seat.kind === "booth") continue;
6587
+ const key = seat.logicalRowId ?? seat.rowId;
6588
+ (seatsByRow.get(key) ?? seatsByRow.set(key, []).get(key)).push(seat);
6589
+ }
6590
+ const seatOrder = (seat) => seat.logicalSeatIndex ?? Number(seat.id.split(":").pop() ?? 0);
6591
+ for (const obj of view.objects) {
6592
+ if (obj.type !== "row") continue;
6593
+ if (obj.segmentedRow && obj.segmentedRow.componentIndex !== 0) continue;
6594
+ const presentation = obj.segmentedRow?.labelPresentation ?? obj.labelPresentation;
6595
+ if (presentation?.visible === false || presentation?.positionPreset === "none") continue;
6596
+ const key = obj.segmentedRow ? obj.segmentedRow.groupId : obj.id;
6597
+ const seats = (seatsByRow.get(key) ?? []).slice().sort((a, b) => seatOrder(a) - seatOrder(b));
6598
+ if (!seats.length) continue;
6599
+ const first = seats[0];
6600
+ const last = seats[seats.length - 1];
6601
+ const labelStyle = presentation?.labelStyle;
6602
+ const ink = stateAwareBookableLabelInk(
6603
+ background,
6604
+ labelStyle?.color ?? this.theme.rowLabelColor ?? this.theme.textColor ?? "#e6e9f0"
6605
+ );
6606
+ const fontSize = labelStyle?.size ?? 12;
6607
+ const rotation = presentation?.rotation ?? 0;
6608
+ const text = obj.segmentedRow?.displayLabel ?? obj.displayLabel ?? obj.label;
6609
+ const opacity = this.objectFilteredOut(obj) ? 0.15 : 1;
6610
+ const push = (x, y) => this.rowLabelPlan.push({ text, x, y, rotation, fontSize, ink, opacity });
6611
+ if (presentation?.position) {
6612
+ push(presentation.position.x, presentation.position.y);
6613
+ continue;
6614
+ }
6615
+ const radians = obj.rotation * Math.PI / 180;
6616
+ const along = { x: Math.cos(radians), y: Math.sin(radians) };
6617
+ const gap = this.seatR + 12;
6618
+ const preset = presentation?.positionPreset ?? "start";
6619
+ if (preset === "start" || preset === "both") push(first.x - along.x * gap, first.y - along.y * gap);
6620
+ if (preset === "end" || preset === "both") push(last.x + along.x * gap, last.y + along.y * gap);
6621
+ }
6622
+ }
6623
+ /** Row labels never carry status; the buyer just dims them under the same
6624
+ * category/price filters that dim their seats. */
6625
+ objectFilteredOut(obj) {
6626
+ const key = obj.categoryKey;
6627
+ if (!key) return false;
6628
+ return Boolean(this.categoryHighlight && key !== this.categoryHighlight || this.categoryFilter && !this.categoryFilter.has(key));
6297
6629
  }
6298
6630
  updateLabels() {
6299
6631
  const effectiveScale = this.effScale();
@@ -6387,6 +6719,30 @@ var _SeatmapRenderer = class _SeatmapRenderer {
6387
6719
  this.seatLabelById.set(seat.id, t2);
6388
6720
  if (++count >= MAX_LABELS) break;
6389
6721
  }
6722
+ for (const rl of this.rowLabelPlan) {
6723
+ const screen = this.worldToScreen({ x: rl.x, y: rl.y });
6724
+ if (screen.x < -40 || screen.x > this.stage.width() + 40 || screen.y < -40 || screen.y > this.stage.height() + 40) continue;
6725
+ const t2 = new Text({
6726
+ x: rl.x,
6727
+ y: rl.y,
6728
+ text: rl.text,
6729
+ fontSize: rl.fontSize,
6730
+ fontStyle: "700",
6731
+ fontFamily: this.labelFont(),
6732
+ fill: rl.ink,
6733
+ rotation: rl.rotation,
6734
+ opacity: rl.opacity,
6735
+ shadowColor: "#05070c",
6736
+ shadowBlur: rl.ink.toLowerCase() === "#ffffff" ? 5 : 0,
6737
+ shadowOpacity: rl.ink.toLowerCase() === "#ffffff" ? 0.8 : 0,
6738
+ shadowForStrokeEnabled: false,
6739
+ listening: false,
6740
+ perfectDrawEnabled: false
6741
+ });
6742
+ t2.offsetX(t2.width() / 2);
6743
+ t2.offsetY(t2.height() / 2);
6744
+ this.labelGroup.add(t2);
6745
+ }
6390
6746
  if (this.isoT > 0 && this.viewMode !== "perspective") this.applyUprightLabels();
6391
6747
  this.overlayLayer.batchDraw();
6392
6748
  }
@@ -7633,7 +7989,7 @@ var PickerController = class {
7633
7989
  const color = sec.color ?? zone?.color ?? categories[0]?.color ?? "#6e7bff";
7634
7990
  return {
7635
7991
  id,
7636
- label: sec.label,
7992
+ label: sec.displayLabel ?? sec.label,
7637
7993
  zoneLabel: zone?.label ?? "",
7638
7994
  ...sec.entrance && sec.entrance.trim() ? { entrance: sec.entrance.trim() } : {},
7639
7995
  color,
@@ -8235,9 +8591,9 @@ function generateSeatThumb(seat, focalPoint, _neighborSeats) {
8235
8591
 
8236
8592
  // src/i18n/bundles.ts
8237
8593
  var LOADERS = {
8238
- es: () => import("./es-SKAODLTR.js").then((m) => ({ default: m.es })),
8239
- de: () => import("./de-73TIXYJH.js").then((m) => ({ default: m.de })),
8240
- fr: () => import("./fr-J4637T6V.js").then((m) => ({ default: m.fr }))
8594
+ es: () => import("./es-6TJS2L7S.js").then((m) => ({ default: m.es })),
8595
+ de: () => import("./de-UHAOVDS3.js").then((m) => ({ default: m.de })),
8596
+ fr: () => import("./fr-MMSY7FPE.js").then((m) => ({ default: m.fr }))
8241
8597
  };
8242
8598
  var loaded = /* @__PURE__ */ new Set(["en"]);
8243
8599
  async function loadLocale(code) {