@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.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,14 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2162
2245
  c.dash([2, 2]);
2163
2246
  break;
2164
2247
  }
2248
+ if (boothLabel) {
2249
+ boothLabel.text(status === "booked" ? "SOLD" : status === "held" ? "HELD" : seat.label);
2250
+ boothLabel.fontSize(status === "free" ? 10 : Math.min(10, Math.max(6, this.boothDims.get(seat.rowId)?.width ?? 40) / 6));
2251
+ boothLabel.fontStyle(status === "free" ? "600" : "800");
2252
+ boothLabel.fill(status === "free" ? this.theme.seatLabelColor ?? DEF_SEAT_LABEL : "#ffffff");
2253
+ boothLabel.offsetX(boothLabel.width() / 2);
2254
+ boothLabel.offsetY(boothLabel.height() / 2);
2255
+ }
2165
2256
  if (this.accessFilter && status === "free" && !selected && !seatMatchesAccess(seat, this.accessFilter)) {
2166
2257
  c.opacity(0.25);
2167
2258
  }
@@ -2189,6 +2280,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2189
2280
  const inFocus = !!sec && (sec.id === this.focusedSectionId || sec.zone === this.focusedSectionId);
2190
2281
  if (!inFocus) c.opacity(FOCUS_DIM_OPACITY);
2191
2282
  }
2283
+ if (this.selectionFocusId && id !== this.selectionFocusId) c.opacity(Math.min(c.opacity(), 0.16));
2192
2284
  }
2193
2285
  /** True when a seat sits in a section/zone currently marked `closed`. */
2194
2286
  seatInClosedSection(id) {
@@ -2256,7 +2348,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2256
2348
  this.drawFocusBackdrop(id);
2257
2349
  this.repaintSectionsAndSeats();
2258
2350
  this.updateLOD();
2259
- this.focusRegion(id);
2351
+ this.focusRegion(id, { minScale: SEAT_FOCUS_SCALE });
2260
2352
  }
2261
2353
  /** Clear an AXS section focus — restore full-bowl brightness + drop the backdrop. */
2262
2354
  clearSectionFocus() {
@@ -3049,30 +3141,101 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3049
3141
  const c = this.circleById.get(id);
3050
3142
  if (on) {
3051
3143
  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
3144
  } else {
3145
+ if (this.selectionFocusId === id) this.setSelectionFocus(null);
3066
3146
  this.selection.delete(id);
3067
- const ring = this.selectionRings.get(id);
3068
- ring?.destroy();
3069
- this.selectionRings.delete(id);
3070
3147
  }
3148
+ this.syncSelectionMarker(id);
3071
3149
  if (c) {
3072
3150
  this.paintSeat(c, id);
3073
3151
  if (!silent && !this.cached) this.seatLayer.batchDraw();
3074
3152
  }
3075
3153
  }
3154
+ /** Rebuild one marker after selected/held/candidate state changes. */
3155
+ syncSelectionMarker(id) {
3156
+ this.selectionMarkers.get(id)?.destroy();
3157
+ this.selectionMarkers.delete(id);
3158
+ if (!this.selection.has(id) && !this.ownedHold.has(id)) return;
3159
+ const seat = this.seatById.get(id);
3160
+ if (!seat) return;
3161
+ const candidate = this.selectionFocusId === id;
3162
+ const dims = this.boothDims.get(seat.rowId);
3163
+ const marker = new import_Group.Group({
3164
+ x: seat.x,
3165
+ y: seat.y,
3166
+ rotation: dims?.rotation ?? 0,
3167
+ listening: false,
3168
+ perfectDrawEnabled: false,
3169
+ opacity: this.selectionFocusId && !candidate ? 0.2 : 1
3170
+ });
3171
+ const common = {
3172
+ stroke: this.effSelection,
3173
+ listening: false,
3174
+ perfectDrawEnabled: false,
3175
+ shadowForStrokeEnabled: false
3176
+ };
3177
+ if (dims) {
3178
+ marker.add(new import_Rect.Rect({
3179
+ ...common,
3180
+ width: dims.width,
3181
+ height: dims.height,
3182
+ offsetX: dims.width / 2,
3183
+ offsetY: dims.height / 2,
3184
+ cornerRadius: 4,
3185
+ strokeWidth: candidate ? 4 : 3
3186
+ }));
3187
+ if (candidate) {
3188
+ marker.add(new import_Rect.Rect({
3189
+ ...common,
3190
+ width: dims.width + 10,
3191
+ height: dims.height + 10,
3192
+ offsetX: (dims.width + 10) / 2,
3193
+ offsetY: (dims.height + 10) / 2,
3194
+ cornerRadius: 7,
3195
+ strokeWidth: 2,
3196
+ opacity: 0.55
3197
+ }));
3198
+ } else {
3199
+ const badgeX = Math.max(0, dims.width / 2 - 14);
3200
+ const badgeY = -Math.max(0, dims.height / 2 - 14);
3201
+ marker.add(new import_Circle.Circle({ x: badgeX, y: badgeY, radius: 10, fill: this.effSelection, listening: false }));
3202
+ marker.add(new import_Line.Line({
3203
+ x: badgeX,
3204
+ y: badgeY,
3205
+ points: [-4.5, 0, -1, 3.5, 5.5, -4.5],
3206
+ stroke: isLightColor(this.effSelection) ? "#0b1220" : "#ffffff",
3207
+ strokeWidth: 2.4,
3208
+ lineCap: "round",
3209
+ lineJoin: "round",
3210
+ listening: false
3211
+ }));
3212
+ }
3213
+ } else {
3214
+ marker.add(new import_Circle.Circle({ ...common, radius: this.seatR + (candidate ? 4.5 : 2.5), strokeWidth: candidate ? 3.5 : 2.5 }));
3215
+ if (candidate) {
3216
+ marker.add(new import_Circle.Circle({
3217
+ ...common,
3218
+ radius: this.seatR + 8,
3219
+ strokeWidth: 2,
3220
+ opacity: 0.55
3221
+ }));
3222
+ } else {
3223
+ marker.add(new import_Line.Line({
3224
+ points: [-this.seatR * 0.52, 0, -this.seatR * 0.12, this.seatR * 0.4, this.seatR * 0.6, -this.seatR * 0.46],
3225
+ stroke: "#ffffff",
3226
+ strokeWidth: Math.max(2.6, this.seatR * 0.34),
3227
+ lineCap: "round",
3228
+ lineJoin: "round",
3229
+ shadowColor: "#0b1220",
3230
+ shadowBlur: 1.5,
3231
+ shadowOpacity: 0.55,
3232
+ listening: false
3233
+ }));
3234
+ }
3235
+ }
3236
+ this.selectionMarkers.set(id, marker);
3237
+ this.overlayLayer.add(marker);
3238
+ }
3076
3239
  // ---- interaction ----------------------------------------------------------
3077
3240
  /**
3078
3241
  * The stage scale `focusRegion(id)` would settle at — i.e. the zoom that
@@ -3090,7 +3253,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3090
3253
  const { min, max } = this.zoomBounds();
3091
3254
  const margin = 1.12;
3092
3255
  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);
3256
+ const frameScale = Math.min(w / (b.width * margin), h / (b.height * margin));
3257
+ return clamp(Math.max(frameScale, SEAT_FOCUS_SCALE), min, max);
3094
3258
  }
3095
3259
  /**
3096
3260
  * Resolve a seat tap: honour the 3D deck-drill and the AXS seat-pick gate,
@@ -3148,7 +3312,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3148
3312
  }
3149
3313
  wireInteraction() {
3150
3314
  this.seatLayer.on("click tap", (e) => {
3151
- if (this.moved > 8) return;
3315
+ if (this.moved > PAN_START_SLOP_PX) return;
3152
3316
  const id = seatIdOf(e.target);
3153
3317
  if (!id) return;
3154
3318
  this.handleSeatTap(id);
@@ -3178,7 +3342,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3178
3342
  this.zoomAbout(this.stage.scaleX() * clamp(factor, 0.5, 2), pointer);
3179
3343
  });
3180
3344
  this.stage.on("click tap", (e) => {
3181
- if (this.moved > 8) return;
3345
+ if (this.moved > PAN_START_SLOP_PX) return;
3182
3346
  const pointer = this.stage.getPointerPosition();
3183
3347
  if (!pointer) return;
3184
3348
  if (!this.cached) {
@@ -3262,12 +3426,13 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3262
3426
  this.stage.batchDraw();
3263
3427
  }
3264
3428
  /** Zoom + pan so world-rect `b` fills the viewport (with a small margin). */
3265
- zoomToBounds(b) {
3429
+ zoomToBounds(b, minScale) {
3266
3430
  const w = this.stage.width();
3267
3431
  const h = this.stage.height();
3268
3432
  const { min, max } = this.zoomBounds();
3269
3433
  const margin = 1.12;
3270
- const scale = clamp(Math.min(w / (b.width * margin), h / (b.height * margin)), min, max);
3434
+ const frameScale = Math.min(w / (b.width * margin), h / (b.height * margin));
3435
+ const scale = clamp(Math.max(frameScale, minScale ?? min), min, max);
3271
3436
  this.stage.scale({ x: scale, y: scale });
3272
3437
  this.stage.position({
3273
3438
  x: w / 2 - (b.x + b.width / 2) * scale,
@@ -3291,14 +3456,15 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3291
3456
  if (!b) return;
3292
3457
  this.cancelGlide();
3293
3458
  if (opts?.animate === false || this.reducedMotion) {
3294
- this.zoomToBounds(b);
3459
+ this.zoomToBounds(b, opts?.minScale);
3295
3460
  return;
3296
3461
  }
3297
3462
  const w = this.stage.width();
3298
3463
  const h = this.stage.height();
3299
3464
  const { min, max } = this.zoomBounds();
3300
3465
  const margin = 1.12;
3301
- const toScale = clamp(Math.min(w / (b.width * margin), h / (b.height * margin)), min, max);
3466
+ const frameScale = Math.min(w / (b.width * margin), h / (b.height * margin));
3467
+ const toScale = clamp(Math.max(frameScale, opts?.minScale ?? min), min, max);
3302
3468
  const toX = w / 2 - (b.x + b.width / 2) * toScale;
3303
3469
  const toY = h / 2 - (b.y + b.height / 2) * toScale;
3304
3470
  const fromScale = this.stage.scaleX();
@@ -3353,7 +3519,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3353
3519
  this.zoomToFit();
3354
3520
  return;
3355
3521
  }
3356
- const target = rung === "sections" ? (SECTION_PROMINENT_SCALE + CACHE_THRESHOLD) / 2 : Math.max(SEAT_LEGIBLE_SCALE * 1.1, CACHE_THRESHOLD * 1.3);
3522
+ const target = rung === "sections" ? (SECTION_PROMINENT_SCALE + CACHE_THRESHOLD) / 2 : Math.max(SEAT_FOCUS_SCALE, CACHE_THRESHOLD * 1.3);
3357
3523
  const w = this.stage.width();
3358
3524
  const h = this.stage.height();
3359
3525
  const cx = this.bounds.x + this.bounds.width / 2;
@@ -3439,19 +3605,21 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3439
3605
  for (const seat of this.seats) {
3440
3606
  if (seat.kind === "booth") continue;
3441
3607
  if (seat.x < x0 || seat.x > x1 || seat.y < y0 || seat.y > y1) continue;
3608
+ const status = this.statusById.get(seat.id) ?? "free";
3609
+ const statusCue = status === "booked" ? "\xD7" : status === "held" && !this.ownedHold.has(seat.id) ? "H" : null;
3442
3610
  const t2 = new import_Text.Text({
3443
3611
  x: seat.x,
3444
3612
  y: seat.y,
3445
- text: seat.label,
3446
- fontSize: 7,
3447
- fontStyle: "600",
3613
+ text: statusCue ?? seat.label,
3614
+ fontSize: statusCue ? status === "booked" ? 13 : 8 : 7,
3615
+ fontStyle: statusCue ? "800" : "600",
3448
3616
  fontFamily: this.labelFont(),
3449
- fill: this.theme.seatLabelColor ?? DEF_SEAT_LABEL,
3617
+ fill: statusCue ? "#ffffff" : this.theme.seatLabelColor ?? DEF_SEAT_LABEL,
3450
3618
  listening: false,
3451
3619
  perfectDrawEnabled: false
3452
3620
  });
3453
3621
  const maxW = this.seatR * 2 - 3;
3454
- if (t2.width() > maxW) t2.fontSize(Math.max(4, 7 * maxW / t2.width()));
3622
+ if (t2.width() > maxW) t2.fontSize(Math.max(4, t2.fontSize() * maxW / t2.width()));
3455
3623
  if (t2.fontSize() < 4.2) {
3456
3624
  t2.destroy();
3457
3625
  continue;
@@ -3553,6 +3721,8 @@ var PickerController = class {
3553
3721
  this.seatTiers = /* @__PURE__ */ new Map();
3554
3722
  /** id → seat, for the section-summary breakdown (renderer members are ids). */
3555
3723
  this.seatById = /* @__PURE__ */ new Map();
3724
+ /** id → buyer-facing spatial metadata used by every tooltip/confirm surface. */
3725
+ this.seatContext = /* @__PURE__ */ new Map();
3556
3726
  this.allIds = [];
3557
3727
  // realtime socket
3558
3728
  this.ws = null;
@@ -3631,12 +3801,28 @@ var PickerController = class {
3631
3801
  this.labelToId = /* @__PURE__ */ new Map();
3632
3802
  this.labelToSeat = /* @__PURE__ */ new Map();
3633
3803
  this.seatById = /* @__PURE__ */ new Map();
3804
+ this.seatContext = /* @__PURE__ */ new Map();
3634
3805
  this.allIds = [];
3806
+ const chartObjects = new Map(allObjects(res.doc).map((object) => [object.id, object]));
3807
+ const membership = computeSections(res.doc);
3808
+ const sectionLabels = new Map(
3809
+ [...membership.sections, ...membership.ungrouped ? [membership.ungrouped] : []].map((section) => [section.id, section.label])
3810
+ );
3635
3811
  for (const s of expandChart(res.doc)) {
3636
3812
  this.labelToId.set(s.label, s.id);
3637
3813
  this.labelToSeat.set(s.label, s);
3638
3814
  this.seatById.set(s.id, s);
3639
3815
  this.allIds.push(s.id);
3816
+ const source = chartObjects.get(s.rowId);
3817
+ const sourceLabel = source && "label" in source && typeof source.label === "string" ? source.label : void 0;
3818
+ const rowLabel = s.kind === "booth" ? void 0 : sourceLabel;
3819
+ const labelParts = s.label.split("-");
3820
+ const seatNumber = rowLabel && s.label.startsWith(`${rowLabel}-`) ? s.label.slice(rowLabel.length + 1) : s.kind === "booth" ? s.label : labelParts[labelParts.length - 1] ?? s.label;
3821
+ this.seatContext.set(s.id, {
3822
+ sectionLabel: sectionLabels.get(membership.objectToSection.get(s.rowId) ?? ""),
3823
+ rowLabel,
3824
+ seatNumber
3825
+ });
3640
3826
  }
3641
3827
  const currency = res.event.currency ?? this.opts.currency;
3642
3828
  this.currency = currency ?? "USD";
@@ -3706,6 +3892,11 @@ var PickerController = class {
3706
3892
  if (!this.renderer) return [];
3707
3893
  return this.renderer.getSelection().map((s) => this.toSeat(s));
3708
3894
  }
3895
+ /** Enriched metadata for a seat confirmation card or tooltip. */
3896
+ seatDetails(seatId) {
3897
+ const seat = this.seatById.get(seatId);
3898
+ return seat ? this.describeSeat(seat) : null;
3899
+ }
3709
3900
  clearSelection() {
3710
3901
  this.renderer?.clearSelection();
3711
3902
  this.emitSelectionChange();
@@ -3745,6 +3936,19 @@ var PickerController = class {
3745
3936
  throw err;
3746
3937
  }
3747
3938
  }
3939
+ /** Restore an active server hold without creating or extending inventory. */
3940
+ async resumeHold(holdId) {
3941
+ if (this.closed || !holdId || !this.api.resume) return null;
3942
+ const result = await this.api.resume(this.key, holdId);
3943
+ if (this.closed) return null;
3944
+ const labels = [...new Set(result.items.map((item) => item.label))];
3945
+ if (!labels.length) return null;
3946
+ this.setHold(
3947
+ { holdId: result.holdId, labels, expiresAt: result.expiresAt, items: result.items },
3948
+ "restored"
3949
+ );
3950
+ return this.hold_;
3951
+ }
3748
3952
  /**
3749
3953
  * P4 "need more time?": extend the OPEN hold's server-side expiry and re-arm
3750
3954
  * the client expiry timer to match (via setHold), so the controller doesn't
@@ -3834,7 +4038,7 @@ var PickerController = class {
3834
4038
  async bestAvailable(qty, categoryKey) {
3835
4039
  const r = this.renderer;
3836
4040
  if (!r) return null;
3837
- if (this.hold_) await this.release();
4041
+ if (this.hold_ && !await this.release()) return null;
3838
4042
  try {
3839
4043
  const result = await this.api.bestAvailable(this.key, qty, categoryKey);
3840
4044
  r.clearSelection();
@@ -3919,15 +4123,25 @@ var PickerController = class {
3919
4123
  /** Release the whole open hold (if any), repaint those seats free. */
3920
4124
  async release() {
3921
4125
  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");
4126
+ if (!hold) return true;
3926
4127
  try {
3927
- await this.api.release(this.key, hold.labels, hold.holdId);
4128
+ const result = await this.api.release(this.key, hold.labels, hold.holdId);
4129
+ if (!this.releaseConfirmed(result, hold.labels)) {
4130
+ await this.resnapshot();
4131
+ return false;
4132
+ }
3928
4133
  } catch (err) {
3929
4134
  this.emitError(err);
4135
+ return false;
4136
+ }
4137
+ if (this.hold_?.holdId !== hold.holdId) return true;
4138
+ this.clearHold();
4139
+ const ids = hold.labels.map((l) => this.labelToId.get(l)).filter((v) => !!v);
4140
+ if (ids.length) {
4141
+ this.renderer?.deselect(ids);
4142
+ this.renderer?.setStatus(ids, "free");
3930
4143
  }
4144
+ return true;
3931
4145
  }
3932
4146
  /**
3933
4147
  * Release just some labels from the open hold, keeping the rest held (used when
@@ -3935,19 +4149,35 @@ var PickerController = class {
3935
4149
  */
3936
4150
  async releaseLabels(labels) {
3937
4151
  const hold = this.hold_;
3938
- if (!hold) return;
4152
+ if (!hold) return true;
3939
4153
  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");
4154
+ if (!drop.length) return true;
3946
4155
  try {
3947
- await this.api.release(this.key, drop, hold.holdId);
4156
+ const result = await this.api.release(this.key, drop, hold.holdId);
4157
+ if (!this.releaseConfirmed(result, drop)) {
4158
+ await this.resnapshot();
4159
+ return false;
4160
+ }
3948
4161
  } catch (err) {
3949
4162
  this.emitError(err);
4163
+ return false;
3950
4164
  }
4165
+ if (this.hold_?.holdId !== hold.holdId) return true;
4166
+ const remaining = hold.labels.filter((l) => !drop.includes(l));
4167
+ const remainingItems = hold.items?.filter((item) => !drop.includes(item.label));
4168
+ if (remaining.length) this.setHold({ ...hold, labels: remaining, items: remainingItems });
4169
+ else this.clearHold();
4170
+ const ids = drop.map((l) => this.labelToId.get(l)).filter((v) => !!v);
4171
+ if (ids.length) {
4172
+ this.renderer?.deselect(ids);
4173
+ this.renderer?.setStatus(ids, "free");
4174
+ }
4175
+ return true;
4176
+ }
4177
+ /** New transports return the exact labels released; tolerate older adapters. */
4178
+ releaseConfirmed(result, requested) {
4179
+ const released = result?.released;
4180
+ return !Array.isArray(released) || requested.every((label) => released.includes(label));
3951
4181
  }
3952
4182
  // ---- renderer proxies (so consumers don't reach through) ------------------
3953
4183
  setStatus(ids, status) {
@@ -3971,6 +4201,9 @@ var PickerController = class {
3971
4201
  worldToScreen(p) {
3972
4202
  return this.renderer?.worldToScreen(p) ?? { x: 0, y: 0 };
3973
4203
  }
4204
+ setSelectionFocus(seatId) {
4205
+ this.renderer?.setSelectionFocus?.(seatId);
4206
+ }
3974
4207
  setAccessibilityFilter(types) {
3975
4208
  this.renderer?.setAccessibilityFilter?.(types);
3976
4209
  }
@@ -4158,7 +4391,8 @@ var PickerController = class {
4158
4391
  categoryLabel: cat?.label ?? s.categoryKey,
4159
4392
  categoryColor: cat?.color ?? "#6e7bff",
4160
4393
  status: this.renderer?.getStatus(s.id) ?? "free",
4161
- currency: this.currency
4394
+ currency: this.currency,
4395
+ ...this.seatContext.get(s.id)
4162
4396
  };
4163
4397
  }
4164
4398
  priceFor(categoryKey) {
@@ -4249,16 +4483,21 @@ var PickerController = class {
4249
4483
  this.emitSelectionChange();
4250
4484
  }
4251
4485
  /** Set the open hold + (re)arm the server-authoritative expiry timer. */
4252
- setHold(hold) {
4486
+ setHold(hold, source = "created") {
4253
4487
  const full = { ...hold, seats: this.seatsForLabels(hold.labels) };
4254
4488
  this.hold_ = full;
4489
+ this.renderer?.setOwnedHold?.(
4490
+ full.labels.map((label) => this.labelToId.get(label)).filter((id) => !!id)
4491
+ );
4255
4492
  if (this.expiryTimer) clearTimeout(this.expiryTimer);
4256
4493
  const ms = Math.max(0, full.expiresAt - Date.now());
4257
4494
  this.expiryTimer = setTimeout(() => void this.expireActiveHold(), ms);
4258
- this.opts.onHold?.(full);
4495
+ if (source === "restored") this.opts.onHoldRestored?.(full);
4496
+ else this.opts.onHold?.(full);
4259
4497
  }
4260
4498
  clearHold() {
4261
4499
  this.hold_ = null;
4500
+ this.renderer?.setOwnedHold?.(null);
4262
4501
  if (this.expiryTimer) {
4263
4502
  clearTimeout(this.expiryTimer);
4264
4503
  this.expiryTimer = null;
@@ -4290,6 +4529,9 @@ var PickerController = class {
4290
4529
  if (!r) return;
4291
4530
  this.liveStatuses = new Map(Object.entries(seats));
4292
4531
  if (this.allIds.length) r.setStatus(this.allIds, "free");
4532
+ r.setOwnedHold?.(
4533
+ (this.hold_?.labels ?? []).map((label) => this.labelToId.get(label)).filter((id) => !!id)
4534
+ );
4293
4535
  const byStatus = { free: [], held: [], booked: [], not_for_sale: [] };
4294
4536
  for (const [label, st] of Object.entries(seats)) {
4295
4537
  const id = this.labelToId.get(label);