@seatlayer/core 0.9.0 → 0.10.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
@@ -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);
@@ -641,6 +649,13 @@ var ZONE_SUB_PX = 12;
641
649
  var HELD_FILL = "#6b7280";
642
650
  var TAKEN_FILL = "#374151";
643
651
  var NFS_STROKE = "#4b5563";
652
+ var CLOSED_SECTION_FILL = "#586070";
653
+ var CLOSED_SEAT_FILL = "#4b5563";
654
+ var CLOSED_SEAT_OPACITY = 0.4;
655
+ var FOCUS_DIM_OPACITY = 0.16;
656
+ var FOCUS_DESATURATE = 0.72;
657
+ var FOCUS_NEUTRAL = "#6b7280";
658
+ var FOCUS_BACKDROP_FILL = "rgba(244,246,248,0.06)";
644
659
  var CB_PALETTE = ["#E69F00", "#56B4E9", "#009E73", "#F0E442", "#0072B2", "#D55E00", "#CC79A7"];
645
660
  var ACCESS_RING = {
646
661
  wheelchair: "#3b82f6",
@@ -787,6 +802,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
787
802
  this.accessFilter = null;
788
803
  /** Category highlight (legend hover): dims free seats NOT of this category. */
789
804
  this.categoryHighlight = null;
805
+ /** Price-band filter (F4): dim free seats whose category is NOT in this set. */
806
+ this.categoryFilter = null;
790
807
  // Section/zone overlays (bgLayer) — the 3-rung LOD: seats → section blocks →
791
808
  // zone blocks. Kept for the melt restyle and for hit-testing a zoomed-out tap.
792
809
  this.sections = [];
@@ -795,6 +812,13 @@ var _SeatmapRenderer = class _SeatmapRenderer {
795
812
  this.catPrice = /* @__PURE__ */ new Map();
796
813
  /** Section/zone ids to render dimmed (organizer manager: held-back inventory). */
797
814
  this.dimmedSections = /* @__PURE__ */ new Set();
815
+ /** Phase 2: section/zone ids in the event-level `closed` state — flat grey
816
+ * block, seats greyed + not pickable, but the section stays rendered. */
817
+ this.closedSections = /* @__PURE__ */ new Set();
818
+ /** AXS section-focus: the currently-focused section id (others dim), or null. */
819
+ this.focusedSectionId = null;
820
+ /** Light backdrop panel drawn behind the focused section (removed on clear). */
821
+ this.focusBackdrop = null;
798
822
  /** Object id → floor id (multi-floor only) — resolves a deck tap in the 3D stack. */
799
823
  this.objectFloor = /* @__PURE__ */ new Map();
800
824
  /** Zone id → colour (drives extruded side faces in iso view). */
@@ -1275,6 +1299,27 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1275
1299
  if (cur === null || next === null) return cur === next;
1276
1300
  return cur.length === next.length && cur.every((t2, i) => t2 === next[i]);
1277
1301
  }
1302
+ /**
1303
+ * Price-band filter (F4): dim free, unselected seats whose category key is NOT
1304
+ * in `keys`. `null` clears the filter (all categories fully visible). The
1305
+ * widget resolves which categories fall inside the buyer's chosen band.
1306
+ */
1307
+ setCategoryFilter(keys) {
1308
+ const next = keys === null ? null : new Set(keys);
1309
+ const same = next === null && this.categoryFilter === null || next !== null && this.categoryFilter !== null && next.size === this.categoryFilter.size && [...next].every((k) => this.categoryFilter.has(k));
1310
+ if (same) return;
1311
+ this.categoryFilter = next;
1312
+ for (const seat of this.seats) {
1313
+ const c = this.circleById.get(seat.id);
1314
+ if (c) this.paintSeat(c, seat.id);
1315
+ }
1316
+ if (this.cached) {
1317
+ this.seatLayer.clearCache();
1318
+ this.cacheSeatLayer();
1319
+ } else {
1320
+ this.seatLayer.batchDraw();
1321
+ }
1322
+ }
1278
1323
  // ---- isometric ("3D") view mode -------------------------------------------
1279
1324
  /**
1280
1325
  * Switch the projection between flat top-down and the isometric "3D" view
@@ -1601,12 +1646,33 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1601
1646
  if (this.categoryHighlight && status === "free" && !selected && seat.categoryKey !== this.categoryHighlight) {
1602
1647
  c.opacity(0.25);
1603
1648
  }
1649
+ if (this.categoryFilter && status === "free" && !selected && !this.categoryFilter.has(seat.categoryKey)) {
1650
+ c.opacity(0.22);
1651
+ }
1604
1652
  if (this.dimmedSections.size) {
1605
1653
  const sec = this.seatSection.get(id);
1606
1654
  if (sec && (this.dimmedSections.has(sec.id) || sec.zone != null && this.dimmedSections.has(sec.zone))) {
1607
1655
  c.opacity(0.18);
1608
1656
  }
1609
1657
  }
1658
+ if (this.closedSections.size && this.seatInClosedSection(id)) {
1659
+ c.fill(CLOSED_SEAT_FILL);
1660
+ c.stroke("");
1661
+ c.strokeWidth(0);
1662
+ c.dash([]);
1663
+ c.opacity(CLOSED_SEAT_OPACITY);
1664
+ }
1665
+ if (this.focusedSectionId) {
1666
+ const sec = this.seatSection.get(id);
1667
+ const inFocus = !!sec && (sec.id === this.focusedSectionId || sec.zone === this.focusedSectionId);
1668
+ if (!inFocus) c.opacity(FOCUS_DIM_OPACITY);
1669
+ }
1670
+ }
1671
+ /** True when a seat sits in a section/zone currently marked `closed`. */
1672
+ seatInClosedSection(id) {
1673
+ if (!this.closedSections.size) return false;
1674
+ const sec = this.seatSection.get(id);
1675
+ return !!sec && (this.closedSections.has(sec.id) || sec.zone != null && this.closedSections.has(sec.zone));
1610
1676
  }
1611
1677
  /**
1612
1678
  * Colorblind-safe mode: swap category hues for the Okabe-Ito palette and
@@ -1646,6 +1712,109 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1646
1712
  this.seatLayer.batchDraw();
1647
1713
  }
1648
1714
  }
1715
+ /**
1716
+ * Phase 2 event-level section states: mark these section/zone ids `closed` —
1717
+ * flat grey block, seats greyed + not pickable, but the section stays rendered
1718
+ * (unlike the buyer's applyHidden which strips it). `null`/empty clears.
1719
+ */
1720
+ setClosedSections(ids) {
1721
+ const next = new Set(ids ?? []);
1722
+ if (next.size === this.closedSections.size && [...next].every((i) => this.closedSections.has(i))) return;
1723
+ this.closedSections = next;
1724
+ this.repaintSectionsAndSeats();
1725
+ }
1726
+ /**
1727
+ * AXS section-focus: dim + desaturate every other section, draw a calm backdrop
1728
+ * panel behind this section's seats, and glide the camera in to frame it (the
1729
+ * seat-pick gate below only lets buyers pick once seats are ≥ LABEL_SCALE big).
1730
+ */
1731
+ focusSection(id) {
1732
+ if (!this.sections.some((s) => s.id === id)) return;
1733
+ this.focusedSectionId = id;
1734
+ this.drawFocusBackdrop(id);
1735
+ this.repaintSectionsAndSeats();
1736
+ this.updateLOD();
1737
+ this.focusRegion(id);
1738
+ }
1739
+ /** Clear an AXS section focus — restore full-bowl brightness + drop the backdrop. */
1740
+ clearSectionFocus() {
1741
+ if (!this.focusedSectionId) return;
1742
+ this.focusedSectionId = null;
1743
+ if (this.focusBackdrop) {
1744
+ this.focusBackdrop.destroy();
1745
+ this.focusBackdrop = null;
1746
+ }
1747
+ this.repaintSectionsAndSeats();
1748
+ this.updateLOD();
1749
+ }
1750
+ /** The currently AXS-focused section id, or null. */
1751
+ getFocusedSection() {
1752
+ return this.focusedSectionId;
1753
+ }
1754
+ /** Draw (or replace) the light backdrop panel behind the focused section. */
1755
+ drawFocusBackdrop(id) {
1756
+ if (this.focusBackdrop) {
1757
+ this.focusBackdrop.destroy();
1758
+ this.focusBackdrop = null;
1759
+ }
1760
+ const sec = this.sections.find((s) => s.id === id);
1761
+ if (!sec) return;
1762
+ const panel = new Line({
1763
+ points: sec.outline.flatMap((p) => [p.x, p.y]),
1764
+ closed: true,
1765
+ fill: FOCUS_BACKDROP_FILL,
1766
+ stroke: rgba("#ffffff", 0.1),
1767
+ strokeWidth: 1,
1768
+ listening: false,
1769
+ perfectDrawEnabled: false
1770
+ });
1771
+ this.bgLayer.add(panel);
1772
+ panel.moveToTop();
1773
+ this.focusBackdrop = panel;
1774
+ }
1775
+ /** Repaint every seat + section block to reflect closed/focus state, then redraw. */
1776
+ repaintSectionsAndSeats() {
1777
+ for (const seat of this.seats) {
1778
+ const c = this.circleById.get(seat.id);
1779
+ if (c) this.paintSeat(c, seat.id);
1780
+ }
1781
+ for (const sec of this.sections) sec.blockPoly.fill(this.sectionBlockFill(sec));
1782
+ if (this.cached) {
1783
+ this.seatLayer.clearCache();
1784
+ this.cacheSeatLayer();
1785
+ } else {
1786
+ this.seatLayer.batchDraw();
1787
+ }
1788
+ this.bgLayer.batchDraw();
1789
+ }
1790
+ /** The world-space rectangle currently visible in the viewport (minimap F3). */
1791
+ getVisibleWorldRect() {
1792
+ const tl = this.screenToWorld({ x: 0, y: 0 });
1793
+ const br = this.screenToWorld({ x: this.stage.width(), y: this.stage.height() });
1794
+ return {
1795
+ x: Math.min(tl.x, br.x),
1796
+ y: Math.min(tl.y, br.y),
1797
+ width: Math.abs(br.x - tl.x),
1798
+ height: Math.abs(br.y - tl.y)
1799
+ };
1800
+ }
1801
+ /** Axis-aligned world bounds of all seats + section outlines (minimap F3 frame). */
1802
+ getWorldBounds() {
1803
+ let minX = Infinity;
1804
+ let minY = Infinity;
1805
+ let maxX = -Infinity;
1806
+ let maxY = -Infinity;
1807
+ const grow = (x, y) => {
1808
+ if (x < minX) minX = x;
1809
+ if (y < minY) minY = y;
1810
+ if (x > maxX) maxX = x;
1811
+ if (y > maxY) maxY = y;
1812
+ };
1813
+ for (const s of this.seats) grow(s.x, s.y);
1814
+ for (const sec of this.sections) for (const p of sec.outline) grow(p.x, p.y);
1815
+ if (!Number.isFinite(minX)) return { x: 0, y: 0, width: 1, height: 1 };
1816
+ return { x: minX, y: minY, width: Math.max(1, maxX - minX), height: Math.max(1, maxY - minY) };
1817
+ }
1649
1818
  /** Legend hover: highlight one category (dim the rest), or null to clear. */
1650
1819
  setCategoryHighlight(key) {
1651
1820
  if (this.categoryHighlight === key) return;
@@ -1663,6 +1832,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1663
1832
  }
1664
1833
  renderBackground(doc) {
1665
1834
  if (doc.backgroundImage) this.renderBackgroundImage(doc.backgroundImage);
1835
+ for (const obj of doc.objects) if (obj.type === "decorImage") this.renderDecorImage(obj);
1666
1836
  for (const obj of doc.objects) if (obj.type === "section") this.renderSection(obj);
1667
1837
  this.renderZones(doc);
1668
1838
  for (const obj of doc.objects) {
@@ -1711,6 +1881,35 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1711
1881
  };
1712
1882
  img.src = bg.url;
1713
1883
  }
1884
+ /**
1885
+ * A decor graphic (rink / court / stage art). The KImage node is added to the
1886
+ * bgLayer synchronously so it keeps its z-slot beneath the sections drawn right
1887
+ * after; the bitmap is decoded async and pasted in on load. A single node = a
1888
+ * single drawImage per frame, and it rides the same layer cache — effectively
1889
+ * zero per-frame cost. Never listens, so it can't intercept a seat click.
1890
+ */
1891
+ renderDecorImage(obj) {
1892
+ const img = new window.Image();
1893
+ const node = new KImage({
1894
+ image: img,
1895
+ x: obj.x + obj.width / 2,
1896
+ y: obj.y + obj.height / 2,
1897
+ offsetX: obj.width / 2,
1898
+ offsetY: obj.height / 2,
1899
+ width: obj.width,
1900
+ height: obj.height,
1901
+ rotation: obj.rotation ?? 0,
1902
+ opacity: clamp(obj.opacity ?? 1, 0, 1),
1903
+ listening: false,
1904
+ perfectDrawEnabled: false
1905
+ });
1906
+ this.bgLayer.add(node);
1907
+ img.onload = () => {
1908
+ if (!node.getLayer()) return;
1909
+ this.bgLayer.batchDraw();
1910
+ };
1911
+ img.src = obj.href;
1912
+ }
1714
1913
  renderTable(obj) {
1715
1914
  if (obj.shape === "round") {
1716
1915
  this.bgLayer.add(
@@ -2032,11 +2231,32 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2032
2231
  }
2033
2232
  /** Recompute a section's availability-tinted fill + "N LEFT" (cheap; on status change). */
2034
2233
  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));
2234
+ sec.blockPoly.fill(this.sectionBlockFill(sec));
2037
2235
  sec.subLabel.text(t("map.seatsLeft", { count: sec.free }));
2038
2236
  sec.subLabel.offsetX(sec.subLabel.width() / 2);
2039
2237
  }
2238
+ /** True when a section/zone is currently in the `closed` event-state. */
2239
+ isSectionClosed(sec) {
2240
+ return this.closedSections.has(sec.id) || sec.zone != null && this.closedSections.has(sec.zone);
2241
+ }
2242
+ /**
2243
+ * The block-fill colour for a section: flat desaturated grey when `closed`,
2244
+ * else the availability-darkened category mix; then desaturated toward neutral
2245
+ * when another section holds focus (AXS dim treatment).
2246
+ */
2247
+ sectionBlockFill(sec) {
2248
+ let fill;
2249
+ if (this.isSectionClosed(sec)) {
2250
+ fill = CLOSED_SECTION_FILL;
2251
+ } else {
2252
+ const sold = sec.total > 0 ? (sec.total - sec.free) / sec.total : 0;
2253
+ fill = darken(sec.baseFill, sold * SOLD_DARKEN);
2254
+ }
2255
+ if (this.focusedSectionId && sec.id !== this.focusedSectionId && sec.zone !== this.focusedSectionId) {
2256
+ fill = lerpColor(fill, FOCUS_NEUTRAL, FOCUS_DESATURATE);
2257
+ }
2258
+ return fill;
2259
+ }
2040
2260
  /**
2041
2261
  * Zone rung: one giant screen-constant label per zone (+ optional "FROM $n"),
2042
2262
  * shown at the farthest zoom in place of per-section detail. Skipped entirely
@@ -2128,12 +2348,14 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2128
2348
  const sx = this.stage.scaleX();
2129
2349
  const rescale = this.lodScale === 0 || Math.abs(scale - this.lodScale) / (this.lodScale || 1) > 0.02;
2130
2350
  if (rescale) this.lodScale = scale;
2351
+ const focus = this.focusedSectionId;
2131
2352
  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));
2353
+ const dim = focus && sec.id !== focus && sec.zone !== focus ? FOCUS_DIM_OPACITY : 1;
2354
+ sec.blockPoly.opacity(BLOCK_FILL_ALPHA * blockT * dim);
2355
+ for (const line of sec.rowLines) line.opacity(blockT * (1 - zoneT) * dim);
2134
2356
  sec.nameLabel.fill(lerpColor("#aab3c5", "#ffffff", blockT));
2135
- sec.nameLabel.opacity(1 - zoneT);
2136
- sec.subLabel.opacity(blockT * (1 - zoneT));
2357
+ sec.nameLabel.opacity((1 - zoneT) * dim);
2358
+ sec.subLabel.opacity(blockT * (1 - zoneT) * dim);
2137
2359
  if (rescale) {
2138
2360
  this.sizeLabel(sec.nameLabel, SECTION_LABEL_PX / sx, sec.centroid.y - SECTION_SUB_PX / sx);
2139
2361
  this.sizeLabel(sec.subLabel, SECTION_SUB_PX / sx, sec.centroid.y + SECTION_LABEL_PX / sx);
@@ -2149,8 +2371,50 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2149
2371
  if (zone.sub) this.sizeLabel(zone.sub, ZONE_SUB_PX / sx, cy + ZONE_LABEL_PX / sx);
2150
2372
  }
2151
2373
  }
2374
+ this.decollideRungLabels(sx);
2152
2375
  this.bgLayer.batchDraw();
2153
2376
  }
2377
+ /**
2378
+ * Greedy label de-collision for the zone/section rungs (same approach as the
2379
+ * designer's cullRowLabels): price/"N LEFT" sublabels are lowest priority and
2380
+ * drop first; name labels keep top-to-bottom, left-to-right; anything whose
2381
+ * on-screen box (+4px gap) overlaps an already-kept box hides. Recomputed on
2382
+ * every LOD pass so hidden labels reappear as zoom spreads them apart.
2383
+ * Culling multiplies the opacity applySectionLod just assigned (never raises).
2384
+ */
2385
+ decollideRungLabels(sx) {
2386
+ const GAP = 4;
2387
+ const cands = [];
2388
+ const boxOf = (t2) => {
2389
+ const p = this.worldToScreen({ x: t2.x(), y: t2.y() });
2390
+ const w = t2.width() * sx;
2391
+ const h = t2.height() * sx;
2392
+ return { x: p.x - w / 2, y: p.y - h / 2, w, h };
2393
+ };
2394
+ for (const zone of this.zones) {
2395
+ if (zone.label.opacity() > 0.05) cands.push({ node: zone.label, tier: 0, box: boxOf(zone.label) });
2396
+ if (zone.sub && zone.sub.opacity() > 0.05) cands.push({ node: zone.sub, tier: 2, owner: zone.label, box: boxOf(zone.sub) });
2397
+ }
2398
+ for (const sec of this.sections) {
2399
+ if (sec.nameLabel.opacity() > 0.05) cands.push({ node: sec.nameLabel, tier: 1, box: boxOf(sec.nameLabel) });
2400
+ if (sec.subLabel.opacity() > 0.05) cands.push({ node: sec.subLabel, tier: 3, owner: sec.nameLabel, box: boxOf(sec.subLabel) });
2401
+ }
2402
+ if (cands.length < 2) return;
2403
+ cands.sort((a, b) => a.tier - b.tier || a.box.y - b.box.y || a.box.x - b.box.x);
2404
+ const kept = [];
2405
+ const culled = /* @__PURE__ */ new Set();
2406
+ const collides = (b) => kept.some(
2407
+ (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
2408
+ );
2409
+ for (const c of cands) {
2410
+ if (c.owner && culled.has(c.owner) || collides(c.box)) {
2411
+ c.node.opacity(0);
2412
+ culled.add(c.node);
2413
+ } else {
2414
+ kept.push(c.box);
2415
+ }
2416
+ }
2417
+ }
2154
2418
  /** Set a centred label's world fontSize (for a target screen px) and re-anchor it. */
2155
2419
  sizeLabel(t2, fontSize, y) {
2156
2420
  t2.fontSize(Math.max(1, fontSize));
@@ -2197,6 +2461,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2197
2461
  }
2198
2462
  // ---- selection ------------------------------------------------------------
2199
2463
  isSelectable(id) {
2464
+ if (this.seatInClosedSection(id)) return false;
2200
2465
  const statuses = this.opts.selectableStatuses ?? ["free"];
2201
2466
  return statuses.includes(this.statusById.get(id) ?? "free");
2202
2467
  }
@@ -2280,6 +2545,14 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2280
2545
  return;
2281
2546
  }
2282
2547
  }
2548
+ if (this.effScale() < LABEL_SCALE && this.sections.length) {
2549
+ const sec = this.seatSection.get(id);
2550
+ if (sec) {
2551
+ if (this.opts.onSectionTap) this.opts.onSectionTap(sec.id);
2552
+ else this.focusSection(sec.id);
2553
+ return;
2554
+ }
2555
+ }
2283
2556
  this.toggleSeat(id);
2284
2557
  });
2285
2558
  this.seatLayer.on("mouseover", (e) => {
@@ -2639,6 +2912,9 @@ var PickerController = class {
2639
2912
  this._doc = null;
2640
2913
  /** Section/zone ids hidden from buyers this event (3.3) — seats vanish, not grey. */
2641
2914
  this.hidden = /* @__PURE__ */ new Set();
2915
+ /** Section/zone ids in the `closed` event-state (Phase 2) — seats stay, greyed
2916
+ * + not pickable. Kept separate from `hidden` (which strips them). */
2917
+ this.closedSections = /* @__PURE__ */ new Set();
2642
2918
  /** label ⇄ id maps — backend speaks labels, the engine speaks ids. */
2643
2919
  this.labelToId = /* @__PURE__ */ new Map();
2644
2920
  this.labelToSeat = /* @__PURE__ */ new Map();
@@ -2679,6 +2955,19 @@ var PickerController = class {
2679
2955
  this.renderer?.setChart(this.visibleDoc());
2680
2956
  return true;
2681
2957
  }
2958
+ /** Adopt a new closed-section set; restyle (grey + non-pickable) if it differs.
2959
+ * Cheap restyle — no chart rebuild — so mid-sale open/close repaints live. */
2960
+ syncClosed(ids) {
2961
+ const next = (ids ?? []).filter((x) => typeof x === "string");
2962
+ if (next.length === this.closedSections.size && next.every((id) => this.closedSections.has(id))) return false;
2963
+ this.closedSections = new Set(next);
2964
+ this.renderer?.setClosedSections?.(next);
2965
+ return true;
2966
+ }
2967
+ /** Whether a section is currently in the `closed` state (card must not open). */
2968
+ isSectionClosed(id) {
2969
+ return this.closedSections.has(id);
2970
+ }
2682
2971
  currentHold() {
2683
2972
  return this.hold_;
2684
2973
  }
@@ -2746,9 +3035,11 @@ var PickerController = class {
2746
3035
  }
2747
3036
  this.renderer = renderer;
2748
3037
  let seats = null;
3038
+ let closedIds = [];
2749
3039
  try {
2750
3040
  const objs = await this.api.objects(this.key);
2751
3041
  this.hidden = new Set(objs.hidden ?? []);
3042
+ closedIds = (objs.closed ?? []).filter((x) => typeof x === "string");
2752
3043
  seats = objs.seats;
2753
3044
  } catch {
2754
3045
  }
@@ -2759,6 +3050,8 @@ var PickerController = class {
2759
3050
  }
2760
3051
  renderer.setChart(this.visibleDoc());
2761
3052
  if (this.opts.colorblindSafe) renderer.setColorblindSafe?.(true);
3053
+ this.closedSections = new Set(closedIds);
3054
+ if (closedIds.length) renderer.setClosedSections?.(closedIds);
2762
3055
  if (seats) this.applySeatsMap(seats);
2763
3056
  this.connect();
2764
3057
  return {
@@ -2843,13 +3136,23 @@ var PickerController = class {
2843
3136
  */
2844
3137
  categoryAvailability() {
2845
3138
  const out = {};
3139
+ const closedMembers = this.closedMemberIds();
2846
3140
  for (const [id, s] of this.seatById) {
3141
+ if (closedMembers.has(id)) continue;
2847
3142
  if ((this.getStatus(id) ?? "free") === "free") {
2848
3143
  out[s.categoryKey] = (out[s.categoryKey] ?? 0) + 1;
2849
3144
  }
2850
3145
  }
2851
3146
  return out;
2852
3147
  }
3148
+ /** Seat ids belonging to a currently-closed section (excluded from counts). */
3149
+ closedMemberIds() {
3150
+ const out = /* @__PURE__ */ new Set();
3151
+ const r = this.renderer;
3152
+ if (!r || !this.closedSections.size) return out;
3153
+ for (const id of this.closedSections) for (const sid of r.sectionMembers?.(id) ?? []) out.add(sid);
3154
+ return out;
3155
+ }
2853
3156
  getGAAreas() {
2854
3157
  const doc = this.visibleDoc();
2855
3158
  if (!doc) return [];
@@ -3094,20 +3397,39 @@ var PickerController = class {
3094
3397
  }
3095
3398
  this.renderer?.setRung?.(rung);
3096
3399
  }
3400
+ /** Price-band filter (F4): dim free seats whose category is outside `keys`
3401
+ * (null clears). The widget resolves which categories fall in the band. */
3402
+ setCategoryFilter(keys) {
3403
+ this.renderer?.setCategoryFilter?.(keys);
3404
+ }
3405
+ /** World-space rect currently visible + full chart bounds (F3 minimap frame). */
3406
+ getViewport() {
3407
+ const r = this.renderer;
3408
+ if (!r?.getVisibleWorldRect || !r.getWorldBounds) return null;
3409
+ return { visible: r.getVisibleWorldRect(), bounds: r.getWorldBounds() };
3410
+ }
3097
3411
  /** Glide in on a section and surface its summary (same path as a section tap). */
3098
3412
  focusSection(id) {
3099
3413
  this.handleSectionTap(id);
3100
3414
  }
3101
- /** Zoom back out to the whole chart and clear the section-summary card. */
3415
+ /** Zoom back out to the whole chart and clear the section-summary card + focus. */
3102
3416
  overview() {
3417
+ this.renderer?.clearSectionFocus?.();
3103
3418
  this.renderer?.zoomToFit();
3104
3419
  this.opts.onSectionFocus?.(null);
3105
3420
  }
3106
- /** Glide the camera into a tapped section and emit its computed summary. */
3421
+ /** Glide the camera into a tapped section and emit its computed summary. Uses
3422
+ * the AXS focus treatment (dim + backdrop) when the engine supports it; a
3423
+ * closed section is framed but never opens a buyer card. */
3107
3424
  handleSectionTap(id) {
3108
3425
  const r = this.renderer;
3109
3426
  if (!r) return;
3110
- r.focusRegion?.(id);
3427
+ if (r.focusSection) r.focusSection(id);
3428
+ else r.focusRegion?.(id);
3429
+ if (this.isSectionClosed(id)) {
3430
+ this.opts.onSectionFocus?.(null);
3431
+ return;
3432
+ }
3111
3433
  this.opts.onSectionFocus?.(this.sectionSummary(id));
3112
3434
  }
3113
3435
  /**
@@ -3381,6 +3703,9 @@ var PickerController = class {
3381
3703
  void this.resnapshot();
3382
3704
  this.opts.onStatusChange?.();
3383
3705
  }
3706
+ if (Array.isArray(m.closed) && this.syncClosed(m.closed)) {
3707
+ this.opts.onStatusChange?.();
3708
+ }
3384
3709
  if (m.type === "hidden") return;
3385
3710
  if (m.seats && typeof m.seats === "object") {
3386
3711
  this.applySeatsMap(m.seats);