@seatlayer/core 0.1.2 → 0.1.4

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
@@ -78,7 +78,15 @@ function expandRowSlots(row) {
78
78
  const start = row.seatLabelStart ?? 1;
79
79
  const dir = row.seatNumbering?.direction ?? "ltr";
80
80
  const step = row.seatNumbering?.step ?? 1;
81
- const seatNumber = (i) => start + (dir === "rtl" ? row.seatCount - 1 - i : i) * step;
81
+ const n = row.seatCount;
82
+ let seatNumber;
83
+ if (dir === "center") {
84
+ const rank = new Array(n);
85
+ 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);
86
+ seatNumber = (i) => start + rank[i] * step;
87
+ } else {
88
+ seatNumber = (i) => start + (dir === "rtl" ? n - 1 - i : i) * step;
89
+ }
82
90
  const ov = overrideMap(row);
83
91
  return rowSeatPositions(row).map((p, i) => {
84
92
  const o = ov.get(i);
@@ -113,30 +121,41 @@ function expandRow(row) {
113
121
  }
114
122
  return seats;
115
123
  }
116
- function expandTable(t) {
124
+ function expandTable(t2) {
117
125
  const seats = [];
118
- const n = Math.max(0, Math.round(t.seatCount));
126
+ const n = Math.max(0, Math.round(t2.seatCount));
119
127
  if (n === 0) return seats;
120
128
  const mk = (i, x, y) => ({
121
- id: `${t.id}:${i}`,
122
- label: `${t.label}-${i + 1}`,
129
+ id: `${t2.id}:${i}`,
130
+ label: `${t2.label}-${i + 1}`,
123
131
  x,
124
132
  y,
125
- rowId: t.id,
126
- categoryKey: t.categoryKey
133
+ rowId: t2.id,
134
+ categoryKey: t2.categoryKey
127
135
  });
128
- if (t.shape === "round") {
129
- const R = (t.radius ?? 40) + TABLE_SEAT_OFFSET;
130
- const base = t.rotation * DEG;
136
+ if (t2.shape === "round") {
137
+ const R = (t2.radius ?? 40) + TABLE_SEAT_OFFSET;
138
+ const base = t2.rotation * DEG;
139
+ const arc = Math.max(0, Math.min(360, t2.seatArc ?? 360));
140
+ if (arc >= 360 || n === 1) {
141
+ for (let i = 0; i < n; i++) {
142
+ const a = base + i / n * 2 * Math.PI;
143
+ seats.push(mk(i, t2.center.x + R * Math.cos(a), t2.center.y + R * Math.sin(a)));
144
+ }
145
+ return seats;
146
+ }
147
+ const arcRad = arc * DEG;
148
+ const halfGap = (2 * Math.PI - arcRad) / 2;
149
+ const start = base + halfGap;
131
150
  for (let i = 0; i < n; i++) {
132
- const a = base + i / n * 2 * Math.PI;
133
- seats.push(mk(i, t.center.x + R * Math.cos(a), t.center.y + R * Math.sin(a)));
151
+ const a = start + i / (n - 1) * arcRad;
152
+ seats.push(mk(i, t2.center.x + R * Math.cos(a), t2.center.y + R * Math.sin(a)));
134
153
  }
135
154
  return seats;
136
155
  }
137
- const w = t.width ?? 80;
138
- const h = t.height ?? 50;
139
- const enabled = t.sides && t.sides.length ? t.sides : ["top", "bottom"];
156
+ const w = t2.width ?? 80;
157
+ const h = t2.height ?? 50;
158
+ const enabled = t2.sides && t2.sides.length ? t2.sides : ["top", "bottom"];
140
159
  const order = ["top", "bottom", "left", "right"].filter((s) => enabled.includes(s));
141
160
  if (!order.length) return seats;
142
161
  const counts = new Map(order.map((s) => [s, 0]));
@@ -163,7 +182,7 @@ function expandTable(t) {
163
182
  localX = w / 2 + TABLE_SEAT_OFFSET;
164
183
  localY = -h / 2 + (j + 0.5) * h / count;
165
184
  }
166
- const p = place(localX, localY, t.rotation, t.center);
185
+ const p = place(localX, localY, t2.rotation, t2.center);
167
186
  seats.push(mk(idx++, p.x, p.y));
168
187
  }
169
188
  }
@@ -234,9 +253,53 @@ function objectCenter(o) {
234
253
  return { x: o.x ?? 0, y: o.y ?? 0 };
235
254
  }
236
255
  }
256
+ function floorsOf(doc) {
257
+ if (doc.floors && doc.floors.length) return doc.floors;
258
+ return [{ id: "floor-0", name: "Main", objects: doc.objects, focalPoint: doc.focalPoint, backgroundImage: doc.backgroundImage }];
259
+ }
260
+ function floorObjects(doc, floorId) {
261
+ const floors = floorsOf(doc);
262
+ return (floorId ? floors.find((f) => f.id === floorId) : floors[0])?.objects ?? [];
263
+ }
264
+ function allObjects(doc) {
265
+ return doc.floors && doc.floors.length ? doc.floors.flatMap((f) => f.objects) : doc.objects;
266
+ }
267
+ function translateObject(o, dx, dy) {
268
+ const p = (pt) => ({ x: pt.x + dx, y: pt.y + dy });
269
+ const pts = (a) => a.map(p);
270
+ switch (o.type) {
271
+ case "row":
272
+ return { ...o, origin: p(o.origin) };
273
+ case "table":
274
+ case "booth":
275
+ return { ...o, center: p(o.center) };
276
+ case "gaArea":
277
+ return { ...o, points: pts(o.points) };
278
+ case "section":
279
+ return { ...o, outline: pts(o.outline) };
280
+ case "text":
281
+ return { ...o, position: p(o.position) };
282
+ case "shape":
283
+ return {
284
+ ...o,
285
+ ...o.points ? { points: pts(o.points) } : {},
286
+ ...o.x != null ? { x: o.x + dx } : {},
287
+ ...o.y != null ? { y: o.y + dy } : {}
288
+ };
289
+ }
290
+ }
291
+ function stackFloors(doc, spread = 900) {
292
+ if (!doc.floors || doc.floors.length < 2) return doc;
293
+ const objects = [];
294
+ doc.floors.forEach((f, i) => {
295
+ const dy = -i * spread;
296
+ for (const o of f.objects) objects.push(dy === 0 ? o : translateObject(o, 0, dy));
297
+ });
298
+ return { ...doc, objects, floors: void 0 };
299
+ }
237
300
  function expandChart(doc) {
238
301
  const out = [];
239
- for (const obj of doc.objects) {
302
+ for (const obj of allObjects(doc)) {
240
303
  if (obj.type === "row") out.push(...expandRow(obj));
241
304
  else if (obj.type === "table") out.push(...expandTable(obj));
242
305
  else if (obj.type === "booth") out.push(...expandBooth(obj));
@@ -256,7 +319,7 @@ function chartBounds(doc) {
256
319
  if (y > maxY) maxY = y;
257
320
  };
258
321
  for (const s of expandChart(doc)) acc(s.x, s.y);
259
- for (const obj of doc.objects) {
322
+ for (const obj of allObjects(doc)) {
260
323
  if (obj.type === "gaArea") {
261
324
  for (const p of obj.points) acc(p.x, p.y);
262
325
  } else if (obj.type === "section") {
@@ -301,6 +364,87 @@ function chartBounds(doc) {
301
364
  };
302
365
  }
303
366
 
367
+ // src/core/sections.ts
368
+ var UNGROUPED_ID = "__ungrouped__";
369
+ function objectSeatLabels(o) {
370
+ if (o.type === "row") return expandRow(o).map((s) => s.label);
371
+ if (o.type === "table") return expandTable(o).map((s) => s.label);
372
+ if (o.type === "booth") return expandBooth(o).map((s) => s.label);
373
+ return [];
374
+ }
375
+ function isSeatObject(o) {
376
+ return o.type === "row" || o.type === "table" || o.type === "booth";
377
+ }
378
+ function computeSections(doc) {
379
+ const objs = allObjects(doc);
380
+ const sectionObjs = objs.filter((o) => o.type === "section");
381
+ const nodes = /* @__PURE__ */ new Map();
382
+ for (const s of sectionObjs) {
383
+ nodes.set(s.id, { id: s.id, label: s.label || "Section", zone: s.zone, seatCount: 0, objectIds: [], seatLabels: [] });
384
+ }
385
+ const ungrouped = { id: UNGROUPED_ID, label: "Other seats", seatCount: 0, objectIds: [], seatLabels: [] };
386
+ const objectToSection = /* @__PURE__ */ new Map();
387
+ for (const obj of objs) {
388
+ if (!isSeatObject(obj)) continue;
389
+ const labels = objectSeatLabels(obj);
390
+ if (labels.length === 0) continue;
391
+ const c = objectCenter(obj);
392
+ const owner = sectionObjs.find((s) => pointInPolygon(c, s.outline));
393
+ const node = owner ? nodes.get(owner.id) : ungrouped;
394
+ node.seatCount += labels.length;
395
+ node.objectIds.push(obj.id);
396
+ node.seatLabels.push(...labels);
397
+ objectToSection.set(obj.id, node.id);
398
+ }
399
+ return {
400
+ sections: sectionObjs.map((s) => nodes.get(s.id)),
401
+ ungrouped: ungrouped.objectIds.length ? ungrouped : null,
402
+ objectToSection
403
+ };
404
+ }
405
+ function isSectionHidden(s, hidden) {
406
+ return hidden.has(s.id) || !!s.zone && hidden.has(s.zone);
407
+ }
408
+ function hiddenObjectIds(doc, hidden) {
409
+ const out = /* @__PURE__ */ new Set();
410
+ if (!hidden.size) return out;
411
+ const { sections, ungrouped, objectToSection } = computeSections(doc);
412
+ const hiddenSectionIds = /* @__PURE__ */ new Set();
413
+ const zoneById = new Map(sections.map((s) => [s.id, s.zone]));
414
+ for (const s of sections) {
415
+ const zone = zoneById.get(s.id);
416
+ if (hidden.has(s.id) || zone && hidden.has(zone)) hiddenSectionIds.add(s.id);
417
+ }
418
+ const ungroupedHidden = !!ungrouped && hidden.has(UNGROUPED_ID);
419
+ for (const [objId, secId] of objectToSection) {
420
+ if (hiddenSectionIds.has(secId) || ungroupedHidden && secId === UNGROUPED_ID) out.add(objId);
421
+ }
422
+ return out;
423
+ }
424
+ function applyHidden(doc, hidden) {
425
+ if (!hidden.size) return doc;
426
+ const hide = hiddenObjectIds(doc, hidden);
427
+ const hiddenSet = hidden instanceof Set ? hidden : new Set(hidden);
428
+ const keep = (o) => {
429
+ if (hide.has(o.id)) return false;
430
+ if (o.type === "section" && isSectionHidden(o, hiddenSet)) return false;
431
+ return true;
432
+ };
433
+ if (doc.floors && doc.floors.length) {
434
+ let changed = false;
435
+ const floors = doc.floors.map((f) => {
436
+ const objects2 = f.objects.filter(keep);
437
+ if (objects2.length !== f.objects.length) changed = true;
438
+ return objects2.length === f.objects.length ? f : { ...f, objects: objects2 };
439
+ });
440
+ if (!changed) return doc;
441
+ return { ...doc, floors, objects: floors[0].objects };
442
+ }
443
+ const objects = doc.objects.filter(keep);
444
+ if (objects.length === doc.objects.length) return doc;
445
+ return { ...doc, objects };
446
+ }
447
+
304
448
  // src/engine/SeatmapRenderer.ts
305
449
  import { Konva } from "konva/lib/Core";
306
450
  import { Stage } from "konva/lib/Stage";
@@ -312,26 +456,170 @@ import { Ellipse } from "konva/lib/shapes/Ellipse";
312
456
  import { Line } from "konva/lib/shapes/Line";
313
457
  import { Text } from "konva/lib/shapes/Text";
314
458
  import { Image as KImage } from "konva/lib/shapes/Image";
459
+
460
+ // src/lib/money.ts
461
+ var DEFAULT_CURRENCY = "USD";
462
+ var displayLocale;
463
+ function setMoneyLocale(locale) {
464
+ displayLocale = locale;
465
+ }
466
+ var formatterCache = /* @__PURE__ */ new Map();
467
+ function formatter(currency, digits) {
468
+ const key = `${displayLocale ?? ""}|${currency}|${digits ?? "auto"}`;
469
+ let fmt = formatterCache.get(key);
470
+ if (!fmt) {
471
+ fmt = new Intl.NumberFormat(displayLocale, {
472
+ style: "currency",
473
+ currency,
474
+ minimumFractionDigits: digits,
475
+ maximumFractionDigits: digits
476
+ });
477
+ formatterCache.set(key, fmt);
478
+ }
479
+ return fmt;
480
+ }
481
+ function formatMoney(amount, currency = DEFAULT_CURRENCY, fractionDigits) {
482
+ const digits = fractionDigits ?? (Number.isInteger(amount) ? 0 : void 0);
483
+ return formatter(currency, digits).format(amount);
484
+ }
485
+ function currencySymbol(currency = DEFAULT_CURRENCY) {
486
+ const part = formatter(currency, 0).formatToParts(0).find((p) => p.type === "currency");
487
+ return part?.value ?? currency;
488
+ }
489
+
490
+ // src/i18n/locales/en.ts
491
+ var en = {
492
+ // shared
493
+ "common.cancel": "Cancel",
494
+ "common.close": "Close",
495
+ "common.done": "Done",
496
+ "common.copied": "\u2713 Copied",
497
+ // buyer picker (extraction in progress — P4 of the port plan)
498
+ "picker.holdSeats": "Hold seats & checkout",
499
+ "picker.completeBooking": "Complete booking",
500
+ "picker.seatsHeld": "Seats held \u2014 {time}",
501
+ "picker.holdExpired": "Your hold expired \u2014 the seats were released. Pick again.",
502
+ "picker.seatTaken": "Seat {label} was just taken by another buyer.",
503
+ "picker.poweredBy": "Powered by SeatLayer",
504
+ "picker.colorblind": "Colorblind-friendly colors",
505
+ "picker.orphanHint": "This leaves a single seat stranded \u2014 consider shifting one seat over.",
506
+ "picker.seats.one": "{count} seat",
507
+ "picker.seats.other": "{count} seats",
508
+ // renderer (drawn on the Konva map — shared by the embed SDK)
509
+ "map.aria": "Seating map. Use arrow keys to move between seats, Enter to select.",
510
+ "map.seatsLeft": "{count} LEFT",
511
+ "map.fromPrice": "FROM {price}",
512
+ // buyer picker page (src/pages/PickerPage.tsx)
513
+ "picker.language": "Language",
514
+ "picker.zoomToFit": "Zoom to fit",
515
+ "picker.viewMode": "View mode",
516
+ "picker.floor": "Floor",
517
+ "picker.zoomLevel": "Zoom level",
518
+ "picker.rungTip.zones": "Farthest rung \u2014 zone blocks (Floor / Lower Bowl / Upper Bowl)",
519
+ "picker.rungTip.sections": "Section blocks \u2014 category-mix fill + availability tint",
520
+ "picker.rungTip.seats": "Individual seats \u2014 blocks melt into dots",
521
+ "picker.rungLabel.zones": "ZONES",
522
+ "picker.rungLabel.sections": "SECTIONS",
523
+ "picker.rungLabel.seats": "SEATS",
524
+ "picker.sectionSummaryAria": "{label} section summary",
525
+ "picker.closeSectionSummary": "Close section summary",
526
+ "picker.seatsLeftInSection.one": "{count} seat left",
527
+ "picker.seatsLeftInSection.other": "{count} seats left",
528
+ "picker.overview": "Overview",
529
+ "picker.tapSeatHint": "Tap any seat to check its view",
530
+ "picker.chartSize": "Chart size",
531
+ "picker.custom": "Custom",
532
+ "picker.categories": "Categories",
533
+ "picker.accessibility": "Accessibility",
534
+ "picker.showAnyAccessible": "Show any accessible seat",
535
+ "picker.any": "Any",
536
+ "picker.yourSeats": "Your seats",
537
+ "picker.emptySeats": "Tap seats on the map",
538
+ "picker.emptySeatsWithGa": "Tap seats on the map \xB7 tap a standing area for GA tickets",
539
+ "picker.oneFewer": "One fewer",
540
+ "picker.oneMore": "One more",
541
+ "picker.remove": "Remove {label}",
542
+ "picker.ticketTierFor": "Ticket tier for {label}",
543
+ "picker.total": "Total: {amount}",
544
+ "picker.viewFromSeat": "View from seat {label}",
545
+ "picker.real360": "REAL 360\xB0",
546
+ "picker.preview": "PREVIEW",
547
+ "picker.open360": "Open 360\xB0 view",
548
+ "picker.sightline": "\u2248 {rows} rows from stage \xB7 {angle}\xB0 off-center",
549
+ "picker.bookedDemo": "Booked! (demo)",
550
+ "picker.bookButton.one": "Book {count} ticket \u2014 {amount}",
551
+ "picker.bookButton.other": "Book {count} tickets \u2014 {amount}",
552
+ "picker.simulateCrowd": "Simulate crowd: {state}",
553
+ "picker.on": "ON",
554
+ "picker.off": "OFF",
555
+ "picker.panorama360": "360\xB0 venue photo",
556
+ "picker.illustrationCaption": "illustration \xB7 \u2248 {m} m from stage"
557
+ };
558
+
559
+ // src/i18n/index.ts
560
+ var SUPPORTED_LOCALES = ["en", "es", "de", "fr"];
561
+ var bundles = { en };
562
+ var overrides = {};
563
+ var active = "en";
564
+ function resolveLocale(explicit, stored) {
565
+ for (const candidate of [explicit, stored, typeof navigator !== "undefined" ? navigator.language : null]) {
566
+ if (!candidate) continue;
567
+ const base = candidate.toLowerCase().split("-")[0];
568
+ if (SUPPORTED_LOCALES.includes(base)) return base;
569
+ }
570
+ return "en";
571
+ }
572
+ function getLocale() {
573
+ return active;
574
+ }
575
+ function setLocale(locale, bundle) {
576
+ if (bundle) bundles[locale] = { ...bundles[locale], ...bundle };
577
+ active = bundles[locale] ? locale : "en";
578
+ setMoneyLocale(active);
579
+ if (typeof document !== "undefined") document.documentElement.lang = active;
580
+ }
581
+ function setStringOverrides(next) {
582
+ overrides = next;
583
+ }
584
+ var PLACEHOLDER = /\{(\w+)\}/g;
585
+ function t(key, vars) {
586
+ const raw = overrides[key] ?? bundles[active]?.[key] ?? en[key] ?? key;
587
+ if (!vars) return raw;
588
+ return raw.replace(PLACEHOLDER, (_, name) => String(vars[name] ?? `{${name}}`));
589
+ }
590
+ function tCount(key, count, vars) {
591
+ const rule = new Intl.PluralRules(active).select(count);
592
+ const exact = overrides[`${key}.${rule}`] ?? bundles[active]?.[`${key}.${rule}`] ?? en[`${key}.${rule}`];
593
+ const raw = exact ?? overrides[`${key}.other`] ?? bundles[active]?.[`${key}.other`] ?? en[`${key}.other`] ?? key;
594
+ return raw.replace(PLACEHOLDER, (_, name) => String({ count, ...vars }[name] ?? `{${name}}`));
595
+ }
596
+ function formatDate(value, opts) {
597
+ return new Intl.DateTimeFormat(active, opts ?? { dateStyle: "medium", timeStyle: "short" }).format(value);
598
+ }
599
+
600
+ // src/engine/SeatmapRenderer.ts
315
601
  var SEAT_RADIUS = 9;
316
602
  var SEAT_LEGIBLE_SCALE = 0.9;
317
603
  var CACHE_THRESHOLD = 0.55 * SEAT_LEGIBLE_SCALE;
318
604
  var LABEL_SCALE = 1;
319
- var SECTION_PROMINENT_SCALE = 0.35 * SEAT_LEGIBLE_SCALE;
605
+ var SECTION_PROMINENT_SCALE = 0.45 * SEAT_LEGIBLE_SCALE;
606
+ var BLOCK_MELT_TOP = 0.9 * SEAT_LEGIBLE_SCALE;
320
607
  var ZONE_PROMINENT_SCALE = 0.55 * SECTION_PROMINENT_SCALE;
321
608
  var MAX_LABELS = 700;
322
609
  var ISO_ANGLE_DEG = -11.5;
323
610
  var ISO_SQUASH = 0.58;
324
- var LIFT_PER_STEP = 46;
611
+ var LIFT_PER_STEP = 58;
325
612
  var ISO_TWEEN_MS = 320;
326
613
  var BLOCK_FILL_ALPHA = 0.85;
327
614
  var SOLD_DARKEN = 0.5;
328
- var SECTION_LABEL_PX = 18;
329
- var SECTION_SUB_PX = 11;
615
+ var SECTION_LABEL_PX = 20;
616
+ var SECTION_SUB_PX = 12.5;
330
617
  var ZONE_LABEL_PX = 30;
331
618
  var ZONE_SUB_PX = 12;
332
619
  var HELD_FILL = "#6b7280";
333
620
  var TAKEN_FILL = "#374151";
334
621
  var NFS_STROKE = "#4b5563";
622
+ var CB_PALETTE = ["#E69F00", "#56B4E9", "#009E73", "#F0E442", "#0072B2", "#D55E00", "#CC79A7"];
335
623
  var ACCESS_RING = {
336
624
  wheelchair: "#3b82f6",
337
625
  companion: "#8b5cf6",
@@ -343,7 +631,7 @@ var ACCESS_RING = {
343
631
  };
344
632
  function seatMatchesAccess(seat, filter) {
345
633
  if (filter.length === 0) return !!seat.accessible;
346
- return !!seat.accessibility?.some((t) => filter.includes(t));
634
+ return !!seat.accessibility?.some((t2) => filter.includes(t2));
347
635
  }
348
636
  var DEF_SEAT_LABEL = "#0b1220";
349
637
  var DEF_SELECTION = "#ffffff";
@@ -412,16 +700,21 @@ function mixColors(parts, fallback) {
412
700
  }
413
701
  return tw > 0 ? toHex(r / tw, g / tw, b / tw) : fallback;
414
702
  }
415
- function lerpColor(a, b, t) {
703
+ function lerpColor(a, b, t2) {
416
704
  const ca = hexToRgb(a);
417
705
  const cb = hexToRgb(b);
418
706
  if (!ca || !cb) return a;
419
- return toHex(ca[0] + (cb[0] - ca[0]) * t, ca[1] + (cb[1] - ca[1]) * t, ca[2] + (cb[2] - ca[2]) * t);
707
+ return toHex(ca[0] + (cb[0] - ca[0]) * t2, ca[1] + (cb[1] - ca[1]) * t2, ca[2] + (cb[2] - ca[2]) * t2);
420
708
  }
421
709
  var _SeatmapRenderer = class _SeatmapRenderer {
422
710
  constructor(container, options = {}) {
423
711
  this.seats = [];
424
712
  this.seatById = /* @__PURE__ */ new Map();
713
+ /** Multi-floor (Batch 5): the last-set chart + which floor we're rendering. */
714
+ this.chartDoc = null;
715
+ this.activeFloorId = "";
716
+ /** When true on a multi-floor chart, render ALL floors stacked (3D overview). */
717
+ this.stacked = false;
425
718
  /** Interactive node per seat/booth — a Circle for seats, a Rect for booths. */
426
719
  this.circleById = /* @__PURE__ */ new Map();
427
720
  /** Booth block geometry, keyed by booth id (= the unit's rowId). */
@@ -429,6 +722,10 @@ var _SeatmapRenderer = class _SeatmapRenderer {
429
722
  this.statusById = /* @__PURE__ */ new Map();
430
723
  this.catColor = /* @__PURE__ */ new Map();
431
724
  this.theme = {};
725
+ /** Colorblind-safe mode (Okabe-Ito hues + hollow booked seats). */
726
+ this.colorblind = false;
727
+ /** Category order from the doc — the stable index into the CB palette. */
728
+ this.catOrder = [];
432
729
  this.selection = /* @__PURE__ */ new Set();
433
730
  this.selectionRings = /* @__PURE__ */ new Map();
434
731
  this.focusedId = null;
@@ -445,6 +742,10 @@ var _SeatmapRenderer = class _SeatmapRenderer {
445
742
  this.zones = [];
446
743
  this.seatSection = /* @__PURE__ */ new Map();
447
744
  this.catPrice = /* @__PURE__ */ new Map();
745
+ /** Section/zone ids to render dimmed (organizer manager: held-back inventory). */
746
+ this.dimmedSections = /* @__PURE__ */ new Set();
747
+ /** Object id → floor id (multi-floor only) — resolves a deck tap in the 3D stack. */
748
+ this.objectFloor = /* @__PURE__ */ new Map();
448
749
  /** Zone id → colour (drives extruded side faces in iso view). */
449
750
  this.zoneColor = /* @__PURE__ */ new Map();
450
751
  this.hasSections = false;
@@ -457,6 +758,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
457
758
  this.isoRaf = 0;
458
759
  /** Chart centre the iso projection pivots about (bounds centre). */
459
760
  this.isoCentre = { x: 0, y: 0 };
761
+ /** rAF for an in-flight camera glide (focusRegion / setRung); 0 = none. */
762
+ this.glideRaf = 0;
460
763
  /** Set in destroy() so an in-flight iso tween bails. */
461
764
  this.destroyed = false;
462
765
  /** Cached scale the section/zone labels were last sized for (scale-compensation). */
@@ -504,6 +807,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
504
807
  // pointer events away from Konva's canvas, killing its click/tap/hover
505
808
  // pipeline. We rely on bubbling instead.
506
809
  this.onPointerDown = (e) => {
810
+ this.cancelGlide();
507
811
  this.pointers.set(e.pointerId, this.toLocal(e));
508
812
  if (this.pointers.size === 1) {
509
813
  this.moved = 0;
@@ -565,6 +869,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
565
869
  };
566
870
  this.container = container;
567
871
  this.opts = { maxSelection: 10, selectableStatuses: ["free"], ...options };
872
+ this.currency = options.currency;
568
873
  Konva.pixelRatio = this.dpr;
569
874
  this.stage = new Stage({
570
875
  container,
@@ -581,7 +886,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
581
886
  if (container.tabIndex < 0) container.tabIndex = 0;
582
887
  container.setAttribute("role", "application");
583
888
  if (!container.getAttribute("aria-label")) {
584
- container.setAttribute("aria-label", "Seating map. Use arrow keys to move between seats, Enter to select.");
889
+ container.setAttribute("aria-label", t("map.aria"));
585
890
  }
586
891
  container.addEventListener("keydown", this.onKeyDown);
587
892
  this.bgLayer = new Layer({ listening: true });
@@ -624,7 +929,15 @@ var _SeatmapRenderer = class _SeatmapRenderer {
624
929
  }
625
930
  }
626
931
  // ---- ISeatmapRenderer -----------------------------------------------------
627
- setChart(doc) {
932
+ setChart(doc, opts) {
933
+ if (doc !== this.chartDoc) this.stacked = false;
934
+ this.chartDoc = doc;
935
+ this.activeFloorId = opts?.floorId ?? floorsOf(doc)[0].id;
936
+ this.objectFloor.clear();
937
+ if (doc.floors && doc.floors.length > 1) {
938
+ for (const f of doc.floors) for (const o of f.objects) this.objectFloor.set(o.id, f.id);
939
+ }
940
+ const view = this.floorView(doc);
628
941
  this.focusedId = null;
629
942
  this.focusRing.visible(false);
630
943
  this.bgLayer.destroyChildren();
@@ -652,7 +965,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
652
965
  this.isoT = 0;
653
966
  this.isoTarget = 0;
654
967
  this.resetLayerTransforms();
655
- this.hasSections = doc.objects.some((o) => o.type === "section");
968
+ this.hasSections = view.objects.some((o) => o.type === "section");
656
969
  for (const z of doc.zones ?? []) if (z.color) this.zoneColor.set(z.id, z.color);
657
970
  this.seatLayer.opacity(1);
658
971
  this.hoverRing.visible(false);
@@ -663,28 +976,61 @@ var _SeatmapRenderer = class _SeatmapRenderer {
663
976
  this.hoverRing.radius(this.seatR + 2);
664
977
  this.catColor.clear();
665
978
  this.catPrice.clear();
979
+ this.catOrder = doc.categories.map((c) => c.key);
666
980
  for (const c of doc.categories) {
667
981
  this.catColor.set(c.key, c.color);
668
982
  if (typeof c.price === "number") this.catPrice.set(c.key, c.price);
669
983
  }
670
- for (const obj of doc.objects) {
984
+ for (const obj of view.objects) {
671
985
  if (obj.type === "booth") {
672
986
  this.boothDims.set(obj.id, { width: obj.width, height: obj.height, rotation: obj.rotation });
673
987
  }
674
988
  }
675
- this.seats = expandChart(doc);
989
+ this.seats = expandChart(view);
676
990
  for (const s of this.seats) {
677
991
  this.seatById.set(s.id, s);
678
992
  this.statusById.set(s.id, "free");
679
993
  }
680
- this.renderBackground(doc);
994
+ this.renderBackground(view);
681
995
  this.renderSeats();
682
996
  this.overlayLayer.add(this.labelGroup);
683
997
  this.overlayLayer.add(this.hoverRing);
684
- this.bounds = chartBounds(doc);
998
+ this.bounds = chartBounds(view);
685
999
  this.isoCentre = { x: this.bounds.x + this.bounds.width / 2, y: this.bounds.y + this.bounds.height / 2 };
686
1000
  this.zoomToFit();
687
1001
  }
1002
+ /** The chart to render: all floors stacked (3D overview), the active floor, or
1003
+ * the whole chart for single-floor charts. */
1004
+ floorView(doc) {
1005
+ if (!doc.floors || !doc.floors.length) return doc;
1006
+ if (this.stacked && doc.floors.length >= 2) return stackFloors(doc);
1007
+ const floor = doc.floors.find((f) => f.id === this.activeFloorId) ?? doc.floors[0];
1008
+ return { ...doc, objects: floor.objects, focalPoint: floor.focalPoint, backgroundImage: floor.backgroundImage, floors: void 0 };
1009
+ }
1010
+ /** Toggle the 3D all-floors stacked overview (Batch 5). Re-renders; no-op on
1011
+ * single-floor charts. The caller re-applies statuses + animates the iso view. */
1012
+ setStacked(on) {
1013
+ if (!this.chartDoc || on === this.stacked) return;
1014
+ const multi = !!this.chartDoc.floors && this.chartDoc.floors.length >= 2;
1015
+ if (!multi) return;
1016
+ this.stacked = on;
1017
+ this.setChart(this.chartDoc, { floorId: this.activeFloorId });
1018
+ }
1019
+ isStacked() {
1020
+ return this.stacked;
1021
+ }
1022
+ /** Switch which floor is shown (2D). Re-renders + re-fits; no-op if unchanged. */
1023
+ setActiveFloor(floorId) {
1024
+ if (!this.chartDoc || floorId === this.activeFloorId) return;
1025
+ this.setChart(this.chartDoc, { floorId });
1026
+ }
1027
+ /** Floors for the host's switcher (single-floor charts return one synthetic floor). */
1028
+ getFloors() {
1029
+ return this.chartDoc ? floorsOf(this.chartDoc).map((f) => ({ id: f.id, name: f.name })) : [];
1030
+ }
1031
+ getActiveFloorId() {
1032
+ return this.activeFloorId;
1033
+ }
688
1034
  setStatus(seatIds, status) {
689
1035
  let touched = false;
690
1036
  const affected = this.hasSections ? /* @__PURE__ */ new Set() : null;
@@ -764,11 +1110,11 @@ var _SeatmapRenderer = class _SeatmapRenderer {
764
1110
  const dur = 620;
765
1111
  const step = (now) => {
766
1112
  if (!ring.getLayer()) return;
767
- const t = Math.min(1, (now - start) / dur);
768
- ring.radius(this.seatR * (1 + t * 1.8));
769
- ring.opacity(0.9 * (1 - t));
1113
+ const t2 = Math.min(1, (now - start) / dur);
1114
+ ring.radius(this.seatR * (1 + t2 * 1.8));
1115
+ ring.opacity(0.9 * (1 - t2));
770
1116
  this.overlayLayer.batchDraw();
771
- if (t < 1) requestAnimationFrame(step);
1117
+ if (t2 < 1) requestAnimationFrame(step);
772
1118
  else {
773
1119
  ring.destroy();
774
1120
  this.overlayLayer.batchDraw();
@@ -875,7 +1221,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
875
1221
  sameAccessFilter(next) {
876
1222
  const cur = this.accessFilter;
877
1223
  if (cur === null || next === null) return cur === next;
878
- return cur.length === next.length && cur.every((t, i) => t === next[i]);
1224
+ return cur.length === next.length && cur.every((t2, i) => t2 === next[i]);
879
1225
  }
880
1226
  // ---- isometric ("3D") view mode -------------------------------------------
881
1227
  /**
@@ -923,6 +1269,10 @@ var _SeatmapRenderer = class _SeatmapRenderer {
923
1269
  };
924
1270
  this.isoRaf = requestAnimationFrame(step);
925
1271
  }
1272
+ /** Current projection — reflects the tween target, not the mid-tween isoT. */
1273
+ getViewMode() {
1274
+ return this.isoTarget === 1 ? "isometric" : "flat";
1275
+ }
926
1276
  /** Iso angle (rad) + y-squash for the current isoT. */
927
1277
  isoParams() {
928
1278
  return { th: ISO_ANGLE_DEG * Math.PI / 180 * this.isoT, sg: 1 - (1 - ISO_SQUASH) * this.isoT };
@@ -980,8 +1330,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
980
1330
  * lift + extruded side faces on elevated sections.
981
1331
  */
982
1332
  applyIso() {
983
- const t = this.isoT;
984
- if (t === 0) {
1333
+ const t2 = this.isoT;
1334
+ if (t2 === 0) {
985
1335
  this.resetLayerTransforms();
986
1336
  } else {
987
1337
  const { th, sg } = this.isoParams();
@@ -1034,13 +1384,13 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1034
1384
  }
1035
1385
  /** Lift elevated sections (+ their members) and update their extruded side faces. */
1036
1386
  applyElevation() {
1037
- const t = this.isoT;
1387
+ const t2 = this.isoT;
1038
1388
  for (const sec of this.sections) {
1039
1389
  if (sec.elevation <= 0) continue;
1040
1390
  const off = this.isoLiftLocal(sec.elevation);
1041
1391
  sec.liftGroupBg?.position(off);
1042
1392
  sec.liftGroupSeat?.position(off);
1043
- const alpha = 0.9 * t;
1393
+ const alpha = 0.9 * t2;
1044
1394
  for (let i = 0; i < sec.sideFaces.length; i++) {
1045
1395
  const face = sec.sideFaces[i];
1046
1396
  const p0 = sec.outline[i];
@@ -1054,6 +1404,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1054
1404
  this.destroyed = true;
1055
1405
  if (this.rafId) cancelAnimationFrame(this.rafId);
1056
1406
  if (this.isoRaf) cancelAnimationFrame(this.isoRaf);
1407
+ if (this.glideRaf) cancelAnimationFrame(this.glideRaf);
1057
1408
  if (this.viewChangeRaf) cancelAnimationFrame(this.viewChangeRaf);
1058
1409
  if (this.recacheTimer) clearTimeout(this.recacheTimer);
1059
1410
  this.resizeObs?.disconnect();
@@ -1133,7 +1484,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1133
1484
  this.circleById.set(seat.id, rect);
1134
1485
  this.paintSeat(rect, seat.id);
1135
1486
  target.add(rect);
1136
- const t = new Text({
1487
+ const t2 = new Text({
1137
1488
  x: seat.x,
1138
1489
  y: seat.y,
1139
1490
  text: seat.label,
@@ -1144,17 +1495,23 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1144
1495
  listening: false,
1145
1496
  perfectDrawEnabled: false
1146
1497
  });
1147
- t.offsetX(t.width() / 2);
1148
- t.offsetY(t.height() / 2);
1498
+ t2.offsetX(t2.width() / 2);
1499
+ t2.offsetY(t2.height() / 2);
1149
1500
  this.hasBoothText = true;
1150
- target.add(t);
1501
+ target.add(t2);
1502
+ }
1503
+ /** The category's display color — Okabe-Ito hue when colorblind-safe is on. */
1504
+ seatBaseColor(categoryKey) {
1505
+ if (!this.colorblind) return this.catColor.get(categoryKey) ?? "#6e7bff";
1506
+ const idx = this.catOrder.indexOf(categoryKey);
1507
+ return CB_PALETTE[(idx >= 0 ? idx : 0) % CB_PALETTE.length];
1151
1508
  }
1152
1509
  /** Apply fill/stroke/opacity for a seat's current status + selection. */
1153
1510
  paintSeat(c, id) {
1154
1511
  const seat = this.seatById.get(id);
1155
1512
  const status = this.statusById.get(id) ?? "free";
1156
1513
  const selected = this.selection.has(id);
1157
- const base = this.catColor.get(seat.categoryKey) ?? "#6e7bff";
1514
+ const base = this.seatBaseColor(seat.categoryKey);
1158
1515
  c.dash([]);
1159
1516
  c.strokeWidth(0);
1160
1517
  c.stroke("");
@@ -1167,8 +1524,15 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1167
1524
  c.fill(HELD_FILL);
1168
1525
  break;
1169
1526
  case "booked":
1170
- c.fill(TAKEN_FILL);
1171
- c.opacity(0.45);
1527
+ if (this.colorblind) {
1528
+ c.fill("rgba(0,0,0,0)");
1529
+ c.stroke(TAKEN_FILL);
1530
+ c.strokeWidth(1.5);
1531
+ c.opacity(0.9);
1532
+ } else {
1533
+ c.fill(TAKEN_FILL);
1534
+ c.opacity(0.45);
1535
+ }
1172
1536
  break;
1173
1537
  case "not_for_sale":
1174
1538
  c.fill(TAKEN_FILL);
@@ -1183,6 +1547,50 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1183
1547
  if (this.categoryHighlight && status === "free" && !selected && seat.categoryKey !== this.categoryHighlight) {
1184
1548
  c.opacity(0.25);
1185
1549
  }
1550
+ if (this.dimmedSections.size) {
1551
+ const sec = this.seatSection.get(id);
1552
+ if (sec && (this.dimmedSections.has(sec.id) || sec.zone != null && this.dimmedSections.has(sec.zone))) {
1553
+ c.opacity(0.18);
1554
+ }
1555
+ }
1556
+ }
1557
+ /**
1558
+ * Colorblind-safe mode: swap category hues for the Okabe-Ito palette and
1559
+ * render booked seats hollow. Off restores the exact default rendering.
1560
+ */
1561
+ setColorblindSafe(on) {
1562
+ if (on === this.colorblind) return;
1563
+ this.colorblind = on;
1564
+ for (const seat of this.seats) {
1565
+ const c = this.circleById.get(seat.id);
1566
+ if (c) this.paintSeat(c, seat.id);
1567
+ }
1568
+ if (this.cached) {
1569
+ this.seatLayer.clearCache();
1570
+ this.cacheSeatLayer();
1571
+ } else {
1572
+ this.seatLayer.batchDraw();
1573
+ }
1574
+ }
1575
+ /**
1576
+ * Dim the seats of these section/zone ids (organizer manager use) so held-back
1577
+ * inventory is visually distinct on the canvas without hiding it. `null`/empty
1578
+ * clears. Unlike the buyer's applyHidden, the sections stay rendered.
1579
+ */
1580
+ setDimmedSections(ids) {
1581
+ const next = new Set(ids ?? []);
1582
+ if (next.size === this.dimmedSections.size && [...next].every((i) => this.dimmedSections.has(i))) return;
1583
+ this.dimmedSections = next;
1584
+ for (const seat of this.seats) {
1585
+ const c = this.circleById.get(seat.id);
1586
+ if (c) this.paintSeat(c, seat.id);
1587
+ }
1588
+ if (this.cached) {
1589
+ this.seatLayer.clearCache();
1590
+ this.cacheSeatLayer();
1591
+ } else {
1592
+ this.seatLayer.batchDraw();
1593
+ }
1186
1594
  }
1187
1595
  /** Legend hover: highlight one category (dim the rest), or null to clear. */
1188
1596
  setCategoryHighlight(key) {
@@ -1365,7 +1773,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1365
1773
  }
1366
1774
  /** Prominent stage caption: uppercase, letter-spaced, larger, softly dimmed. */
1367
1775
  addStageLabel(x, y, text) {
1368
- const t = new Text({
1776
+ const t2 = new Text({
1369
1777
  x,
1370
1778
  y,
1371
1779
  text: text.toUpperCase(),
@@ -1377,9 +1785,9 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1377
1785
  listening: false,
1378
1786
  perfectDrawEnabled: false
1379
1787
  });
1380
- t.offsetX(t.width() / 2);
1381
- t.offsetY(t.height() / 2);
1382
- this.bgLayer.add(t);
1788
+ t2.offsetX(t2.width() / 2);
1789
+ t2.offsetY(t2.height() / 2);
1790
+ this.bgLayer.add(t2);
1383
1791
  }
1384
1792
  renderGA(obj) {
1385
1793
  const color = this.catColor.get(obj.categoryKey) ?? "#6e7bff";
@@ -1460,12 +1868,14 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1460
1868
  this.seatLayer.add(liftGroupSeat);
1461
1869
  }
1462
1870
  const bgTarget = liftGroupBg ?? this.bgLayer;
1871
+ const outlineTint = obj.color ?? this.zoneColor.get(obj.zone ?? "") ?? "#3a4358";
1463
1872
  const outlinePoly = new Line({
1464
1873
  points: pts,
1465
1874
  closed: true,
1466
- stroke: "#3a4358",
1467
- strokeWidth: 1.5,
1468
- fill: rgba(obj.color ?? "#3a4358", 0.06),
1875
+ stroke: rgba(outlineTint, 0.5),
1876
+ strokeWidth: 1.75,
1877
+ fill: rgba(outlineTint, 0.08),
1878
+ lineJoin: "round",
1469
1879
  listening: false,
1470
1880
  perfectDrawEnabled: false
1471
1881
  });
@@ -1481,6 +1891,30 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1481
1891
  perfectDrawEnabled: false
1482
1892
  });
1483
1893
  bgTarget.add(blockPoly);
1894
+ const rowSeats = /* @__PURE__ */ new Map();
1895
+ for (const id of memberIds) {
1896
+ const s = this.seatById.get(id);
1897
+ if (!s) continue;
1898
+ const i = Number(id.slice(id.lastIndexOf(":") + 1)) || 0;
1899
+ (rowSeats.get(s.rowId) ?? rowSeats.set(s.rowId, []).get(s.rowId)).push({ i, x: s.x, y: s.y });
1900
+ }
1901
+ const rowLines = [];
1902
+ for (const arr of rowSeats.values()) {
1903
+ if (arr.length < 2) continue;
1904
+ arr.sort((a, b) => a.i - b.i);
1905
+ const line = new Line({
1906
+ points: arr.flatMap((p) => [p.x, p.y]),
1907
+ stroke: rgba("#ffffff", 0.34),
1908
+ strokeWidth: SEAT_RADIUS * 0.55,
1909
+ lineCap: "round",
1910
+ lineJoin: "round",
1911
+ opacity: 0,
1912
+ listening: false,
1913
+ perfectDrawEnabled: false
1914
+ });
1915
+ rowLines.push(line);
1916
+ bgTarget.add(line);
1917
+ }
1484
1918
  const nameLabel = new Text({
1485
1919
  x: centroid.x,
1486
1920
  y: centroid.y,
@@ -1489,6 +1923,11 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1489
1923
  fontStyle: "700",
1490
1924
  fontFamily: this.labelFont(),
1491
1925
  fill: "#8b93a7",
1926
+ // Dark halo so the label reads over the seat dots at any zoom.
1927
+ shadowColor: "#05070c",
1928
+ shadowBlur: 6,
1929
+ shadowOpacity: 0.9,
1930
+ shadowForStrokeEnabled: false,
1492
1931
  listening: false,
1493
1932
  perfectDrawEnabled: false
1494
1933
  });
@@ -1498,11 +1937,15 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1498
1937
  const subLabel = new Text({
1499
1938
  x: centroid.x,
1500
1939
  y: centroid.y,
1501
- text: `${free} LEFT`,
1940
+ text: t("map.seatsLeft", { count: free }),
1502
1941
  fontSize: 12,
1503
- fontStyle: "600",
1942
+ fontStyle: "700",
1504
1943
  fontFamily: "JetBrains Mono, ui-monospace, monospace",
1505
- fill: rgba("#e6e9f0", 0.8),
1944
+ fill: "#f4f6fb",
1945
+ shadowColor: "#05070c",
1946
+ shadowBlur: 5,
1947
+ shadowOpacity: 0.9,
1948
+ shadowForStrokeEnabled: false,
1506
1949
  opacity: 0,
1507
1950
  listening: false,
1508
1951
  perfectDrawEnabled: false
@@ -1521,6 +1964,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1521
1964
  baseFill,
1522
1965
  outlinePoly,
1523
1966
  blockPoly,
1967
+ rowLines,
1524
1968
  nameLabel,
1525
1969
  subLabel,
1526
1970
  elevation,
@@ -1536,7 +1980,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1536
1980
  refreshSectionFill(sec) {
1537
1981
  const sold = sec.total > 0 ? (sec.total - sec.free) / sec.total : 0;
1538
1982
  sec.blockPoly.fill(darken(sec.baseFill, sold * SOLD_DARKEN));
1539
- sec.subLabel.text(`${sec.free} LEFT`);
1983
+ sec.subLabel.text(t("map.seatsLeft", { count: sec.free }));
1540
1984
  sec.subLabel.offsetX(sec.subLabel.width() / 2);
1541
1985
  }
1542
1986
  /**
@@ -1554,8 +1998,10 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1554
1998
  for (const z of doc.zones) {
1555
1999
  const members = byZone.get(z.id);
1556
2000
  if (!members || !members.length) continue;
1557
- const cx = members.reduce((a, s) => a + s.centroid.x, 0) / members.length;
1558
- const cy = members.reduce((a, s) => a + s.centroid.y, 0) / members.length;
2001
+ let anchor = members[0];
2002
+ for (const s of members) if (s.centroid.y < anchor.centroid.y) anchor = s;
2003
+ const cx = anchor.centroid.x;
2004
+ const cy = anchor.centroid.y;
1559
2005
  let minPrice = Infinity;
1560
2006
  for (const sec of members) {
1561
2007
  for (const id of sec.memberIds) {
@@ -1573,6 +2019,10 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1573
2019
  letterSpacing: 2,
1574
2020
  fontFamily: this.labelFont(),
1575
2021
  fill: z.color ?? "#f2f4f8",
2022
+ shadowColor: "#05070c",
2023
+ shadowBlur: 10,
2024
+ shadowOpacity: 0.95,
2025
+ shadowForStrokeEnabled: false,
1576
2026
  opacity: 0,
1577
2027
  listening: false,
1578
2028
  perfectDrawEnabled: false
@@ -1585,7 +2035,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1585
2035
  sub = new Text({
1586
2036
  x: cx,
1587
2037
  y: cy,
1588
- text: `FROM $${minPrice}`,
2038
+ text: t("map.fromPrice", { price: formatMoney(minPrice, this.currency) }),
1589
2039
  fontSize: 14,
1590
2040
  fontStyle: "600",
1591
2041
  fontFamily: "JetBrains Mono, ui-monospace, monospace",
@@ -1616,7 +2066,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1616
2066
  blockT = scale <= SECTION_PROMINENT_SCALE ? 1 : 0;
1617
2067
  zoneT = scale <= ZONE_PROMINENT_SCALE ? 1 : 0;
1618
2068
  } else {
1619
- blockT = clamp((CACHE_THRESHOLD - scale) / (CACHE_THRESHOLD - SECTION_PROMINENT_SCALE), 0, 1);
2069
+ blockT = clamp((BLOCK_MELT_TOP - scale) / (BLOCK_MELT_TOP - SECTION_PROMINENT_SCALE), 0, 1);
1620
2070
  zoneT = clamp((SECTION_PROMINENT_SCALE - scale) / (SECTION_PROMINENT_SCALE - ZONE_PROMINENT_SCALE), 0, 1);
1621
2071
  }
1622
2072
  if (!this.zones.length) zoneT = 0;
@@ -1626,7 +2076,8 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1626
2076
  if (rescale) this.lodScale = scale;
1627
2077
  for (const sec of this.sections) {
1628
2078
  sec.blockPoly.opacity(BLOCK_FILL_ALPHA * blockT);
1629
- sec.nameLabel.fill(lerpColor("#8b93a7", "#f2f4f8", blockT));
2079
+ for (const line of sec.rowLines) line.opacity(blockT * (1 - zoneT));
2080
+ sec.nameLabel.fill(lerpColor("#aab3c5", "#ffffff", blockT));
1630
2081
  sec.nameLabel.opacity(1 - zoneT);
1631
2082
  sec.subLabel.opacity(blockT * (1 - zoneT));
1632
2083
  if (rescale) {
@@ -1634,9 +2085,10 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1634
2085
  this.sizeLabel(sec.subLabel, SECTION_SUB_PX / sx, sec.centroid.y + SECTION_LABEL_PX / sx);
1635
2086
  }
1636
2087
  }
2088
+ const zoneOpacity = zoneT * (1 - this.isoT);
1637
2089
  for (const zone of this.zones) {
1638
- zone.label.opacity(zoneT);
1639
- if (zone.sub) zone.sub.opacity(zoneT);
2090
+ zone.label.opacity(zoneOpacity);
2091
+ if (zone.sub) zone.sub.opacity(zoneOpacity);
1640
2092
  if (rescale) {
1641
2093
  const cy = zone.label.y();
1642
2094
  this.sizeLabel(zone.label, ZONE_LABEL_PX / sx, cy);
@@ -1646,11 +2098,11 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1646
2098
  this.bgLayer.batchDraw();
1647
2099
  }
1648
2100
  /** Set a centred label's world fontSize (for a target screen px) and re-anchor it. */
1649
- sizeLabel(t, fontSize, y) {
1650
- t.fontSize(Math.max(1, fontSize));
1651
- t.offsetX(t.width() / 2);
1652
- t.offsetY(t.height() / 2);
1653
- t.y(y);
2101
+ sizeLabel(t2, fontSize, y) {
2102
+ t2.fontSize(Math.max(1, fontSize));
2103
+ t2.offsetX(t2.width() / 2);
2104
+ t2.offsetY(t2.height() / 2);
2105
+ t2.y(y);
1654
2106
  }
1655
2107
  /**
1656
2108
  * Map a container-relative screen point back to world coords. Inverts the
@@ -1674,7 +2126,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1674
2126
  return this.sections.find((s) => s.id === id)?.memberIds.slice() ?? [];
1675
2127
  }
1676
2128
  addCentredLabel(layer, text, x, y, fill, fontSize, bold) {
1677
- const t = new Text({
2129
+ const t2 = new Text({
1678
2130
  x,
1679
2131
  y,
1680
2132
  text,
@@ -1685,9 +2137,9 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1685
2137
  listening: false,
1686
2138
  perfectDrawEnabled: false
1687
2139
  });
1688
- t.offsetX(t.width() / 2);
1689
- t.offsetY(t.height() / 2);
1690
- layer.add(t);
2140
+ t2.offsetX(t2.width() / 2);
2141
+ t2.offsetY(t2.height() / 2);
2142
+ layer.add(t2);
1691
2143
  }
1692
2144
  // ---- selection ------------------------------------------------------------
1693
2145
  isSelectable(id) {
@@ -1741,7 +2193,15 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1741
2193
  this.seatLayer.on("click tap", (e) => {
1742
2194
  if (this.moved > 8) return;
1743
2195
  const id = seatIdOf(e.target);
1744
- if (id) this.toggleSeat(id);
2196
+ if (!id) return;
2197
+ if (this.stacked && this.opts.onDeckTap) {
2198
+ const floorId = this.objectFloor.get(this.seatById.get(id)?.rowId ?? "");
2199
+ if (floorId) {
2200
+ this.opts.onDeckTap(floorId);
2201
+ return;
2202
+ }
2203
+ }
2204
+ this.toggleSeat(id);
1745
2205
  });
1746
2206
  this.seatLayer.on("mouseover", (e) => {
1747
2207
  const id = seatIdOf(e.target);
@@ -1771,11 +2231,19 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1771
2231
  if (!this.cached || this.moved > 8) return;
1772
2232
  const pointer = this.stage.getPointerPosition();
1773
2233
  if (!pointer) return;
2234
+ if (this.stacked && this.opts.onDeckTap) {
2235
+ const floorId = this.deckFloorAt();
2236
+ if (floorId) {
2237
+ this.opts.onDeckTap(floorId);
2238
+ return;
2239
+ }
2240
+ }
1774
2241
  if (this.sections.length) {
1775
2242
  const world = this.screenToWorld(pointer);
1776
2243
  const hit = this.sections.find((sn) => pointInPolygon(world, sn.outline));
1777
2244
  if (hit) {
1778
- this.zoomToBounds(polyBounds(hit.outline));
2245
+ if (this.opts.onSectionTap) this.opts.onSectionTap(hit.id);
2246
+ else this.focusRegion(hit.id);
1779
2247
  return;
1780
2248
  }
1781
2249
  }
@@ -1783,6 +2251,30 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1783
2251
  this.zoomAbout(target, pointer);
1784
2252
  });
1785
2253
  }
2254
+ /**
2255
+ * Which deck (floor) a screen tap landed on in the 3D stacked overview. The
2256
+ * seat layer is melt-cached at that zoom so individual seat nodes aren't
2257
+ * hit-testable; instead we invert the iso+stage transform via the layer's
2258
+ * relative pointer and take the nearest seat's floor. Only the floor matters,
2259
+ * so within-floor elevation offsets don't affect the result. Null if none.
2260
+ */
2261
+ deckFloorAt() {
2262
+ if (!this.objectFloor.size) return null;
2263
+ const p = this.seatLayer.getRelativePointerPosition();
2264
+ if (!p) return null;
2265
+ let best = null;
2266
+ let bestD = Infinity;
2267
+ for (const s of this.seats) {
2268
+ const dx = s.x - p.x;
2269
+ const dy = s.y - p.y;
2270
+ const d = dx * dx + dy * dy;
2271
+ if (d < bestD) {
2272
+ bestD = d;
2273
+ best = s.rowId;
2274
+ }
2275
+ }
2276
+ return best ? this.objectFloor.get(best) ?? null : null;
2277
+ }
1786
2278
  // ---- Pan & pinch (raw pointer events — mouse, touch and pen alike) --------
1787
2279
  toLocal(e) {
1788
2280
  const r = this.container.getBoundingClientRect();
@@ -1793,7 +2285,9 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1793
2285
  * reach a readable size on any chart, however large the venue.
1794
2286
  */
1795
2287
  zoomBounds() {
1796
- return { min: 0.5 * this.fitScale, max: Math.max(10 * this.fitScale, 4) };
2288
+ let min = 0.5 * this.fitScale;
2289
+ if (this.zones.length) min = Math.min(min, ZONE_PROMINENT_SCALE * 0.9);
2290
+ return { min, max: Math.max(10 * this.fitScale, 4) };
1797
2291
  }
1798
2292
  /** Zoom so `clientPoint` (stage-relative px) stays fixed under the cursor. */
1799
2293
  zoomAbout(nextScale, clientPoint) {
@@ -1826,6 +2320,92 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1826
2320
  this.afterViewChange();
1827
2321
  this.stage.batchDraw();
1828
2322
  }
2323
+ /**
2324
+ * Smoothly glide the camera to frame a section (by id) or a world-space bounds
2325
+ * rect — the Slice 5 "glide in". Pan+zoom tween over ~450ms easeInOutCubic; the
2326
+ * melt/LOD rides the camera every frame. `prefers-reduced-motion` (or
2327
+ * `opts.animate === false`) snaps via zoomToBounds. A grab (pointer-down) or a
2328
+ * newer glide cancels an in-flight one.
2329
+ */
2330
+ focusRegion(target, opts) {
2331
+ const b = typeof target === "string" ? (() => {
2332
+ const sec = this.sections.find((s) => s.id === target);
2333
+ return sec ? polyBounds(sec.outline) : null;
2334
+ })() : target;
2335
+ if (!b) return;
2336
+ this.cancelGlide();
2337
+ if (opts?.animate === false || this.reducedMotion) {
2338
+ this.zoomToBounds(b);
2339
+ return;
2340
+ }
2341
+ const w = this.stage.width();
2342
+ const h = this.stage.height();
2343
+ const { min, max } = this.zoomBounds();
2344
+ const margin = 1.12;
2345
+ const toScale = clamp(Math.min(w / (b.width * margin), h / (b.height * margin)), min, max);
2346
+ const toX = w / 2 - (b.x + b.width / 2) * toScale;
2347
+ const toY = h / 2 - (b.y + b.height / 2) * toScale;
2348
+ const fromScale = this.stage.scaleX();
2349
+ const fromX = this.stage.x();
2350
+ const fromY = this.stage.y();
2351
+ if (Math.abs(toScale - fromScale) < 1e-4 && Math.abs(toX - fromX) < 0.5 && Math.abs(toY - fromY) < 0.5) {
2352
+ this.afterViewChange();
2353
+ return;
2354
+ }
2355
+ const start = performance.now();
2356
+ const DUR = 450;
2357
+ const step = (now) => {
2358
+ if (this.destroyed) {
2359
+ this.glideRaf = 0;
2360
+ return;
2361
+ }
2362
+ const raw = Math.min(1, (now - start) / DUR);
2363
+ const e = raw < 0.5 ? 4 * raw * raw * raw : 1 - Math.pow(-2 * raw + 2, 3) / 2;
2364
+ const sc = fromScale + (toScale - fromScale) * e;
2365
+ this.stage.scale({ x: sc, y: sc });
2366
+ this.stage.position({ x: fromX + (toX - fromX) * e, y: fromY + (toY - fromY) * e });
2367
+ this.updateLOD();
2368
+ this.scheduleViewChange();
2369
+ this.stage.batchDraw();
2370
+ if (raw < 1) {
2371
+ this.glideRaf = requestAnimationFrame(step);
2372
+ } else {
2373
+ this.glideRaf = 0;
2374
+ this.afterViewChange();
2375
+ }
2376
+ };
2377
+ this.glideRaf = requestAnimationFrame(step);
2378
+ }
2379
+ /** Cancel an in-flight camera glide (a grab or a newer glide interrupts it). */
2380
+ cancelGlide() {
2381
+ if (this.glideRaf) {
2382
+ cancelAnimationFrame(this.glideRaf);
2383
+ this.glideRaf = 0;
2384
+ }
2385
+ }
2386
+ /** Current LOD rung derived from effective zoom — drives the ZONES/SECTIONS/SEATS pill. */
2387
+ getRung() {
2388
+ const scale = this.effScale();
2389
+ if (scale >= CACHE_THRESHOLD) return "seats";
2390
+ if (this.zones.length && scale < ZONE_PROMINENT_SCALE) return "zones";
2391
+ return "sections";
2392
+ }
2393
+ /** Jump the camera to a rung's zoom band, centred on the chart (glided). */
2394
+ setRung(rung) {
2395
+ if (rung === "zones") {
2396
+ this.cancelGlide();
2397
+ this.zoomToFit();
2398
+ return;
2399
+ }
2400
+ const target = rung === "sections" ? (SECTION_PROMINENT_SCALE + CACHE_THRESHOLD) / 2 : Math.max(SEAT_LEGIBLE_SCALE * 1.1, CACHE_THRESHOLD * 1.3);
2401
+ const w = this.stage.width();
2402
+ const h = this.stage.height();
2403
+ const cx = this.bounds.x + this.bounds.width / 2;
2404
+ const cy = this.bounds.y + this.bounds.height / 2;
2405
+ const bw = w / (target * 1.12);
2406
+ const bh = h / (target * 1.12);
2407
+ this.focusRegion({ x: cx - bw / 2, y: cy - bh / 2, width: bw, height: bh });
2408
+ }
1829
2409
  /** Recompute LOD (cache/labels) after any pan/zoom settles. */
1830
2410
  afterViewChange() {
1831
2411
  this.updateLOD();
@@ -1876,7 +2456,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1876
2456
  let count = 0;
1877
2457
  for (const seat of this.seats) {
1878
2458
  if (seat.x < x0 || seat.x > x1 || seat.y < y0 || seat.y > y1) continue;
1879
- const t = new Text({
2459
+ const t2 = new Text({
1880
2460
  x: seat.x,
1881
2461
  y: seat.y,
1882
2462
  text: seat.label,
@@ -1888,14 +2468,14 @@ var _SeatmapRenderer = class _SeatmapRenderer {
1888
2468
  perfectDrawEnabled: false
1889
2469
  });
1890
2470
  const maxW = this.seatR * 2 - 3;
1891
- if (t.width() > maxW) t.fontSize(Math.max(4, 7 * maxW / t.width()));
1892
- if (t.fontSize() < 4.2) {
1893
- t.destroy();
2471
+ if (t2.width() > maxW) t2.fontSize(Math.max(4, 7 * maxW / t2.width()));
2472
+ if (t2.fontSize() < 4.2) {
2473
+ t2.destroy();
1894
2474
  continue;
1895
2475
  }
1896
- t.offsetX(t.width() / 2);
1897
- t.offsetY(t.height() / 2);
1898
- this.labelGroup.add(t);
2476
+ t2.offsetX(t2.width() / 2);
2477
+ t2.offsetY(t2.height() / 2);
2478
+ this.labelGroup.add(t2);
1899
2479
  if (++count >= MAX_LABELS) break;
1900
2480
  }
1901
2481
  if (this.isoT > 0) this.applyUprightLabels();
@@ -1930,6 +2510,38 @@ function createRenderer(container, opts) {
1930
2510
  return new SeatmapRenderer(container, opts);
1931
2511
  }
1932
2512
 
2513
+ // src/core/orphans.ts
2514
+ function seatIndex(id) {
2515
+ const n = Number(id.slice(id.lastIndexOf(":") + 1));
2516
+ return Number.isFinite(n) ? n : -1;
2517
+ }
2518
+ function strandedSingles(seats, statusOf, selectedIds) {
2519
+ const rows = /* @__PURE__ */ new Map();
2520
+ for (const s of seats) {
2521
+ if (s.kind === "booth") continue;
2522
+ const list = rows.get(s.rowId);
2523
+ if (list) list.push(s);
2524
+ else rows.set(s.rowId, [s]);
2525
+ }
2526
+ const unavailable = (s) => selectedIds.has(s.id) || statusOf(s.id) !== "free";
2527
+ const out = [];
2528
+ for (const list of rows.values()) {
2529
+ if (list.length < 3) continue;
2530
+ list.sort((a, b) => seatIndex(a.id) - seatIndex(b.id));
2531
+ for (let i = 1; i < list.length - 1; i++) {
2532
+ const seat = list[i];
2533
+ if (unavailable(seat)) continue;
2534
+ const left = list[i - 1];
2535
+ const right = list[i + 1];
2536
+ if (seatIndex(seat.id) - seatIndex(left.id) !== 1 || seatIndex(right.id) - seatIndex(seat.id) !== 1) continue;
2537
+ if (!unavailable(left) || !unavailable(right)) continue;
2538
+ if (!selectedIds.has(left.id) && !selectedIds.has(right.id)) continue;
2539
+ out.push(seat);
2540
+ }
2541
+ }
2542
+ return out;
2543
+ }
2544
+
1933
2545
  // src/picker/PickerController.ts
1934
2546
  var DEFAULT_MAX_SELECTION = 10;
1935
2547
  var MAX_BACKOFF_MS = 15e3;
@@ -1946,9 +2558,15 @@ var PickerController = class {
1946
2558
  constructor(options) {
1947
2559
  this.renderer = null;
1948
2560
  this._doc = null;
2561
+ /** Section/zone ids hidden from buyers this event (3.3) — seats vanish, not grey. */
2562
+ this.hidden = /* @__PURE__ */ new Set();
1949
2563
  /** label ⇄ id maps — backend speaks labels, the engine speaks ids. */
1950
2564
  this.labelToId = /* @__PURE__ */ new Map();
1951
2565
  this.labelToSeat = /* @__PURE__ */ new Map();
2566
+ /** seatId → chosen ticket-tier id (absent ⇒ the category's first/default tier). */
2567
+ this.seatTiers = /* @__PURE__ */ new Map();
2568
+ /** id → seat, for the section-summary breakdown (renderer members are ids). */
2569
+ this.seatById = /* @__PURE__ */ new Map();
1952
2570
  this.allIds = [];
1953
2571
  // realtime socket
1954
2572
  this.ws = null;
@@ -1958,6 +2576,8 @@ var PickerController = class {
1958
2576
  // hold state
1959
2577
  this.hold_ = null;
1960
2578
  this.expiryTimer = null;
2579
+ /** Whether the last emitted hint was non-null (avoids clearing repeatedly). */
2580
+ this.hintShown = false;
1961
2581
  this.opts = options;
1962
2582
  this.api = options.transport;
1963
2583
  this.key = options.eventKey;
@@ -1966,6 +2586,18 @@ var PickerController = class {
1966
2586
  get doc() {
1967
2587
  return this._doc;
1968
2588
  }
2589
+ /** The chart with hidden sections' seats removed — what buyers actually see. */
2590
+ visibleDoc() {
2591
+ return this._doc ? applyHidden(this._doc, this.hidden) : { objects: [] };
2592
+ }
2593
+ /** Adopt a new hidden set; rebuild the visible chart only if it differs. */
2594
+ syncHidden(ids) {
2595
+ const next = ids.filter((x) => typeof x === "string");
2596
+ if (next.length === this.hidden.size && next.every((id) => this.hidden.has(id))) return false;
2597
+ this.hidden = new Set(next);
2598
+ this.renderer?.setChart(this.visibleDoc());
2599
+ return true;
2600
+ }
1969
2601
  currentHold() {
1970
2602
  return this.hold_;
1971
2603
  }
@@ -1993,15 +2625,19 @@ var PickerController = class {
1993
2625
  this._doc = res.doc;
1994
2626
  this.labelToId = /* @__PURE__ */ new Map();
1995
2627
  this.labelToSeat = /* @__PURE__ */ new Map();
2628
+ this.seatById = /* @__PURE__ */ new Map();
1996
2629
  this.allIds = [];
1997
2630
  for (const s of expandChart(res.doc)) {
1998
2631
  this.labelToId.set(s.label, s.id);
1999
2632
  this.labelToSeat.set(s.label, s);
2633
+ this.seatById.set(s.id, s);
2000
2634
  this.allIds.push(s.id);
2001
2635
  }
2636
+ const currency = res.event.currency ?? this.opts.currency;
2002
2637
  const renderer = createRenderer(host, {
2003
2638
  maxSelection: this.maxSelection,
2004
2639
  confirmSelection: this.opts.confirmSelection,
2640
+ currency,
2005
2641
  onSelect: (seat) => {
2006
2642
  this.opts.onSelect?.(seat);
2007
2643
  this.emitSelectionChange();
@@ -2014,6 +2650,9 @@ var PickerController = class {
2014
2650
  onFocusSeat: this.opts.onFocusSeat,
2015
2651
  onViewChange: this.opts.onViewChange,
2016
2652
  onGAClick: this.opts.onGAClick,
2653
+ // Section tap at the far/zone rung → glide in + surface the summary card.
2654
+ onSectionTap: (id) => this.handleSectionTap(id),
2655
+ onDeckTap: (floorId) => this.handleDeckTap(floorId),
2017
2656
  onFps: this.opts.onFps
2018
2657
  });
2019
2658
  if (this.closed) {
@@ -2021,15 +2660,29 @@ var PickerController = class {
2021
2660
  return null;
2022
2661
  }
2023
2662
  this.renderer = renderer;
2024
- renderer.setChart(res.doc);
2025
- await this.resnapshot();
2663
+ let seats = null;
2664
+ try {
2665
+ const objs = await this.api.objects(this.key);
2666
+ this.hidden = new Set(objs.hidden ?? []);
2667
+ seats = objs.seats;
2668
+ } catch {
2669
+ }
2670
+ if (this.closed) {
2671
+ renderer.destroy();
2672
+ this.renderer = null;
2673
+ return null;
2674
+ }
2675
+ renderer.setChart(this.visibleDoc());
2676
+ if (this.opts.colorblindSafe) renderer.setColorblindSafe?.(true);
2677
+ if (seats) this.applySeatsMap(seats);
2026
2678
  this.connect();
2027
2679
  return {
2028
2680
  doc: res.doc,
2029
2681
  salesClosed: !!res.event.salesClosed,
2030
2682
  eventName: res.event.name,
2031
2683
  venue: res.event.venue,
2032
- startsAt: res.event.startsAt
2684
+ startsAt: res.event.startsAt,
2685
+ currency
2033
2686
  };
2034
2687
  }
2035
2688
  // ---- selection ------------------------------------------------------------
@@ -2201,6 +2854,126 @@ var PickerController = class {
2201
2854
  setAccessibilityFilter(types) {
2202
2855
  this.renderer?.setAccessibilityFilter?.(types);
2203
2856
  }
2857
+ // ---- multi-floor (Batch 5) ------------------------------------------------
2858
+ /** Floors for the buyer's switcher (>1 ⇒ show it). Single-floor ⇒ one entry. */
2859
+ getFloors() {
2860
+ return this.renderer?.getFloors?.() ?? [];
2861
+ }
2862
+ getActiveFloorId() {
2863
+ return this.renderer?.getActiveFloorId?.() ?? "";
2864
+ }
2865
+ /** Switch the shown floor (2D), then re-apply live seat statuses onto it. */
2866
+ setFloor(id) {
2867
+ const r = this.renderer;
2868
+ if (!r?.setActiveFloor || id === r.getActiveFloorId?.()) return;
2869
+ r.setStacked?.(false);
2870
+ r.setActiveFloor(id);
2871
+ void this.resnapshot();
2872
+ this.emitSelectionChange();
2873
+ }
2874
+ /** 3D all-floors stacked overview ⇄ active floor. Re-applies statuses after. */
2875
+ setStacked(on) {
2876
+ const r = this.renderer;
2877
+ if (!r?.setStacked || on === r.isStacked?.()) return;
2878
+ r.setStacked(on);
2879
+ void this.resnapshot();
2880
+ }
2881
+ isMultiFloor() {
2882
+ return (this._doc?.floors?.length ?? 0) > 1;
2883
+ }
2884
+ /** Tap-a-deck-to-enter: leave the 3D stack, drop to flat 2D on `floorId`, and
2885
+ * tell the host so it can sync its 2D/3D toggle + floor-switcher state. */
2886
+ handleDeckTap(floorId) {
2887
+ const r = this.renderer;
2888
+ if (!r) return;
2889
+ r.setViewMode?.("flat");
2890
+ r.setStacked?.(false);
2891
+ r.setActiveFloor?.(floorId);
2892
+ void this.resnapshot();
2893
+ this.emitSelectionChange();
2894
+ this.opts.onDeckTap?.(floorId);
2895
+ }
2896
+ // ---- big-venue: sections / rungs / projection (Slice 5) -------------------
2897
+ /** Switch the map projection (2D flat ⇄ 3D isometric). No-op on a flat renderer. */
2898
+ setViewMode(mode) {
2899
+ this.renderer?.setViewMode?.(mode);
2900
+ }
2901
+ getViewMode() {
2902
+ return this.renderer?.getViewMode?.() ?? "flat";
2903
+ }
2904
+ /** Current LOD rung (for the ZONES/SECTIONS/SEATS pill). */
2905
+ getRung() {
2906
+ return this.renderer?.getRung?.() ?? "seats";
2907
+ }
2908
+ /** Jump to a rung; ZONES clears any focused summary (back to overview). */
2909
+ setRung(rung) {
2910
+ if (rung === "zones") {
2911
+ this.overview();
2912
+ return;
2913
+ }
2914
+ this.renderer?.setRung?.(rung);
2915
+ }
2916
+ /** Glide in on a section and surface its summary (same path as a section tap). */
2917
+ focusSection(id) {
2918
+ this.handleSectionTap(id);
2919
+ }
2920
+ /** Zoom back out to the whole chart and clear the section-summary card. */
2921
+ overview() {
2922
+ this.renderer?.zoomToFit();
2923
+ this.opts.onSectionFocus?.(null);
2924
+ }
2925
+ /** Glide the camera into a tapped section and emit its computed summary. */
2926
+ handleSectionTap(id) {
2927
+ const r = this.renderer;
2928
+ if (!r) return;
2929
+ r.focusRegion?.(id);
2930
+ this.opts.onSectionFocus?.(this.sectionSummary(id));
2931
+ }
2932
+ /**
2933
+ * Build a section summary from the renderer's spatial membership: section +
2934
+ * zone labels, live seats-left, price range, and the per-category breakdown.
2935
+ */
2936
+ sectionSummary(id) {
2937
+ const r = this.renderer;
2938
+ const doc = this._doc;
2939
+ if (!r || !doc) return null;
2940
+ const sec = doc.objects.find(
2941
+ (o) => o.type === "section" && o.id === id
2942
+ );
2943
+ if (!sec) return null;
2944
+ const memberIds = r.sectionMembers?.(id) ?? [];
2945
+ const byCat = /* @__PURE__ */ new Map();
2946
+ let seatsLeft = 0;
2947
+ for (const sid of memberIds) {
2948
+ const seat = this.seatById.get(sid);
2949
+ if (!seat) continue;
2950
+ byCat.set(seat.categoryKey, (byCat.get(seat.categoryKey) ?? 0) + 1);
2951
+ if (r.getStatus(sid) === "free") seatsLeft++;
2952
+ }
2953
+ const categories = [...byCat.entries()].map(([key, count]) => {
2954
+ const cat = doc.categories.find((c) => c.key === key);
2955
+ return {
2956
+ key,
2957
+ count,
2958
+ label: cat?.label ?? key,
2959
+ color: cat?.color ?? "#6e7bff",
2960
+ price: cat?.price ?? 0
2961
+ };
2962
+ }).sort((a, b) => a.price - b.price);
2963
+ const prices = categories.map((c) => c.price);
2964
+ const zone = sec.zone ? doc.zones?.find((z) => z.id === sec.zone) : void 0;
2965
+ const color = sec.color ?? zone?.color ?? categories[0]?.color ?? "#6e7bff";
2966
+ return {
2967
+ id,
2968
+ label: sec.label,
2969
+ zoneLabel: zone?.label ?? "",
2970
+ color,
2971
+ seatsLeft,
2972
+ priceMin: prices.length ? prices[0] : 0,
2973
+ priceMax: prices.length ? prices[prices.length - 1] : 0,
2974
+ categories
2975
+ };
2976
+ }
2204
2977
  destroy() {
2205
2978
  this.closed = true;
2206
2979
  if (this.reconnectTimer) {
@@ -2223,13 +2996,80 @@ var PickerController = class {
2223
2996
  }
2224
2997
  // ---- internals ------------------------------------------------------------
2225
2998
  toSeat(s) {
2226
- return { id: s.id, label: s.label, categoryKey: s.categoryKey, price: this.priceFor(s.categoryKey) };
2999
+ const tiers = this.tiersFor(s.categoryKey);
3000
+ if (!tiers) {
3001
+ return { id: s.id, label: s.label, categoryKey: s.categoryKey, price: this.priceFor(s.categoryKey) };
3002
+ }
3003
+ const chosen = tiers.find((t2) => t2.id === this.seatTiers.get(s.id)) ?? tiers[0];
3004
+ return {
3005
+ id: s.id,
3006
+ label: s.label,
3007
+ categoryKey: s.categoryKey,
3008
+ price: chosen.price,
3009
+ tiers,
3010
+ tierId: chosen.id
3011
+ };
2227
3012
  }
2228
3013
  priceFor(categoryKey) {
2229
3014
  return this._doc?.categories.find((c) => c.key === categoryKey)?.price ?? 0;
2230
3015
  }
3016
+ tiersFor(categoryKey) {
3017
+ const t2 = this._doc?.categories.find((c) => c.key === categoryKey)?.tiers;
3018
+ return t2 && t2.length ? t2 : void 0;
3019
+ }
3020
+ /**
3021
+ * Choose a ticket tier for a selected seat (e.g. Adult → Child). Re-emits the
3022
+ * selection so the host's cart + the eventual hold carry the new tier + price.
3023
+ * `tierId = null` reverts to the category's first (default) tier. No-op if the
3024
+ * seat's category has no tiers or the id isn't one of them.
3025
+ */
3026
+ setSeatTier(seatId, tierId) {
3027
+ const seat = this.seatById.get(seatId);
3028
+ if (!seat) return;
3029
+ const tiers = this.tiersFor(seat.categoryKey);
3030
+ if (!tiers) return;
3031
+ if (tierId == null) this.seatTiers.delete(seatId);
3032
+ else if (tiers.some((t2) => t2.id === tierId)) this.seatTiers.set(seatId, tierId);
3033
+ else return;
3034
+ this.emitSelectionChange();
3035
+ if (this.hold_) this.hold_ = { ...this.hold_, seats: this.seatsForLabels(this.hold_.labels) };
3036
+ }
3037
+ /** PickerSeats (with chosen tier) for a set of held labels. */
3038
+ seatsForLabels(labels) {
3039
+ const out = [];
3040
+ for (const l of labels) {
3041
+ const s = this.labelToSeat.get(l);
3042
+ if (s) out.push(this.toSeat(s));
3043
+ }
3044
+ return out;
3045
+ }
2231
3046
  emitSelectionChange() {
2232
3047
  this.opts.onSelectionChange?.(this.getSelection());
3048
+ this.emitOrphanHint();
3049
+ }
3050
+ /**
3051
+ * Orphan-seat advice on manual selection: when the buyer's current picks
3052
+ * strand one (or more) single free seats between unavailable same-row
3053
+ * neighbors, surface a localized, non-blocking hint. Cleared (null) as soon
3054
+ * as the selection stops stranding anyone.
3055
+ */
3056
+ emitOrphanHint() {
3057
+ if (!this.opts.onHint) return;
3058
+ const r = this.renderer;
3059
+ if (!r) return;
3060
+ const selected = new Set(r.getSelection().map((s) => s.id));
3061
+ const stranded = selected.size ? strandedSingles(this.seatById.values(), (id) => r.getStatus(id), selected) : [];
3062
+ if (stranded.length > 0) {
3063
+ this.hintShown = true;
3064
+ this.opts.onHint(t("picker.orphanHint"));
3065
+ } else if (this.hintShown) {
3066
+ this.hintShown = false;
3067
+ this.opts.onHint(null);
3068
+ }
3069
+ }
3070
+ /** Toggle colorblind-safe rendering at runtime (see PickerOptions.colorblindSafe). */
3071
+ setColorblindSafe(on) {
3072
+ this.renderer?.setColorblindSafe?.(on);
2233
3073
  }
2234
3074
  emitError(err) {
2235
3075
  if (this.opts.onError) this.opts.onError(err);
@@ -2253,11 +3093,12 @@ var PickerController = class {
2253
3093
  }
2254
3094
  /** Set the open hold + (re)arm the server-authoritative expiry timer. */
2255
3095
  setHold(hold) {
2256
- this.hold_ = hold;
3096
+ const full = { ...hold, seats: this.seatsForLabels(hold.labels) };
3097
+ this.hold_ = full;
2257
3098
  if (this.expiryTimer) clearTimeout(this.expiryTimer);
2258
- const ms = Math.max(0, hold.expiresAt - Date.now());
3099
+ const ms = Math.max(0, full.expiresAt - Date.now());
2259
3100
  this.expiryTimer = setTimeout(() => this.onHoldExpired(), ms);
2260
- this.opts.onHold?.(hold);
3101
+ this.opts.onHold?.(full);
2261
3102
  }
2262
3103
  clearHold() {
2263
3104
  this.hold_ = null;
@@ -2321,6 +3162,11 @@ var PickerController = class {
2321
3162
  const r = this.renderer;
2322
3163
  if (!r || !msg || typeof msg !== "object") return;
2323
3164
  const m = msg;
3165
+ if (Array.isArray(m.hidden) && this.syncHidden(m.hidden)) {
3166
+ void this.resnapshot();
3167
+ this.opts.onStatusChange?.();
3168
+ }
3169
+ if (m.type === "hidden") return;
2324
3170
  if (m.seats && typeof m.seats === "object") {
2325
3171
  this.applySeatsMap(m.seats);
2326
3172
  } else if (Array.isArray(m.changes)) {
@@ -2357,21 +3203,67 @@ var PickerController = class {
2357
3203
  }, delay);
2358
3204
  }
2359
3205
  };
3206
+
3207
+ // src/i18n/bundles.ts
3208
+ var LOADERS = {
3209
+ es: () => import("./es-YNYMMP37.js").then((m) => ({ default: m.es })),
3210
+ de: () => import("./de-A4KJQ374.js").then((m) => ({ default: m.de })),
3211
+ fr: () => import("./fr-DQ4KWKKF.js").then((m) => ({ default: m.fr }))
3212
+ };
3213
+ var loaded = /* @__PURE__ */ new Set(["en"]);
3214
+ async function loadLocale(code) {
3215
+ const locale = resolveLocale(code);
3216
+ if (locale === "en" || loaded.has(locale)) {
3217
+ setLocale(locale);
3218
+ return locale;
3219
+ }
3220
+ try {
3221
+ const mod = await LOADERS[locale]();
3222
+ setLocale(locale, mod.default);
3223
+ loaded.add(locale);
3224
+ return locale;
3225
+ } catch {
3226
+ setLocale("en");
3227
+ return "en";
3228
+ }
3229
+ }
2360
3230
  export {
2361
3231
  ACCESSIBILITY_TYPES,
2362
3232
  CHART_STORAGE_KEY,
3233
+ DEFAULT_CURRENCY,
2363
3234
  PickerController,
3235
+ SUPPORTED_LOCALES,
2364
3236
  SeatmapRenderer,
3237
+ UNGROUPED_ID,
2365
3238
  accessibilityMeta,
3239
+ allObjects,
3240
+ applyHidden,
2366
3241
  chartBounds,
3242
+ computeSections,
2367
3243
  createRenderer,
3244
+ currencySymbol,
2368
3245
  expandBooth,
2369
3246
  expandChart,
2370
3247
  expandRow,
2371
3248
  expandRowSlots,
2372
3249
  expandTable,
3250
+ floorObjects,
3251
+ floorsOf,
3252
+ formatDate,
3253
+ formatMoney,
3254
+ getLocale,
3255
+ hiddenObjectIds,
3256
+ isSectionHidden,
2373
3257
  layerOf,
3258
+ loadLocale,
2374
3259
  objectCenter,
2375
- pointInPolygon
3260
+ pointInPolygon,
3261
+ resolveLocale,
3262
+ setLocale,
3263
+ setMoneyLocale,
3264
+ setStringOverrides,
3265
+ stackFloors,
3266
+ t,
3267
+ tCount
2376
3268
  };
2377
3269
  //# sourceMappingURL=index.js.map