@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.cjs CHANGED
@@ -954,6 +954,8 @@ var LABEL_SCALE = 1;
954
954
  var SEAT_TAP_SLOP_PX = 14;
955
955
  var SECTION_PROMINENT_SCALE = 0.45 * SEAT_LEGIBLE_SCALE;
956
956
  var BLOCK_MELT_TOP = 0.9 * SEAT_LEGIBLE_SCALE;
957
+ var SEAT_FOCUS_SCALE = Math.max(SEAT_LEGIBLE_SCALE * 1.1, BLOCK_MELT_TOP);
958
+ var PAN_START_SLOP_PX = 8;
957
959
  var ZONE_PROMINENT_SCALE = 0.55 * SECTION_PROMINENT_SCALE;
958
960
  var MAX_LABELS = 700;
959
961
  var MARQUEE_RING_CAP = 2500;
@@ -1104,6 +1106,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1104
1106
  this.circleById = /* @__PURE__ */ new Map();
1105
1107
  /** Booth block geometry, keyed by booth id (= the unit's rowId). */
1106
1108
  this.boothDims = /* @__PURE__ */ new Map();
1109
+ /** Booth label node so status changes can say HELD/SOLD on the full block. */
1110
+ this.boothLabelById = /* @__PURE__ */ new Map();
1107
1111
  this.statusById = /* @__PURE__ */ new Map();
1108
1112
  this.catColor = /* @__PURE__ */ new Map();
1109
1113
  this.theme = {};
@@ -1114,7 +1118,12 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1114
1118
  /** Category order from the doc — the stable index into the CB palette. */
1115
1119
  this.catOrder = [];
1116
1120
  this.selection = /* @__PURE__ */ new Set();
1117
- this.selectionRings = /* @__PURE__ */ new Map();
1121
+ /** Whole-seat/block selection markers: outline + non-colour check cue. */
1122
+ this.selectionMarkers = /* @__PURE__ */ new Map();
1123
+ /** Held by this picker instance, not by another buyer. */
1124
+ this.ownedHold = /* @__PURE__ */ new Set();
1125
+ /** One selected seat being inspected before it is committed to the cart. */
1126
+ this.selectionFocusId = null;
1118
1127
  this.focusedId = null;
1119
1128
  /**
1120
1129
  * Accessibility filter: `null` = off; `[]` = dim all non-accessible free seats;
@@ -1183,7 +1192,9 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1183
1192
  this.pointers = /* @__PURE__ */ new Map();
1184
1193
  this.pinch = null;
1185
1194
  this.panLast = null;
1186
- /** Cumulative gesture movement in px — clicks are suppressed after a real pan/pinch. */
1195
+ this.panStart = null;
1196
+ this.panStarted = false;
1197
+ /** Maximum displacement from gesture start — suppress taps only after a real pan/pinch. */
1187
1198
  this.moved = 0;
1188
1199
  /**
1189
1200
  * Manage-mode rubber-band marquee (option-gated). `start`/`cur` are WORLD-space
@@ -1226,15 +1237,18 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1226
1237
  // pipeline. We rely on bubbling instead.
1227
1238
  this.onPointerDown = (e) => {
1228
1239
  this.cancelGlide();
1229
- this.pointers.set(e.pointerId, this.toLocal(e));
1240
+ const local = this.toLocal(e);
1241
+ this.pointers.set(e.pointerId, local);
1230
1242
  if (this.pointers.size === 1) {
1231
1243
  this.moved = 0;
1232
1244
  this.pinch = null;
1245
+ this.panStart = local;
1246
+ this.panStarted = false;
1233
1247
  if (this.opts.manageMode && this.opts.marqueeSelect && e.pointerType !== "touch" && e.button === 0 && this.getRung() === "seats") {
1234
- this.beginMarquee(this.toLocal(e));
1248
+ this.beginMarquee(local);
1235
1249
  this.panLast = null;
1236
1250
  } else {
1237
- this.panLast = this.toLocal(e);
1251
+ this.panLast = local;
1238
1252
  }
1239
1253
  } else if (this.pointers.size === 2) {
1240
1254
  if (this.marquee) this.cancelMarquee();
@@ -1247,15 +1261,21 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1247
1261
  worldMid: { x: (mid.x - this.stage.x()) / s, y: (mid.y - this.stage.y()) / s }
1248
1262
  };
1249
1263
  this.panLast = null;
1264
+ this.panStart = null;
1265
+ this.panStarted = true;
1266
+ this.moved = PAN_START_SLOP_PX + 1;
1250
1267
  }
1251
1268
  };
1252
1269
  this.onPointerMove = (e) => {
1253
1270
  if (!this.pointers.has(e.pointerId)) return;
1254
1271
  e.preventDefault();
1255
1272
  const p = this.toLocal(e);
1256
- const prev = this.pointers.get(e.pointerId);
1257
- this.moved += Math.hypot(p.x - prev.x, p.y - prev.y);
1258
1273
  this.pointers.set(e.pointerId, p);
1274
+ if (this.pointers.size === 1 && this.panStart) {
1275
+ this.moved = Math.max(this.moved, Math.hypot(p.x - this.panStart.x, p.y - this.panStart.y));
1276
+ } else if (this.pointers.size >= 2) {
1277
+ this.moved = PAN_START_SLOP_PX + 1;
1278
+ }
1259
1279
  if (this.marquee) {
1260
1280
  this.updateMarquee(p);
1261
1281
  return;
@@ -1275,6 +1295,10 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1275
1295
  this.stage.batchDraw();
1276
1296
  this.scheduleViewChange();
1277
1297
  } else if (this.panLast && this.pointers.size === 1) {
1298
+ if (!this.panStarted) {
1299
+ if (this.moved <= PAN_START_SLOP_PX) return;
1300
+ this.panStarted = true;
1301
+ }
1278
1302
  this.stage.position({
1279
1303
  x: this.stage.x() + (p.x - this.panLast.x),
1280
1304
  y: this.stage.y() + (p.y - this.panLast.y)
@@ -1295,9 +1319,13 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1295
1319
  if (this.pointers.size < 2) this.pinch = null;
1296
1320
  if (this.pointers.size === 1) {
1297
1321
  this.panLast = [...this.pointers.values()][0];
1322
+ this.panStart = this.panLast;
1323
+ this.panStarted = true;
1298
1324
  }
1299
1325
  if (this.pointers.size === 0) {
1300
1326
  this.panLast = null;
1327
+ this.panStart = null;
1328
+ this.panStarted = false;
1301
1329
  this.afterViewChange();
1302
1330
  }
1303
1331
  };
@@ -1379,7 +1407,10 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1379
1407
  this.labelGroup.destroyChildren();
1380
1408
  this.circleById.clear();
1381
1409
  this.boothDims.clear();
1382
- this.selectionRings.clear();
1410
+ this.boothLabelById.clear();
1411
+ this.selectionMarkers.clear();
1412
+ this.ownedHold.clear();
1413
+ this.selectionFocusId = null;
1383
1414
  this.statusById.clear();
1384
1415
  this.selection.clear();
1385
1416
  this.seatById.clear();
@@ -1499,6 +1530,48 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1499
1530
  } else {
1500
1531
  this.seatLayer.batchDraw();
1501
1532
  }
1533
+ if (this.effScale() > LABEL_SCALE) this.updateLabels();
1534
+ }
1535
+ setOwnedHold(seatIds) {
1536
+ const next = new Set((seatIds ?? []).filter((id) => this.statusById.has(id)));
1537
+ const touched = /* @__PURE__ */ new Set([...this.ownedHold, ...next]);
1538
+ this.ownedHold = next;
1539
+ for (const id of touched) {
1540
+ const shape = this.circleById.get(id);
1541
+ if (shape) this.paintSeat(shape, id);
1542
+ this.syncSelectionMarker(id);
1543
+ }
1544
+ if (!touched.size) return;
1545
+ if (this.cached) {
1546
+ if (this.recacheTimer) clearTimeout(this.recacheTimer);
1547
+ this.recacheTimer = setTimeout(() => this.cacheSeatLayer(), 150);
1548
+ } else {
1549
+ this.seatLayer.batchDraw();
1550
+ }
1551
+ if (this.effScale() > LABEL_SCALE) this.updateLabels();
1552
+ this.overlayLayer.batchDraw();
1553
+ }
1554
+ setSelectionFocus(seatId) {
1555
+ const next = seatId && this.selection.has(seatId) ? seatId : null;
1556
+ if (next === this.selectionFocusId) return;
1557
+ const previous = this.selectionFocusId;
1558
+ this.selectionFocusId = next;
1559
+ for (const seat of this.seats) {
1560
+ const shape = this.circleById.get(seat.id);
1561
+ if (shape) this.paintSeat(shape, seat.id);
1562
+ }
1563
+ if (previous) this.syncSelectionMarker(previous);
1564
+ if (next) this.syncSelectionMarker(next);
1565
+ for (const [id, marker] of this.selectionMarkers) {
1566
+ marker.opacity(!next || id === next ? 1 : 0.2);
1567
+ }
1568
+ if (this.cached) {
1569
+ this.seatLayer.clearCache();
1570
+ this.cacheSeatLayer();
1571
+ } else {
1572
+ this.seatLayer.batchDraw();
1573
+ }
1574
+ this.overlayLayer.batchDraw();
1502
1575
  }
1503
1576
  getStatus(seatId) {
1504
1577
  return this.statusById.get(seatId) ?? "free";
@@ -1521,6 +1594,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1521
1594
  setManageInteraction(options) {
1522
1595
  if (this.marquee) this.cancelMarquee();
1523
1596
  this.panLast = null;
1597
+ this.panStart = null;
1598
+ this.panStarted = false;
1524
1599
  this.opts.manageMode = options.manageMode;
1525
1600
  this.opts.marqueeSelect = options.marqueeSelect;
1526
1601
  this.opts.selectableStatuses = [...options.selectableStatuses];
@@ -2117,6 +2192,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2117
2192
  t2.offsetX(t2.width() / 2);
2118
2193
  t2.offsetY(t2.height() / 2);
2119
2194
  this.hasBoothText = true;
2195
+ this.boothLabelById.set(seat.id, t2);
2120
2196
  target.add(t2);
2121
2197
  }
2122
2198
  /** The category's display color — Okabe-Ito hue when colorblind-safe is on. */
@@ -2131,6 +2207,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2131
2207
  const status = this.statusById.get(id) ?? "free";
2132
2208
  const selected = this.selection.has(id);
2133
2209
  const base = this.seatBaseColor(seat.categoryKey);
2210
+ const boothLabel = this.boothLabelById.get(id);
2134
2211
  c.dash([]);
2135
2212
  c.strokeWidth(0);
2136
2213
  c.stroke("");
@@ -2142,7 +2219,13 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2142
2219
  );
2143
2220
  break;
2144
2221
  case "held":
2145
- c.fill(HELD_FILL);
2222
+ if (this.ownedHold.has(id)) {
2223
+ c.fill(lighten(base, this.effSelection === DEF_SELECTION ? 0.24 : 0.1));
2224
+ c.stroke(this.effSelection);
2225
+ c.strokeWidth(3);
2226
+ } else {
2227
+ c.fill(HELD_FILL);
2228
+ }
2146
2229
  break;
2147
2230
  case "booked":
2148
2231
  if (this.colorblind) {
@@ -2162,6 +2245,15 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2162
2245
  c.dash([2, 2]);
2163
2246
  break;
2164
2247
  }
2248
+ if (boothLabel) {
2249
+ const owned = status === "held" && this.ownedHold.has(id);
2250
+ boothLabel.text(status === "booked" ? "SOLD" : status === "held" ? owned ? "HELD BY YOU" : "HELD" : seat.label);
2251
+ boothLabel.fontSize(status === "free" ? 10 : Math.min(10, Math.max(6, this.boothDims.get(seat.rowId)?.width ?? 40) / 6));
2252
+ boothLabel.fontStyle(status === "free" ? "600" : "800");
2253
+ boothLabel.fill(status === "free" ? this.theme.seatLabelColor ?? DEF_SEAT_LABEL : "#ffffff");
2254
+ boothLabel.offsetX(boothLabel.width() / 2);
2255
+ boothLabel.offsetY(boothLabel.height() / 2);
2256
+ }
2165
2257
  if (this.accessFilter && status === "free" && !selected && !seatMatchesAccess(seat, this.accessFilter)) {
2166
2258
  c.opacity(0.25);
2167
2259
  }
@@ -2189,6 +2281,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2189
2281
  const inFocus = !!sec && (sec.id === this.focusedSectionId || sec.zone === this.focusedSectionId);
2190
2282
  if (!inFocus) c.opacity(FOCUS_DIM_OPACITY);
2191
2283
  }
2284
+ if (this.selectionFocusId && id !== this.selectionFocusId) c.opacity(Math.min(c.opacity(), 0.16));
2192
2285
  }
2193
2286
  /** True when a seat sits in a section/zone currently marked `closed`. */
2194
2287
  seatInClosedSection(id) {
@@ -2256,7 +2349,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2256
2349
  this.drawFocusBackdrop(id);
2257
2350
  this.repaintSectionsAndSeats();
2258
2351
  this.updateLOD();
2259
- this.focusRegion(id);
2352
+ this.focusRegion(id, { minScale: SEAT_FOCUS_SCALE });
2260
2353
  }
2261
2354
  /** Clear an AXS section focus — restore full-bowl brightness + drop the backdrop. */
2262
2355
  clearSectionFocus() {
@@ -3049,30 +3142,101 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3049
3142
  const c = this.circleById.get(id);
3050
3143
  if (on) {
3051
3144
  this.selection.add(id);
3052
- const seat = this.seatById.get(id);
3053
- const ring = new import_Circle.Circle({
3054
- x: seat.x,
3055
- y: seat.y,
3056
- radius: this.seatR,
3057
- stroke: this.effSelection,
3058
- strokeWidth: 3,
3059
- listening: false,
3060
- perfectDrawEnabled: false,
3061
- shadowForStrokeEnabled: false
3062
- });
3063
- this.selectionRings.set(id, ring);
3064
- this.overlayLayer.add(ring);
3065
3145
  } else {
3146
+ if (this.selectionFocusId === id) this.setSelectionFocus(null);
3066
3147
  this.selection.delete(id);
3067
- const ring = this.selectionRings.get(id);
3068
- ring?.destroy();
3069
- this.selectionRings.delete(id);
3070
3148
  }
3149
+ this.syncSelectionMarker(id);
3071
3150
  if (c) {
3072
3151
  this.paintSeat(c, id);
3073
3152
  if (!silent && !this.cached) this.seatLayer.batchDraw();
3074
3153
  }
3075
3154
  }
3155
+ /** Rebuild one marker after selected/held/candidate state changes. */
3156
+ syncSelectionMarker(id) {
3157
+ this.selectionMarkers.get(id)?.destroy();
3158
+ this.selectionMarkers.delete(id);
3159
+ if (!this.selection.has(id) && !this.ownedHold.has(id)) return;
3160
+ const seat = this.seatById.get(id);
3161
+ if (!seat) return;
3162
+ const candidate = this.selectionFocusId === id;
3163
+ const dims = this.boothDims.get(seat.rowId);
3164
+ const marker = new import_Group.Group({
3165
+ x: seat.x,
3166
+ y: seat.y,
3167
+ rotation: dims?.rotation ?? 0,
3168
+ listening: false,
3169
+ perfectDrawEnabled: false,
3170
+ opacity: this.selectionFocusId && !candidate ? 0.2 : 1
3171
+ });
3172
+ const common = {
3173
+ stroke: this.effSelection,
3174
+ listening: false,
3175
+ perfectDrawEnabled: false,
3176
+ shadowForStrokeEnabled: false
3177
+ };
3178
+ if (dims) {
3179
+ marker.add(new import_Rect.Rect({
3180
+ ...common,
3181
+ width: dims.width,
3182
+ height: dims.height,
3183
+ offsetX: dims.width / 2,
3184
+ offsetY: dims.height / 2,
3185
+ cornerRadius: 4,
3186
+ strokeWidth: candidate ? 4 : 3
3187
+ }));
3188
+ if (candidate) {
3189
+ marker.add(new import_Rect.Rect({
3190
+ ...common,
3191
+ width: dims.width + 10,
3192
+ height: dims.height + 10,
3193
+ offsetX: (dims.width + 10) / 2,
3194
+ offsetY: (dims.height + 10) / 2,
3195
+ cornerRadius: 7,
3196
+ strokeWidth: 2,
3197
+ opacity: 0.55
3198
+ }));
3199
+ } else {
3200
+ const badgeX = Math.max(0, dims.width / 2 - 14);
3201
+ const badgeY = -Math.max(0, dims.height / 2 - 14);
3202
+ marker.add(new import_Circle.Circle({ x: badgeX, y: badgeY, radius: 10, fill: this.effSelection, listening: false }));
3203
+ marker.add(new import_Line.Line({
3204
+ x: badgeX,
3205
+ y: badgeY,
3206
+ points: [-4.5, 0, -1, 3.5, 5.5, -4.5],
3207
+ stroke: isLightColor(this.effSelection) ? "#0b1220" : "#ffffff",
3208
+ strokeWidth: 2.4,
3209
+ lineCap: "round",
3210
+ lineJoin: "round",
3211
+ listening: false
3212
+ }));
3213
+ }
3214
+ } else {
3215
+ marker.add(new import_Circle.Circle({ ...common, radius: this.seatR + (candidate ? 4.5 : 2.5), strokeWidth: candidate ? 3.5 : 2.5 }));
3216
+ if (candidate) {
3217
+ marker.add(new import_Circle.Circle({
3218
+ ...common,
3219
+ radius: this.seatR + 8,
3220
+ strokeWidth: 2,
3221
+ opacity: 0.55
3222
+ }));
3223
+ } else {
3224
+ marker.add(new import_Line.Line({
3225
+ points: [-this.seatR * 0.52, 0, -this.seatR * 0.12, this.seatR * 0.4, this.seatR * 0.6, -this.seatR * 0.46],
3226
+ stroke: "#ffffff",
3227
+ strokeWidth: Math.max(2.6, this.seatR * 0.34),
3228
+ lineCap: "round",
3229
+ lineJoin: "round",
3230
+ shadowColor: "#0b1220",
3231
+ shadowBlur: 1.5,
3232
+ shadowOpacity: 0.55,
3233
+ listening: false
3234
+ }));
3235
+ }
3236
+ }
3237
+ this.selectionMarkers.set(id, marker);
3238
+ this.overlayLayer.add(marker);
3239
+ }
3076
3240
  // ---- interaction ----------------------------------------------------------
3077
3241
  /**
3078
3242
  * The stage scale `focusRegion(id)` would settle at — i.e. the zoom that
@@ -3090,7 +3254,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3090
3254
  const { min, max } = this.zoomBounds();
3091
3255
  const margin = 1.12;
3092
3256
  if (b.width <= 0 || b.height <= 0) return this.stage.scaleX();
3093
- return clamp(Math.min(w / (b.width * margin), h / (b.height * margin)), min, max);
3257
+ const frameScale = Math.min(w / (b.width * margin), h / (b.height * margin));
3258
+ return clamp(Math.max(frameScale, SEAT_FOCUS_SCALE), min, max);
3094
3259
  }
3095
3260
  /**
3096
3261
  * Resolve a seat tap: honour the 3D deck-drill and the AXS seat-pick gate,
@@ -3148,7 +3313,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3148
3313
  }
3149
3314
  wireInteraction() {
3150
3315
  this.seatLayer.on("click tap", (e) => {
3151
- if (this.moved > 8) return;
3316
+ if (this.moved > PAN_START_SLOP_PX) return;
3152
3317
  const id = seatIdOf(e.target);
3153
3318
  if (!id) return;
3154
3319
  this.handleSeatTap(id);
@@ -3178,7 +3343,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3178
3343
  this.zoomAbout(this.stage.scaleX() * clamp(factor, 0.5, 2), pointer);
3179
3344
  });
3180
3345
  this.stage.on("click tap", (e) => {
3181
- if (this.moved > 8) return;
3346
+ if (this.moved > PAN_START_SLOP_PX) return;
3182
3347
  const pointer = this.stage.getPointerPosition();
3183
3348
  if (!pointer) return;
3184
3349
  if (!this.cached) {
@@ -3262,12 +3427,13 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3262
3427
  this.stage.batchDraw();
3263
3428
  }
3264
3429
  /** Zoom + pan so world-rect `b` fills the viewport (with a small margin). */
3265
- zoomToBounds(b) {
3430
+ zoomToBounds(b, minScale) {
3266
3431
  const w = this.stage.width();
3267
3432
  const h = this.stage.height();
3268
3433
  const { min, max } = this.zoomBounds();
3269
3434
  const margin = 1.12;
3270
- const scale = clamp(Math.min(w / (b.width * margin), h / (b.height * margin)), min, max);
3435
+ const frameScale = Math.min(w / (b.width * margin), h / (b.height * margin));
3436
+ const scale = clamp(Math.max(frameScale, minScale ?? min), min, max);
3271
3437
  this.stage.scale({ x: scale, y: scale });
3272
3438
  this.stage.position({
3273
3439
  x: w / 2 - (b.x + b.width / 2) * scale,
@@ -3291,14 +3457,15 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3291
3457
  if (!b) return;
3292
3458
  this.cancelGlide();
3293
3459
  if (opts?.animate === false || this.reducedMotion) {
3294
- this.zoomToBounds(b);
3460
+ this.zoomToBounds(b, opts?.minScale);
3295
3461
  return;
3296
3462
  }
3297
3463
  const w = this.stage.width();
3298
3464
  const h = this.stage.height();
3299
3465
  const { min, max } = this.zoomBounds();
3300
3466
  const margin = 1.12;
3301
- const toScale = clamp(Math.min(w / (b.width * margin), h / (b.height * margin)), min, max);
3467
+ const frameScale = Math.min(w / (b.width * margin), h / (b.height * margin));
3468
+ const toScale = clamp(Math.max(frameScale, opts?.minScale ?? min), min, max);
3302
3469
  const toX = w / 2 - (b.x + b.width / 2) * toScale;
3303
3470
  const toY = h / 2 - (b.y + b.height / 2) * toScale;
3304
3471
  const fromScale = this.stage.scaleX();
@@ -3353,7 +3520,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3353
3520
  this.zoomToFit();
3354
3521
  return;
3355
3522
  }
3356
- const target = rung === "sections" ? (SECTION_PROMINENT_SCALE + CACHE_THRESHOLD) / 2 : Math.max(SEAT_LEGIBLE_SCALE * 1.1, CACHE_THRESHOLD * 1.3);
3523
+ const target = rung === "sections" ? (SECTION_PROMINENT_SCALE + CACHE_THRESHOLD) / 2 : Math.max(SEAT_FOCUS_SCALE, CACHE_THRESHOLD * 1.3);
3357
3524
  const w = this.stage.width();
3358
3525
  const h = this.stage.height();
3359
3526
  const cx = this.bounds.x + this.bounds.width / 2;
@@ -3439,19 +3606,21 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3439
3606
  for (const seat of this.seats) {
3440
3607
  if (seat.kind === "booth") continue;
3441
3608
  if (seat.x < x0 || seat.x > x1 || seat.y < y0 || seat.y > y1) continue;
3609
+ const status = this.statusById.get(seat.id) ?? "free";
3610
+ const statusCue = status === "booked" ? "\xD7" : status === "held" && !this.ownedHold.has(seat.id) ? "H" : null;
3442
3611
  const t2 = new import_Text.Text({
3443
3612
  x: seat.x,
3444
3613
  y: seat.y,
3445
- text: seat.label,
3446
- fontSize: 7,
3447
- fontStyle: "600",
3614
+ text: statusCue ?? seat.label,
3615
+ fontSize: statusCue ? status === "booked" ? 13 : 8 : 7,
3616
+ fontStyle: statusCue ? "800" : "600",
3448
3617
  fontFamily: this.labelFont(),
3449
- fill: this.theme.seatLabelColor ?? DEF_SEAT_LABEL,
3618
+ fill: statusCue ? "#ffffff" : this.theme.seatLabelColor ?? DEF_SEAT_LABEL,
3450
3619
  listening: false,
3451
3620
  perfectDrawEnabled: false
3452
3621
  });
3453
3622
  const maxW = this.seatR * 2 - 3;
3454
- if (t2.width() > maxW) t2.fontSize(Math.max(4, 7 * maxW / t2.width()));
3623
+ if (t2.width() > maxW) t2.fontSize(Math.max(4, t2.fontSize() * maxW / t2.width()));
3455
3624
  if (t2.fontSize() < 4.2) {
3456
3625
  t2.destroy();
3457
3626
  continue;
@@ -3553,6 +3722,8 @@ var PickerController = class {
3553
3722
  this.seatTiers = /* @__PURE__ */ new Map();
3554
3723
  /** id → seat, for the section-summary breakdown (renderer members are ids). */
3555
3724
  this.seatById = /* @__PURE__ */ new Map();
3725
+ /** id → buyer-facing spatial metadata used by every tooltip/confirm surface. */
3726
+ this.seatContext = /* @__PURE__ */ new Map();
3556
3727
  this.allIds = [];
3557
3728
  // realtime socket
3558
3729
  this.ws = null;
@@ -3631,12 +3802,28 @@ var PickerController = class {
3631
3802
  this.labelToId = /* @__PURE__ */ new Map();
3632
3803
  this.labelToSeat = /* @__PURE__ */ new Map();
3633
3804
  this.seatById = /* @__PURE__ */ new Map();
3805
+ this.seatContext = /* @__PURE__ */ new Map();
3634
3806
  this.allIds = [];
3807
+ const chartObjects = new Map(allObjects(res.doc).map((object) => [object.id, object]));
3808
+ const membership = computeSections(res.doc);
3809
+ const sectionLabels = new Map(
3810
+ [...membership.sections, ...membership.ungrouped ? [membership.ungrouped] : []].map((section) => [section.id, section.label])
3811
+ );
3635
3812
  for (const s of expandChart(res.doc)) {
3636
3813
  this.labelToId.set(s.label, s.id);
3637
3814
  this.labelToSeat.set(s.label, s);
3638
3815
  this.seatById.set(s.id, s);
3639
3816
  this.allIds.push(s.id);
3817
+ const source = chartObjects.get(s.rowId);
3818
+ const sourceLabel = source && "label" in source && typeof source.label === "string" ? source.label : void 0;
3819
+ const rowLabel = s.kind === "booth" ? void 0 : sourceLabel;
3820
+ const labelParts = s.label.split("-");
3821
+ const seatNumber = rowLabel && s.label.startsWith(`${rowLabel}-`) ? s.label.slice(rowLabel.length + 1) : s.kind === "booth" ? s.label : labelParts[labelParts.length - 1] ?? s.label;
3822
+ this.seatContext.set(s.id, {
3823
+ sectionLabel: sectionLabels.get(membership.objectToSection.get(s.rowId) ?? ""),
3824
+ rowLabel,
3825
+ seatNumber
3826
+ });
3640
3827
  }
3641
3828
  const currency = res.event.currency ?? this.opts.currency;
3642
3829
  this.currency = currency ?? "USD";
@@ -3706,6 +3893,11 @@ var PickerController = class {
3706
3893
  if (!this.renderer) return [];
3707
3894
  return this.renderer.getSelection().map((s) => this.toSeat(s));
3708
3895
  }
3896
+ /** Enriched metadata for a seat confirmation card or tooltip. */
3897
+ seatDetails(seatId) {
3898
+ const seat = this.seatById.get(seatId);
3899
+ return seat ? this.describeSeat(seat) : null;
3900
+ }
3709
3901
  clearSelection() {
3710
3902
  this.renderer?.clearSelection();
3711
3903
  this.emitSelectionChange();
@@ -3745,6 +3937,19 @@ var PickerController = class {
3745
3937
  throw err;
3746
3938
  }
3747
3939
  }
3940
+ /** Restore an active server hold without creating or extending inventory. */
3941
+ async resumeHold(holdId) {
3942
+ if (this.closed || !holdId || !this.api.resume) return null;
3943
+ const result = await this.api.resume(this.key, holdId);
3944
+ if (this.closed) return null;
3945
+ const labels = [...new Set(result.items.map((item) => item.label))];
3946
+ if (!labels.length) return null;
3947
+ this.setHold(
3948
+ { holdId: result.holdId, labels, expiresAt: result.expiresAt, items: result.items },
3949
+ "restored"
3950
+ );
3951
+ return this.hold_;
3952
+ }
3748
3953
  /**
3749
3954
  * P4 "need more time?": extend the OPEN hold's server-side expiry and re-arm
3750
3955
  * the client expiry timer to match (via setHold), so the controller doesn't
@@ -3834,7 +4039,7 @@ var PickerController = class {
3834
4039
  async bestAvailable(qty, categoryKey) {
3835
4040
  const r = this.renderer;
3836
4041
  if (!r) return null;
3837
- if (this.hold_) await this.release();
4042
+ if (this.hold_ && !await this.release()) return null;
3838
4043
  try {
3839
4044
  const result = await this.api.bestAvailable(this.key, qty, categoryKey);
3840
4045
  r.clearSelection();
@@ -3919,15 +4124,25 @@ var PickerController = class {
3919
4124
  /** Release the whole open hold (if any), repaint those seats free. */
3920
4125
  async release() {
3921
4126
  const hold = this.hold_;
3922
- if (!hold) return;
3923
- this.clearHold();
3924
- const ids = hold.labels.map((l) => this.labelToId.get(l)).filter((v) => !!v);
3925
- if (ids.length) this.renderer?.setStatus(ids, "free");
4127
+ if (!hold) return true;
3926
4128
  try {
3927
- await this.api.release(this.key, hold.labels, hold.holdId);
4129
+ const result = await this.api.release(this.key, hold.labels, hold.holdId);
4130
+ if (!this.releaseConfirmed(result, hold.labels)) {
4131
+ await this.resnapshot();
4132
+ return false;
4133
+ }
3928
4134
  } catch (err) {
3929
4135
  this.emitError(err);
4136
+ return false;
4137
+ }
4138
+ if (this.hold_?.holdId !== hold.holdId) return true;
4139
+ this.clearHold();
4140
+ const ids = hold.labels.map((l) => this.labelToId.get(l)).filter((v) => !!v);
4141
+ if (ids.length) {
4142
+ this.renderer?.deselect(ids);
4143
+ this.renderer?.setStatus(ids, "free");
3930
4144
  }
4145
+ return true;
3931
4146
  }
3932
4147
  /**
3933
4148
  * Release just some labels from the open hold, keeping the rest held (used when
@@ -3935,19 +4150,35 @@ var PickerController = class {
3935
4150
  */
3936
4151
  async releaseLabels(labels) {
3937
4152
  const hold = this.hold_;
3938
- if (!hold) return;
4153
+ if (!hold) return true;
3939
4154
  const drop = labels.filter((l) => hold.labels.includes(l));
3940
- if (!drop.length) return;
3941
- const remaining = hold.labels.filter((l) => !drop.includes(l));
3942
- if (remaining.length) this.setHold({ ...hold, labels: remaining });
3943
- else this.clearHold();
3944
- const ids = drop.map((l) => this.labelToId.get(l)).filter((v) => !!v);
3945
- if (ids.length) this.renderer?.setStatus(ids, "free");
4155
+ if (!drop.length) return true;
3946
4156
  try {
3947
- await this.api.release(this.key, drop, hold.holdId);
4157
+ const result = await this.api.release(this.key, drop, hold.holdId);
4158
+ if (!this.releaseConfirmed(result, drop)) {
4159
+ await this.resnapshot();
4160
+ return false;
4161
+ }
3948
4162
  } catch (err) {
3949
4163
  this.emitError(err);
4164
+ return false;
3950
4165
  }
4166
+ if (this.hold_?.holdId !== hold.holdId) return true;
4167
+ const remaining = hold.labels.filter((l) => !drop.includes(l));
4168
+ const remainingItems = hold.items?.filter((item) => !drop.includes(item.label));
4169
+ if (remaining.length) this.setHold({ ...hold, labels: remaining, items: remainingItems });
4170
+ else this.clearHold();
4171
+ const ids = drop.map((l) => this.labelToId.get(l)).filter((v) => !!v);
4172
+ if (ids.length) {
4173
+ this.renderer?.deselect(ids);
4174
+ this.renderer?.setStatus(ids, "free");
4175
+ }
4176
+ return true;
4177
+ }
4178
+ /** New transports return the exact labels released; tolerate older adapters. */
4179
+ releaseConfirmed(result, requested) {
4180
+ const released = result?.released;
4181
+ return !Array.isArray(released) || requested.every((label) => released.includes(label));
3951
4182
  }
3952
4183
  // ---- renderer proxies (so consumers don't reach through) ------------------
3953
4184
  setStatus(ids, status) {
@@ -3971,6 +4202,9 @@ var PickerController = class {
3971
4202
  worldToScreen(p) {
3972
4203
  return this.renderer?.worldToScreen(p) ?? { x: 0, y: 0 };
3973
4204
  }
4205
+ setSelectionFocus(seatId) {
4206
+ this.renderer?.setSelectionFocus?.(seatId);
4207
+ }
3974
4208
  setAccessibilityFilter(types) {
3975
4209
  this.renderer?.setAccessibilityFilter?.(types);
3976
4210
  }
@@ -4158,7 +4392,8 @@ var PickerController = class {
4158
4392
  categoryLabel: cat?.label ?? s.categoryKey,
4159
4393
  categoryColor: cat?.color ?? "#6e7bff",
4160
4394
  status: this.renderer?.getStatus(s.id) ?? "free",
4161
- currency: this.currency
4395
+ currency: this.currency,
4396
+ ...this.seatContext.get(s.id)
4162
4397
  };
4163
4398
  }
4164
4399
  priceFor(categoryKey) {
@@ -4249,16 +4484,21 @@ var PickerController = class {
4249
4484
  this.emitSelectionChange();
4250
4485
  }
4251
4486
  /** Set the open hold + (re)arm the server-authoritative expiry timer. */
4252
- setHold(hold) {
4487
+ setHold(hold, source = "created") {
4253
4488
  const full = { ...hold, seats: this.seatsForLabels(hold.labels) };
4254
4489
  this.hold_ = full;
4490
+ this.renderer?.setOwnedHold?.(
4491
+ full.labels.map((label) => this.labelToId.get(label)).filter((id) => !!id)
4492
+ );
4255
4493
  if (this.expiryTimer) clearTimeout(this.expiryTimer);
4256
4494
  const ms = Math.max(0, full.expiresAt - Date.now());
4257
4495
  this.expiryTimer = setTimeout(() => void this.expireActiveHold(), ms);
4258
- this.opts.onHold?.(full);
4496
+ if (source === "restored") this.opts.onHoldRestored?.(full);
4497
+ else this.opts.onHold?.(full);
4259
4498
  }
4260
4499
  clearHold() {
4261
4500
  this.hold_ = null;
4501
+ this.renderer?.setOwnedHold?.(null);
4262
4502
  if (this.expiryTimer) {
4263
4503
  clearTimeout(this.expiryTimer);
4264
4504
  this.expiryTimer = null;
@@ -4290,6 +4530,9 @@ var PickerController = class {
4290
4530
  if (!r) return;
4291
4531
  this.liveStatuses = new Map(Object.entries(seats));
4292
4532
  if (this.allIds.length) r.setStatus(this.allIds, "free");
4533
+ r.setOwnedHold?.(
4534
+ (this.hold_?.labels ?? []).map((label) => this.labelToId.get(label)).filter((id) => !!id)
4535
+ );
4293
4536
  const byStatus = { free: [], held: [], booked: [], not_for_sale: [] };
4294
4537
  for (const [label, st] of Object.entries(seats)) {
4295
4538
  const id = this.labelToId.get(label);