@seatlayer/core 0.14.0 → 0.15.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
@@ -635,6 +635,8 @@ var LABEL_SCALE = 1;
635
635
  var SEAT_TAP_SLOP_PX = 14;
636
636
  var SECTION_PROMINENT_SCALE = 0.45 * SEAT_LEGIBLE_SCALE;
637
637
  var BLOCK_MELT_TOP = 0.9 * SEAT_LEGIBLE_SCALE;
638
+ var SEAT_FOCUS_SCALE = Math.max(SEAT_LEGIBLE_SCALE * 1.1, BLOCK_MELT_TOP);
639
+ var PAN_START_SLOP_PX = 8;
638
640
  var ZONE_PROMINENT_SCALE = 0.55 * SECTION_PROMINENT_SCALE;
639
641
  var MAX_LABELS = 700;
640
642
  var MARQUEE_RING_CAP = 2500;
@@ -785,6 +787,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
785
787
  this.circleById = /* @__PURE__ */ new Map();
786
788
  /** Booth block geometry, keyed by booth id (= the unit's rowId). */
787
789
  this.boothDims = /* @__PURE__ */ new Map();
790
+ /** Booth label node so status changes can say HELD/SOLD on the full block. */
791
+ this.boothLabelById = /* @__PURE__ */ new Map();
788
792
  this.statusById = /* @__PURE__ */ new Map();
789
793
  this.catColor = /* @__PURE__ */ new Map();
790
794
  this.theme = {};
@@ -795,7 +799,12 @@ var _SeatmapRenderer = class _SeatmapRenderer {
795
799
  /** Category order from the doc — the stable index into the CB palette. */
796
800
  this.catOrder = [];
797
801
  this.selection = /* @__PURE__ */ new Set();
798
- this.selectionRings = /* @__PURE__ */ new Map();
802
+ /** Whole-seat/block selection markers: outline + non-colour check cue. */
803
+ this.selectionMarkers = /* @__PURE__ */ new Map();
804
+ /** Held by this picker instance, not by another buyer. */
805
+ this.ownedHold = /* @__PURE__ */ new Set();
806
+ /** One selected seat being inspected before it is committed to the cart. */
807
+ this.selectionFocusId = null;
799
808
  this.focusedId = null;
800
809
  /**
801
810
  * Accessibility filter: `null` = off; `[]` = dim all non-accessible free seats;
@@ -864,7 +873,9 @@ var _SeatmapRenderer = class _SeatmapRenderer {
864
873
  this.pointers = /* @__PURE__ */ new Map();
865
874
  this.pinch = null;
866
875
  this.panLast = null;
867
- /** Cumulative gesture movement in px — clicks are suppressed after a real pan/pinch. */
876
+ this.panStart = null;
877
+ this.panStarted = false;
878
+ /** Maximum displacement from gesture start — suppress taps only after a real pan/pinch. */
868
879
  this.moved = 0;
869
880
  /**
870
881
  * Manage-mode rubber-band marquee (option-gated). `start`/`cur` are WORLD-space
@@ -907,15 +918,18 @@ var _SeatmapRenderer = class _SeatmapRenderer {
907
918
  // pipeline. We rely on bubbling instead.
908
919
  this.onPointerDown = (e) => {
909
920
  this.cancelGlide();
910
- this.pointers.set(e.pointerId, this.toLocal(e));
921
+ const local = this.toLocal(e);
922
+ this.pointers.set(e.pointerId, local);
911
923
  if (this.pointers.size === 1) {
912
924
  this.moved = 0;
913
925
  this.pinch = null;
926
+ this.panStart = local;
927
+ this.panStarted = false;
914
928
  if (this.opts.manageMode && this.opts.marqueeSelect && e.pointerType !== "touch" && e.button === 0 && this.getRung() === "seats") {
915
- this.beginMarquee(this.toLocal(e));
929
+ this.beginMarquee(local);
916
930
  this.panLast = null;
917
931
  } else {
918
- this.panLast = this.toLocal(e);
932
+ this.panLast = local;
919
933
  }
920
934
  } else if (this.pointers.size === 2) {
921
935
  if (this.marquee) this.cancelMarquee();
@@ -928,15 +942,21 @@ var _SeatmapRenderer = class _SeatmapRenderer {
928
942
  worldMid: { x: (mid.x - this.stage.x()) / s, y: (mid.y - this.stage.y()) / s }
929
943
  };
930
944
  this.panLast = null;
945
+ this.panStart = null;
946
+ this.panStarted = true;
947
+ this.moved = PAN_START_SLOP_PX + 1;
931
948
  }
932
949
  };
933
950
  this.onPointerMove = (e) => {
934
951
  if (!this.pointers.has(e.pointerId)) return;
935
952
  e.preventDefault();
936
953
  const p = this.toLocal(e);
937
- const prev = this.pointers.get(e.pointerId);
938
- this.moved += Math.hypot(p.x - prev.x, p.y - prev.y);
939
954
  this.pointers.set(e.pointerId, p);
955
+ if (this.pointers.size === 1 && this.panStart) {
956
+ this.moved = Math.max(this.moved, Math.hypot(p.x - this.panStart.x, p.y - this.panStart.y));
957
+ } else if (this.pointers.size >= 2) {
958
+ this.moved = PAN_START_SLOP_PX + 1;
959
+ }
940
960
  if (this.marquee) {
941
961
  this.updateMarquee(p);
942
962
  return;
@@ -956,6 +976,10 @@ var _SeatmapRenderer = class _SeatmapRenderer {
956
976
  this.stage.batchDraw();
957
977
  this.scheduleViewChange();
958
978
  } else if (this.panLast && this.pointers.size === 1) {
979
+ if (!this.panStarted) {
980
+ if (this.moved <= PAN_START_SLOP_PX) return;
981
+ this.panStarted = true;
982
+ }
959
983
  this.stage.position({
960
984
  x: this.stage.x() + (p.x - this.panLast.x),
961
985
  y: this.stage.y() + (p.y - this.panLast.y)
@@ -976,9 +1000,13 @@ var _SeatmapRenderer = class _SeatmapRenderer {
976
1000
  if (this.pointers.size < 2) this.pinch = null;
977
1001
  if (this.pointers.size === 1) {
978
1002
  this.panLast = [...this.pointers.values()][0];
1003
+ this.panStart = this.panLast;
1004
+ this.panStarted = true;
979
1005
  }
980
1006
  if (this.pointers.size === 0) {
981
1007
  this.panLast = null;
1008
+ this.panStart = null;
1009
+ this.panStarted = false;
982
1010
  this.afterViewChange();
983
1011
  }
984
1012
  };
@@ -1060,7 +1088,10 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1060
1088
  this.labelGroup.destroyChildren();
1061
1089
  this.circleById.clear();
1062
1090
  this.boothDims.clear();
1063
- this.selectionRings.clear();
1091
+ this.boothLabelById.clear();
1092
+ this.selectionMarkers.clear();
1093
+ this.ownedHold.clear();
1094
+ this.selectionFocusId = null;
1064
1095
  this.statusById.clear();
1065
1096
  this.selection.clear();
1066
1097
  this.seatById.clear();
@@ -1180,6 +1211,48 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1180
1211
  } else {
1181
1212
  this.seatLayer.batchDraw();
1182
1213
  }
1214
+ if (this.effScale() > LABEL_SCALE) this.updateLabels();
1215
+ }
1216
+ setOwnedHold(seatIds) {
1217
+ const next = new Set((seatIds ?? []).filter((id) => this.statusById.has(id)));
1218
+ const touched = /* @__PURE__ */ new Set([...this.ownedHold, ...next]);
1219
+ this.ownedHold = next;
1220
+ for (const id of touched) {
1221
+ const shape = this.circleById.get(id);
1222
+ if (shape) this.paintSeat(shape, id);
1223
+ this.syncSelectionMarker(id);
1224
+ }
1225
+ if (!touched.size) return;
1226
+ if (this.cached) {
1227
+ if (this.recacheTimer) clearTimeout(this.recacheTimer);
1228
+ this.recacheTimer = setTimeout(() => this.cacheSeatLayer(), 150);
1229
+ } else {
1230
+ this.seatLayer.batchDraw();
1231
+ }
1232
+ if (this.effScale() > LABEL_SCALE) this.updateLabels();
1233
+ this.overlayLayer.batchDraw();
1234
+ }
1235
+ setSelectionFocus(seatId) {
1236
+ const next = seatId && this.selection.has(seatId) ? seatId : null;
1237
+ if (next === this.selectionFocusId) return;
1238
+ const previous = this.selectionFocusId;
1239
+ this.selectionFocusId = next;
1240
+ for (const seat of this.seats) {
1241
+ const shape = this.circleById.get(seat.id);
1242
+ if (shape) this.paintSeat(shape, seat.id);
1243
+ }
1244
+ if (previous) this.syncSelectionMarker(previous);
1245
+ if (next) this.syncSelectionMarker(next);
1246
+ for (const [id, marker] of this.selectionMarkers) {
1247
+ marker.opacity(!next || id === next ? 1 : 0.2);
1248
+ }
1249
+ if (this.cached) {
1250
+ this.seatLayer.clearCache();
1251
+ this.cacheSeatLayer();
1252
+ } else {
1253
+ this.seatLayer.batchDraw();
1254
+ }
1255
+ this.overlayLayer.batchDraw();
1183
1256
  }
1184
1257
  getStatus(seatId) {
1185
1258
  return this.statusById.get(seatId) ?? "free";
@@ -1202,6 +1275,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1202
1275
  setManageInteraction(options) {
1203
1276
  if (this.marquee) this.cancelMarquee();
1204
1277
  this.panLast = null;
1278
+ this.panStart = null;
1279
+ this.panStarted = false;
1205
1280
  this.opts.manageMode = options.manageMode;
1206
1281
  this.opts.marqueeSelect = options.marqueeSelect;
1207
1282
  this.opts.selectableStatuses = [...options.selectableStatuses];
@@ -1798,6 +1873,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1798
1873
  t2.offsetX(t2.width() / 2);
1799
1874
  t2.offsetY(t2.height() / 2);
1800
1875
  this.hasBoothText = true;
1876
+ this.boothLabelById.set(seat.id, t2);
1801
1877
  target.add(t2);
1802
1878
  }
1803
1879
  /** The category's display color — Okabe-Ito hue when colorblind-safe is on. */
@@ -1812,6 +1888,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1812
1888
  const status = this.statusById.get(id) ?? "free";
1813
1889
  const selected = this.selection.has(id);
1814
1890
  const base = this.seatBaseColor(seat.categoryKey);
1891
+ const boothLabel = this.boothLabelById.get(id);
1815
1892
  c.dash([]);
1816
1893
  c.strokeWidth(0);
1817
1894
  c.stroke("");
@@ -1823,7 +1900,13 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1823
1900
  );
1824
1901
  break;
1825
1902
  case "held":
1826
- c.fill(HELD_FILL);
1903
+ if (this.ownedHold.has(id)) {
1904
+ c.fill(lighten(base, this.effSelection === DEF_SELECTION ? 0.24 : 0.1));
1905
+ c.stroke(this.effSelection);
1906
+ c.strokeWidth(3);
1907
+ } else {
1908
+ c.fill(HELD_FILL);
1909
+ }
1827
1910
  break;
1828
1911
  case "booked":
1829
1912
  if (this.colorblind) {
@@ -1843,6 +1926,14 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1843
1926
  c.dash([2, 2]);
1844
1927
  break;
1845
1928
  }
1929
+ if (boothLabel) {
1930
+ boothLabel.text(status === "booked" ? "SOLD" : status === "held" ? "HELD" : seat.label);
1931
+ boothLabel.fontSize(status === "free" ? 10 : Math.min(10, Math.max(6, this.boothDims.get(seat.rowId)?.width ?? 40) / 6));
1932
+ boothLabel.fontStyle(status === "free" ? "600" : "800");
1933
+ boothLabel.fill(status === "free" ? this.theme.seatLabelColor ?? DEF_SEAT_LABEL : "#ffffff");
1934
+ boothLabel.offsetX(boothLabel.width() / 2);
1935
+ boothLabel.offsetY(boothLabel.height() / 2);
1936
+ }
1846
1937
  if (this.accessFilter && status === "free" && !selected && !seatMatchesAccess(seat, this.accessFilter)) {
1847
1938
  c.opacity(0.25);
1848
1939
  }
@@ -1870,6 +1961,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1870
1961
  const inFocus = !!sec && (sec.id === this.focusedSectionId || sec.zone === this.focusedSectionId);
1871
1962
  if (!inFocus) c.opacity(FOCUS_DIM_OPACITY);
1872
1963
  }
1964
+ if (this.selectionFocusId && id !== this.selectionFocusId) c.opacity(Math.min(c.opacity(), 0.16));
1873
1965
  }
1874
1966
  /** True when a seat sits in a section/zone currently marked `closed`. */
1875
1967
  seatInClosedSection(id) {
@@ -1937,7 +2029,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1937
2029
  this.drawFocusBackdrop(id);
1938
2030
  this.repaintSectionsAndSeats();
1939
2031
  this.updateLOD();
1940
- this.focusRegion(id);
2032
+ this.focusRegion(id, { minScale: SEAT_FOCUS_SCALE });
1941
2033
  }
1942
2034
  /** Clear an AXS section focus — restore full-bowl brightness + drop the backdrop. */
1943
2035
  clearSectionFocus() {
@@ -2730,30 +2822,101 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2730
2822
  const c = this.circleById.get(id);
2731
2823
  if (on) {
2732
2824
  this.selection.add(id);
2733
- const seat = this.seatById.get(id);
2734
- const ring = new Circle({
2735
- x: seat.x,
2736
- y: seat.y,
2737
- radius: this.seatR,
2738
- stroke: this.effSelection,
2739
- strokeWidth: 3,
2740
- listening: false,
2741
- perfectDrawEnabled: false,
2742
- shadowForStrokeEnabled: false
2743
- });
2744
- this.selectionRings.set(id, ring);
2745
- this.overlayLayer.add(ring);
2746
2825
  } else {
2826
+ if (this.selectionFocusId === id) this.setSelectionFocus(null);
2747
2827
  this.selection.delete(id);
2748
- const ring = this.selectionRings.get(id);
2749
- ring?.destroy();
2750
- this.selectionRings.delete(id);
2751
2828
  }
2829
+ this.syncSelectionMarker(id);
2752
2830
  if (c) {
2753
2831
  this.paintSeat(c, id);
2754
2832
  if (!silent && !this.cached) this.seatLayer.batchDraw();
2755
2833
  }
2756
2834
  }
2835
+ /** Rebuild one marker after selected/held/candidate state changes. */
2836
+ syncSelectionMarker(id) {
2837
+ this.selectionMarkers.get(id)?.destroy();
2838
+ this.selectionMarkers.delete(id);
2839
+ if (!this.selection.has(id) && !this.ownedHold.has(id)) return;
2840
+ const seat = this.seatById.get(id);
2841
+ if (!seat) return;
2842
+ const candidate = this.selectionFocusId === id;
2843
+ const dims = this.boothDims.get(seat.rowId);
2844
+ const marker = new Group({
2845
+ x: seat.x,
2846
+ y: seat.y,
2847
+ rotation: dims?.rotation ?? 0,
2848
+ listening: false,
2849
+ perfectDrawEnabled: false,
2850
+ opacity: this.selectionFocusId && !candidate ? 0.2 : 1
2851
+ });
2852
+ const common = {
2853
+ stroke: this.effSelection,
2854
+ listening: false,
2855
+ perfectDrawEnabled: false,
2856
+ shadowForStrokeEnabled: false
2857
+ };
2858
+ if (dims) {
2859
+ marker.add(new Rect({
2860
+ ...common,
2861
+ width: dims.width,
2862
+ height: dims.height,
2863
+ offsetX: dims.width / 2,
2864
+ offsetY: dims.height / 2,
2865
+ cornerRadius: 4,
2866
+ strokeWidth: candidate ? 4 : 3
2867
+ }));
2868
+ if (candidate) {
2869
+ marker.add(new Rect({
2870
+ ...common,
2871
+ width: dims.width + 10,
2872
+ height: dims.height + 10,
2873
+ offsetX: (dims.width + 10) / 2,
2874
+ offsetY: (dims.height + 10) / 2,
2875
+ cornerRadius: 7,
2876
+ strokeWidth: 2,
2877
+ opacity: 0.55
2878
+ }));
2879
+ } else {
2880
+ const badgeX = Math.max(0, dims.width / 2 - 14);
2881
+ const badgeY = -Math.max(0, dims.height / 2 - 14);
2882
+ marker.add(new Circle({ x: badgeX, y: badgeY, radius: 10, fill: this.effSelection, listening: false }));
2883
+ marker.add(new Line({
2884
+ x: badgeX,
2885
+ y: badgeY,
2886
+ points: [-4.5, 0, -1, 3.5, 5.5, -4.5],
2887
+ stroke: isLightColor(this.effSelection) ? "#0b1220" : "#ffffff",
2888
+ strokeWidth: 2.4,
2889
+ lineCap: "round",
2890
+ lineJoin: "round",
2891
+ listening: false
2892
+ }));
2893
+ }
2894
+ } else {
2895
+ marker.add(new Circle({ ...common, radius: this.seatR + (candidate ? 4.5 : 2.5), strokeWidth: candidate ? 3.5 : 2.5 }));
2896
+ if (candidate) {
2897
+ marker.add(new Circle({
2898
+ ...common,
2899
+ radius: this.seatR + 8,
2900
+ strokeWidth: 2,
2901
+ opacity: 0.55
2902
+ }));
2903
+ } else {
2904
+ marker.add(new Line({
2905
+ points: [-this.seatR * 0.52, 0, -this.seatR * 0.12, this.seatR * 0.4, this.seatR * 0.6, -this.seatR * 0.46],
2906
+ stroke: "#ffffff",
2907
+ strokeWidth: Math.max(2.6, this.seatR * 0.34),
2908
+ lineCap: "round",
2909
+ lineJoin: "round",
2910
+ shadowColor: "#0b1220",
2911
+ shadowBlur: 1.5,
2912
+ shadowOpacity: 0.55,
2913
+ listening: false
2914
+ }));
2915
+ }
2916
+ }
2917
+ this.selectionMarkers.set(id, marker);
2918
+ this.overlayLayer.add(marker);
2919
+ }
2757
2920
  // ---- interaction ----------------------------------------------------------
2758
2921
  /**
2759
2922
  * The stage scale `focusRegion(id)` would settle at — i.e. the zoom that
@@ -2771,7 +2934,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2771
2934
  const { min, max } = this.zoomBounds();
2772
2935
  const margin = 1.12;
2773
2936
  if (b.width <= 0 || b.height <= 0) return this.stage.scaleX();
2774
- return clamp(Math.min(w / (b.width * margin), h / (b.height * margin)), min, max);
2937
+ const frameScale = Math.min(w / (b.width * margin), h / (b.height * margin));
2938
+ return clamp(Math.max(frameScale, SEAT_FOCUS_SCALE), min, max);
2775
2939
  }
2776
2940
  /**
2777
2941
  * Resolve a seat tap: honour the 3D deck-drill and the AXS seat-pick gate,
@@ -2829,7 +2993,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2829
2993
  }
2830
2994
  wireInteraction() {
2831
2995
  this.seatLayer.on("click tap", (e) => {
2832
- if (this.moved > 8) return;
2996
+ if (this.moved > PAN_START_SLOP_PX) return;
2833
2997
  const id = seatIdOf(e.target);
2834
2998
  if (!id) return;
2835
2999
  this.handleSeatTap(id);
@@ -2859,7 +3023,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2859
3023
  this.zoomAbout(this.stage.scaleX() * clamp(factor, 0.5, 2), pointer);
2860
3024
  });
2861
3025
  this.stage.on("click tap", (e) => {
2862
- if (this.moved > 8) return;
3026
+ if (this.moved > PAN_START_SLOP_PX) return;
2863
3027
  const pointer = this.stage.getPointerPosition();
2864
3028
  if (!pointer) return;
2865
3029
  if (!this.cached) {
@@ -2943,12 +3107,13 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2943
3107
  this.stage.batchDraw();
2944
3108
  }
2945
3109
  /** Zoom + pan so world-rect `b` fills the viewport (with a small margin). */
2946
- zoomToBounds(b) {
3110
+ zoomToBounds(b, minScale) {
2947
3111
  const w = this.stage.width();
2948
3112
  const h = this.stage.height();
2949
3113
  const { min, max } = this.zoomBounds();
2950
3114
  const margin = 1.12;
2951
- const scale = clamp(Math.min(w / (b.width * margin), h / (b.height * margin)), min, max);
3115
+ const frameScale = Math.min(w / (b.width * margin), h / (b.height * margin));
3116
+ const scale = clamp(Math.max(frameScale, minScale ?? min), min, max);
2952
3117
  this.stage.scale({ x: scale, y: scale });
2953
3118
  this.stage.position({
2954
3119
  x: w / 2 - (b.x + b.width / 2) * scale,
@@ -2972,14 +3137,15 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2972
3137
  if (!b) return;
2973
3138
  this.cancelGlide();
2974
3139
  if (opts?.animate === false || this.reducedMotion) {
2975
- this.zoomToBounds(b);
3140
+ this.zoomToBounds(b, opts?.minScale);
2976
3141
  return;
2977
3142
  }
2978
3143
  const w = this.stage.width();
2979
3144
  const h = this.stage.height();
2980
3145
  const { min, max } = this.zoomBounds();
2981
3146
  const margin = 1.12;
2982
- const toScale = clamp(Math.min(w / (b.width * margin), h / (b.height * margin)), min, max);
3147
+ const frameScale = Math.min(w / (b.width * margin), h / (b.height * margin));
3148
+ const toScale = clamp(Math.max(frameScale, opts?.minScale ?? min), min, max);
2983
3149
  const toX = w / 2 - (b.x + b.width / 2) * toScale;
2984
3150
  const toY = h / 2 - (b.y + b.height / 2) * toScale;
2985
3151
  const fromScale = this.stage.scaleX();
@@ -3034,7 +3200,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3034
3200
  this.zoomToFit();
3035
3201
  return;
3036
3202
  }
3037
- const target = rung === "sections" ? (SECTION_PROMINENT_SCALE + CACHE_THRESHOLD) / 2 : Math.max(SEAT_LEGIBLE_SCALE * 1.1, CACHE_THRESHOLD * 1.3);
3203
+ const target = rung === "sections" ? (SECTION_PROMINENT_SCALE + CACHE_THRESHOLD) / 2 : Math.max(SEAT_FOCUS_SCALE, CACHE_THRESHOLD * 1.3);
3038
3204
  const w = this.stage.width();
3039
3205
  const h = this.stage.height();
3040
3206
  const cx = this.bounds.x + this.bounds.width / 2;
@@ -3120,19 +3286,21 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3120
3286
  for (const seat of this.seats) {
3121
3287
  if (seat.kind === "booth") continue;
3122
3288
  if (seat.x < x0 || seat.x > x1 || seat.y < y0 || seat.y > y1) continue;
3289
+ const status = this.statusById.get(seat.id) ?? "free";
3290
+ const statusCue = status === "booked" ? "\xD7" : status === "held" && !this.ownedHold.has(seat.id) ? "H" : null;
3123
3291
  const t2 = new Text({
3124
3292
  x: seat.x,
3125
3293
  y: seat.y,
3126
- text: seat.label,
3127
- fontSize: 7,
3128
- fontStyle: "600",
3294
+ text: statusCue ?? seat.label,
3295
+ fontSize: statusCue ? status === "booked" ? 13 : 8 : 7,
3296
+ fontStyle: statusCue ? "800" : "600",
3129
3297
  fontFamily: this.labelFont(),
3130
- fill: this.theme.seatLabelColor ?? DEF_SEAT_LABEL,
3298
+ fill: statusCue ? "#ffffff" : this.theme.seatLabelColor ?? DEF_SEAT_LABEL,
3131
3299
  listening: false,
3132
3300
  perfectDrawEnabled: false
3133
3301
  });
3134
3302
  const maxW = this.seatR * 2 - 3;
3135
- if (t2.width() > maxW) t2.fontSize(Math.max(4, 7 * maxW / t2.width()));
3303
+ if (t2.width() > maxW) t2.fontSize(Math.max(4, t2.fontSize() * maxW / t2.width()));
3136
3304
  if (t2.fontSize() < 4.2) {
3137
3305
  t2.destroy();
3138
3306
  continue;
@@ -3234,6 +3402,8 @@ var PickerController = class {
3234
3402
  this.seatTiers = /* @__PURE__ */ new Map();
3235
3403
  /** id → seat, for the section-summary breakdown (renderer members are ids). */
3236
3404
  this.seatById = /* @__PURE__ */ new Map();
3405
+ /** id → buyer-facing spatial metadata used by every tooltip/confirm surface. */
3406
+ this.seatContext = /* @__PURE__ */ new Map();
3237
3407
  this.allIds = [];
3238
3408
  // realtime socket
3239
3409
  this.ws = null;
@@ -3312,12 +3482,28 @@ var PickerController = class {
3312
3482
  this.labelToId = /* @__PURE__ */ new Map();
3313
3483
  this.labelToSeat = /* @__PURE__ */ new Map();
3314
3484
  this.seatById = /* @__PURE__ */ new Map();
3485
+ this.seatContext = /* @__PURE__ */ new Map();
3315
3486
  this.allIds = [];
3487
+ const chartObjects = new Map(allObjects(res.doc).map((object) => [object.id, object]));
3488
+ const membership = computeSections(res.doc);
3489
+ const sectionLabels = new Map(
3490
+ [...membership.sections, ...membership.ungrouped ? [membership.ungrouped] : []].map((section) => [section.id, section.label])
3491
+ );
3316
3492
  for (const s of expandChart(res.doc)) {
3317
3493
  this.labelToId.set(s.label, s.id);
3318
3494
  this.labelToSeat.set(s.label, s);
3319
3495
  this.seatById.set(s.id, s);
3320
3496
  this.allIds.push(s.id);
3497
+ const source = chartObjects.get(s.rowId);
3498
+ const sourceLabel = source && "label" in source && typeof source.label === "string" ? source.label : void 0;
3499
+ const rowLabel = s.kind === "booth" ? void 0 : sourceLabel;
3500
+ const labelParts = s.label.split("-");
3501
+ const seatNumber = rowLabel && s.label.startsWith(`${rowLabel}-`) ? s.label.slice(rowLabel.length + 1) : s.kind === "booth" ? s.label : labelParts[labelParts.length - 1] ?? s.label;
3502
+ this.seatContext.set(s.id, {
3503
+ sectionLabel: sectionLabels.get(membership.objectToSection.get(s.rowId) ?? ""),
3504
+ rowLabel,
3505
+ seatNumber
3506
+ });
3321
3507
  }
3322
3508
  const currency = res.event.currency ?? this.opts.currency;
3323
3509
  this.currency = currency ?? "USD";
@@ -3387,6 +3573,11 @@ var PickerController = class {
3387
3573
  if (!this.renderer) return [];
3388
3574
  return this.renderer.getSelection().map((s) => this.toSeat(s));
3389
3575
  }
3576
+ /** Enriched metadata for a seat confirmation card or tooltip. */
3577
+ seatDetails(seatId) {
3578
+ const seat = this.seatById.get(seatId);
3579
+ return seat ? this.describeSeat(seat) : null;
3580
+ }
3390
3581
  clearSelection() {
3391
3582
  this.renderer?.clearSelection();
3392
3583
  this.emitSelectionChange();
@@ -3426,6 +3617,19 @@ var PickerController = class {
3426
3617
  throw err;
3427
3618
  }
3428
3619
  }
3620
+ /** Restore an active server hold without creating or extending inventory. */
3621
+ async resumeHold(holdId) {
3622
+ if (this.closed || !holdId || !this.api.resume) return null;
3623
+ const result = await this.api.resume(this.key, holdId);
3624
+ if (this.closed) return null;
3625
+ const labels = [...new Set(result.items.map((item) => item.label))];
3626
+ if (!labels.length) return null;
3627
+ this.setHold(
3628
+ { holdId: result.holdId, labels, expiresAt: result.expiresAt, items: result.items },
3629
+ "restored"
3630
+ );
3631
+ return this.hold_;
3632
+ }
3429
3633
  /**
3430
3634
  * P4 "need more time?": extend the OPEN hold's server-side expiry and re-arm
3431
3635
  * the client expiry timer to match (via setHold), so the controller doesn't
@@ -3515,7 +3719,7 @@ var PickerController = class {
3515
3719
  async bestAvailable(qty, categoryKey) {
3516
3720
  const r = this.renderer;
3517
3721
  if (!r) return null;
3518
- if (this.hold_) await this.release();
3722
+ if (this.hold_ && !await this.release()) return null;
3519
3723
  try {
3520
3724
  const result = await this.api.bestAvailable(this.key, qty, categoryKey);
3521
3725
  r.clearSelection();
@@ -3600,15 +3804,25 @@ var PickerController = class {
3600
3804
  /** Release the whole open hold (if any), repaint those seats free. */
3601
3805
  async release() {
3602
3806
  const hold = this.hold_;
3603
- if (!hold) return;
3604
- this.clearHold();
3605
- const ids = hold.labels.map((l) => this.labelToId.get(l)).filter((v) => !!v);
3606
- if (ids.length) this.renderer?.setStatus(ids, "free");
3807
+ if (!hold) return true;
3607
3808
  try {
3608
- await this.api.release(this.key, hold.labels, hold.holdId);
3809
+ const result = await this.api.release(this.key, hold.labels, hold.holdId);
3810
+ if (!this.releaseConfirmed(result, hold.labels)) {
3811
+ await this.resnapshot();
3812
+ return false;
3813
+ }
3609
3814
  } catch (err) {
3610
3815
  this.emitError(err);
3816
+ return false;
3817
+ }
3818
+ if (this.hold_?.holdId !== hold.holdId) return true;
3819
+ this.clearHold();
3820
+ const ids = hold.labels.map((l) => this.labelToId.get(l)).filter((v) => !!v);
3821
+ if (ids.length) {
3822
+ this.renderer?.deselect(ids);
3823
+ this.renderer?.setStatus(ids, "free");
3611
3824
  }
3825
+ return true;
3612
3826
  }
3613
3827
  /**
3614
3828
  * Release just some labels from the open hold, keeping the rest held (used when
@@ -3616,19 +3830,35 @@ var PickerController = class {
3616
3830
  */
3617
3831
  async releaseLabels(labels) {
3618
3832
  const hold = this.hold_;
3619
- if (!hold) return;
3833
+ if (!hold) return true;
3620
3834
  const drop = labels.filter((l) => hold.labels.includes(l));
3621
- if (!drop.length) return;
3622
- const remaining = hold.labels.filter((l) => !drop.includes(l));
3623
- if (remaining.length) this.setHold({ ...hold, labels: remaining });
3624
- else this.clearHold();
3625
- const ids = drop.map((l) => this.labelToId.get(l)).filter((v) => !!v);
3626
- if (ids.length) this.renderer?.setStatus(ids, "free");
3835
+ if (!drop.length) return true;
3627
3836
  try {
3628
- await this.api.release(this.key, drop, hold.holdId);
3837
+ const result = await this.api.release(this.key, drop, hold.holdId);
3838
+ if (!this.releaseConfirmed(result, drop)) {
3839
+ await this.resnapshot();
3840
+ return false;
3841
+ }
3629
3842
  } catch (err) {
3630
3843
  this.emitError(err);
3844
+ return false;
3631
3845
  }
3846
+ if (this.hold_?.holdId !== hold.holdId) return true;
3847
+ const remaining = hold.labels.filter((l) => !drop.includes(l));
3848
+ const remainingItems = hold.items?.filter((item) => !drop.includes(item.label));
3849
+ if (remaining.length) this.setHold({ ...hold, labels: remaining, items: remainingItems });
3850
+ else this.clearHold();
3851
+ const ids = drop.map((l) => this.labelToId.get(l)).filter((v) => !!v);
3852
+ if (ids.length) {
3853
+ this.renderer?.deselect(ids);
3854
+ this.renderer?.setStatus(ids, "free");
3855
+ }
3856
+ return true;
3857
+ }
3858
+ /** New transports return the exact labels released; tolerate older adapters. */
3859
+ releaseConfirmed(result, requested) {
3860
+ const released = result?.released;
3861
+ return !Array.isArray(released) || requested.every((label) => released.includes(label));
3632
3862
  }
3633
3863
  // ---- renderer proxies (so consumers don't reach through) ------------------
3634
3864
  setStatus(ids, status) {
@@ -3652,6 +3882,9 @@ var PickerController = class {
3652
3882
  worldToScreen(p) {
3653
3883
  return this.renderer?.worldToScreen(p) ?? { x: 0, y: 0 };
3654
3884
  }
3885
+ setSelectionFocus(seatId) {
3886
+ this.renderer?.setSelectionFocus?.(seatId);
3887
+ }
3655
3888
  setAccessibilityFilter(types) {
3656
3889
  this.renderer?.setAccessibilityFilter?.(types);
3657
3890
  }
@@ -3839,7 +4072,8 @@ var PickerController = class {
3839
4072
  categoryLabel: cat?.label ?? s.categoryKey,
3840
4073
  categoryColor: cat?.color ?? "#6e7bff",
3841
4074
  status: this.renderer?.getStatus(s.id) ?? "free",
3842
- currency: this.currency
4075
+ currency: this.currency,
4076
+ ...this.seatContext.get(s.id)
3843
4077
  };
3844
4078
  }
3845
4079
  priceFor(categoryKey) {
@@ -3930,16 +4164,21 @@ var PickerController = class {
3930
4164
  this.emitSelectionChange();
3931
4165
  }
3932
4166
  /** Set the open hold + (re)arm the server-authoritative expiry timer. */
3933
- setHold(hold) {
4167
+ setHold(hold, source = "created") {
3934
4168
  const full = { ...hold, seats: this.seatsForLabels(hold.labels) };
3935
4169
  this.hold_ = full;
4170
+ this.renderer?.setOwnedHold?.(
4171
+ full.labels.map((label) => this.labelToId.get(label)).filter((id) => !!id)
4172
+ );
3936
4173
  if (this.expiryTimer) clearTimeout(this.expiryTimer);
3937
4174
  const ms = Math.max(0, full.expiresAt - Date.now());
3938
4175
  this.expiryTimer = setTimeout(() => void this.expireActiveHold(), ms);
3939
- this.opts.onHold?.(full);
4176
+ if (source === "restored") this.opts.onHoldRestored?.(full);
4177
+ else this.opts.onHold?.(full);
3940
4178
  }
3941
4179
  clearHold() {
3942
4180
  this.hold_ = null;
4181
+ this.renderer?.setOwnedHold?.(null);
3943
4182
  if (this.expiryTimer) {
3944
4183
  clearTimeout(this.expiryTimer);
3945
4184
  this.expiryTimer = null;
@@ -3971,6 +4210,9 @@ var PickerController = class {
3971
4210
  if (!r) return;
3972
4211
  this.liveStatuses = new Map(Object.entries(seats));
3973
4212
  if (this.allIds.length) r.setStatus(this.allIds, "free");
4213
+ r.setOwnedHold?.(
4214
+ (this.hold_?.labels ?? []).map((label) => this.labelToId.get(label)).filter((id) => !!id)
4215
+ );
3974
4216
  const byStatus = { free: [], held: [], booked: [], not_for_sale: [] };
3975
4217
  for (const [label, st] of Object.entries(seats)) {
3976
4218
  const id = this.labelToId.get(label);