@seatlayer/core 0.13.0 → 0.15.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
@@ -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,15 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1843
1926
  c.dash([2, 2]);
1844
1927
  break;
1845
1928
  }
1929
+ if (boothLabel) {
1930
+ const owned = status === "held" && this.ownedHold.has(id);
1931
+ boothLabel.text(status === "booked" ? "SOLD" : status === "held" ? owned ? "HELD BY YOU" : "HELD" : seat.label);
1932
+ boothLabel.fontSize(status === "free" ? 10 : Math.min(10, Math.max(6, this.boothDims.get(seat.rowId)?.width ?? 40) / 6));
1933
+ boothLabel.fontStyle(status === "free" ? "600" : "800");
1934
+ boothLabel.fill(status === "free" ? this.theme.seatLabelColor ?? DEF_SEAT_LABEL : "#ffffff");
1935
+ boothLabel.offsetX(boothLabel.width() / 2);
1936
+ boothLabel.offsetY(boothLabel.height() / 2);
1937
+ }
1846
1938
  if (this.accessFilter && status === "free" && !selected && !seatMatchesAccess(seat, this.accessFilter)) {
1847
1939
  c.opacity(0.25);
1848
1940
  }
@@ -1870,6 +1962,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1870
1962
  const inFocus = !!sec && (sec.id === this.focusedSectionId || sec.zone === this.focusedSectionId);
1871
1963
  if (!inFocus) c.opacity(FOCUS_DIM_OPACITY);
1872
1964
  }
1965
+ if (this.selectionFocusId && id !== this.selectionFocusId) c.opacity(Math.min(c.opacity(), 0.16));
1873
1966
  }
1874
1967
  /** True when a seat sits in a section/zone currently marked `closed`. */
1875
1968
  seatInClosedSection(id) {
@@ -1937,7 +2030,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1937
2030
  this.drawFocusBackdrop(id);
1938
2031
  this.repaintSectionsAndSeats();
1939
2032
  this.updateLOD();
1940
- this.focusRegion(id);
2033
+ this.focusRegion(id, { minScale: SEAT_FOCUS_SCALE });
1941
2034
  }
1942
2035
  /** Clear an AXS section focus — restore full-bowl brightness + drop the backdrop. */
1943
2036
  clearSectionFocus() {
@@ -2730,30 +2823,101 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2730
2823
  const c = this.circleById.get(id);
2731
2824
  if (on) {
2732
2825
  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
2826
  } else {
2827
+ if (this.selectionFocusId === id) this.setSelectionFocus(null);
2747
2828
  this.selection.delete(id);
2748
- const ring = this.selectionRings.get(id);
2749
- ring?.destroy();
2750
- this.selectionRings.delete(id);
2751
2829
  }
2830
+ this.syncSelectionMarker(id);
2752
2831
  if (c) {
2753
2832
  this.paintSeat(c, id);
2754
2833
  if (!silent && !this.cached) this.seatLayer.batchDraw();
2755
2834
  }
2756
2835
  }
2836
+ /** Rebuild one marker after selected/held/candidate state changes. */
2837
+ syncSelectionMarker(id) {
2838
+ this.selectionMarkers.get(id)?.destroy();
2839
+ this.selectionMarkers.delete(id);
2840
+ if (!this.selection.has(id) && !this.ownedHold.has(id)) return;
2841
+ const seat = this.seatById.get(id);
2842
+ if (!seat) return;
2843
+ const candidate = this.selectionFocusId === id;
2844
+ const dims = this.boothDims.get(seat.rowId);
2845
+ const marker = new Group({
2846
+ x: seat.x,
2847
+ y: seat.y,
2848
+ rotation: dims?.rotation ?? 0,
2849
+ listening: false,
2850
+ perfectDrawEnabled: false,
2851
+ opacity: this.selectionFocusId && !candidate ? 0.2 : 1
2852
+ });
2853
+ const common = {
2854
+ stroke: this.effSelection,
2855
+ listening: false,
2856
+ perfectDrawEnabled: false,
2857
+ shadowForStrokeEnabled: false
2858
+ };
2859
+ if (dims) {
2860
+ marker.add(new Rect({
2861
+ ...common,
2862
+ width: dims.width,
2863
+ height: dims.height,
2864
+ offsetX: dims.width / 2,
2865
+ offsetY: dims.height / 2,
2866
+ cornerRadius: 4,
2867
+ strokeWidth: candidate ? 4 : 3
2868
+ }));
2869
+ if (candidate) {
2870
+ marker.add(new Rect({
2871
+ ...common,
2872
+ width: dims.width + 10,
2873
+ height: dims.height + 10,
2874
+ offsetX: (dims.width + 10) / 2,
2875
+ offsetY: (dims.height + 10) / 2,
2876
+ cornerRadius: 7,
2877
+ strokeWidth: 2,
2878
+ opacity: 0.55
2879
+ }));
2880
+ } else {
2881
+ const badgeX = Math.max(0, dims.width / 2 - 14);
2882
+ const badgeY = -Math.max(0, dims.height / 2 - 14);
2883
+ marker.add(new Circle({ x: badgeX, y: badgeY, radius: 10, fill: this.effSelection, listening: false }));
2884
+ marker.add(new Line({
2885
+ x: badgeX,
2886
+ y: badgeY,
2887
+ points: [-4.5, 0, -1, 3.5, 5.5, -4.5],
2888
+ stroke: isLightColor(this.effSelection) ? "#0b1220" : "#ffffff",
2889
+ strokeWidth: 2.4,
2890
+ lineCap: "round",
2891
+ lineJoin: "round",
2892
+ listening: false
2893
+ }));
2894
+ }
2895
+ } else {
2896
+ marker.add(new Circle({ ...common, radius: this.seatR + (candidate ? 4.5 : 2.5), strokeWidth: candidate ? 3.5 : 2.5 }));
2897
+ if (candidate) {
2898
+ marker.add(new Circle({
2899
+ ...common,
2900
+ radius: this.seatR + 8,
2901
+ strokeWidth: 2,
2902
+ opacity: 0.55
2903
+ }));
2904
+ } else {
2905
+ marker.add(new Line({
2906
+ points: [-this.seatR * 0.52, 0, -this.seatR * 0.12, this.seatR * 0.4, this.seatR * 0.6, -this.seatR * 0.46],
2907
+ stroke: "#ffffff",
2908
+ strokeWidth: Math.max(2.6, this.seatR * 0.34),
2909
+ lineCap: "round",
2910
+ lineJoin: "round",
2911
+ shadowColor: "#0b1220",
2912
+ shadowBlur: 1.5,
2913
+ shadowOpacity: 0.55,
2914
+ listening: false
2915
+ }));
2916
+ }
2917
+ }
2918
+ this.selectionMarkers.set(id, marker);
2919
+ this.overlayLayer.add(marker);
2920
+ }
2757
2921
  // ---- interaction ----------------------------------------------------------
2758
2922
  /**
2759
2923
  * The stage scale `focusRegion(id)` would settle at — i.e. the zoom that
@@ -2771,7 +2935,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2771
2935
  const { min, max } = this.zoomBounds();
2772
2936
  const margin = 1.12;
2773
2937
  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);
2938
+ const frameScale = Math.min(w / (b.width * margin), h / (b.height * margin));
2939
+ return clamp(Math.max(frameScale, SEAT_FOCUS_SCALE), min, max);
2775
2940
  }
2776
2941
  /**
2777
2942
  * Resolve a seat tap: honour the 3D deck-drill and the AXS seat-pick gate,
@@ -2829,7 +2994,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2829
2994
  }
2830
2995
  wireInteraction() {
2831
2996
  this.seatLayer.on("click tap", (e) => {
2832
- if (this.moved > 8) return;
2997
+ if (this.moved > PAN_START_SLOP_PX) return;
2833
2998
  const id = seatIdOf(e.target);
2834
2999
  if (!id) return;
2835
3000
  this.handleSeatTap(id);
@@ -2859,7 +3024,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2859
3024
  this.zoomAbout(this.stage.scaleX() * clamp(factor, 0.5, 2), pointer);
2860
3025
  });
2861
3026
  this.stage.on("click tap", (e) => {
2862
- if (this.moved > 8) return;
3027
+ if (this.moved > PAN_START_SLOP_PX) return;
2863
3028
  const pointer = this.stage.getPointerPosition();
2864
3029
  if (!pointer) return;
2865
3030
  if (!this.cached) {
@@ -2943,12 +3108,13 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2943
3108
  this.stage.batchDraw();
2944
3109
  }
2945
3110
  /** Zoom + pan so world-rect `b` fills the viewport (with a small margin). */
2946
- zoomToBounds(b) {
3111
+ zoomToBounds(b, minScale) {
2947
3112
  const w = this.stage.width();
2948
3113
  const h = this.stage.height();
2949
3114
  const { min, max } = this.zoomBounds();
2950
3115
  const margin = 1.12;
2951
- const scale = clamp(Math.min(w / (b.width * margin), h / (b.height * margin)), min, max);
3116
+ const frameScale = Math.min(w / (b.width * margin), h / (b.height * margin));
3117
+ const scale = clamp(Math.max(frameScale, minScale ?? min), min, max);
2952
3118
  this.stage.scale({ x: scale, y: scale });
2953
3119
  this.stage.position({
2954
3120
  x: w / 2 - (b.x + b.width / 2) * scale,
@@ -2972,14 +3138,15 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2972
3138
  if (!b) return;
2973
3139
  this.cancelGlide();
2974
3140
  if (opts?.animate === false || this.reducedMotion) {
2975
- this.zoomToBounds(b);
3141
+ this.zoomToBounds(b, opts?.minScale);
2976
3142
  return;
2977
3143
  }
2978
3144
  const w = this.stage.width();
2979
3145
  const h = this.stage.height();
2980
3146
  const { min, max } = this.zoomBounds();
2981
3147
  const margin = 1.12;
2982
- const toScale = clamp(Math.min(w / (b.width * margin), h / (b.height * margin)), min, max);
3148
+ const frameScale = Math.min(w / (b.width * margin), h / (b.height * margin));
3149
+ const toScale = clamp(Math.max(frameScale, opts?.minScale ?? min), min, max);
2983
3150
  const toX = w / 2 - (b.x + b.width / 2) * toScale;
2984
3151
  const toY = h / 2 - (b.y + b.height / 2) * toScale;
2985
3152
  const fromScale = this.stage.scaleX();
@@ -3034,7 +3201,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3034
3201
  this.zoomToFit();
3035
3202
  return;
3036
3203
  }
3037
- const target = rung === "sections" ? (SECTION_PROMINENT_SCALE + CACHE_THRESHOLD) / 2 : Math.max(SEAT_LEGIBLE_SCALE * 1.1, CACHE_THRESHOLD * 1.3);
3204
+ const target = rung === "sections" ? (SECTION_PROMINENT_SCALE + CACHE_THRESHOLD) / 2 : Math.max(SEAT_FOCUS_SCALE, CACHE_THRESHOLD * 1.3);
3038
3205
  const w = this.stage.width();
3039
3206
  const h = this.stage.height();
3040
3207
  const cx = this.bounds.x + this.bounds.width / 2;
@@ -3120,19 +3287,21 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3120
3287
  for (const seat of this.seats) {
3121
3288
  if (seat.kind === "booth") continue;
3122
3289
  if (seat.x < x0 || seat.x > x1 || seat.y < y0 || seat.y > y1) continue;
3290
+ const status = this.statusById.get(seat.id) ?? "free";
3291
+ const statusCue = status === "booked" ? "\xD7" : status === "held" && !this.ownedHold.has(seat.id) ? "H" : null;
3123
3292
  const t2 = new Text({
3124
3293
  x: seat.x,
3125
3294
  y: seat.y,
3126
- text: seat.label,
3127
- fontSize: 7,
3128
- fontStyle: "600",
3295
+ text: statusCue ?? seat.label,
3296
+ fontSize: statusCue ? status === "booked" ? 13 : 8 : 7,
3297
+ fontStyle: statusCue ? "800" : "600",
3129
3298
  fontFamily: this.labelFont(),
3130
- fill: this.theme.seatLabelColor ?? DEF_SEAT_LABEL,
3299
+ fill: statusCue ? "#ffffff" : this.theme.seatLabelColor ?? DEF_SEAT_LABEL,
3131
3300
  listening: false,
3132
3301
  perfectDrawEnabled: false
3133
3302
  });
3134
3303
  const maxW = this.seatR * 2 - 3;
3135
- if (t2.width() > maxW) t2.fontSize(Math.max(4, 7 * maxW / t2.width()));
3304
+ if (t2.width() > maxW) t2.fontSize(Math.max(4, t2.fontSize() * maxW / t2.width()));
3136
3305
  if (t2.fontSize() < 4.2) {
3137
3306
  t2.destroy();
3138
3307
  continue;
@@ -3234,6 +3403,8 @@ var PickerController = class {
3234
3403
  this.seatTiers = /* @__PURE__ */ new Map();
3235
3404
  /** id → seat, for the section-summary breakdown (renderer members are ids). */
3236
3405
  this.seatById = /* @__PURE__ */ new Map();
3406
+ /** id → buyer-facing spatial metadata used by every tooltip/confirm surface. */
3407
+ this.seatContext = /* @__PURE__ */ new Map();
3237
3408
  this.allIds = [];
3238
3409
  // realtime socket
3239
3410
  this.ws = null;
@@ -3312,12 +3483,28 @@ var PickerController = class {
3312
3483
  this.labelToId = /* @__PURE__ */ new Map();
3313
3484
  this.labelToSeat = /* @__PURE__ */ new Map();
3314
3485
  this.seatById = /* @__PURE__ */ new Map();
3486
+ this.seatContext = /* @__PURE__ */ new Map();
3315
3487
  this.allIds = [];
3488
+ const chartObjects = new Map(allObjects(res.doc).map((object) => [object.id, object]));
3489
+ const membership = computeSections(res.doc);
3490
+ const sectionLabels = new Map(
3491
+ [...membership.sections, ...membership.ungrouped ? [membership.ungrouped] : []].map((section) => [section.id, section.label])
3492
+ );
3316
3493
  for (const s of expandChart(res.doc)) {
3317
3494
  this.labelToId.set(s.label, s.id);
3318
3495
  this.labelToSeat.set(s.label, s);
3319
3496
  this.seatById.set(s.id, s);
3320
3497
  this.allIds.push(s.id);
3498
+ const source = chartObjects.get(s.rowId);
3499
+ const sourceLabel = source && "label" in source && typeof source.label === "string" ? source.label : void 0;
3500
+ const rowLabel = s.kind === "booth" ? void 0 : sourceLabel;
3501
+ const labelParts = s.label.split("-");
3502
+ const seatNumber = rowLabel && s.label.startsWith(`${rowLabel}-`) ? s.label.slice(rowLabel.length + 1) : s.kind === "booth" ? s.label : labelParts[labelParts.length - 1] ?? s.label;
3503
+ this.seatContext.set(s.id, {
3504
+ sectionLabel: sectionLabels.get(membership.objectToSection.get(s.rowId) ?? ""),
3505
+ rowLabel,
3506
+ seatNumber
3507
+ });
3321
3508
  }
3322
3509
  const currency = res.event.currency ?? this.opts.currency;
3323
3510
  this.currency = currency ?? "USD";
@@ -3387,6 +3574,11 @@ var PickerController = class {
3387
3574
  if (!this.renderer) return [];
3388
3575
  return this.renderer.getSelection().map((s) => this.toSeat(s));
3389
3576
  }
3577
+ /** Enriched metadata for a seat confirmation card or tooltip. */
3578
+ seatDetails(seatId) {
3579
+ const seat = this.seatById.get(seatId);
3580
+ return seat ? this.describeSeat(seat) : null;
3581
+ }
3390
3582
  clearSelection() {
3391
3583
  this.renderer?.clearSelection();
3392
3584
  this.emitSelectionChange();
@@ -3426,6 +3618,19 @@ var PickerController = class {
3426
3618
  throw err;
3427
3619
  }
3428
3620
  }
3621
+ /** Restore an active server hold without creating or extending inventory. */
3622
+ async resumeHold(holdId) {
3623
+ if (this.closed || !holdId || !this.api.resume) return null;
3624
+ const result = await this.api.resume(this.key, holdId);
3625
+ if (this.closed) return null;
3626
+ const labels = [...new Set(result.items.map((item) => item.label))];
3627
+ if (!labels.length) return null;
3628
+ this.setHold(
3629
+ { holdId: result.holdId, labels, expiresAt: result.expiresAt, items: result.items },
3630
+ "restored"
3631
+ );
3632
+ return this.hold_;
3633
+ }
3429
3634
  /**
3430
3635
  * P4 "need more time?": extend the OPEN hold's server-side expiry and re-arm
3431
3636
  * the client expiry timer to match (via setHold), so the controller doesn't
@@ -3515,7 +3720,7 @@ var PickerController = class {
3515
3720
  async bestAvailable(qty, categoryKey) {
3516
3721
  const r = this.renderer;
3517
3722
  if (!r) return null;
3518
- if (this.hold_) await this.release();
3723
+ if (this.hold_ && !await this.release()) return null;
3519
3724
  try {
3520
3725
  const result = await this.api.bestAvailable(this.key, qty, categoryKey);
3521
3726
  r.clearSelection();
@@ -3600,15 +3805,25 @@ var PickerController = class {
3600
3805
  /** Release the whole open hold (if any), repaint those seats free. */
3601
3806
  async release() {
3602
3807
  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");
3808
+ if (!hold) return true;
3607
3809
  try {
3608
- await this.api.release(this.key, hold.labels, hold.holdId);
3810
+ const result = await this.api.release(this.key, hold.labels, hold.holdId);
3811
+ if (!this.releaseConfirmed(result, hold.labels)) {
3812
+ await this.resnapshot();
3813
+ return false;
3814
+ }
3609
3815
  } catch (err) {
3610
3816
  this.emitError(err);
3817
+ return false;
3818
+ }
3819
+ if (this.hold_?.holdId !== hold.holdId) return true;
3820
+ this.clearHold();
3821
+ const ids = hold.labels.map((l) => this.labelToId.get(l)).filter((v) => !!v);
3822
+ if (ids.length) {
3823
+ this.renderer?.deselect(ids);
3824
+ this.renderer?.setStatus(ids, "free");
3611
3825
  }
3826
+ return true;
3612
3827
  }
3613
3828
  /**
3614
3829
  * Release just some labels from the open hold, keeping the rest held (used when
@@ -3616,19 +3831,35 @@ var PickerController = class {
3616
3831
  */
3617
3832
  async releaseLabels(labels) {
3618
3833
  const hold = this.hold_;
3619
- if (!hold) return;
3834
+ if (!hold) return true;
3620
3835
  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");
3836
+ if (!drop.length) return true;
3627
3837
  try {
3628
- await this.api.release(this.key, drop, hold.holdId);
3838
+ const result = await this.api.release(this.key, drop, hold.holdId);
3839
+ if (!this.releaseConfirmed(result, drop)) {
3840
+ await this.resnapshot();
3841
+ return false;
3842
+ }
3629
3843
  } catch (err) {
3630
3844
  this.emitError(err);
3845
+ return false;
3631
3846
  }
3847
+ if (this.hold_?.holdId !== hold.holdId) return true;
3848
+ const remaining = hold.labels.filter((l) => !drop.includes(l));
3849
+ const remainingItems = hold.items?.filter((item) => !drop.includes(item.label));
3850
+ if (remaining.length) this.setHold({ ...hold, labels: remaining, items: remainingItems });
3851
+ else this.clearHold();
3852
+ const ids = drop.map((l) => this.labelToId.get(l)).filter((v) => !!v);
3853
+ if (ids.length) {
3854
+ this.renderer?.deselect(ids);
3855
+ this.renderer?.setStatus(ids, "free");
3856
+ }
3857
+ return true;
3858
+ }
3859
+ /** New transports return the exact labels released; tolerate older adapters. */
3860
+ releaseConfirmed(result, requested) {
3861
+ const released = result?.released;
3862
+ return !Array.isArray(released) || requested.every((label) => released.includes(label));
3632
3863
  }
3633
3864
  // ---- renderer proxies (so consumers don't reach through) ------------------
3634
3865
  setStatus(ids, status) {
@@ -3652,6 +3883,9 @@ var PickerController = class {
3652
3883
  worldToScreen(p) {
3653
3884
  return this.renderer?.worldToScreen(p) ?? { x: 0, y: 0 };
3654
3885
  }
3886
+ setSelectionFocus(seatId) {
3887
+ this.renderer?.setSelectionFocus?.(seatId);
3888
+ }
3655
3889
  setAccessibilityFilter(types) {
3656
3890
  this.renderer?.setAccessibilityFilter?.(types);
3657
3891
  }
@@ -3839,7 +4073,8 @@ var PickerController = class {
3839
4073
  categoryLabel: cat?.label ?? s.categoryKey,
3840
4074
  categoryColor: cat?.color ?? "#6e7bff",
3841
4075
  status: this.renderer?.getStatus(s.id) ?? "free",
3842
- currency: this.currency
4076
+ currency: this.currency,
4077
+ ...this.seatContext.get(s.id)
3843
4078
  };
3844
4079
  }
3845
4080
  priceFor(categoryKey) {
@@ -3930,16 +4165,21 @@ var PickerController = class {
3930
4165
  this.emitSelectionChange();
3931
4166
  }
3932
4167
  /** Set the open hold + (re)arm the server-authoritative expiry timer. */
3933
- setHold(hold) {
4168
+ setHold(hold, source = "created") {
3934
4169
  const full = { ...hold, seats: this.seatsForLabels(hold.labels) };
3935
4170
  this.hold_ = full;
4171
+ this.renderer?.setOwnedHold?.(
4172
+ full.labels.map((label) => this.labelToId.get(label)).filter((id) => !!id)
4173
+ );
3936
4174
  if (this.expiryTimer) clearTimeout(this.expiryTimer);
3937
4175
  const ms = Math.max(0, full.expiresAt - Date.now());
3938
4176
  this.expiryTimer = setTimeout(() => void this.expireActiveHold(), ms);
3939
- this.opts.onHold?.(full);
4177
+ if (source === "restored") this.opts.onHoldRestored?.(full);
4178
+ else this.opts.onHold?.(full);
3940
4179
  }
3941
4180
  clearHold() {
3942
4181
  this.hold_ = null;
4182
+ this.renderer?.setOwnedHold?.(null);
3943
4183
  if (this.expiryTimer) {
3944
4184
  clearTimeout(this.expiryTimer);
3945
4185
  this.expiryTimer = null;
@@ -3971,6 +4211,9 @@ var PickerController = class {
3971
4211
  if (!r) return;
3972
4212
  this.liveStatuses = new Map(Object.entries(seats));
3973
4213
  if (this.allIds.length) r.setStatus(this.allIds, "free");
4214
+ r.setOwnedHold?.(
4215
+ (this.hold_?.labels ?? []).map((label) => this.labelToId.get(label)).filter((id) => !!id)
4216
+ );
3974
4217
  const byStatus = { free: [], held: [], booked: [], not_for_sale: [] };
3975
4218
  for (const [label, st] of Object.entries(seats)) {
3976
4219
  const id = this.labelToId.get(label);