@seatlayer/core 0.8.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.cjs CHANGED
@@ -339,9 +339,10 @@ function layerOf(obj) {
339
339
  case "booth":
340
340
  case "section":
341
341
  return "interactive";
342
- // stage / décor live on 'shape'; free text is background furniture.
342
+ // stage / décor live on 'shape'; free text + decor images are background furniture.
343
343
  case "shape":
344
344
  case "text":
345
+ case "decorImage":
345
346
  return "background";
346
347
  default:
347
348
  return "interactive";
@@ -570,6 +571,8 @@ function objectCenter(o) {
570
571
  return { x: o.x + o.width / 2, y: o.y + o.height / 2 };
571
572
  }
572
573
  return { x: o.x ?? 0, y: o.y ?? 0 };
574
+ case "decorImage":
575
+ return { x: o.x + o.width / 2, y: o.y + o.height / 2 };
573
576
  }
574
577
  }
575
578
  function floorsOf(doc) {
@@ -605,6 +608,8 @@ function translateObject(o, dx, dy) {
605
608
  ...o.x != null ? { x: o.x + dx } : {},
606
609
  ...o.y != null ? { y: o.y + dy } : {}
607
610
  };
611
+ case "decorImage":
612
+ return { ...o, x: o.x + dx, y: o.y + dy };
608
613
  }
609
614
  }
610
615
  function stackFloors(doc, spread = 900) {
@@ -643,6 +648,9 @@ function chartBounds(doc) {
643
648
  for (const p of obj.points) acc(p.x, p.y);
644
649
  } else if (obj.type === "section") {
645
650
  for (const p of obj.outline) acc(p.x, p.y);
651
+ } else if (obj.type === "decorImage") {
652
+ acc(obj.x, obj.y);
653
+ acc(obj.x + obj.width, obj.y + obj.height);
646
654
  } else if (obj.type === "shape") {
647
655
  if (obj.points && obj.points.length) {
648
656
  for (const p of obj.points) acc(p.x, p.y);
@@ -960,6 +968,13 @@ var ZONE_SUB_PX = 12;
960
968
  var HELD_FILL = "#6b7280";
961
969
  var TAKEN_FILL = "#374151";
962
970
  var NFS_STROKE = "#4b5563";
971
+ var CLOSED_SECTION_FILL = "#586070";
972
+ var CLOSED_SEAT_FILL = "#4b5563";
973
+ var CLOSED_SEAT_OPACITY = 0.4;
974
+ var FOCUS_DIM_OPACITY = 0.16;
975
+ var FOCUS_DESATURATE = 0.72;
976
+ var FOCUS_NEUTRAL = "#6b7280";
977
+ var FOCUS_BACKDROP_FILL = "rgba(244,246,248,0.06)";
963
978
  var CB_PALETTE = ["#E69F00", "#56B4E9", "#009E73", "#F0E442", "#0072B2", "#D55E00", "#CC79A7"];
964
979
  var ACCESS_RING = {
965
980
  wheelchair: "#3b82f6",
@@ -1106,6 +1121,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1106
1121
  this.accessFilter = null;
1107
1122
  /** Category highlight (legend hover): dims free seats NOT of this category. */
1108
1123
  this.categoryHighlight = null;
1124
+ /** Price-band filter (F4): dim free seats whose category is NOT in this set. */
1125
+ this.categoryFilter = null;
1109
1126
  // Section/zone overlays (bgLayer) — the 3-rung LOD: seats → section blocks →
1110
1127
  // zone blocks. Kept for the melt restyle and for hit-testing a zoomed-out tap.
1111
1128
  this.sections = [];
@@ -1114,6 +1131,13 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1114
1131
  this.catPrice = /* @__PURE__ */ new Map();
1115
1132
  /** Section/zone ids to render dimmed (organizer manager: held-back inventory). */
1116
1133
  this.dimmedSections = /* @__PURE__ */ new Set();
1134
+ /** Phase 2: section/zone ids in the event-level `closed` state — flat grey
1135
+ * block, seats greyed + not pickable, but the section stays rendered. */
1136
+ this.closedSections = /* @__PURE__ */ new Set();
1137
+ /** AXS section-focus: the currently-focused section id (others dim), or null. */
1138
+ this.focusedSectionId = null;
1139
+ /** Light backdrop panel drawn behind the focused section (removed on clear). */
1140
+ this.focusBackdrop = null;
1117
1141
  /** Object id → floor id (multi-floor only) — resolves a deck tap in the 3D stack. */
1118
1142
  this.objectFloor = /* @__PURE__ */ new Map();
1119
1143
  /** Zone id → colour (drives extruded side faces in iso view). */
@@ -1594,6 +1618,27 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1594
1618
  if (cur === null || next === null) return cur === next;
1595
1619
  return cur.length === next.length && cur.every((t2, i) => t2 === next[i]);
1596
1620
  }
1621
+ /**
1622
+ * Price-band filter (F4): dim free, unselected seats whose category key is NOT
1623
+ * in `keys`. `null` clears the filter (all categories fully visible). The
1624
+ * widget resolves which categories fall inside the buyer's chosen band.
1625
+ */
1626
+ setCategoryFilter(keys) {
1627
+ const next = keys === null ? null : new Set(keys);
1628
+ const same = next === null && this.categoryFilter === null || next !== null && this.categoryFilter !== null && next.size === this.categoryFilter.size && [...next].every((k) => this.categoryFilter.has(k));
1629
+ if (same) return;
1630
+ this.categoryFilter = next;
1631
+ for (const seat of this.seats) {
1632
+ const c = this.circleById.get(seat.id);
1633
+ if (c) this.paintSeat(c, seat.id);
1634
+ }
1635
+ if (this.cached) {
1636
+ this.seatLayer.clearCache();
1637
+ this.cacheSeatLayer();
1638
+ } else {
1639
+ this.seatLayer.batchDraw();
1640
+ }
1641
+ }
1597
1642
  // ---- isometric ("3D") view mode -------------------------------------------
1598
1643
  /**
1599
1644
  * Switch the projection between flat top-down and the isometric "3D" view
@@ -1920,12 +1965,33 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1920
1965
  if (this.categoryHighlight && status === "free" && !selected && seat.categoryKey !== this.categoryHighlight) {
1921
1966
  c.opacity(0.25);
1922
1967
  }
1968
+ if (this.categoryFilter && status === "free" && !selected && !this.categoryFilter.has(seat.categoryKey)) {
1969
+ c.opacity(0.22);
1970
+ }
1923
1971
  if (this.dimmedSections.size) {
1924
1972
  const sec = this.seatSection.get(id);
1925
1973
  if (sec && (this.dimmedSections.has(sec.id) || sec.zone != null && this.dimmedSections.has(sec.zone))) {
1926
1974
  c.opacity(0.18);
1927
1975
  }
1928
1976
  }
1977
+ if (this.closedSections.size && this.seatInClosedSection(id)) {
1978
+ c.fill(CLOSED_SEAT_FILL);
1979
+ c.stroke("");
1980
+ c.strokeWidth(0);
1981
+ c.dash([]);
1982
+ c.opacity(CLOSED_SEAT_OPACITY);
1983
+ }
1984
+ if (this.focusedSectionId) {
1985
+ const sec = this.seatSection.get(id);
1986
+ const inFocus = !!sec && (sec.id === this.focusedSectionId || sec.zone === this.focusedSectionId);
1987
+ if (!inFocus) c.opacity(FOCUS_DIM_OPACITY);
1988
+ }
1989
+ }
1990
+ /** True when a seat sits in a section/zone currently marked `closed`. */
1991
+ seatInClosedSection(id) {
1992
+ if (!this.closedSections.size) return false;
1993
+ const sec = this.seatSection.get(id);
1994
+ return !!sec && (this.closedSections.has(sec.id) || sec.zone != null && this.closedSections.has(sec.zone));
1929
1995
  }
1930
1996
  /**
1931
1997
  * Colorblind-safe mode: swap category hues for the Okabe-Ito palette and
@@ -1965,6 +2031,109 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1965
2031
  this.seatLayer.batchDraw();
1966
2032
  }
1967
2033
  }
2034
+ /**
2035
+ * Phase 2 event-level section states: mark these section/zone ids `closed` —
2036
+ * flat grey block, seats greyed + not pickable, but the section stays rendered
2037
+ * (unlike the buyer's applyHidden which strips it). `null`/empty clears.
2038
+ */
2039
+ setClosedSections(ids) {
2040
+ const next = new Set(ids ?? []);
2041
+ if (next.size === this.closedSections.size && [...next].every((i) => this.closedSections.has(i))) return;
2042
+ this.closedSections = next;
2043
+ this.repaintSectionsAndSeats();
2044
+ }
2045
+ /**
2046
+ * AXS section-focus: dim + desaturate every other section, draw a calm backdrop
2047
+ * panel behind this section's seats, and glide the camera in to frame it (the
2048
+ * seat-pick gate below only lets buyers pick once seats are ≥ LABEL_SCALE big).
2049
+ */
2050
+ focusSection(id) {
2051
+ if (!this.sections.some((s) => s.id === id)) return;
2052
+ this.focusedSectionId = id;
2053
+ this.drawFocusBackdrop(id);
2054
+ this.repaintSectionsAndSeats();
2055
+ this.updateLOD();
2056
+ this.focusRegion(id);
2057
+ }
2058
+ /** Clear an AXS section focus — restore full-bowl brightness + drop the backdrop. */
2059
+ clearSectionFocus() {
2060
+ if (!this.focusedSectionId) return;
2061
+ this.focusedSectionId = null;
2062
+ if (this.focusBackdrop) {
2063
+ this.focusBackdrop.destroy();
2064
+ this.focusBackdrop = null;
2065
+ }
2066
+ this.repaintSectionsAndSeats();
2067
+ this.updateLOD();
2068
+ }
2069
+ /** The currently AXS-focused section id, or null. */
2070
+ getFocusedSection() {
2071
+ return this.focusedSectionId;
2072
+ }
2073
+ /** Draw (or replace) the light backdrop panel behind the focused section. */
2074
+ drawFocusBackdrop(id) {
2075
+ if (this.focusBackdrop) {
2076
+ this.focusBackdrop.destroy();
2077
+ this.focusBackdrop = null;
2078
+ }
2079
+ const sec = this.sections.find((s) => s.id === id);
2080
+ if (!sec) return;
2081
+ const panel = new import_Line.Line({
2082
+ points: sec.outline.flatMap((p) => [p.x, p.y]),
2083
+ closed: true,
2084
+ fill: FOCUS_BACKDROP_FILL,
2085
+ stroke: rgba("#ffffff", 0.1),
2086
+ strokeWidth: 1,
2087
+ listening: false,
2088
+ perfectDrawEnabled: false
2089
+ });
2090
+ this.bgLayer.add(panel);
2091
+ panel.moveToTop();
2092
+ this.focusBackdrop = panel;
2093
+ }
2094
+ /** Repaint every seat + section block to reflect closed/focus state, then redraw. */
2095
+ repaintSectionsAndSeats() {
2096
+ for (const seat of this.seats) {
2097
+ const c = this.circleById.get(seat.id);
2098
+ if (c) this.paintSeat(c, seat.id);
2099
+ }
2100
+ for (const sec of this.sections) sec.blockPoly.fill(this.sectionBlockFill(sec));
2101
+ if (this.cached) {
2102
+ this.seatLayer.clearCache();
2103
+ this.cacheSeatLayer();
2104
+ } else {
2105
+ this.seatLayer.batchDraw();
2106
+ }
2107
+ this.bgLayer.batchDraw();
2108
+ }
2109
+ /** The world-space rectangle currently visible in the viewport (minimap F3). */
2110
+ getVisibleWorldRect() {
2111
+ const tl = this.screenToWorld({ x: 0, y: 0 });
2112
+ const br = this.screenToWorld({ x: this.stage.width(), y: this.stage.height() });
2113
+ return {
2114
+ x: Math.min(tl.x, br.x),
2115
+ y: Math.min(tl.y, br.y),
2116
+ width: Math.abs(br.x - tl.x),
2117
+ height: Math.abs(br.y - tl.y)
2118
+ };
2119
+ }
2120
+ /** Axis-aligned world bounds of all seats + section outlines (minimap F3 frame). */
2121
+ getWorldBounds() {
2122
+ let minX = Infinity;
2123
+ let minY = Infinity;
2124
+ let maxX = -Infinity;
2125
+ let maxY = -Infinity;
2126
+ const grow = (x, y) => {
2127
+ if (x < minX) minX = x;
2128
+ if (y < minY) minY = y;
2129
+ if (x > maxX) maxX = x;
2130
+ if (y > maxY) maxY = y;
2131
+ };
2132
+ for (const s of this.seats) grow(s.x, s.y);
2133
+ for (const sec of this.sections) for (const p of sec.outline) grow(p.x, p.y);
2134
+ if (!Number.isFinite(minX)) return { x: 0, y: 0, width: 1, height: 1 };
2135
+ return { x: minX, y: minY, width: Math.max(1, maxX - minX), height: Math.max(1, maxY - minY) };
2136
+ }
1968
2137
  /** Legend hover: highlight one category (dim the rest), or null to clear. */
1969
2138
  setCategoryHighlight(key) {
1970
2139
  if (this.categoryHighlight === key) return;
@@ -1982,6 +2151,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1982
2151
  }
1983
2152
  renderBackground(doc) {
1984
2153
  if (doc.backgroundImage) this.renderBackgroundImage(doc.backgroundImage);
2154
+ for (const obj of doc.objects) if (obj.type === "decorImage") this.renderDecorImage(obj);
1985
2155
  for (const obj of doc.objects) if (obj.type === "section") this.renderSection(obj);
1986
2156
  this.renderZones(doc);
1987
2157
  for (const obj of doc.objects) {
@@ -2030,6 +2200,35 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2030
2200
  };
2031
2201
  img.src = bg.url;
2032
2202
  }
2203
+ /**
2204
+ * A decor graphic (rink / court / stage art). The KImage node is added to the
2205
+ * bgLayer synchronously so it keeps its z-slot beneath the sections drawn right
2206
+ * after; the bitmap is decoded async and pasted in on load. A single node = a
2207
+ * single drawImage per frame, and it rides the same layer cache — effectively
2208
+ * zero per-frame cost. Never listens, so it can't intercept a seat click.
2209
+ */
2210
+ renderDecorImage(obj) {
2211
+ const img = new window.Image();
2212
+ const node = new import_Image.Image({
2213
+ image: img,
2214
+ x: obj.x + obj.width / 2,
2215
+ y: obj.y + obj.height / 2,
2216
+ offsetX: obj.width / 2,
2217
+ offsetY: obj.height / 2,
2218
+ width: obj.width,
2219
+ height: obj.height,
2220
+ rotation: obj.rotation ?? 0,
2221
+ opacity: clamp(obj.opacity ?? 1, 0, 1),
2222
+ listening: false,
2223
+ perfectDrawEnabled: false
2224
+ });
2225
+ this.bgLayer.add(node);
2226
+ img.onload = () => {
2227
+ if (!node.getLayer()) return;
2228
+ this.bgLayer.batchDraw();
2229
+ };
2230
+ img.src = obj.href;
2231
+ }
2033
2232
  renderTable(obj) {
2034
2233
  if (obj.shape === "round") {
2035
2234
  this.bgLayer.add(
@@ -2351,11 +2550,32 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2351
2550
  }
2352
2551
  /** Recompute a section's availability-tinted fill + "N LEFT" (cheap; on status change). */
2353
2552
  refreshSectionFill(sec) {
2354
- const sold = sec.total > 0 ? (sec.total - sec.free) / sec.total : 0;
2355
- sec.blockPoly.fill(darken(sec.baseFill, sold * SOLD_DARKEN));
2553
+ sec.blockPoly.fill(this.sectionBlockFill(sec));
2356
2554
  sec.subLabel.text(t("map.seatsLeft", { count: sec.free }));
2357
2555
  sec.subLabel.offsetX(sec.subLabel.width() / 2);
2358
2556
  }
2557
+ /** True when a section/zone is currently in the `closed` event-state. */
2558
+ isSectionClosed(sec) {
2559
+ return this.closedSections.has(sec.id) || sec.zone != null && this.closedSections.has(sec.zone);
2560
+ }
2561
+ /**
2562
+ * The block-fill colour for a section: flat desaturated grey when `closed`,
2563
+ * else the availability-darkened category mix; then desaturated toward neutral
2564
+ * when another section holds focus (AXS dim treatment).
2565
+ */
2566
+ sectionBlockFill(sec) {
2567
+ let fill;
2568
+ if (this.isSectionClosed(sec)) {
2569
+ fill = CLOSED_SECTION_FILL;
2570
+ } else {
2571
+ const sold = sec.total > 0 ? (sec.total - sec.free) / sec.total : 0;
2572
+ fill = darken(sec.baseFill, sold * SOLD_DARKEN);
2573
+ }
2574
+ if (this.focusedSectionId && sec.id !== this.focusedSectionId && sec.zone !== this.focusedSectionId) {
2575
+ fill = lerpColor(fill, FOCUS_NEUTRAL, FOCUS_DESATURATE);
2576
+ }
2577
+ return fill;
2578
+ }
2359
2579
  /**
2360
2580
  * Zone rung: one giant screen-constant label per zone (+ optional "FROM $n"),
2361
2581
  * shown at the farthest zoom in place of per-section detail. Skipped entirely
@@ -2447,12 +2667,14 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2447
2667
  const sx = this.stage.scaleX();
2448
2668
  const rescale = this.lodScale === 0 || Math.abs(scale - this.lodScale) / (this.lodScale || 1) > 0.02;
2449
2669
  if (rescale) this.lodScale = scale;
2670
+ const focus = this.focusedSectionId;
2450
2671
  for (const sec of this.sections) {
2451
- sec.blockPoly.opacity(BLOCK_FILL_ALPHA * blockT);
2452
- for (const line of sec.rowLines) line.opacity(blockT * (1 - zoneT));
2672
+ const dim = focus && sec.id !== focus && sec.zone !== focus ? FOCUS_DIM_OPACITY : 1;
2673
+ sec.blockPoly.opacity(BLOCK_FILL_ALPHA * blockT * dim);
2674
+ for (const line of sec.rowLines) line.opacity(blockT * (1 - zoneT) * dim);
2453
2675
  sec.nameLabel.fill(lerpColor("#aab3c5", "#ffffff", blockT));
2454
- sec.nameLabel.opacity(1 - zoneT);
2455
- sec.subLabel.opacity(blockT * (1 - zoneT));
2676
+ sec.nameLabel.opacity((1 - zoneT) * dim);
2677
+ sec.subLabel.opacity(blockT * (1 - zoneT) * dim);
2456
2678
  if (rescale) {
2457
2679
  this.sizeLabel(sec.nameLabel, SECTION_LABEL_PX / sx, sec.centroid.y - SECTION_SUB_PX / sx);
2458
2680
  this.sizeLabel(sec.subLabel, SECTION_SUB_PX / sx, sec.centroid.y + SECTION_LABEL_PX / sx);
@@ -2468,8 +2690,50 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2468
2690
  if (zone.sub) this.sizeLabel(zone.sub, ZONE_SUB_PX / sx, cy + ZONE_LABEL_PX / sx);
2469
2691
  }
2470
2692
  }
2693
+ this.decollideRungLabels(sx);
2471
2694
  this.bgLayer.batchDraw();
2472
2695
  }
2696
+ /**
2697
+ * Greedy label de-collision for the zone/section rungs (same approach as the
2698
+ * designer's cullRowLabels): price/"N LEFT" sublabels are lowest priority and
2699
+ * drop first; name labels keep top-to-bottom, left-to-right; anything whose
2700
+ * on-screen box (+4px gap) overlaps an already-kept box hides. Recomputed on
2701
+ * every LOD pass so hidden labels reappear as zoom spreads them apart.
2702
+ * Culling multiplies the opacity applySectionLod just assigned (never raises).
2703
+ */
2704
+ decollideRungLabels(sx) {
2705
+ const GAP = 4;
2706
+ const cands = [];
2707
+ const boxOf = (t2) => {
2708
+ const p = this.worldToScreen({ x: t2.x(), y: t2.y() });
2709
+ const w = t2.width() * sx;
2710
+ const h = t2.height() * sx;
2711
+ return { x: p.x - w / 2, y: p.y - h / 2, w, h };
2712
+ };
2713
+ for (const zone of this.zones) {
2714
+ if (zone.label.opacity() > 0.05) cands.push({ node: zone.label, tier: 0, box: boxOf(zone.label) });
2715
+ if (zone.sub && zone.sub.opacity() > 0.05) cands.push({ node: zone.sub, tier: 2, owner: zone.label, box: boxOf(zone.sub) });
2716
+ }
2717
+ for (const sec of this.sections) {
2718
+ if (sec.nameLabel.opacity() > 0.05) cands.push({ node: sec.nameLabel, tier: 1, box: boxOf(sec.nameLabel) });
2719
+ if (sec.subLabel.opacity() > 0.05) cands.push({ node: sec.subLabel, tier: 3, owner: sec.nameLabel, box: boxOf(sec.subLabel) });
2720
+ }
2721
+ if (cands.length < 2) return;
2722
+ cands.sort((a, b) => a.tier - b.tier || a.box.y - b.box.y || a.box.x - b.box.x);
2723
+ const kept = [];
2724
+ const culled = /* @__PURE__ */ new Set();
2725
+ const collides = (b) => kept.some(
2726
+ (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
2727
+ );
2728
+ for (const c of cands) {
2729
+ if (c.owner && culled.has(c.owner) || collides(c.box)) {
2730
+ c.node.opacity(0);
2731
+ culled.add(c.node);
2732
+ } else {
2733
+ kept.push(c.box);
2734
+ }
2735
+ }
2736
+ }
2473
2737
  /** Set a centred label's world fontSize (for a target screen px) and re-anchor it. */
2474
2738
  sizeLabel(t2, fontSize, y) {
2475
2739
  t2.fontSize(Math.max(1, fontSize));
@@ -2516,6 +2780,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2516
2780
  }
2517
2781
  // ---- selection ------------------------------------------------------------
2518
2782
  isSelectable(id) {
2783
+ if (this.seatInClosedSection(id)) return false;
2519
2784
  const statuses = this.opts.selectableStatuses ?? ["free"];
2520
2785
  return statuses.includes(this.statusById.get(id) ?? "free");
2521
2786
  }
@@ -2599,6 +2864,14 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2599
2864
  return;
2600
2865
  }
2601
2866
  }
2867
+ if (this.effScale() < LABEL_SCALE && this.sections.length) {
2868
+ const sec = this.seatSection.get(id);
2869
+ if (sec) {
2870
+ if (this.opts.onSectionTap) this.opts.onSectionTap(sec.id);
2871
+ else this.focusSection(sec.id);
2872
+ return;
2873
+ }
2874
+ }
2602
2875
  this.toggleSeat(id);
2603
2876
  });
2604
2877
  this.seatLayer.on("mouseover", (e) => {
@@ -2958,6 +3231,9 @@ var PickerController = class {
2958
3231
  this._doc = null;
2959
3232
  /** Section/zone ids hidden from buyers this event (3.3) — seats vanish, not grey. */
2960
3233
  this.hidden = /* @__PURE__ */ new Set();
3234
+ /** Section/zone ids in the `closed` event-state (Phase 2) — seats stay, greyed
3235
+ * + not pickable. Kept separate from `hidden` (which strips them). */
3236
+ this.closedSections = /* @__PURE__ */ new Set();
2961
3237
  /** label ⇄ id maps — backend speaks labels, the engine speaks ids. */
2962
3238
  this.labelToId = /* @__PURE__ */ new Map();
2963
3239
  this.labelToSeat = /* @__PURE__ */ new Map();
@@ -2998,6 +3274,19 @@ var PickerController = class {
2998
3274
  this.renderer?.setChart(this.visibleDoc());
2999
3275
  return true;
3000
3276
  }
3277
+ /** Adopt a new closed-section set; restyle (grey + non-pickable) if it differs.
3278
+ * Cheap restyle — no chart rebuild — so mid-sale open/close repaints live. */
3279
+ syncClosed(ids) {
3280
+ const next = (ids ?? []).filter((x) => typeof x === "string");
3281
+ if (next.length === this.closedSections.size && next.every((id) => this.closedSections.has(id))) return false;
3282
+ this.closedSections = new Set(next);
3283
+ this.renderer?.setClosedSections?.(next);
3284
+ return true;
3285
+ }
3286
+ /** Whether a section is currently in the `closed` state (card must not open). */
3287
+ isSectionClosed(id) {
3288
+ return this.closedSections.has(id);
3289
+ }
3001
3290
  currentHold() {
3002
3291
  return this.hold_;
3003
3292
  }
@@ -3065,9 +3354,11 @@ var PickerController = class {
3065
3354
  }
3066
3355
  this.renderer = renderer;
3067
3356
  let seats = null;
3357
+ let closedIds = [];
3068
3358
  try {
3069
3359
  const objs = await this.api.objects(this.key);
3070
3360
  this.hidden = new Set(objs.hidden ?? []);
3361
+ closedIds = (objs.closed ?? []).filter((x) => typeof x === "string");
3071
3362
  seats = objs.seats;
3072
3363
  } catch {
3073
3364
  }
@@ -3078,6 +3369,8 @@ var PickerController = class {
3078
3369
  }
3079
3370
  renderer.setChart(this.visibleDoc());
3080
3371
  if (this.opts.colorblindSafe) renderer.setColorblindSafe?.(true);
3372
+ this.closedSections = new Set(closedIds);
3373
+ if (closedIds.length) renderer.setClosedSections?.(closedIds);
3081
3374
  if (seats) this.applySeatsMap(seats);
3082
3375
  this.connect();
3083
3376
  return {
@@ -3135,6 +3428,26 @@ var PickerController = class {
3135
3428
  throw err;
3136
3429
  }
3137
3430
  }
3431
+ /**
3432
+ * P4 "need more time?": extend the OPEN hold's server-side expiry and re-arm
3433
+ * the client expiry timer to match (via setHold), so the controller doesn't
3434
+ * fire a false expiry and start polling. Returns the new hold, or null if
3435
+ * there's no open hold or the transport can't extend / the server refused
3436
+ * (hold gone, expired, or at its renewal cap). Never throws for the refusal
3437
+ * case — the caller decides the copy.
3438
+ */
3439
+ async extendHold(ttlMs) {
3440
+ const current = this.hold_;
3441
+ if (!current || !this.api.extend) return null;
3442
+ try {
3443
+ const result = await this.api.extend(this.key, current.holdId, ttlMs);
3444
+ if (this.hold_?.holdId !== current.holdId) return this.hold_;
3445
+ this.setHold({ holdId: current.holdId, labels: current.labels, expiresAt: result.expiresAt, items: current.items });
3446
+ return this.hold_;
3447
+ } catch {
3448
+ return null;
3449
+ }
3450
+ }
3138
3451
  /** Public GA inventory derived from the live synthetic-unit status stream. */
3139
3452
  /**
3140
3453
  * Live seats-left per category key (status 'free' right now). Recompute on
@@ -3142,13 +3455,23 @@ var PickerController = class {
3142
3455
  */
3143
3456
  categoryAvailability() {
3144
3457
  const out = {};
3458
+ const closedMembers = this.closedMemberIds();
3145
3459
  for (const [id, s] of this.seatById) {
3460
+ if (closedMembers.has(id)) continue;
3146
3461
  if ((this.getStatus(id) ?? "free") === "free") {
3147
3462
  out[s.categoryKey] = (out[s.categoryKey] ?? 0) + 1;
3148
3463
  }
3149
3464
  }
3150
3465
  return out;
3151
3466
  }
3467
+ /** Seat ids belonging to a currently-closed section (excluded from counts). */
3468
+ closedMemberIds() {
3469
+ const out = /* @__PURE__ */ new Set();
3470
+ const r = this.renderer;
3471
+ if (!r || !this.closedSections.size) return out;
3472
+ for (const id of this.closedSections) for (const sid of r.sectionMembers?.(id) ?? []) out.add(sid);
3473
+ return out;
3474
+ }
3152
3475
  getGAAreas() {
3153
3476
  const doc = this.visibleDoc();
3154
3477
  if (!doc) return [];
@@ -3393,20 +3716,39 @@ var PickerController = class {
3393
3716
  }
3394
3717
  this.renderer?.setRung?.(rung);
3395
3718
  }
3719
+ /** Price-band filter (F4): dim free seats whose category is outside `keys`
3720
+ * (null clears). The widget resolves which categories fall in the band. */
3721
+ setCategoryFilter(keys) {
3722
+ this.renderer?.setCategoryFilter?.(keys);
3723
+ }
3724
+ /** World-space rect currently visible + full chart bounds (F3 minimap frame). */
3725
+ getViewport() {
3726
+ const r = this.renderer;
3727
+ if (!r?.getVisibleWorldRect || !r.getWorldBounds) return null;
3728
+ return { visible: r.getVisibleWorldRect(), bounds: r.getWorldBounds() };
3729
+ }
3396
3730
  /** Glide in on a section and surface its summary (same path as a section tap). */
3397
3731
  focusSection(id) {
3398
3732
  this.handleSectionTap(id);
3399
3733
  }
3400
- /** Zoom back out to the whole chart and clear the section-summary card. */
3734
+ /** Zoom back out to the whole chart and clear the section-summary card + focus. */
3401
3735
  overview() {
3736
+ this.renderer?.clearSectionFocus?.();
3402
3737
  this.renderer?.zoomToFit();
3403
3738
  this.opts.onSectionFocus?.(null);
3404
3739
  }
3405
- /** Glide the camera into a tapped section and emit its computed summary. */
3740
+ /** Glide the camera into a tapped section and emit its computed summary. Uses
3741
+ * the AXS focus treatment (dim + backdrop) when the engine supports it; a
3742
+ * closed section is framed but never opens a buyer card. */
3406
3743
  handleSectionTap(id) {
3407
3744
  const r = this.renderer;
3408
3745
  if (!r) return;
3409
- r.focusRegion?.(id);
3746
+ if (r.focusSection) r.focusSection(id);
3747
+ else r.focusRegion?.(id);
3748
+ if (this.isSectionClosed(id)) {
3749
+ this.opts.onSectionFocus?.(null);
3750
+ return;
3751
+ }
3410
3752
  this.opts.onSectionFocus?.(this.sectionSummary(id));
3411
3753
  }
3412
3754
  /**
@@ -3680,6 +4022,9 @@ var PickerController = class {
3680
4022
  void this.resnapshot();
3681
4023
  this.opts.onStatusChange?.();
3682
4024
  }
4025
+ if (Array.isArray(m.closed) && this.syncClosed(m.closed)) {
4026
+ this.opts.onStatusChange?.();
4027
+ }
3683
4028
  if (m.type === "hidden") return;
3684
4029
  if (m.seats && typeof m.seats === "object") {
3685
4030
  this.applySeatsMap(m.seats);