@seatlayer/core 0.22.0 → 0.24.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
@@ -111,6 +111,53 @@ function transformSectionOutlinePath(path, transform, radiusScale = 1, reflected
111
111
  };
112
112
  }
113
113
 
114
+ // src/core/labeling.ts
115
+ var FULL_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
116
+ var LOWER_ALPHABET = "abcdefghijklmnopqrstuvwxyz";
117
+ function toBijectiveBase(index, alphabet) {
118
+ const base = alphabet.length;
119
+ let n = index + 1;
120
+ let out = "";
121
+ while (n > 0) {
122
+ n -= 1;
123
+ const rem = n % base;
124
+ out = alphabet[rem] + out;
125
+ n = Math.floor(n / base);
126
+ }
127
+ return out;
128
+ }
129
+ function toLetters(value, lower = false) {
130
+ const alphabet = lower ? LOWER_ALPHABET : FULL_ALPHABET;
131
+ return toBijectiveBase(Math.max(0, Math.floor(value) - 1), alphabet);
132
+ }
133
+ var ROMAN_TABLE = [
134
+ [1e3, "M"],
135
+ [900, "CM"],
136
+ [500, "D"],
137
+ [400, "CD"],
138
+ [100, "C"],
139
+ [90, "XC"],
140
+ [50, "L"],
141
+ [40, "XL"],
142
+ [10, "X"],
143
+ [9, "IX"],
144
+ [5, "V"],
145
+ [4, "IV"],
146
+ [1, "I"]
147
+ ];
148
+ function toRoman(value) {
149
+ if (!Number.isFinite(value) || value <= 0) return String(value);
150
+ let remaining = Math.floor(value);
151
+ let out = "";
152
+ for (const [n, sym] of ROMAN_TABLE) {
153
+ while (remaining >= n) {
154
+ out += sym;
155
+ remaining -= n;
156
+ }
157
+ }
158
+ return out;
159
+ }
160
+
114
161
  // src/core/layout.ts
115
162
  function overrideAccessibility(o) {
116
163
  if (!o) return [];
@@ -156,24 +203,62 @@ function overrideMap(row) {
156
203
  if (row.overrides) for (const o of row.overrides) m.set(o.index, o);
157
204
  return m;
158
205
  }
159
- function expandRowSlots(row) {
160
- const start = row.seatLabelStart ?? 1;
206
+ function centerRank(n) {
207
+ const rank = new Array(n);
208
+ Array.from({ length: n }, (_, i) => i).sort((a, b) => Math.abs(2 * a - (n - 1)) - Math.abs(2 * b - (n - 1)) || a - b).forEach((idx, k) => rank[idx] = k);
209
+ return rank;
210
+ }
211
+ function seatLabelPart(row, i) {
212
+ const rawStart = row.seatLabelStart ?? 1;
161
213
  const dir = row.seatNumbering?.direction ?? "ltr";
162
214
  const step = row.seatNumbering?.step ?? 1;
215
+ const scheme = row.seatNumbering?.scheme ?? "decimal";
216
+ const prefix = row.seatNumbering?.prefix ?? "";
217
+ const endAt = row.seatNumbering?.endAt;
163
218
  const n = row.seatCount;
164
- let seatNumber;
165
- if (dir === "center") {
166
- const rank = new Array(n);
167
- Array.from({ length: n }, (_, i) => i).sort((a, b) => Math.abs(2 * a - (n - 1)) - Math.abs(2 * b - (n - 1)) || a - b).forEach((idx, k) => rank[idx] = k);
168
- seatNumber = (i) => start + rank[i] * step;
169
- } else {
170
- seatNumber = (i) => start + (dir === "rtl" ? n - 1 - i : i) * step;
219
+ if (scheme === "updown") {
220
+ const half = Math.ceil(n / 2);
221
+ const core2 = i < half ? rawStart + 2 * i : rawStart - 1 + 2 * (n - i);
222
+ return `${prefix}${core2}`;
223
+ }
224
+ const effStep = scheme === "odd" || scheme === "even" ? 2 : step;
225
+ const start = endAt != null && Number.isFinite(endAt) ? endAt - (n - 1) * effStep : rawStart;
226
+ const p = dir === "center" ? centerRank(n)[i] : dir === "rtl" ? n - 1 - i : i;
227
+ let core;
228
+ switch (scheme) {
229
+ case "odd": {
230
+ const firstOdd = start % 2 === 1 ? start : start + 1;
231
+ core = String(firstOdd + p * 2);
232
+ break;
233
+ }
234
+ case "even": {
235
+ const firstEven = start % 2 === 0 ? start : start + 1;
236
+ core = String(firstEven + p * 2);
237
+ break;
238
+ }
239
+ case "roman":
240
+ core = toRoman(start + p * step);
241
+ break;
242
+ case "letters-upper":
243
+ core = toLetters(start + p * step, false);
244
+ break;
245
+ case "letters-lower":
246
+ core = toLetters(start + p * step, true);
247
+ break;
248
+ case "decimal":
249
+ default:
250
+ core = String(start + p * step);
251
+ break;
171
252
  }
253
+ return `${prefix}${core}`;
254
+ }
255
+ function expandRowSlots(row) {
172
256
  const ov = overrideMap(row);
173
257
  return rowSeatPositions(row).map((p, i) => {
174
258
  const o = ov.get(i);
175
259
  const accessibility = overrideAccessibility(o);
176
- const inventoryLabel = o?.label ?? `${row.label}-${seatNumber(i)}`;
260
+ const part = seatLabelPart(row, i);
261
+ const inventoryLabel = o?.label ?? `${row.label}-${part}`;
177
262
  const displayPrefix = row.displayLabel ?? row.label;
178
263
  const commercial = { ...row.commercial, ...o?.commercial };
179
264
  return {
@@ -181,7 +266,7 @@ function expandRowSlots(row) {
181
266
  x: p.x + (o?.dx ?? 0),
182
267
  y: p.y + (o?.dy ?? 0),
183
268
  label: inventoryLabel,
184
- displayLabel: o?.displayLabel ?? `${displayPrefix}-${seatNumber(i)}`,
269
+ displayLabel: o?.displayLabel ?? `${displayPrefix}-${part}`,
185
270
  categoryKey: o?.categoryKey ?? row.categoryKey,
186
271
  skipped: !!o?.skip,
187
272
  accessible: accessibility.length > 0,
@@ -323,8 +408,8 @@ function polygonLabelPoint(outer, holes) {
323
408
  const xs = outer.map((point) => point.x);
324
409
  const ys = outer.map((point) => point.y);
325
410
  const bounds = { minX: Math.min(...xs), maxX: Math.max(...xs), minY: Math.min(...ys), maxY: Math.max(...ys) };
326
- const centroid = polygonCentroid(outer);
327
- if (pointInPolygonWithHoles(centroid, outer, holes)) return centroid;
411
+ const centroid2 = polygonCentroid(outer);
412
+ if (pointInPolygonWithHoles(centroid2, outer, holes)) return centroid2;
328
413
  let best = outer[0];
329
414
  let bestScore = -Infinity;
330
415
  const rings = [outer, ...holes ?? []];
@@ -648,6 +733,7 @@ import { Rect } from "konva/lib/shapes/Rect";
648
733
  import { Ellipse } from "konva/lib/shapes/Ellipse";
649
734
  import { Line } from "konva/lib/shapes/Line";
650
735
  import { Text } from "konva/lib/shapes/Text";
736
+ import { Path } from "konva/lib/shapes/Path";
651
737
  import { Image as KImage } from "konva/lib/shapes/Image";
652
738
  import { Shape } from "konva/lib/Shape";
653
739
 
@@ -664,6 +750,8 @@ var LIGHT_BOOKABLE_LABEL_INK = "#ffffff";
664
750
  function isBookableLabelLegibleAtScale(fontSize, effectiveScale) {
665
751
  return fontSize * effectiveScale >= MIN_VISIBLE_BOOKABLE_LABEL_PX;
666
752
  }
753
+ var ACCESS_GLYPH_VIEWBOX = 24;
754
+ var ACCESS_GLYPH_PATH = "M12 2c1.1 0 2 .9 2 2s-.9 2-2 2-2-.9-2-2 .9-2 2-2zm9 7h-6v13h-2v-6h-2v6H9V9H3V7h18v2z";
667
755
  function bookableMarkerLabel(publicLabel) {
668
756
  return /-(\d{1,5})$/.exec(publicLabel)?.[1] ?? publicLabel;
669
757
  }
@@ -743,29 +831,17 @@ var en = {
743
831
  "common.done": "Done",
744
832
  "common.copied": "\u2713 Copied",
745
833
  // buyer picker
746
- "picker.holdSeats": "Hold seats & checkout",
747
- "picker.completeBooking": "Complete booking",
748
- "picker.seatsHeld": "Seats held \u2014 {time}",
749
834
  "picker.holdExpired": "Your hold expired \u2014 the seats were released. Pick again.",
750
- "picker.seatTaken": "Seat {label} was just taken by another buyer.",
751
835
  "picker.poweredBy": "Powered by SeatLayer",
752
836
  "picker.testMode": "TEST MODE",
753
- "picker.colorblind": "Colorblind-friendly colors",
754
837
  "picker.orphanHint": "This leaves a single seat stranded \u2014 consider shifting one seat over.",
755
- "picker.seats.one": "{count} seat",
756
- "picker.seats.other": "{count} seats",
757
838
  // renderer (drawn on the Konva map — shared by the embed SDK)
758
839
  "map.aria": "Seating map. Use arrow keys to move between seats, Enter to select.",
759
840
  "map.seatsLeft": "{count} LEFT",
760
841
  "map.fromPrice": "FROM {price}",
761
842
  "map.statusHeld": "On hold",
762
843
  "map.statusTaken": "Taken",
763
- // buyer picker page (src/pages/PickerPage.tsx)
764
- "picker.language": "Language",
765
- "picker.zoomToFit": "Zoom to fit",
766
- "picker.seatCountLabel": "seats",
767
- "picker.capacity": "capacity",
768
- "picker.viewMode": "View mode",
844
+ // buyer picker widget (src/picker/widget/SeatPicker.ts)
769
845
  "picker.floor": "Floor",
770
846
  "picker.zoomLevel": "Zoom level",
771
847
  "picker.rungTip.zones": "Venue overview \u2014 groups of sections such as North Stand or VIP",
@@ -780,37 +856,19 @@ var en = {
780
856
  "picker.seatsLeftInSection.other": "{count} seats left",
781
857
  "picker.overview": "Overview",
782
858
  "picker.tapSeatHint": "Tap any seat to check its view",
783
- "picker.chartSize": "Chart size",
784
- "picker.custom": "Custom",
785
- "picker.categories": "Categories",
786
- "picker.accessibility": "Accessibility",
787
- "picker.showAnyAccessible": "Show any accessible seat",
788
- "picker.any": "Any",
789
- "picker.yourSeats": "Your seats",
790
- "picker.emptySeats": "Tap seats on the map",
791
- "picker.emptySeatsWithGa": "Tap seats on the map \xB7 tap a standing area for GA tickets",
792
- "picker.oneFewer": "One fewer",
793
- "picker.oneMore": "One more",
794
- "picker.remove": "Remove {label}",
795
859
  "picker.ticketTierFor": "Ticket tier for {label}",
796
- "picker.total": "Total: {amount}",
797
860
  "picker.viewFromSeat": "View from seat {label}",
798
861
  "picker.real360": "REAL 360\xB0",
799
862
  "picker.preview": "PREVIEW",
800
- "picker.open360": "Open 360\xB0 view",
801
863
  "picker.sightline": "\u2248 {m} m to stage \xB7 clear sightline",
802
- "picker.bookedDemo": "Booked! (demo)",
803
- "picker.bookButton.one": "Book {count} ticket \u2014 {amount}",
804
- "picker.bookButton.other": "Book {count} tickets \u2014 {amount}",
805
- "picker.simulateCrowd": "Simulate crowd: {state}",
806
- "picker.on": "ON",
807
- "picker.off": "OFF",
808
864
  "picker.panorama360": "360\xB0 venue photo",
809
865
  "picker.illustrationCaption": "illustration \xB7 \u2248 {m} m from stage",
810
866
  "picker.restrictedView": "Restricted view",
811
867
  "picker.obstructedView": "Obstructed view",
812
868
  "picker.premiumSeat": "Premium seat",
813
- "picker.hideLimitedView": "Hide limited-view seats"
869
+ "picker.hideLimitedView": "Hide limited-view seats",
870
+ "picker.bestSeatsPremium": "Best seats",
871
+ "picker.premiumFallbackNote": "No premium block of {count} \u2014 showing best overall"
814
872
  };
815
873
 
816
874
  // src/i18n/index.ts
@@ -861,6 +919,7 @@ var CACHE_THRESHOLD = 0.55 * SEAT_LEGIBLE_SCALE;
861
919
  var LABEL_SCALE = MIN_VISIBLE_BOOKABLE_LABEL_PX / SEAT_LABEL_FONT_SIZE;
862
920
  var MIN_FITTED_SEAT_LABEL_FONT_SIZE = 4;
863
921
  var SEAT_TAP_SLOP_PX = 14;
922
+ var SEAT_GLYPH_MIN_PX = 6.5;
864
923
  var SECTION_PROMINENT_SCALE = 0.45 * SEAT_LEGIBLE_SCALE;
865
924
  var BLOCK_MELT_TOP = 0.9 * SEAT_LEGIBLE_SCALE;
866
925
  var SEAT_FOCUS_SCALE = Math.max(SEAT_LEGIBLE_SCALE * 1.1, BLOCK_MELT_TOP);
@@ -1150,6 +1209,15 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1150
1209
  this.boothLabelById = /* @__PURE__ */ new Map();
1151
1210
  /** Viewport seat labels are rebuilt after each settled camera change. */
1152
1211
  this.seatLabelById = /* @__PURE__ */ new Map();
1212
+ /** Coloured accommodation ring per accessible seat (few per chart). */
1213
+ this.accessRingById = /* @__PURE__ */ new Map();
1214
+ /** Centred accessibility glyph per accessible seat — shown once the seat is
1215
+ * big enough on-screen (see {@link SEAT_GLYPH_MIN_PX}); the ring is the
1216
+ * smaller-zoom fallback. Kept in a map so zoom toggles touch only the handful
1217
+ * of accessible seats, never all 13k nodes. */
1218
+ this.accessGlyphById = /* @__PURE__ */ new Map();
1219
+ /** Whether the accessibility glyph is legible at the current camera scale. */
1220
+ this.accessGlyphVisible = false;
1153
1221
  /** Authored free-text nodes obey the same rendered-size visibility floor. */
1154
1222
  this.freeTextById = /* @__PURE__ */ new Map();
1155
1223
  /** Stage/rink landmarks retain a readable screen-space caption at overview. */
@@ -1462,6 +1530,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1462
1530
  this.boothDims.clear();
1463
1531
  this.boothLabelById.clear();
1464
1532
  this.seatLabelById.clear();
1533
+ this.accessRingById.clear();
1534
+ this.accessGlyphById.clear();
1465
1535
  this.freeTextById.clear();
1466
1536
  this.primaryFocalLabels.clear();
1467
1537
  this.gaById.clear();
@@ -2370,18 +2440,21 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2370
2440
  this.paintSeat(c, seat.id);
2371
2441
  target.add(c);
2372
2442
  if (seat.accessible) {
2373
- target.add(
2374
- new Circle({
2375
- x: seat.x,
2376
- y: seat.y,
2377
- radius: this.seatR + 1,
2378
- stroke: accessibilityRingColor(seat.accessibility),
2379
- strokeWidth: 2,
2380
- listening: false,
2381
- perfectDrawEnabled: false,
2382
- shadowForStrokeEnabled: false
2383
- })
2384
- );
2443
+ const ring = new Circle({
2444
+ x: seat.x,
2445
+ y: seat.y,
2446
+ radius: this.seatR + 1.5,
2447
+ stroke: accessibilityRingColor(seat.accessibility),
2448
+ strokeWidth: 2.5,
2449
+ listening: false,
2450
+ perfectDrawEnabled: false,
2451
+ shadowForStrokeEnabled: false
2452
+ });
2453
+ this.accessRingById.set(seat.id, ring);
2454
+ target.add(ring);
2455
+ const glyph = this.buildAccessGlyph(seat, typeof c.fill() === "string" ? c.fill() : "");
2456
+ this.accessGlyphById.set(seat.id, glyph);
2457
+ target.add(glyph);
2385
2458
  }
2386
2459
  }
2387
2460
  }
@@ -2425,6 +2498,53 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2425
2498
  target.add(t2);
2426
2499
  this.paintSeat(rect, seat.id);
2427
2500
  }
2501
+ /**
2502
+ * Build the centred accessibility glyph for a seat. Sized relative to the seat
2503
+ * radius (so it always fills the marker at any zoom) and given a contrast-aware
2504
+ * fill against the seat's paint — recomputed per state in {@link paintSeat}.
2505
+ */
2506
+ buildAccessGlyph(seat, seatFill) {
2507
+ const k = this.seatR * 1.5 / ACCESS_GLYPH_VIEWBOX;
2508
+ return new Path({
2509
+ x: seat.x,
2510
+ y: seat.y,
2511
+ data: ACCESS_GLYPH_PATH,
2512
+ offsetX: ACCESS_GLYPH_VIEWBOX / 2,
2513
+ offsetY: ACCESS_GLYPH_VIEWBOX / 2,
2514
+ scaleX: k,
2515
+ scaleY: k,
2516
+ fill: stateAwareBookableLabelInk(seatFill, "#ffffff"),
2517
+ listening: false,
2518
+ visible: this.accessGlyphVisible,
2519
+ perfectDrawEnabled: false,
2520
+ shadowForStrokeEnabled: false
2521
+ });
2522
+ }
2523
+ /**
2524
+ * Toggle the accessibility glyphs for the current camera scale: shown once the
2525
+ * effective on-screen seat radius clears {@link SEAT_GLYPH_MIN_PX}, otherwise
2526
+ * hidden so only the ring remains. Iterates the (small) accessible-seat set,
2527
+ * never the full node graph, so it is cheap to call on every view change.
2528
+ */
2529
+ updateAccessGlyphs(scale) {
2530
+ if (!this.accessGlyphById.size) return;
2531
+ this.accessGlyphVisible = this.seatR * scale >= SEAT_GLYPH_MIN_PX;
2532
+ for (const [id, glyph] of this.accessGlyphById) {
2533
+ glyph.visible(this.accessGlyphVisible && this.accessGlyphEligible(id));
2534
+ }
2535
+ }
2536
+ /**
2537
+ * Whether an accessible seat's glyph should show for its current status. It is
2538
+ * hidden on seats a buyer cannot take (sold, or another buyer's hold) where the
2539
+ * overlay status cue (diagonal mark / lock) carries the state instead — mirrors
2540
+ * the `unavailable` test in {@link updateLabels} so the two never collide.
2541
+ */
2542
+ accessGlyphEligible(id) {
2543
+ const status = this.statusById.get(id) ?? "free";
2544
+ if (status === "booked") return false;
2545
+ if (status === "held" && !this.ownedHold.has(id) && !this.opts.manageMode) return false;
2546
+ return true;
2547
+ }
2428
2548
  /** The category's display color — Okabe-Ito hue when colorblind-safe is on. */
2429
2549
  seatBaseColor(categoryKey) {
2430
2550
  if (!this.colorblind) return this.catColor.get(categoryKey) ?? "#6e7bff";
@@ -2538,6 +2658,14 @@ var _SeatmapRenderer = class _SeatmapRenderer {
2538
2658
  isBookableLabelLegibleAtScale(bookableLabel.fontSize(), this.effScale()) && c.opacity() >= 0.5
2539
2659
  );
2540
2660
  }
2661
+ const accessGlyph = this.accessGlyphById.get(id);
2662
+ if (accessGlyph) {
2663
+ const fill = c.fill();
2664
+ accessGlyph.fill(stateAwareBookableLabelInk(typeof fill === "string" ? fill : "", "#ffffff"));
2665
+ accessGlyph.opacity(c.opacity());
2666
+ accessGlyph.visible(this.accessGlyphVisible && this.accessGlyphEligible(id));
2667
+ }
2668
+ this.accessRingById.get(id)?.opacity(c.opacity());
2541
2669
  }
2542
2670
  /** True when a seat sits in a section/zone currently marked `closed`. */
2543
2671
  seatInClosedSection(id) {
@@ -3016,7 +3144,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3016
3144
  * Membership and category mix are still precomputed for the detailed state.
3017
3145
  */
3018
3146
  renderSection(obj) {
3019
- const centroid = polygonLabelPoint(obj.outline, obj.holes);
3147
+ const centroid2 = polygonLabelPoint(obj.outline, obj.holes);
3020
3148
  const palette = overviewPalette(this.canvasBackground);
3021
3149
  const memberIds = [];
3022
3150
  const catCounts = /* @__PURE__ */ new Map();
@@ -3078,8 +3206,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3078
3206
  const preferredInk = labelStyle?.color ?? palette.sectionInk;
3079
3207
  const labelScale = (labelStyle?.size ?? 18) / 18;
3080
3208
  const nameLabel = new Text({
3081
- x: obj.labelPresentation?.position?.x ?? centroid.x,
3082
- y: obj.labelPresentation?.position?.y ?? centroid.y,
3209
+ x: obj.labelPresentation?.position?.x ?? centroid2.x,
3210
+ y: obj.labelPresentation?.position?.y ?? centroid2.y,
3083
3211
  text: obj.displayLabel ?? obj.label,
3084
3212
  rotation: obj.labelPresentation?.rotation ?? 0,
3085
3213
  visible: obj.labelPresentation?.visible !== false,
@@ -3094,8 +3222,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3094
3222
  nameLabel.offsetY(nameLabel.height() / 2);
3095
3223
  bgTarget.add(nameLabel);
3096
3224
  const subLabel = new Text({
3097
- x: centroid.x,
3098
- y: centroid.y,
3225
+ x: centroid2.x,
3226
+ y: centroid2.y,
3099
3227
  text: t("map.seatsLeft", { count: free }),
3100
3228
  fontSize: 12,
3101
3229
  fontStyle: "700",
@@ -3114,8 +3242,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
3114
3242
  outline: obj.outline,
3115
3243
  ...obj.outlinePath ? { outlinePath: obj.outlinePath } : {},
3116
3244
  holes: obj.holes ?? [],
3117
- centroid,
3118
- labelAnchors: polygonLabelCandidates(obj.outline, obj.holes ?? [], centroid),
3245
+ centroid: centroid2,
3246
+ labelAnchors: polygonLabelCandidates(obj.outline, obj.holes ?? [], centroid2),
3119
3247
  zone: obj.zone,
3120
3248
  memberIds,
3121
3249
  total: memberIds.length,
@@ -4263,6 +4391,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4263
4391
  if (this.hasSections) this.applySectionLod(scale);
4264
4392
  else if (this.primaryFocalLabels.size) this.bgLayer.batchDraw();
4265
4393
  this.paintGAStateForView();
4394
+ this.updateAccessGlyphs(scale);
4266
4395
  const shouldCache = scale < CACHE_THRESHOLD;
4267
4396
  if (shouldCache && !this.cached) {
4268
4397
  this.cacheSeatLayer();
@@ -4345,6 +4474,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4345
4474
  for (const seat of this.seats) {
4346
4475
  if (seat.kind === "booth") continue;
4347
4476
  if (seat.x < x0 || seat.x > x1 || seat.y < y0 || seat.y > y1) continue;
4477
+ if (seat.accessible && this.accessGlyphVisible) continue;
4348
4478
  const shape = this.circleById.get(seat.id);
4349
4479
  if ((shape?.opacity() ?? 1) < 0.5) continue;
4350
4480
  const status = this.statusById.get(seat.id) ?? "free";
@@ -4475,6 +4605,103 @@ function strandedSingles(seats, statusOf, selectedIds) {
4475
4605
  return out;
4476
4606
  }
4477
4607
 
4608
+ // src/core/bestAvailable.ts
4609
+ function seatIndex2(seat, fallback) {
4610
+ const i = seat.id.lastIndexOf(":");
4611
+ if (i < 0) return fallback;
4612
+ const n = Number(seat.id.slice(i + 1));
4613
+ return Number.isFinite(n) ? n : fallback;
4614
+ }
4615
+ function dist2(a, b) {
4616
+ const dx = a.x - b.x;
4617
+ const dy = a.y - b.y;
4618
+ return dx * dx + dy * dy;
4619
+ }
4620
+ function centroid(seats) {
4621
+ let x = 0;
4622
+ let y = 0;
4623
+ for (const s of seats) {
4624
+ x += s.x;
4625
+ y += s.y;
4626
+ }
4627
+ return { x: x / seats.length, y: y / seats.length };
4628
+ }
4629
+ function isPremium(seat) {
4630
+ return seat.commercial?.premium === true;
4631
+ }
4632
+ function pickBestAvailable(seats, available, opts) {
4633
+ const qty = Math.floor(opts.qty);
4634
+ if (!Number.isFinite(qty) || qty <= 0) return { labels: [], reason: "sold_out" };
4635
+ const { categoryKey, focal, preferPremium } = opts;
4636
+ const rows = /* @__PURE__ */ new Map();
4637
+ const eligibleAll = [];
4638
+ seats.forEach((seat, i) => {
4639
+ const elig = available.has(seat.label) && (!categoryKey || seat.categoryKey === categoryKey);
4640
+ let arr = rows.get(seat.rowId);
4641
+ if (!arr) {
4642
+ arr = [];
4643
+ rows.set(seat.rowId, arr);
4644
+ }
4645
+ arr.push({ seat, index: seatIndex2(seat, i), elig });
4646
+ if (elig) eligibleAll.push(seat);
4647
+ });
4648
+ if (eligibleAll.length === 0) return { labels: [], reason: "sold_out" };
4649
+ let best = null;
4650
+ const better = (c) => {
4651
+ if (!best) return true;
4652
+ if (preferPremium && c.nonPremium !== best.nonPremium) return c.nonPremium < best.nonPremium;
4653
+ if (c.orphan !== best.orphan) return c.orphan < best.orphan;
4654
+ if (c.d2 !== best.d2) return c.d2 < best.d2;
4655
+ const r = c.rowId.localeCompare(best.rowId);
4656
+ if (r !== 0) return r < 0;
4657
+ return c.startIndex < best.startIndex;
4658
+ };
4659
+ for (const [rowId, slotsUnsorted] of rows) {
4660
+ const slots = [...slotsUnsorted].sort((a, b) => a.index - b.index);
4661
+ let i = 0;
4662
+ while (i < slots.length) {
4663
+ if (!slots[i].elig) {
4664
+ i++;
4665
+ continue;
4666
+ }
4667
+ let j = i;
4668
+ while (j < slots.length && slots[j].elig) j++;
4669
+ const segLen = j - i;
4670
+ for (let p = 0; p + qty <= segLen; p++) {
4671
+ const leftRem = p;
4672
+ const rightRem = segLen - (p + qty);
4673
+ const orphan = leftRem === 1 || rightRem === 1 ? 1 : 0;
4674
+ const runSeats = slots.slice(i + p, i + p + qty).map((s) => s.seat);
4675
+ const c = {
4676
+ labels: runSeats.map((s) => s.label),
4677
+ seats: runSeats,
4678
+ rowId,
4679
+ startIndex: slots[i + p].index,
4680
+ orphan,
4681
+ nonPremium: preferPremium ? runSeats.reduce((n, s) => n + (isPremium(s) ? 0 : 1), 0) : 0,
4682
+ d2: dist2(centroid(runSeats), focal)
4683
+ };
4684
+ if (better(c)) best = c;
4685
+ }
4686
+ i = j;
4687
+ }
4688
+ }
4689
+ if (best) return { labels: best.labels };
4690
+ if (eligibleAll.length < qty) return { labels: [], reason: "not_enough_together" };
4691
+ const ranked = eligibleAll.map((seat, i) => ({ seat, i, d2: dist2(seat, focal) })).sort((a, b) => {
4692
+ if (preferPremium) {
4693
+ const pa = isPremium(a.seat) ? 0 : 1;
4694
+ const pb = isPremium(b.seat) ? 0 : 1;
4695
+ if (pa !== pb) return pa - pb;
4696
+ }
4697
+ if (a.d2 !== b.d2) return a.d2 - b.d2;
4698
+ const r = a.seat.rowId.localeCompare(b.seat.rowId);
4699
+ if (r !== 0) return r;
4700
+ return seatIndex2(a.seat, a.i) - seatIndex2(b.seat, b.i);
4701
+ });
4702
+ return { labels: ranked.slice(0, qty).map((r) => r.seat.label) };
4703
+ }
4704
+
4478
4705
  // src/picker/PickerController.ts
4479
4706
  var DEFAULT_MAX_SELECTION = 10;
4480
4707
  var MAX_BACKOFF_MS = 15e3;
@@ -4597,9 +4824,11 @@ var PickerController = class {
4597
4824
  this.allIds.push(s.id);
4598
4825
  const source = chartObjects.get(s.rowId);
4599
4826
  const sourceLabel = source && "label" in source && typeof source.label === "string" ? source.label : void 0;
4600
- const rowLabel = s.kind === "booth" ? void 0 : sourceLabel;
4601
- const labelParts = s.label.split("-");
4602
- const seatNumber = rowLabel && s.label.startsWith(`${rowLabel}-`) ? s.label.slice(rowLabel.length + 1) : s.kind === "booth" ? s.label : labelParts[labelParts.length - 1] ?? s.label;
4827
+ const sourceDisplayLabel = source && "displayLabel" in source && typeof source.displayLabel === "string" && source.displayLabel ? source.displayLabel : sourceLabel;
4828
+ const rowLabel = s.kind === "booth" ? void 0 : sourceDisplayLabel;
4829
+ const visibleSeatLabel = s.displayLabel ?? s.label;
4830
+ const labelParts = visibleSeatLabel.split("-");
4831
+ const seatNumber = sourceDisplayLabel && visibleSeatLabel.startsWith(`${sourceDisplayLabel}-`) ? visibleSeatLabel.slice(sourceDisplayLabel.length + 1) : s.kind === "booth" ? visibleSeatLabel : labelParts[labelParts.length - 1] ?? visibleSeatLabel;
4603
4832
  this.seatContext.set(s.id, {
4604
4833
  sectionLabel: sectionLabels.get(membership.objectToSection.get(s.rowId) ?? ""),
4605
4834
  rowLabel,
@@ -4826,10 +5055,77 @@ var PickerController = class {
4826
5055
  this.setHold({ holdId: result.holdId, labels: combined.map((s) => s.label), expiresAt: result.expiresAt, items: result.items });
4827
5056
  return this.hold_;
4828
5057
  }
4829
- /** Server-picks `qty` best free seats and holds them atomically. Throws on failure. */
4830
- async bestAvailable(qty, categoryKey) {
5058
+ /** True when any seat on the visible chart carries the `premium` commercial
5059
+ * flag — gates the buyer widget's "★ Best seats" premium quick-pick (chip is
5060
+ * present-only, exactly like the accessibility filter chips). */
5061
+ hasPremiumSeats() {
5062
+ for (const seat of this.labelToSeat.values()) {
5063
+ if (seat.commercial?.premium) return true;
5064
+ }
5065
+ return false;
5066
+ }
5067
+ /**
5068
+ * Client-side premium pre-pass: find the best contiguous block of `qty`
5069
+ * PREMIUM-flagged free seats (orphan-avoiding, closest to the focal point)
5070
+ * using the local seat geometry + live availability, mirroring the server's
5071
+ * held-back exclusion via the visible (hidden-stripped) chart and current
5072
+ * seat status. Returns a FULL premium block of `qty`, or null when none
5073
+ * exists (the caller then falls back to the normal server pick).
5074
+ */
5075
+ pickPremiumBlock(qty, categoryKey) {
5076
+ const doc = this.visibleDoc();
5077
+ if (!doc?.objects?.length) return null;
5078
+ const focal = doc.focalPoint ?? { x: 0, y: 0 };
5079
+ const seats = expandChart(doc);
5080
+ const held = new Set(this.hold_?.labels ?? []);
5081
+ const available = /* @__PURE__ */ new Set();
5082
+ for (const seat of seats) {
5083
+ if (held.has(seat.label)) continue;
5084
+ const id = this.labelToId.get(seat.label);
5085
+ const status = id ? this.getStatus(id) : void 0;
5086
+ if ((status ?? "free") === "free") available.add(seat.label);
5087
+ }
5088
+ const pick = pickBestAvailable(seats, available, { qty, categoryKey, focal, preferPremium: true });
5089
+ if (pick.labels.length !== qty) return null;
5090
+ const allPremium = pick.labels.every((l) => this.labelToSeat.get(l)?.commercial?.premium);
5091
+ return allPremium ? [...pick.labels] : null;
5092
+ }
5093
+ /**
5094
+ * Server-picks `qty` best free seats and holds them atomically. Throws on
5095
+ * failure. With `preferPremium`, first tries to hold the best PREMIUM block
5096
+ * locally; when none matches the requested quantity it falls back to the
5097
+ * normal server pick (the widget surfaces a subtle note on that fallback).
5098
+ */
5099
+ async bestAvailable(qty, categoryKey, opts = {}) {
4831
5100
  const r = this.renderer;
4832
5101
  if (!r) return null;
5102
+ if (opts.preferPremium) {
5103
+ const block = this.pickPremiumBlock(qty, categoryKey);
5104
+ if (block) {
5105
+ if (this.hold_ && !await this.release()) return null;
5106
+ try {
5107
+ const selection = block.map((label) => {
5108
+ const seat = this.labelToSeat.get(label);
5109
+ const resolved = seat ? this.toSeat(seat) : null;
5110
+ return { label, ...resolved?.tierId ? { tierId: resolved.tierId } : {} };
5111
+ });
5112
+ const result = await this.api.hold(this.key, selection);
5113
+ r.clearSelection();
5114
+ const ids = block.map((l) => this.labelToId.get(l)).filter((v) => !!v);
5115
+ if (ids.length) r.setStatus(ids, "held");
5116
+ this.setHold({ holdId: result.holdId, labels: [...block], expiresAt: result.expiresAt, items: result.items });
5117
+ const seats = block.map((l) => this.labelToSeat.get(l)).filter((s) => !!s).map((s) => this.toSeat(s));
5118
+ this.opts.onSelectionChange?.(seats);
5119
+ return this.hold_;
5120
+ } catch (err) {
5121
+ const { status, reason } = errInfo(err);
5122
+ if (status === 409 && reason === "event_closed") {
5123
+ this.opts.onSalesClosed?.();
5124
+ throw err;
5125
+ }
5126
+ }
5127
+ }
5128
+ }
4833
5129
  if (this.hold_ && !await this.release()) return null;
4834
5130
  try {
4835
5131
  const result = await this.api.bestAvailable(this.key, qty, categoryKey);
@@ -5202,14 +5498,16 @@ var PickerController = class {
5202
5498
  // ---- internals ------------------------------------------------------------
5203
5499
  toSeat(s) {
5204
5500
  const commercial = s.commercial ? { commercial: s.commercial } : void 0;
5501
+ const display = s.displayLabel ? { displayLabel: s.displayLabel } : void 0;
5205
5502
  const tiers = this.tiersFor(s.categoryKey);
5206
5503
  if (!tiers) {
5207
- return { id: s.id, label: s.label, categoryKey: s.categoryKey, price: this.priceFor(s.categoryKey), ...commercial };
5504
+ return { id: s.id, label: s.label, ...display, categoryKey: s.categoryKey, price: this.priceFor(s.categoryKey), ...commercial };
5208
5505
  }
5209
5506
  const chosen = tiers.find((t2) => t2.id === this.seatTiers.get(s.id)) ?? tiers[0];
5210
5507
  return {
5211
5508
  id: s.id,
5212
5509
  label: s.label,
5510
+ ...display,
5213
5511
  categoryKey: s.categoryKey,
5214
5512
  price: chosen.price,
5215
5513
  tiers,
@@ -5702,9 +6000,9 @@ function generateSeatThumb(seat, focalPoint, _neighborSeats) {
5702
6000
 
5703
6001
  // src/i18n/bundles.ts
5704
6002
  var LOADERS = {
5705
- es: () => import("./es-ALPR5FBB.js").then((m) => ({ default: m.es })),
5706
- de: () => import("./de-KPTY7UOT.js").then((m) => ({ default: m.de })),
5707
- fr: () => import("./fr-HVQLQD2N.js").then((m) => ({ default: m.fr }))
6003
+ es: () => import("./es-47HFAWS6.js").then((m) => ({ default: m.es })),
6004
+ de: () => import("./de-EIG65UFU.js").then((m) => ({ default: m.de })),
6005
+ fr: () => import("./fr-7IXNNFBS.js").then((m) => ({ default: m.fr }))
5708
6006
  };
5709
6007
  var loaded = /* @__PURE__ */ new Set(["en"]);
5710
6008
  async function loadLocale(code) {
@@ -5769,6 +6067,8 @@ export {
5769
6067
  pointInPolygonWithHoles,
5770
6068
  polygonLabelPoint,
5771
6069
  resolveLocale,
6070
+ rowSeatPositions,
6071
+ seatLabelPart,
5772
6072
  setLocale,
5773
6073
  setMoneyLocale,
5774
6074
  setStringOverrides,