@seatlayer/core 0.9.0 → 0.10.1

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
@@ -20,9 +20,10 @@ function layerOf(obj) {
20
20
  case "booth":
21
21
  case "section":
22
22
  return "interactive";
23
- // stage / décor live on 'shape'; free text is background furniture.
23
+ // stage / décor live on 'shape'; free text + decor images are background furniture.
24
24
  case "shape":
25
25
  case "text":
26
+ case "decorImage":
26
27
  return "background";
27
28
  default:
28
29
  return "interactive";
@@ -251,6 +252,8 @@ function objectCenter(o) {
251
252
  return { x: o.x + o.width / 2, y: o.y + o.height / 2 };
252
253
  }
253
254
  return { x: o.x ?? 0, y: o.y ?? 0 };
255
+ case "decorImage":
256
+ return { x: o.x + o.width / 2, y: o.y + o.height / 2 };
254
257
  }
255
258
  }
256
259
  function floorsOf(doc) {
@@ -286,6 +289,8 @@ function translateObject(o, dx, dy) {
286
289
  ...o.x != null ? { x: o.x + dx } : {},
287
290
  ...o.y != null ? { y: o.y + dy } : {}
288
291
  };
292
+ case "decorImage":
293
+ return { ...o, x: o.x + dx, y: o.y + dy };
289
294
  }
290
295
  }
291
296
  function stackFloors(doc, spread = 900) {
@@ -324,6 +329,9 @@ function chartBounds(doc) {
324
329
  for (const p of obj.points) acc(p.x, p.y);
325
330
  } else if (obj.type === "section") {
326
331
  for (const p of obj.outline) acc(p.x, p.y);
332
+ } else if (obj.type === "decorImage") {
333
+ acc(obj.x, obj.y);
334
+ acc(obj.x + obj.width, obj.y + obj.height);
327
335
  } else if (obj.type === "shape") {
328
336
  if (obj.points && obj.points.length) {
329
337
  for (const p of obj.points) acc(p.x, p.y);
@@ -624,6 +632,7 @@ var SEAT_RADIUS = 9;
624
632
  var SEAT_LEGIBLE_SCALE = 0.9;
625
633
  var CACHE_THRESHOLD = 0.55 * SEAT_LEGIBLE_SCALE;
626
634
  var LABEL_SCALE = 1;
635
+ var SEAT_TAP_SLOP_PX = 14;
627
636
  var SECTION_PROMINENT_SCALE = 0.45 * SEAT_LEGIBLE_SCALE;
628
637
  var BLOCK_MELT_TOP = 0.9 * SEAT_LEGIBLE_SCALE;
629
638
  var ZONE_PROMINENT_SCALE = 0.55 * SECTION_PROMINENT_SCALE;
@@ -641,6 +650,13 @@ var ZONE_SUB_PX = 12;
641
650
  var HELD_FILL = "#6b7280";
642
651
  var TAKEN_FILL = "#374151";
643
652
  var NFS_STROKE = "#4b5563";
653
+ var CLOSED_SECTION_FILL = "#586070";
654
+ var CLOSED_SEAT_FILL = "#4b5563";
655
+ var CLOSED_SEAT_OPACITY = 0.4;
656
+ var FOCUS_DIM_OPACITY = 0.16;
657
+ var FOCUS_DESATURATE = 0.72;
658
+ var FOCUS_NEUTRAL = "#6b7280";
659
+ var FOCUS_BACKDROP_FILL = "rgba(244,246,248,0.06)";
644
660
  var CB_PALETTE = ["#E69F00", "#56B4E9", "#009E73", "#F0E442", "#0072B2", "#D55E00", "#CC79A7"];
645
661
  var ACCESS_RING = {
646
662
  wheelchair: "#3b82f6",
@@ -787,6 +803,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
787
803
  this.accessFilter = null;
788
804
  /** Category highlight (legend hover): dims free seats NOT of this category. */
789
805
  this.categoryHighlight = null;
806
+ /** Price-band filter (F4): dim free seats whose category is NOT in this set. */
807
+ this.categoryFilter = null;
790
808
  // Section/zone overlays (bgLayer) — the 3-rung LOD: seats → section blocks →
791
809
  // zone blocks. Kept for the melt restyle and for hit-testing a zoomed-out tap.
792
810
  this.sections = [];
@@ -795,6 +813,13 @@ var _SeatmapRenderer = class _SeatmapRenderer {
795
813
  this.catPrice = /* @__PURE__ */ new Map();
796
814
  /** Section/zone ids to render dimmed (organizer manager: held-back inventory). */
797
815
  this.dimmedSections = /* @__PURE__ */ new Set();
816
+ /** Phase 2: section/zone ids in the event-level `closed` state — flat grey
817
+ * block, seats greyed + not pickable, but the section stays rendered. */
818
+ this.closedSections = /* @__PURE__ */ new Set();
819
+ /** AXS section-focus: the currently-focused section id (others dim), or null. */
820
+ this.focusedSectionId = null;
821
+ /** Light backdrop panel drawn behind the focused section (removed on clear). */
822
+ this.focusBackdrop = null;
798
823
  /** Object id → floor id (multi-floor only) — resolves a deck tap in the 3D stack. */
799
824
  this.objectFloor = /* @__PURE__ */ new Map();
800
825
  /** Zone id → colour (drives extruded side faces in iso view). */
@@ -1275,6 +1300,27 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1275
1300
  if (cur === null || next === null) return cur === next;
1276
1301
  return cur.length === next.length && cur.every((t2, i) => t2 === next[i]);
1277
1302
  }
1303
+ /**
1304
+ * Price-band filter (F4): dim free, unselected seats whose category key is NOT
1305
+ * in `keys`. `null` clears the filter (all categories fully visible). The
1306
+ * widget resolves which categories fall inside the buyer's chosen band.
1307
+ */
1308
+ setCategoryFilter(keys) {
1309
+ const next = keys === null ? null : new Set(keys);
1310
+ const same = next === null && this.categoryFilter === null || next !== null && this.categoryFilter !== null && next.size === this.categoryFilter.size && [...next].every((k) => this.categoryFilter.has(k));
1311
+ if (same) return;
1312
+ this.categoryFilter = next;
1313
+ for (const seat of this.seats) {
1314
+ const c = this.circleById.get(seat.id);
1315
+ if (c) this.paintSeat(c, seat.id);
1316
+ }
1317
+ if (this.cached) {
1318
+ this.seatLayer.clearCache();
1319
+ this.cacheSeatLayer();
1320
+ } else {
1321
+ this.seatLayer.batchDraw();
1322
+ }
1323
+ }
1278
1324
  // ---- isometric ("3D") view mode -------------------------------------------
1279
1325
  /**
1280
1326
  * Switch the projection between flat top-down and the isometric "3D" view
@@ -1601,12 +1647,33 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1601
1647
  if (this.categoryHighlight && status === "free" && !selected && seat.categoryKey !== this.categoryHighlight) {
1602
1648
  c.opacity(0.25);
1603
1649
  }
1650
+ if (this.categoryFilter && status === "free" && !selected && !this.categoryFilter.has(seat.categoryKey)) {
1651
+ c.opacity(0.22);
1652
+ }
1604
1653
  if (this.dimmedSections.size) {
1605
1654
  const sec = this.seatSection.get(id);
1606
1655
  if (sec && (this.dimmedSections.has(sec.id) || sec.zone != null && this.dimmedSections.has(sec.zone))) {
1607
1656
  c.opacity(0.18);
1608
1657
  }
1609
1658
  }
1659
+ if (this.closedSections.size && this.seatInClosedSection(id)) {
1660
+ c.fill(CLOSED_SEAT_FILL);
1661
+ c.stroke("");
1662
+ c.strokeWidth(0);
1663
+ c.dash([]);
1664
+ c.opacity(CLOSED_SEAT_OPACITY);
1665
+ }
1666
+ if (this.focusedSectionId) {
1667
+ const sec = this.seatSection.get(id);
1668
+ const inFocus = !!sec && (sec.id === this.focusedSectionId || sec.zone === this.focusedSectionId);
1669
+ if (!inFocus) c.opacity(FOCUS_DIM_OPACITY);
1670
+ }
1671
+ }
1672
+ /** True when a seat sits in a section/zone currently marked `closed`. */
1673
+ seatInClosedSection(id) {
1674
+ if (!this.closedSections.size) return false;
1675
+ const sec = this.seatSection.get(id);
1676
+ return !!sec && (this.closedSections.has(sec.id) || sec.zone != null && this.closedSections.has(sec.zone));
1610
1677
  }
1611
1678
  /**
1612
1679
  * Colorblind-safe mode: swap category hues for the Okabe-Ito palette and
@@ -1646,6 +1713,109 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1646
1713
  this.seatLayer.batchDraw();
1647
1714
  }
1648
1715
  }
1716
+ /**
1717
+ * Phase 2 event-level section states: mark these section/zone ids `closed` —
1718
+ * flat grey block, seats greyed + not pickable, but the section stays rendered
1719
+ * (unlike the buyer's applyHidden which strips it). `null`/empty clears.
1720
+ */
1721
+ setClosedSections(ids) {
1722
+ const next = new Set(ids ?? []);
1723
+ if (next.size === this.closedSections.size && [...next].every((i) => this.closedSections.has(i))) return;
1724
+ this.closedSections = next;
1725
+ this.repaintSectionsAndSeats();
1726
+ }
1727
+ /**
1728
+ * AXS section-focus: dim + desaturate every other section, draw a calm backdrop
1729
+ * panel behind this section's seats, and glide the camera in to frame it (the
1730
+ * seat-pick gate below only lets buyers pick once seats are ≥ LABEL_SCALE big).
1731
+ */
1732
+ focusSection(id) {
1733
+ if (!this.sections.some((s) => s.id === id)) return;
1734
+ this.focusedSectionId = id;
1735
+ this.drawFocusBackdrop(id);
1736
+ this.repaintSectionsAndSeats();
1737
+ this.updateLOD();
1738
+ this.focusRegion(id);
1739
+ }
1740
+ /** Clear an AXS section focus — restore full-bowl brightness + drop the backdrop. */
1741
+ clearSectionFocus() {
1742
+ if (!this.focusedSectionId) return;
1743
+ this.focusedSectionId = null;
1744
+ if (this.focusBackdrop) {
1745
+ this.focusBackdrop.destroy();
1746
+ this.focusBackdrop = null;
1747
+ }
1748
+ this.repaintSectionsAndSeats();
1749
+ this.updateLOD();
1750
+ }
1751
+ /** The currently AXS-focused section id, or null. */
1752
+ getFocusedSection() {
1753
+ return this.focusedSectionId;
1754
+ }
1755
+ /** Draw (or replace) the light backdrop panel behind the focused section. */
1756
+ drawFocusBackdrop(id) {
1757
+ if (this.focusBackdrop) {
1758
+ this.focusBackdrop.destroy();
1759
+ this.focusBackdrop = null;
1760
+ }
1761
+ const sec = this.sections.find((s) => s.id === id);
1762
+ if (!sec) return;
1763
+ const panel = new Line({
1764
+ points: sec.outline.flatMap((p) => [p.x, p.y]),
1765
+ closed: true,
1766
+ fill: FOCUS_BACKDROP_FILL,
1767
+ stroke: rgba("#ffffff", 0.1),
1768
+ strokeWidth: 1,
1769
+ listening: false,
1770
+ perfectDrawEnabled: false
1771
+ });
1772
+ this.bgLayer.add(panel);
1773
+ panel.moveToTop();
1774
+ this.focusBackdrop = panel;
1775
+ }
1776
+ /** Repaint every seat + section block to reflect closed/focus state, then redraw. */
1777
+ repaintSectionsAndSeats() {
1778
+ for (const seat of this.seats) {
1779
+ const c = this.circleById.get(seat.id);
1780
+ if (c) this.paintSeat(c, seat.id);
1781
+ }
1782
+ for (const sec of this.sections) sec.blockPoly.fill(this.sectionBlockFill(sec));
1783
+ if (this.cached) {
1784
+ this.seatLayer.clearCache();
1785
+ this.cacheSeatLayer();
1786
+ } else {
1787
+ this.seatLayer.batchDraw();
1788
+ }
1789
+ this.bgLayer.batchDraw();
1790
+ }
1791
+ /** The world-space rectangle currently visible in the viewport (minimap F3). */
1792
+ getVisibleWorldRect() {
1793
+ const tl = this.screenToWorld({ x: 0, y: 0 });
1794
+ const br = this.screenToWorld({ x: this.stage.width(), y: this.stage.height() });
1795
+ return {
1796
+ x: Math.min(tl.x, br.x),
1797
+ y: Math.min(tl.y, br.y),
1798
+ width: Math.abs(br.x - tl.x),
1799
+ height: Math.abs(br.y - tl.y)
1800
+ };
1801
+ }
1802
+ /** Axis-aligned world bounds of all seats + section outlines (minimap F3 frame). */
1803
+ getWorldBounds() {
1804
+ let minX = Infinity;
1805
+ let minY = Infinity;
1806
+ let maxX = -Infinity;
1807
+ let maxY = -Infinity;
1808
+ const grow = (x, y) => {
1809
+ if (x < minX) minX = x;
1810
+ if (y < minY) minY = y;
1811
+ if (x > maxX) maxX = x;
1812
+ if (y > maxY) maxY = y;
1813
+ };
1814
+ for (const s of this.seats) grow(s.x, s.y);
1815
+ for (const sec of this.sections) for (const p of sec.outline) grow(p.x, p.y);
1816
+ if (!Number.isFinite(minX)) return { x: 0, y: 0, width: 1, height: 1 };
1817
+ return { x: minX, y: minY, width: Math.max(1, maxX - minX), height: Math.max(1, maxY - minY) };
1818
+ }
1649
1819
  /** Legend hover: highlight one category (dim the rest), or null to clear. */
1650
1820
  setCategoryHighlight(key) {
1651
1821
  if (this.categoryHighlight === key) return;
@@ -1663,6 +1833,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1663
1833
  }
1664
1834
  renderBackground(doc) {
1665
1835
  if (doc.backgroundImage) this.renderBackgroundImage(doc.backgroundImage);
1836
+ for (const obj of doc.objects) if (obj.type === "decorImage") this.renderDecorImage(obj);
1666
1837
  for (const obj of doc.objects) if (obj.type === "section") this.renderSection(obj);
1667
1838
  this.renderZones(doc);
1668
1839
  for (const obj of doc.objects) {
@@ -1711,6 +1882,35 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1711
1882
  };
1712
1883
  img.src = bg.url;
1713
1884
  }
1885
+ /**
1886
+ * A decor graphic (rink / court / stage art). The KImage node is added to the
1887
+ * bgLayer synchronously so it keeps its z-slot beneath the sections drawn right
1888
+ * after; the bitmap is decoded async and pasted in on load. A single node = a
1889
+ * single drawImage per frame, and it rides the same layer cache — effectively
1890
+ * zero per-frame cost. Never listens, so it can't intercept a seat click.
1891
+ */
1892
+ renderDecorImage(obj) {
1893
+ const img = new window.Image();
1894
+ const node = new KImage({
1895
+ image: img,
1896
+ x: obj.x + obj.width / 2,
1897
+ y: obj.y + obj.height / 2,
1898
+ offsetX: obj.width / 2,
1899
+ offsetY: obj.height / 2,
1900
+ width: obj.width,
1901
+ height: obj.height,
1902
+ rotation: obj.rotation ?? 0,
1903
+ opacity: clamp(obj.opacity ?? 1, 0, 1),
1904
+ listening: false,
1905
+ perfectDrawEnabled: false
1906
+ });
1907
+ this.bgLayer.add(node);
1908
+ img.onload = () => {
1909
+ if (!node.getLayer()) return;
1910
+ this.bgLayer.batchDraw();
1911
+ };
1912
+ img.src = obj.href;
1913
+ }
1714
1914
  renderTable(obj) {
1715
1915
  if (obj.shape === "round") {
1716
1916
  this.bgLayer.add(
@@ -2032,11 +2232,32 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2032
2232
  }
2033
2233
  /** Recompute a section's availability-tinted fill + "N LEFT" (cheap; on status change). */
2034
2234
  refreshSectionFill(sec) {
2035
- const sold = sec.total > 0 ? (sec.total - sec.free) / sec.total : 0;
2036
- sec.blockPoly.fill(darken(sec.baseFill, sold * SOLD_DARKEN));
2235
+ sec.blockPoly.fill(this.sectionBlockFill(sec));
2037
2236
  sec.subLabel.text(t("map.seatsLeft", { count: sec.free }));
2038
2237
  sec.subLabel.offsetX(sec.subLabel.width() / 2);
2039
2238
  }
2239
+ /** True when a section/zone is currently in the `closed` event-state. */
2240
+ isSectionClosed(sec) {
2241
+ return this.closedSections.has(sec.id) || sec.zone != null && this.closedSections.has(sec.zone);
2242
+ }
2243
+ /**
2244
+ * The block-fill colour for a section: flat desaturated grey when `closed`,
2245
+ * else the availability-darkened category mix; then desaturated toward neutral
2246
+ * when another section holds focus (AXS dim treatment).
2247
+ */
2248
+ sectionBlockFill(sec) {
2249
+ let fill;
2250
+ if (this.isSectionClosed(sec)) {
2251
+ fill = CLOSED_SECTION_FILL;
2252
+ } else {
2253
+ const sold = sec.total > 0 ? (sec.total - sec.free) / sec.total : 0;
2254
+ fill = darken(sec.baseFill, sold * SOLD_DARKEN);
2255
+ }
2256
+ if (this.focusedSectionId && sec.id !== this.focusedSectionId && sec.zone !== this.focusedSectionId) {
2257
+ fill = lerpColor(fill, FOCUS_NEUTRAL, FOCUS_DESATURATE);
2258
+ }
2259
+ return fill;
2260
+ }
2040
2261
  /**
2041
2262
  * Zone rung: one giant screen-constant label per zone (+ optional "FROM $n"),
2042
2263
  * shown at the farthest zoom in place of per-section detail. Skipped entirely
@@ -2128,12 +2349,14 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2128
2349
  const sx = this.stage.scaleX();
2129
2350
  const rescale = this.lodScale === 0 || Math.abs(scale - this.lodScale) / (this.lodScale || 1) > 0.02;
2130
2351
  if (rescale) this.lodScale = scale;
2352
+ const focus = this.focusedSectionId;
2131
2353
  for (const sec of this.sections) {
2132
- sec.blockPoly.opacity(BLOCK_FILL_ALPHA * blockT);
2133
- for (const line of sec.rowLines) line.opacity(blockT * (1 - zoneT));
2354
+ const dim = focus && sec.id !== focus && sec.zone !== focus ? FOCUS_DIM_OPACITY : 1;
2355
+ sec.blockPoly.opacity(BLOCK_FILL_ALPHA * blockT * dim);
2356
+ for (const line of sec.rowLines) line.opacity(blockT * (1 - zoneT) * dim);
2134
2357
  sec.nameLabel.fill(lerpColor("#aab3c5", "#ffffff", blockT));
2135
- sec.nameLabel.opacity(1 - zoneT);
2136
- sec.subLabel.opacity(blockT * (1 - zoneT));
2358
+ sec.nameLabel.opacity((1 - zoneT) * dim);
2359
+ sec.subLabel.opacity(blockT * (1 - zoneT) * dim);
2137
2360
  if (rescale) {
2138
2361
  this.sizeLabel(sec.nameLabel, SECTION_LABEL_PX / sx, sec.centroid.y - SECTION_SUB_PX / sx);
2139
2362
  this.sizeLabel(sec.subLabel, SECTION_SUB_PX / sx, sec.centroid.y + SECTION_LABEL_PX / sx);
@@ -2149,8 +2372,50 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2149
2372
  if (zone.sub) this.sizeLabel(zone.sub, ZONE_SUB_PX / sx, cy + ZONE_LABEL_PX / sx);
2150
2373
  }
2151
2374
  }
2375
+ this.decollideRungLabels(sx);
2152
2376
  this.bgLayer.batchDraw();
2153
2377
  }
2378
+ /**
2379
+ * Greedy label de-collision for the zone/section rungs (same approach as the
2380
+ * designer's cullRowLabels): price/"N LEFT" sublabels are lowest priority and
2381
+ * drop first; name labels keep top-to-bottom, left-to-right; anything whose
2382
+ * on-screen box (+4px gap) overlaps an already-kept box hides. Recomputed on
2383
+ * every LOD pass so hidden labels reappear as zoom spreads them apart.
2384
+ * Culling multiplies the opacity applySectionLod just assigned (never raises).
2385
+ */
2386
+ decollideRungLabels(sx) {
2387
+ const GAP = 4;
2388
+ const cands = [];
2389
+ const boxOf = (t2) => {
2390
+ const p = this.worldToScreen({ x: t2.x(), y: t2.y() });
2391
+ const w = t2.width() * sx;
2392
+ const h = t2.height() * sx;
2393
+ return { x: p.x - w / 2, y: p.y - h / 2, w, h };
2394
+ };
2395
+ for (const zone of this.zones) {
2396
+ if (zone.label.opacity() > 0.05) cands.push({ node: zone.label, tier: 0, box: boxOf(zone.label) });
2397
+ if (zone.sub && zone.sub.opacity() > 0.05) cands.push({ node: zone.sub, tier: 2, owner: zone.label, box: boxOf(zone.sub) });
2398
+ }
2399
+ for (const sec of this.sections) {
2400
+ if (sec.nameLabel.opacity() > 0.05) cands.push({ node: sec.nameLabel, tier: 1, box: boxOf(sec.nameLabel) });
2401
+ if (sec.subLabel.opacity() > 0.05) cands.push({ node: sec.subLabel, tier: 3, owner: sec.nameLabel, box: boxOf(sec.subLabel) });
2402
+ }
2403
+ if (cands.length < 2) return;
2404
+ cands.sort((a, b) => a.tier - b.tier || a.box.y - b.box.y || a.box.x - b.box.x);
2405
+ const kept = [];
2406
+ const culled = /* @__PURE__ */ new Set();
2407
+ const collides = (b) => kept.some(
2408
+ (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
2409
+ );
2410
+ for (const c of cands) {
2411
+ if (c.owner && culled.has(c.owner) || collides(c.box)) {
2412
+ c.node.opacity(0);
2413
+ culled.add(c.node);
2414
+ } else {
2415
+ kept.push(c.box);
2416
+ }
2417
+ }
2418
+ }
2154
2419
  /** Set a centred label's world fontSize (for a target screen px) and re-anchor it. */
2155
2420
  sizeLabel(t2, fontSize, y) {
2156
2421
  t2.fontSize(Math.max(1, fontSize));
@@ -2197,6 +2462,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2197
2462
  }
2198
2463
  // ---- selection ------------------------------------------------------------
2199
2464
  isSelectable(id) {
2465
+ if (this.seatInClosedSection(id)) return false;
2200
2466
  const statuses = this.opts.selectableStatuses ?? ["free"];
2201
2467
  return statuses.includes(this.statusById.get(id) ?? "free");
2202
2468
  }
@@ -2268,19 +2534,84 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2268
2534
  }
2269
2535
  }
2270
2536
  // ---- interaction ----------------------------------------------------------
2537
+ /**
2538
+ * The stage scale `focusRegion(id)` would settle at — i.e. the zoom that
2539
+ * frames this section in the current viewport. Used to decide whether
2540
+ * redirecting a seat tap to section-focus would actually zoom in FURTHER, or
2541
+ * whether the section already fills the viewport (small container) so the tap
2542
+ * must fall through and pick.
2543
+ */
2544
+ sectionFrameScale(id) {
2545
+ const sec = this.sections.find((s) => s.id === id);
2546
+ if (!sec) return this.stage.scaleX();
2547
+ const b = polyBounds(sec.outline);
2548
+ const w = this.stage.width();
2549
+ const h = this.stage.height();
2550
+ const { min, max } = this.zoomBounds();
2551
+ const margin = 1.12;
2552
+ if (b.width <= 0 || b.height <= 0) return this.stage.scaleX();
2553
+ return clamp(Math.min(w / (b.width * margin), h / (b.height * margin)), min, max);
2554
+ }
2555
+ /**
2556
+ * Resolve a seat tap: honour the 3D deck-drill and the AXS seat-pick gate,
2557
+ * then toggle. The gate (§4) redirects small on-screen seats to section-focus
2558
+ * to avoid blind mis-picks at overview scales — but ONLY when focusing would
2559
+ * zoom the seat up to a safe size. If we're already focused on the section, or
2560
+ * the section already fills the viewport (a narrow — OR even a wide — container
2561
+ * frames a big section BELOW LABEL_SCALE), redirecting is a no-op that would
2562
+ * leave the seat permanently unpickable (§5 `picking-gate-unreachable`), so we
2563
+ * fall through and PICK. This is the invariant: seats are never unpickable.
2564
+ */
2565
+ handleSeatTap(id) {
2566
+ if (this.stacked && this.opts.onDeckTap) {
2567
+ const floorId = this.objectFloor.get(this.seatById.get(id)?.rowId ?? "");
2568
+ if (floorId) {
2569
+ this.opts.onDeckTap(floorId);
2570
+ return;
2571
+ }
2572
+ }
2573
+ if (this.effScale() < LABEL_SCALE && this.sections.length) {
2574
+ const sec = this.seatSection.get(id);
2575
+ if (sec) {
2576
+ const alreadyFocused = this.focusedSectionId === sec.id;
2577
+ const canZoomInFurther = this.sectionFrameScale(sec.id) > this.stage.scaleX() * 1.02;
2578
+ if (!alreadyFocused && canZoomInFurther) {
2579
+ if (this.opts.onSectionTap) this.opts.onSectionTap(sec.id);
2580
+ else this.focusSection(sec.id);
2581
+ return;
2582
+ }
2583
+ }
2584
+ }
2585
+ this.toggleSeat(id);
2586
+ }
2587
+ /**
2588
+ * Nearest SELECTABLE seat whose centre is within `slopPx` (screen space) of a
2589
+ * seat circle edge, or null. Enlarges the effective tap target for small seats
2590
+ * so a near-miss on mobile still lands on the intended seat, without ever
2591
+ * hijacking a clean tap on empty space beyond the slop.
2592
+ */
2593
+ nearestSeatToScreen(screen, slopPx) {
2594
+ const s = this.stage.scaleX() || 1;
2595
+ const reachWorld = this.seatR + slopPx / s;
2596
+ const world = this.screenToWorld(screen);
2597
+ let best = null;
2598
+ let bestD = reachWorld;
2599
+ for (const seat of this.seats) {
2600
+ if (!this.selection.has(seat.id) && !this.isSelectable(seat.id)) continue;
2601
+ const d = Math.hypot(seat.x - world.x, seat.y - world.y);
2602
+ if (d < bestD) {
2603
+ bestD = d;
2604
+ best = seat.id;
2605
+ }
2606
+ }
2607
+ return best;
2608
+ }
2271
2609
  wireInteraction() {
2272
2610
  this.seatLayer.on("click tap", (e) => {
2273
2611
  if (this.moved > 8) return;
2274
2612
  const id = seatIdOf(e.target);
2275
2613
  if (!id) return;
2276
- if (this.stacked && this.opts.onDeckTap) {
2277
- const floorId = this.objectFloor.get(this.seatById.get(id)?.rowId ?? "");
2278
- if (floorId) {
2279
- this.opts.onDeckTap(floorId);
2280
- return;
2281
- }
2282
- }
2283
- this.toggleSeat(id);
2614
+ this.handleSeatTap(id);
2284
2615
  });
2285
2616
  this.seatLayer.on("mouseover", (e) => {
2286
2617
  const id = seatIdOf(e.target);
@@ -2306,10 +2637,16 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2306
2637
  const factor = Math.exp(-e.evt.deltaY * 2e-3);
2307
2638
  this.zoomAbout(this.stage.scaleX() * clamp(factor, 0.5, 2), pointer);
2308
2639
  });
2309
- this.stage.on("click tap", () => {
2310
- if (!this.cached || this.moved > 8) return;
2640
+ this.stage.on("click tap", (e) => {
2641
+ if (this.moved > 8) return;
2311
2642
  const pointer = this.stage.getPointerPosition();
2312
2643
  if (!pointer) return;
2644
+ if (!this.cached) {
2645
+ if (seatIdOf(e.target)) return;
2646
+ const near = this.nearestSeatToScreen(pointer, SEAT_TAP_SLOP_PX);
2647
+ if (near) this.handleSeatTap(near);
2648
+ return;
2649
+ }
2313
2650
  if (this.stacked && this.opts.onDeckTap) {
2314
2651
  const floorId = this.deckFloorAt();
2315
2652
  if (floorId) {
@@ -2639,6 +2976,9 @@ var PickerController = class {
2639
2976
  this._doc = null;
2640
2977
  /** Section/zone ids hidden from buyers this event (3.3) — seats vanish, not grey. */
2641
2978
  this.hidden = /* @__PURE__ */ new Set();
2979
+ /** Section/zone ids in the `closed` event-state (Phase 2) — seats stay, greyed
2980
+ * + not pickable. Kept separate from `hidden` (which strips them). */
2981
+ this.closedSections = /* @__PURE__ */ new Set();
2642
2982
  /** label ⇄ id maps — backend speaks labels, the engine speaks ids. */
2643
2983
  this.labelToId = /* @__PURE__ */ new Map();
2644
2984
  this.labelToSeat = /* @__PURE__ */ new Map();
@@ -2679,6 +3019,19 @@ var PickerController = class {
2679
3019
  this.renderer?.setChart(this.visibleDoc());
2680
3020
  return true;
2681
3021
  }
3022
+ /** Adopt a new closed-section set; restyle (grey + non-pickable) if it differs.
3023
+ * Cheap restyle — no chart rebuild — so mid-sale open/close repaints live. */
3024
+ syncClosed(ids) {
3025
+ const next = (ids ?? []).filter((x) => typeof x === "string");
3026
+ if (next.length === this.closedSections.size && next.every((id) => this.closedSections.has(id))) return false;
3027
+ this.closedSections = new Set(next);
3028
+ this.renderer?.setClosedSections?.(next);
3029
+ return true;
3030
+ }
3031
+ /** Whether a section is currently in the `closed` state (card must not open). */
3032
+ isSectionClosed(id) {
3033
+ return this.closedSections.has(id);
3034
+ }
2682
3035
  currentHold() {
2683
3036
  return this.hold_;
2684
3037
  }
@@ -2746,9 +3099,11 @@ var PickerController = class {
2746
3099
  }
2747
3100
  this.renderer = renderer;
2748
3101
  let seats = null;
3102
+ let closedIds = [];
2749
3103
  try {
2750
3104
  const objs = await this.api.objects(this.key);
2751
3105
  this.hidden = new Set(objs.hidden ?? []);
3106
+ closedIds = (objs.closed ?? []).filter((x) => typeof x === "string");
2752
3107
  seats = objs.seats;
2753
3108
  } catch {
2754
3109
  }
@@ -2759,6 +3114,8 @@ var PickerController = class {
2759
3114
  }
2760
3115
  renderer.setChart(this.visibleDoc());
2761
3116
  if (this.opts.colorblindSafe) renderer.setColorblindSafe?.(true);
3117
+ this.closedSections = new Set(closedIds);
3118
+ if (closedIds.length) renderer.setClosedSections?.(closedIds);
2762
3119
  if (seats) this.applySeatsMap(seats);
2763
3120
  this.connect();
2764
3121
  return {
@@ -2843,13 +3200,23 @@ var PickerController = class {
2843
3200
  */
2844
3201
  categoryAvailability() {
2845
3202
  const out = {};
3203
+ const closedMembers = this.closedMemberIds();
2846
3204
  for (const [id, s] of this.seatById) {
3205
+ if (closedMembers.has(id)) continue;
2847
3206
  if ((this.getStatus(id) ?? "free") === "free") {
2848
3207
  out[s.categoryKey] = (out[s.categoryKey] ?? 0) + 1;
2849
3208
  }
2850
3209
  }
2851
3210
  return out;
2852
3211
  }
3212
+ /** Seat ids belonging to a currently-closed section (excluded from counts). */
3213
+ closedMemberIds() {
3214
+ const out = /* @__PURE__ */ new Set();
3215
+ const r = this.renderer;
3216
+ if (!r || !this.closedSections.size) return out;
3217
+ for (const id of this.closedSections) for (const sid of r.sectionMembers?.(id) ?? []) out.add(sid);
3218
+ return out;
3219
+ }
2853
3220
  getGAAreas() {
2854
3221
  const doc = this.visibleDoc();
2855
3222
  if (!doc) return [];
@@ -3094,20 +3461,39 @@ var PickerController = class {
3094
3461
  }
3095
3462
  this.renderer?.setRung?.(rung);
3096
3463
  }
3464
+ /** Price-band filter (F4): dim free seats whose category is outside `keys`
3465
+ * (null clears). The widget resolves which categories fall in the band. */
3466
+ setCategoryFilter(keys) {
3467
+ this.renderer?.setCategoryFilter?.(keys);
3468
+ }
3469
+ /** World-space rect currently visible + full chart bounds (F3 minimap frame). */
3470
+ getViewport() {
3471
+ const r = this.renderer;
3472
+ if (!r?.getVisibleWorldRect || !r.getWorldBounds) return null;
3473
+ return { visible: r.getVisibleWorldRect(), bounds: r.getWorldBounds() };
3474
+ }
3097
3475
  /** Glide in on a section and surface its summary (same path as a section tap). */
3098
3476
  focusSection(id) {
3099
3477
  this.handleSectionTap(id);
3100
3478
  }
3101
- /** Zoom back out to the whole chart and clear the section-summary card. */
3479
+ /** Zoom back out to the whole chart and clear the section-summary card + focus. */
3102
3480
  overview() {
3481
+ this.renderer?.clearSectionFocus?.();
3103
3482
  this.renderer?.zoomToFit();
3104
3483
  this.opts.onSectionFocus?.(null);
3105
3484
  }
3106
- /** Glide the camera into a tapped section and emit its computed summary. */
3485
+ /** Glide the camera into a tapped section and emit its computed summary. Uses
3486
+ * the AXS focus treatment (dim + backdrop) when the engine supports it; a
3487
+ * closed section is framed but never opens a buyer card. */
3107
3488
  handleSectionTap(id) {
3108
3489
  const r = this.renderer;
3109
3490
  if (!r) return;
3110
- r.focusRegion?.(id);
3491
+ if (r.focusSection) r.focusSection(id);
3492
+ else r.focusRegion?.(id);
3493
+ if (this.isSectionClosed(id)) {
3494
+ this.opts.onSectionFocus?.(null);
3495
+ return;
3496
+ }
3111
3497
  this.opts.onSectionFocus?.(this.sectionSummary(id));
3112
3498
  }
3113
3499
  /**
@@ -3381,6 +3767,9 @@ var PickerController = class {
3381
3767
  void this.resnapshot();
3382
3768
  this.opts.onStatusChange?.();
3383
3769
  }
3770
+ if (Array.isArray(m.closed) && this.syncClosed(m.closed)) {
3771
+ this.opts.onStatusChange?.();
3772
+ }
3384
3773
  if (m.type === "hidden") return;
3385
3774
  if (m.seats && typeof m.seats === "object") {
3386
3775
  this.applySeatsMap(m.seats);