@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.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);
@@ -943,6 +951,7 @@ var SEAT_RADIUS = 9;
943
951
  var SEAT_LEGIBLE_SCALE = 0.9;
944
952
  var CACHE_THRESHOLD = 0.55 * SEAT_LEGIBLE_SCALE;
945
953
  var LABEL_SCALE = 1;
954
+ var SEAT_TAP_SLOP_PX = 14;
946
955
  var SECTION_PROMINENT_SCALE = 0.45 * SEAT_LEGIBLE_SCALE;
947
956
  var BLOCK_MELT_TOP = 0.9 * SEAT_LEGIBLE_SCALE;
948
957
  var ZONE_PROMINENT_SCALE = 0.55 * SECTION_PROMINENT_SCALE;
@@ -960,6 +969,13 @@ var ZONE_SUB_PX = 12;
960
969
  var HELD_FILL = "#6b7280";
961
970
  var TAKEN_FILL = "#374151";
962
971
  var NFS_STROKE = "#4b5563";
972
+ var CLOSED_SECTION_FILL = "#586070";
973
+ var CLOSED_SEAT_FILL = "#4b5563";
974
+ var CLOSED_SEAT_OPACITY = 0.4;
975
+ var FOCUS_DIM_OPACITY = 0.16;
976
+ var FOCUS_DESATURATE = 0.72;
977
+ var FOCUS_NEUTRAL = "#6b7280";
978
+ var FOCUS_BACKDROP_FILL = "rgba(244,246,248,0.06)";
963
979
  var CB_PALETTE = ["#E69F00", "#56B4E9", "#009E73", "#F0E442", "#0072B2", "#D55E00", "#CC79A7"];
964
980
  var ACCESS_RING = {
965
981
  wheelchair: "#3b82f6",
@@ -1106,6 +1122,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1106
1122
  this.accessFilter = null;
1107
1123
  /** Category highlight (legend hover): dims free seats NOT of this category. */
1108
1124
  this.categoryHighlight = null;
1125
+ /** Price-band filter (F4): dim free seats whose category is NOT in this set. */
1126
+ this.categoryFilter = null;
1109
1127
  // Section/zone overlays (bgLayer) — the 3-rung LOD: seats → section blocks →
1110
1128
  // zone blocks. Kept for the melt restyle and for hit-testing a zoomed-out tap.
1111
1129
  this.sections = [];
@@ -1114,6 +1132,13 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1114
1132
  this.catPrice = /* @__PURE__ */ new Map();
1115
1133
  /** Section/zone ids to render dimmed (organizer manager: held-back inventory). */
1116
1134
  this.dimmedSections = /* @__PURE__ */ new Set();
1135
+ /** Phase 2: section/zone ids in the event-level `closed` state — flat grey
1136
+ * block, seats greyed + not pickable, but the section stays rendered. */
1137
+ this.closedSections = /* @__PURE__ */ new Set();
1138
+ /** AXS section-focus: the currently-focused section id (others dim), or null. */
1139
+ this.focusedSectionId = null;
1140
+ /** Light backdrop panel drawn behind the focused section (removed on clear). */
1141
+ this.focusBackdrop = null;
1117
1142
  /** Object id → floor id (multi-floor only) — resolves a deck tap in the 3D stack. */
1118
1143
  this.objectFloor = /* @__PURE__ */ new Map();
1119
1144
  /** Zone id → colour (drives extruded side faces in iso view). */
@@ -1594,6 +1619,27 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1594
1619
  if (cur === null || next === null) return cur === next;
1595
1620
  return cur.length === next.length && cur.every((t2, i) => t2 === next[i]);
1596
1621
  }
1622
+ /**
1623
+ * Price-band filter (F4): dim free, unselected seats whose category key is NOT
1624
+ * in `keys`. `null` clears the filter (all categories fully visible). The
1625
+ * widget resolves which categories fall inside the buyer's chosen band.
1626
+ */
1627
+ setCategoryFilter(keys) {
1628
+ const next = keys === null ? null : new Set(keys);
1629
+ const same = next === null && this.categoryFilter === null || next !== null && this.categoryFilter !== null && next.size === this.categoryFilter.size && [...next].every((k) => this.categoryFilter.has(k));
1630
+ if (same) return;
1631
+ this.categoryFilter = next;
1632
+ for (const seat of this.seats) {
1633
+ const c = this.circleById.get(seat.id);
1634
+ if (c) this.paintSeat(c, seat.id);
1635
+ }
1636
+ if (this.cached) {
1637
+ this.seatLayer.clearCache();
1638
+ this.cacheSeatLayer();
1639
+ } else {
1640
+ this.seatLayer.batchDraw();
1641
+ }
1642
+ }
1597
1643
  // ---- isometric ("3D") view mode -------------------------------------------
1598
1644
  /**
1599
1645
  * Switch the projection between flat top-down and the isometric "3D" view
@@ -1920,12 +1966,33 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1920
1966
  if (this.categoryHighlight && status === "free" && !selected && seat.categoryKey !== this.categoryHighlight) {
1921
1967
  c.opacity(0.25);
1922
1968
  }
1969
+ if (this.categoryFilter && status === "free" && !selected && !this.categoryFilter.has(seat.categoryKey)) {
1970
+ c.opacity(0.22);
1971
+ }
1923
1972
  if (this.dimmedSections.size) {
1924
1973
  const sec = this.seatSection.get(id);
1925
1974
  if (sec && (this.dimmedSections.has(sec.id) || sec.zone != null && this.dimmedSections.has(sec.zone))) {
1926
1975
  c.opacity(0.18);
1927
1976
  }
1928
1977
  }
1978
+ if (this.closedSections.size && this.seatInClosedSection(id)) {
1979
+ c.fill(CLOSED_SEAT_FILL);
1980
+ c.stroke("");
1981
+ c.strokeWidth(0);
1982
+ c.dash([]);
1983
+ c.opacity(CLOSED_SEAT_OPACITY);
1984
+ }
1985
+ if (this.focusedSectionId) {
1986
+ const sec = this.seatSection.get(id);
1987
+ const inFocus = !!sec && (sec.id === this.focusedSectionId || sec.zone === this.focusedSectionId);
1988
+ if (!inFocus) c.opacity(FOCUS_DIM_OPACITY);
1989
+ }
1990
+ }
1991
+ /** True when a seat sits in a section/zone currently marked `closed`. */
1992
+ seatInClosedSection(id) {
1993
+ if (!this.closedSections.size) return false;
1994
+ const sec = this.seatSection.get(id);
1995
+ return !!sec && (this.closedSections.has(sec.id) || sec.zone != null && this.closedSections.has(sec.zone));
1929
1996
  }
1930
1997
  /**
1931
1998
  * Colorblind-safe mode: swap category hues for the Okabe-Ito palette and
@@ -1965,6 +2032,109 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1965
2032
  this.seatLayer.batchDraw();
1966
2033
  }
1967
2034
  }
2035
+ /**
2036
+ * Phase 2 event-level section states: mark these section/zone ids `closed` —
2037
+ * flat grey block, seats greyed + not pickable, but the section stays rendered
2038
+ * (unlike the buyer's applyHidden which strips it). `null`/empty clears.
2039
+ */
2040
+ setClosedSections(ids) {
2041
+ const next = new Set(ids ?? []);
2042
+ if (next.size === this.closedSections.size && [...next].every((i) => this.closedSections.has(i))) return;
2043
+ this.closedSections = next;
2044
+ this.repaintSectionsAndSeats();
2045
+ }
2046
+ /**
2047
+ * AXS section-focus: dim + desaturate every other section, draw a calm backdrop
2048
+ * panel behind this section's seats, and glide the camera in to frame it (the
2049
+ * seat-pick gate below only lets buyers pick once seats are ≥ LABEL_SCALE big).
2050
+ */
2051
+ focusSection(id) {
2052
+ if (!this.sections.some((s) => s.id === id)) return;
2053
+ this.focusedSectionId = id;
2054
+ this.drawFocusBackdrop(id);
2055
+ this.repaintSectionsAndSeats();
2056
+ this.updateLOD();
2057
+ this.focusRegion(id);
2058
+ }
2059
+ /** Clear an AXS section focus — restore full-bowl brightness + drop the backdrop. */
2060
+ clearSectionFocus() {
2061
+ if (!this.focusedSectionId) return;
2062
+ this.focusedSectionId = null;
2063
+ if (this.focusBackdrop) {
2064
+ this.focusBackdrop.destroy();
2065
+ this.focusBackdrop = null;
2066
+ }
2067
+ this.repaintSectionsAndSeats();
2068
+ this.updateLOD();
2069
+ }
2070
+ /** The currently AXS-focused section id, or null. */
2071
+ getFocusedSection() {
2072
+ return this.focusedSectionId;
2073
+ }
2074
+ /** Draw (or replace) the light backdrop panel behind the focused section. */
2075
+ drawFocusBackdrop(id) {
2076
+ if (this.focusBackdrop) {
2077
+ this.focusBackdrop.destroy();
2078
+ this.focusBackdrop = null;
2079
+ }
2080
+ const sec = this.sections.find((s) => s.id === id);
2081
+ if (!sec) return;
2082
+ const panel = new import_Line.Line({
2083
+ points: sec.outline.flatMap((p) => [p.x, p.y]),
2084
+ closed: true,
2085
+ fill: FOCUS_BACKDROP_FILL,
2086
+ stroke: rgba("#ffffff", 0.1),
2087
+ strokeWidth: 1,
2088
+ listening: false,
2089
+ perfectDrawEnabled: false
2090
+ });
2091
+ this.bgLayer.add(panel);
2092
+ panel.moveToTop();
2093
+ this.focusBackdrop = panel;
2094
+ }
2095
+ /** Repaint every seat + section block to reflect closed/focus state, then redraw. */
2096
+ repaintSectionsAndSeats() {
2097
+ for (const seat of this.seats) {
2098
+ const c = this.circleById.get(seat.id);
2099
+ if (c) this.paintSeat(c, seat.id);
2100
+ }
2101
+ for (const sec of this.sections) sec.blockPoly.fill(this.sectionBlockFill(sec));
2102
+ if (this.cached) {
2103
+ this.seatLayer.clearCache();
2104
+ this.cacheSeatLayer();
2105
+ } else {
2106
+ this.seatLayer.batchDraw();
2107
+ }
2108
+ this.bgLayer.batchDraw();
2109
+ }
2110
+ /** The world-space rectangle currently visible in the viewport (minimap F3). */
2111
+ getVisibleWorldRect() {
2112
+ const tl = this.screenToWorld({ x: 0, y: 0 });
2113
+ const br = this.screenToWorld({ x: this.stage.width(), y: this.stage.height() });
2114
+ return {
2115
+ x: Math.min(tl.x, br.x),
2116
+ y: Math.min(tl.y, br.y),
2117
+ width: Math.abs(br.x - tl.x),
2118
+ height: Math.abs(br.y - tl.y)
2119
+ };
2120
+ }
2121
+ /** Axis-aligned world bounds of all seats + section outlines (minimap F3 frame). */
2122
+ getWorldBounds() {
2123
+ let minX = Infinity;
2124
+ let minY = Infinity;
2125
+ let maxX = -Infinity;
2126
+ let maxY = -Infinity;
2127
+ const grow = (x, y) => {
2128
+ if (x < minX) minX = x;
2129
+ if (y < minY) minY = y;
2130
+ if (x > maxX) maxX = x;
2131
+ if (y > maxY) maxY = y;
2132
+ };
2133
+ for (const s of this.seats) grow(s.x, s.y);
2134
+ for (const sec of this.sections) for (const p of sec.outline) grow(p.x, p.y);
2135
+ if (!Number.isFinite(minX)) return { x: 0, y: 0, width: 1, height: 1 };
2136
+ return { x: minX, y: minY, width: Math.max(1, maxX - minX), height: Math.max(1, maxY - minY) };
2137
+ }
1968
2138
  /** Legend hover: highlight one category (dim the rest), or null to clear. */
1969
2139
  setCategoryHighlight(key) {
1970
2140
  if (this.categoryHighlight === key) return;
@@ -1982,6 +2152,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1982
2152
  }
1983
2153
  renderBackground(doc) {
1984
2154
  if (doc.backgroundImage) this.renderBackgroundImage(doc.backgroundImage);
2155
+ for (const obj of doc.objects) if (obj.type === "decorImage") this.renderDecorImage(obj);
1985
2156
  for (const obj of doc.objects) if (obj.type === "section") this.renderSection(obj);
1986
2157
  this.renderZones(doc);
1987
2158
  for (const obj of doc.objects) {
@@ -2030,6 +2201,35 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2030
2201
  };
2031
2202
  img.src = bg.url;
2032
2203
  }
2204
+ /**
2205
+ * A decor graphic (rink / court / stage art). The KImage node is added to the
2206
+ * bgLayer synchronously so it keeps its z-slot beneath the sections drawn right
2207
+ * after; the bitmap is decoded async and pasted in on load. A single node = a
2208
+ * single drawImage per frame, and it rides the same layer cache — effectively
2209
+ * zero per-frame cost. Never listens, so it can't intercept a seat click.
2210
+ */
2211
+ renderDecorImage(obj) {
2212
+ const img = new window.Image();
2213
+ const node = new import_Image.Image({
2214
+ image: img,
2215
+ x: obj.x + obj.width / 2,
2216
+ y: obj.y + obj.height / 2,
2217
+ offsetX: obj.width / 2,
2218
+ offsetY: obj.height / 2,
2219
+ width: obj.width,
2220
+ height: obj.height,
2221
+ rotation: obj.rotation ?? 0,
2222
+ opacity: clamp(obj.opacity ?? 1, 0, 1),
2223
+ listening: false,
2224
+ perfectDrawEnabled: false
2225
+ });
2226
+ this.bgLayer.add(node);
2227
+ img.onload = () => {
2228
+ if (!node.getLayer()) return;
2229
+ this.bgLayer.batchDraw();
2230
+ };
2231
+ img.src = obj.href;
2232
+ }
2033
2233
  renderTable(obj) {
2034
2234
  if (obj.shape === "round") {
2035
2235
  this.bgLayer.add(
@@ -2351,11 +2551,32 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2351
2551
  }
2352
2552
  /** Recompute a section's availability-tinted fill + "N LEFT" (cheap; on status change). */
2353
2553
  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));
2554
+ sec.blockPoly.fill(this.sectionBlockFill(sec));
2356
2555
  sec.subLabel.text(t("map.seatsLeft", { count: sec.free }));
2357
2556
  sec.subLabel.offsetX(sec.subLabel.width() / 2);
2358
2557
  }
2558
+ /** True when a section/zone is currently in the `closed` event-state. */
2559
+ isSectionClosed(sec) {
2560
+ return this.closedSections.has(sec.id) || sec.zone != null && this.closedSections.has(sec.zone);
2561
+ }
2562
+ /**
2563
+ * The block-fill colour for a section: flat desaturated grey when `closed`,
2564
+ * else the availability-darkened category mix; then desaturated toward neutral
2565
+ * when another section holds focus (AXS dim treatment).
2566
+ */
2567
+ sectionBlockFill(sec) {
2568
+ let fill;
2569
+ if (this.isSectionClosed(sec)) {
2570
+ fill = CLOSED_SECTION_FILL;
2571
+ } else {
2572
+ const sold = sec.total > 0 ? (sec.total - sec.free) / sec.total : 0;
2573
+ fill = darken(sec.baseFill, sold * SOLD_DARKEN);
2574
+ }
2575
+ if (this.focusedSectionId && sec.id !== this.focusedSectionId && sec.zone !== this.focusedSectionId) {
2576
+ fill = lerpColor(fill, FOCUS_NEUTRAL, FOCUS_DESATURATE);
2577
+ }
2578
+ return fill;
2579
+ }
2359
2580
  /**
2360
2581
  * Zone rung: one giant screen-constant label per zone (+ optional "FROM $n"),
2361
2582
  * shown at the farthest zoom in place of per-section detail. Skipped entirely
@@ -2447,12 +2668,14 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2447
2668
  const sx = this.stage.scaleX();
2448
2669
  const rescale = this.lodScale === 0 || Math.abs(scale - this.lodScale) / (this.lodScale || 1) > 0.02;
2449
2670
  if (rescale) this.lodScale = scale;
2671
+ const focus = this.focusedSectionId;
2450
2672
  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));
2673
+ const dim = focus && sec.id !== focus && sec.zone !== focus ? FOCUS_DIM_OPACITY : 1;
2674
+ sec.blockPoly.opacity(BLOCK_FILL_ALPHA * blockT * dim);
2675
+ for (const line of sec.rowLines) line.opacity(blockT * (1 - zoneT) * dim);
2453
2676
  sec.nameLabel.fill(lerpColor("#aab3c5", "#ffffff", blockT));
2454
- sec.nameLabel.opacity(1 - zoneT);
2455
- sec.subLabel.opacity(blockT * (1 - zoneT));
2677
+ sec.nameLabel.opacity((1 - zoneT) * dim);
2678
+ sec.subLabel.opacity(blockT * (1 - zoneT) * dim);
2456
2679
  if (rescale) {
2457
2680
  this.sizeLabel(sec.nameLabel, SECTION_LABEL_PX / sx, sec.centroid.y - SECTION_SUB_PX / sx);
2458
2681
  this.sizeLabel(sec.subLabel, SECTION_SUB_PX / sx, sec.centroid.y + SECTION_LABEL_PX / sx);
@@ -2468,8 +2691,50 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2468
2691
  if (zone.sub) this.sizeLabel(zone.sub, ZONE_SUB_PX / sx, cy + ZONE_LABEL_PX / sx);
2469
2692
  }
2470
2693
  }
2694
+ this.decollideRungLabels(sx);
2471
2695
  this.bgLayer.batchDraw();
2472
2696
  }
2697
+ /**
2698
+ * Greedy label de-collision for the zone/section rungs (same approach as the
2699
+ * designer's cullRowLabels): price/"N LEFT" sublabels are lowest priority and
2700
+ * drop first; name labels keep top-to-bottom, left-to-right; anything whose
2701
+ * on-screen box (+4px gap) overlaps an already-kept box hides. Recomputed on
2702
+ * every LOD pass so hidden labels reappear as zoom spreads them apart.
2703
+ * Culling multiplies the opacity applySectionLod just assigned (never raises).
2704
+ */
2705
+ decollideRungLabels(sx) {
2706
+ const GAP = 4;
2707
+ const cands = [];
2708
+ const boxOf = (t2) => {
2709
+ const p = this.worldToScreen({ x: t2.x(), y: t2.y() });
2710
+ const w = t2.width() * sx;
2711
+ const h = t2.height() * sx;
2712
+ return { x: p.x - w / 2, y: p.y - h / 2, w, h };
2713
+ };
2714
+ for (const zone of this.zones) {
2715
+ if (zone.label.opacity() > 0.05) cands.push({ node: zone.label, tier: 0, box: boxOf(zone.label) });
2716
+ if (zone.sub && zone.sub.opacity() > 0.05) cands.push({ node: zone.sub, tier: 2, owner: zone.label, box: boxOf(zone.sub) });
2717
+ }
2718
+ for (const sec of this.sections) {
2719
+ if (sec.nameLabel.opacity() > 0.05) cands.push({ node: sec.nameLabel, tier: 1, box: boxOf(sec.nameLabel) });
2720
+ if (sec.subLabel.opacity() > 0.05) cands.push({ node: sec.subLabel, tier: 3, owner: sec.nameLabel, box: boxOf(sec.subLabel) });
2721
+ }
2722
+ if (cands.length < 2) return;
2723
+ cands.sort((a, b) => a.tier - b.tier || a.box.y - b.box.y || a.box.x - b.box.x);
2724
+ const kept = [];
2725
+ const culled = /* @__PURE__ */ new Set();
2726
+ const collides = (b) => kept.some(
2727
+ (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
2728
+ );
2729
+ for (const c of cands) {
2730
+ if (c.owner && culled.has(c.owner) || collides(c.box)) {
2731
+ c.node.opacity(0);
2732
+ culled.add(c.node);
2733
+ } else {
2734
+ kept.push(c.box);
2735
+ }
2736
+ }
2737
+ }
2473
2738
  /** Set a centred label's world fontSize (for a target screen px) and re-anchor it. */
2474
2739
  sizeLabel(t2, fontSize, y) {
2475
2740
  t2.fontSize(Math.max(1, fontSize));
@@ -2516,6 +2781,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2516
2781
  }
2517
2782
  // ---- selection ------------------------------------------------------------
2518
2783
  isSelectable(id) {
2784
+ if (this.seatInClosedSection(id)) return false;
2519
2785
  const statuses = this.opts.selectableStatuses ?? ["free"];
2520
2786
  return statuses.includes(this.statusById.get(id) ?? "free");
2521
2787
  }
@@ -2587,19 +2853,84 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2587
2853
  }
2588
2854
  }
2589
2855
  // ---- interaction ----------------------------------------------------------
2856
+ /**
2857
+ * The stage scale `focusRegion(id)` would settle at — i.e. the zoom that
2858
+ * frames this section in the current viewport. Used to decide whether
2859
+ * redirecting a seat tap to section-focus would actually zoom in FURTHER, or
2860
+ * whether the section already fills the viewport (small container) so the tap
2861
+ * must fall through and pick.
2862
+ */
2863
+ sectionFrameScale(id) {
2864
+ const sec = this.sections.find((s) => s.id === id);
2865
+ if (!sec) return this.stage.scaleX();
2866
+ const b = polyBounds(sec.outline);
2867
+ const w = this.stage.width();
2868
+ const h = this.stage.height();
2869
+ const { min, max } = this.zoomBounds();
2870
+ const margin = 1.12;
2871
+ if (b.width <= 0 || b.height <= 0) return this.stage.scaleX();
2872
+ return clamp(Math.min(w / (b.width * margin), h / (b.height * margin)), min, max);
2873
+ }
2874
+ /**
2875
+ * Resolve a seat tap: honour the 3D deck-drill and the AXS seat-pick gate,
2876
+ * then toggle. The gate (§4) redirects small on-screen seats to section-focus
2877
+ * to avoid blind mis-picks at overview scales — but ONLY when focusing would
2878
+ * zoom the seat up to a safe size. If we're already focused on the section, or
2879
+ * the section already fills the viewport (a narrow — OR even a wide — container
2880
+ * frames a big section BELOW LABEL_SCALE), redirecting is a no-op that would
2881
+ * leave the seat permanently unpickable (§5 `picking-gate-unreachable`), so we
2882
+ * fall through and PICK. This is the invariant: seats are never unpickable.
2883
+ */
2884
+ handleSeatTap(id) {
2885
+ if (this.stacked && this.opts.onDeckTap) {
2886
+ const floorId = this.objectFloor.get(this.seatById.get(id)?.rowId ?? "");
2887
+ if (floorId) {
2888
+ this.opts.onDeckTap(floorId);
2889
+ return;
2890
+ }
2891
+ }
2892
+ if (this.effScale() < LABEL_SCALE && this.sections.length) {
2893
+ const sec = this.seatSection.get(id);
2894
+ if (sec) {
2895
+ const alreadyFocused = this.focusedSectionId === sec.id;
2896
+ const canZoomInFurther = this.sectionFrameScale(sec.id) > this.stage.scaleX() * 1.02;
2897
+ if (!alreadyFocused && canZoomInFurther) {
2898
+ if (this.opts.onSectionTap) this.opts.onSectionTap(sec.id);
2899
+ else this.focusSection(sec.id);
2900
+ return;
2901
+ }
2902
+ }
2903
+ }
2904
+ this.toggleSeat(id);
2905
+ }
2906
+ /**
2907
+ * Nearest SELECTABLE seat whose centre is within `slopPx` (screen space) of a
2908
+ * seat circle edge, or null. Enlarges the effective tap target for small seats
2909
+ * so a near-miss on mobile still lands on the intended seat, without ever
2910
+ * hijacking a clean tap on empty space beyond the slop.
2911
+ */
2912
+ nearestSeatToScreen(screen, slopPx) {
2913
+ const s = this.stage.scaleX() || 1;
2914
+ const reachWorld = this.seatR + slopPx / s;
2915
+ const world = this.screenToWorld(screen);
2916
+ let best = null;
2917
+ let bestD = reachWorld;
2918
+ for (const seat of this.seats) {
2919
+ if (!this.selection.has(seat.id) && !this.isSelectable(seat.id)) continue;
2920
+ const d = Math.hypot(seat.x - world.x, seat.y - world.y);
2921
+ if (d < bestD) {
2922
+ bestD = d;
2923
+ best = seat.id;
2924
+ }
2925
+ }
2926
+ return best;
2927
+ }
2590
2928
  wireInteraction() {
2591
2929
  this.seatLayer.on("click tap", (e) => {
2592
2930
  if (this.moved > 8) return;
2593
2931
  const id = seatIdOf(e.target);
2594
2932
  if (!id) return;
2595
- if (this.stacked && this.opts.onDeckTap) {
2596
- const floorId = this.objectFloor.get(this.seatById.get(id)?.rowId ?? "");
2597
- if (floorId) {
2598
- this.opts.onDeckTap(floorId);
2599
- return;
2600
- }
2601
- }
2602
- this.toggleSeat(id);
2933
+ this.handleSeatTap(id);
2603
2934
  });
2604
2935
  this.seatLayer.on("mouseover", (e) => {
2605
2936
  const id = seatIdOf(e.target);
@@ -2625,10 +2956,16 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2625
2956
  const factor = Math.exp(-e.evt.deltaY * 2e-3);
2626
2957
  this.zoomAbout(this.stage.scaleX() * clamp(factor, 0.5, 2), pointer);
2627
2958
  });
2628
- this.stage.on("click tap", () => {
2629
- if (!this.cached || this.moved > 8) return;
2959
+ this.stage.on("click tap", (e) => {
2960
+ if (this.moved > 8) return;
2630
2961
  const pointer = this.stage.getPointerPosition();
2631
2962
  if (!pointer) return;
2963
+ if (!this.cached) {
2964
+ if (seatIdOf(e.target)) return;
2965
+ const near = this.nearestSeatToScreen(pointer, SEAT_TAP_SLOP_PX);
2966
+ if (near) this.handleSeatTap(near);
2967
+ return;
2968
+ }
2632
2969
  if (this.stacked && this.opts.onDeckTap) {
2633
2970
  const floorId = this.deckFloorAt();
2634
2971
  if (floorId) {
@@ -2958,6 +3295,9 @@ var PickerController = class {
2958
3295
  this._doc = null;
2959
3296
  /** Section/zone ids hidden from buyers this event (3.3) — seats vanish, not grey. */
2960
3297
  this.hidden = /* @__PURE__ */ new Set();
3298
+ /** Section/zone ids in the `closed` event-state (Phase 2) — seats stay, greyed
3299
+ * + not pickable. Kept separate from `hidden` (which strips them). */
3300
+ this.closedSections = /* @__PURE__ */ new Set();
2961
3301
  /** label ⇄ id maps — backend speaks labels, the engine speaks ids. */
2962
3302
  this.labelToId = /* @__PURE__ */ new Map();
2963
3303
  this.labelToSeat = /* @__PURE__ */ new Map();
@@ -2998,6 +3338,19 @@ var PickerController = class {
2998
3338
  this.renderer?.setChart(this.visibleDoc());
2999
3339
  return true;
3000
3340
  }
3341
+ /** Adopt a new closed-section set; restyle (grey + non-pickable) if it differs.
3342
+ * Cheap restyle — no chart rebuild — so mid-sale open/close repaints live. */
3343
+ syncClosed(ids) {
3344
+ const next = (ids ?? []).filter((x) => typeof x === "string");
3345
+ if (next.length === this.closedSections.size && next.every((id) => this.closedSections.has(id))) return false;
3346
+ this.closedSections = new Set(next);
3347
+ this.renderer?.setClosedSections?.(next);
3348
+ return true;
3349
+ }
3350
+ /** Whether a section is currently in the `closed` state (card must not open). */
3351
+ isSectionClosed(id) {
3352
+ return this.closedSections.has(id);
3353
+ }
3001
3354
  currentHold() {
3002
3355
  return this.hold_;
3003
3356
  }
@@ -3065,9 +3418,11 @@ var PickerController = class {
3065
3418
  }
3066
3419
  this.renderer = renderer;
3067
3420
  let seats = null;
3421
+ let closedIds = [];
3068
3422
  try {
3069
3423
  const objs = await this.api.objects(this.key);
3070
3424
  this.hidden = new Set(objs.hidden ?? []);
3425
+ closedIds = (objs.closed ?? []).filter((x) => typeof x === "string");
3071
3426
  seats = objs.seats;
3072
3427
  } catch {
3073
3428
  }
@@ -3078,6 +3433,8 @@ var PickerController = class {
3078
3433
  }
3079
3434
  renderer.setChart(this.visibleDoc());
3080
3435
  if (this.opts.colorblindSafe) renderer.setColorblindSafe?.(true);
3436
+ this.closedSections = new Set(closedIds);
3437
+ if (closedIds.length) renderer.setClosedSections?.(closedIds);
3081
3438
  if (seats) this.applySeatsMap(seats);
3082
3439
  this.connect();
3083
3440
  return {
@@ -3162,13 +3519,23 @@ var PickerController = class {
3162
3519
  */
3163
3520
  categoryAvailability() {
3164
3521
  const out = {};
3522
+ const closedMembers = this.closedMemberIds();
3165
3523
  for (const [id, s] of this.seatById) {
3524
+ if (closedMembers.has(id)) continue;
3166
3525
  if ((this.getStatus(id) ?? "free") === "free") {
3167
3526
  out[s.categoryKey] = (out[s.categoryKey] ?? 0) + 1;
3168
3527
  }
3169
3528
  }
3170
3529
  return out;
3171
3530
  }
3531
+ /** Seat ids belonging to a currently-closed section (excluded from counts). */
3532
+ closedMemberIds() {
3533
+ const out = /* @__PURE__ */ new Set();
3534
+ const r = this.renderer;
3535
+ if (!r || !this.closedSections.size) return out;
3536
+ for (const id of this.closedSections) for (const sid of r.sectionMembers?.(id) ?? []) out.add(sid);
3537
+ return out;
3538
+ }
3172
3539
  getGAAreas() {
3173
3540
  const doc = this.visibleDoc();
3174
3541
  if (!doc) return [];
@@ -3413,20 +3780,39 @@ var PickerController = class {
3413
3780
  }
3414
3781
  this.renderer?.setRung?.(rung);
3415
3782
  }
3783
+ /** Price-band filter (F4): dim free seats whose category is outside `keys`
3784
+ * (null clears). The widget resolves which categories fall in the band. */
3785
+ setCategoryFilter(keys) {
3786
+ this.renderer?.setCategoryFilter?.(keys);
3787
+ }
3788
+ /** World-space rect currently visible + full chart bounds (F3 minimap frame). */
3789
+ getViewport() {
3790
+ const r = this.renderer;
3791
+ if (!r?.getVisibleWorldRect || !r.getWorldBounds) return null;
3792
+ return { visible: r.getVisibleWorldRect(), bounds: r.getWorldBounds() };
3793
+ }
3416
3794
  /** Glide in on a section and surface its summary (same path as a section tap). */
3417
3795
  focusSection(id) {
3418
3796
  this.handleSectionTap(id);
3419
3797
  }
3420
- /** Zoom back out to the whole chart and clear the section-summary card. */
3798
+ /** Zoom back out to the whole chart and clear the section-summary card + focus. */
3421
3799
  overview() {
3800
+ this.renderer?.clearSectionFocus?.();
3422
3801
  this.renderer?.zoomToFit();
3423
3802
  this.opts.onSectionFocus?.(null);
3424
3803
  }
3425
- /** Glide the camera into a tapped section and emit its computed summary. */
3804
+ /** Glide the camera into a tapped section and emit its computed summary. Uses
3805
+ * the AXS focus treatment (dim + backdrop) when the engine supports it; a
3806
+ * closed section is framed but never opens a buyer card. */
3426
3807
  handleSectionTap(id) {
3427
3808
  const r = this.renderer;
3428
3809
  if (!r) return;
3429
- r.focusRegion?.(id);
3810
+ if (r.focusSection) r.focusSection(id);
3811
+ else r.focusRegion?.(id);
3812
+ if (this.isSectionClosed(id)) {
3813
+ this.opts.onSectionFocus?.(null);
3814
+ return;
3815
+ }
3430
3816
  this.opts.onSectionFocus?.(this.sectionSummary(id));
3431
3817
  }
3432
3818
  /**
@@ -3700,6 +4086,9 @@ var PickerController = class {
3700
4086
  void this.resnapshot();
3701
4087
  this.opts.onStatusChange?.();
3702
4088
  }
4089
+ if (Array.isArray(m.closed) && this.syncClosed(m.closed)) {
4090
+ this.opts.onStatusChange?.();
4091
+ }
3703
4092
  if (m.type === "hidden") return;
3704
4093
  if (m.seats && typeof m.seats === "object") {
3705
4094
  this.applySeatsMap(m.seats);