@seatlayer/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs ADDED
@@ -0,0 +1,2418 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ ACCESSIBILITY_TYPES: () => ACCESSIBILITY_TYPES,
24
+ CHART_STORAGE_KEY: () => CHART_STORAGE_KEY,
25
+ PickerController: () => PickerController,
26
+ SeatmapRenderer: () => SeatmapRenderer,
27
+ accessibilityMeta: () => accessibilityMeta,
28
+ chartBounds: () => chartBounds,
29
+ createRenderer: () => createRenderer,
30
+ expandBooth: () => expandBooth,
31
+ expandChart: () => expandChart,
32
+ expandRow: () => expandRow,
33
+ expandRowSlots: () => expandRowSlots,
34
+ expandTable: () => expandTable,
35
+ layerOf: () => layerOf,
36
+ objectCenter: () => objectCenter,
37
+ pointInPolygon: () => pointInPolygon
38
+ });
39
+ module.exports = __toCommonJS(index_exports);
40
+
41
+ // src/core/types.ts
42
+ var ACCESSIBILITY_TYPES = [
43
+ { key: "wheelchair", label: "Wheelchair space", short: "Wheelchair", icon: "\u267F" },
44
+ { key: "companion", label: "Companion seat", short: "Companion", icon: "\u{1F9D1}\u200D\u{1F91D}\u200D\u{1F9D1}" },
45
+ { key: "semi-ambulatory", label: "Semi-ambulatory (limited mobility)", short: "Limited mobility", icon: "\u{1F9AF}" },
46
+ { key: "hearing", label: "Assistive listening", short: "Hearing", icon: "\u{1F9BB}" },
47
+ { key: "sign-language", label: "Sign-language view", short: "Sign language", icon: "\u{1F91F}" },
48
+ { key: "plus-size", label: "Plus-size seat", short: "Plus-size", icon: "\u{1F4BA}" },
49
+ { key: "lift-armrest", label: "Lift-up armrest", short: "Lift armrest", icon: "\u2195\uFE0F" }
50
+ ];
51
+ var ACCESSIBILITY_LABEL = new Map(ACCESSIBILITY_TYPES.map((a) => [a.key, a]));
52
+ function accessibilityMeta(key) {
53
+ return ACCESSIBILITY_LABEL.get(key);
54
+ }
55
+ function layerOf(obj) {
56
+ switch (obj.type) {
57
+ case "row":
58
+ case "table":
59
+ case "gaArea":
60
+ case "booth":
61
+ case "section":
62
+ return "interactive";
63
+ // stage / décor live on 'shape'; free text is background furniture.
64
+ case "shape":
65
+ case "text":
66
+ return "background";
67
+ default:
68
+ return "interactive";
69
+ }
70
+ }
71
+ var CHART_STORAGE_KEY = "seatmap.chart";
72
+
73
+ // src/core/layout.ts
74
+ function overrideAccessibility(o) {
75
+ if (!o) return [];
76
+ if (o.accessibility && o.accessibility.length) return o.accessibility;
77
+ return o.accessible ? ["wheelchair"] : [];
78
+ }
79
+ var DEG = Math.PI / 180;
80
+ var SEAT_R = 9;
81
+ var TABLE_SEAT_OFFSET = 16;
82
+ function place(lx, ly, deg, origin) {
83
+ const a = deg * DEG;
84
+ const cos = Math.cos(a);
85
+ const sin = Math.sin(a);
86
+ return {
87
+ x: origin.x + lx * cos - ly * sin,
88
+ y: origin.y + lx * sin + ly * cos
89
+ };
90
+ }
91
+ function rowSeatPositions(row) {
92
+ const { seatCount, seatSpacing, curve, rotation, origin } = row;
93
+ const out = [];
94
+ if (seatCount <= 1) {
95
+ if (seatCount === 1) out.push({ x: origin.x, y: origin.y });
96
+ return out;
97
+ }
98
+ if (curve === 0) {
99
+ for (let i = 0; i < seatCount; i++) out.push(place(i * seatSpacing, 0, rotation, origin));
100
+ return out;
101
+ }
102
+ const arcStep = curve / (seatCount - 1) * DEG;
103
+ const radius = seatSpacing / (2 * Math.sin(Math.abs(arcStep) / 2));
104
+ for (let i = 0; i < seatCount; i++) {
105
+ const phi = i * arcStep;
106
+ const lx = radius * Math.sin(phi);
107
+ const ly = -radius + radius * Math.cos(phi);
108
+ out.push(place(lx, ly, rotation, origin));
109
+ }
110
+ return out;
111
+ }
112
+ function overrideMap(row) {
113
+ const m = /* @__PURE__ */ new Map();
114
+ if (row.overrides) for (const o of row.overrides) m.set(o.index, o);
115
+ return m;
116
+ }
117
+ function expandRowSlots(row) {
118
+ const start = row.seatLabelStart ?? 1;
119
+ const dir = row.seatNumbering?.direction ?? "ltr";
120
+ const step = row.seatNumbering?.step ?? 1;
121
+ const seatNumber = (i) => start + (dir === "rtl" ? row.seatCount - 1 - i : i) * step;
122
+ const ov = overrideMap(row);
123
+ return rowSeatPositions(row).map((p, i) => {
124
+ const o = ov.get(i);
125
+ const accessibility = overrideAccessibility(o);
126
+ return {
127
+ index: i,
128
+ x: p.x + (o?.dx ?? 0),
129
+ y: p.y + (o?.dy ?? 0),
130
+ label: o?.label ?? `${row.label}-${seatNumber(i)}`,
131
+ categoryKey: o?.categoryKey ?? row.categoryKey,
132
+ skipped: !!o?.skip,
133
+ accessible: accessibility.length > 0,
134
+ accessibility
135
+ };
136
+ });
137
+ }
138
+ function expandRow(row) {
139
+ const seats = [];
140
+ for (const slot of expandRowSlots(row)) {
141
+ if (slot.skipped) continue;
142
+ seats.push({
143
+ id: `${row.id}:${slot.index}`,
144
+ label: slot.label,
145
+ x: slot.x,
146
+ y: slot.y,
147
+ rowId: row.id,
148
+ categoryKey: slot.categoryKey,
149
+ accessible: slot.accessible || void 0,
150
+ accessibility: slot.accessibility.length ? slot.accessibility : void 0,
151
+ viewUrl: row.viewFromSeatUrl
152
+ });
153
+ }
154
+ return seats;
155
+ }
156
+ function expandTable(t) {
157
+ const seats = [];
158
+ const n = Math.max(0, Math.round(t.seatCount));
159
+ if (n === 0) return seats;
160
+ const mk = (i, x, y) => ({
161
+ id: `${t.id}:${i}`,
162
+ label: `${t.label}-${i + 1}`,
163
+ x,
164
+ y,
165
+ rowId: t.id,
166
+ categoryKey: t.categoryKey
167
+ });
168
+ if (t.shape === "round") {
169
+ const R = (t.radius ?? 40) + TABLE_SEAT_OFFSET;
170
+ const base = t.rotation * DEG;
171
+ for (let i = 0; i < n; i++) {
172
+ const a = base + i / n * 2 * Math.PI;
173
+ seats.push(mk(i, t.center.x + R * Math.cos(a), t.center.y + R * Math.sin(a)));
174
+ }
175
+ return seats;
176
+ }
177
+ const w = t.width ?? 80;
178
+ const h = t.height ?? 50;
179
+ const enabled = t.sides && t.sides.length ? t.sides : ["top", "bottom"];
180
+ const order = ["top", "bottom", "left", "right"].filter((s) => enabled.includes(s));
181
+ if (!order.length) return seats;
182
+ const counts = new Map(order.map((s) => [s, 0]));
183
+ for (let i = 0; i < n; i++) {
184
+ const s = order[i % order.length];
185
+ counts.set(s, counts.get(s) + 1);
186
+ }
187
+ let idx = 0;
188
+ for (const side of order) {
189
+ const count = counts.get(side);
190
+ for (let j = 0; j < count; j++) {
191
+ let localX;
192
+ let localY;
193
+ if (side === "top") {
194
+ localX = -w / 2 + (j + 0.5) * w / count;
195
+ localY = -h / 2 - TABLE_SEAT_OFFSET;
196
+ } else if (side === "bottom") {
197
+ localX = -w / 2 + (j + 0.5) * w / count;
198
+ localY = h / 2 + TABLE_SEAT_OFFSET;
199
+ } else if (side === "left") {
200
+ localX = -w / 2 - TABLE_SEAT_OFFSET;
201
+ localY = -h / 2 + (j + 0.5) * h / count;
202
+ } else {
203
+ localX = w / 2 + TABLE_SEAT_OFFSET;
204
+ localY = -h / 2 + (j + 0.5) * h / count;
205
+ }
206
+ const p = place(localX, localY, t.rotation, t.center);
207
+ seats.push(mk(idx++, p.x, p.y));
208
+ }
209
+ }
210
+ return seats;
211
+ }
212
+ function expandBooth(b) {
213
+ return [
214
+ {
215
+ id: `${b.id}:0`,
216
+ label: b.label,
217
+ x: b.center.x,
218
+ y: b.center.y,
219
+ rowId: b.id,
220
+ categoryKey: b.categoryKey,
221
+ kind: "booth"
222
+ }
223
+ ];
224
+ }
225
+ function pointInPolygon(p, poly) {
226
+ let inside = false;
227
+ for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
228
+ const xi = poly[i].x;
229
+ const yi = poly[i].y;
230
+ const xj = poly[j].x;
231
+ const yj = poly[j].y;
232
+ const hit = yi > p.y !== yj > p.y && p.x < (xj - xi) * (p.y - yi) / (yj - yi) + xi;
233
+ if (hit) inside = !inside;
234
+ }
235
+ return inside;
236
+ }
237
+ function polygonCentroid(pts) {
238
+ if (!pts.length) return { x: 0, y: 0 };
239
+ let x = 0;
240
+ let y = 0;
241
+ for (const p of pts) {
242
+ x += p.x;
243
+ y += p.y;
244
+ }
245
+ return { x: x / pts.length, y: y / pts.length };
246
+ }
247
+ function objectCenter(o) {
248
+ switch (o.type) {
249
+ case "row": {
250
+ const seats = expandRow(o);
251
+ if (!seats.length) return { x: o.origin.x, y: o.origin.y };
252
+ let x = 0;
253
+ let y = 0;
254
+ for (const s of seats) {
255
+ x += s.x;
256
+ y += s.y;
257
+ }
258
+ return { x: x / seats.length, y: y / seats.length };
259
+ }
260
+ case "table":
261
+ case "booth":
262
+ return { x: o.center.x, y: o.center.y };
263
+ case "gaArea":
264
+ return polygonCentroid(o.points);
265
+ case "section":
266
+ return polygonCentroid(o.outline);
267
+ case "text":
268
+ return { x: o.position.x, y: o.position.y };
269
+ case "shape":
270
+ if (o.points && o.points.length) return polygonCentroid(o.points);
271
+ if (o.x != null && o.y != null && o.width != null && o.height != null) {
272
+ return { x: o.x + o.width / 2, y: o.y + o.height / 2 };
273
+ }
274
+ return { x: o.x ?? 0, y: o.y ?? 0 };
275
+ }
276
+ }
277
+ function expandChart(doc) {
278
+ const out = [];
279
+ for (const obj of doc.objects) {
280
+ if (obj.type === "row") out.push(...expandRow(obj));
281
+ else if (obj.type === "table") out.push(...expandTable(obj));
282
+ else if (obj.type === "booth") out.push(...expandBooth(obj));
283
+ }
284
+ return out;
285
+ }
286
+ var PAD = 40;
287
+ function chartBounds(doc) {
288
+ let minX = Infinity;
289
+ let minY = Infinity;
290
+ let maxX = -Infinity;
291
+ let maxY = -Infinity;
292
+ const acc = (x, y) => {
293
+ if (x < minX) minX = x;
294
+ if (y < minY) minY = y;
295
+ if (x > maxX) maxX = x;
296
+ if (y > maxY) maxY = y;
297
+ };
298
+ for (const s of expandChart(doc)) acc(s.x, s.y);
299
+ for (const obj of doc.objects) {
300
+ if (obj.type === "gaArea") {
301
+ for (const p of obj.points) acc(p.x, p.y);
302
+ } else if (obj.type === "section") {
303
+ for (const p of obj.outline) acc(p.x, p.y);
304
+ } else if (obj.type === "shape") {
305
+ if (obj.points && obj.points.length) {
306
+ for (const p of obj.points) acc(p.x, p.y);
307
+ } else if (obj.x != null && obj.y != null && obj.width != null && obj.height != null) {
308
+ acc(obj.x, obj.y);
309
+ acc(obj.x + obj.width, obj.y + obj.height);
310
+ }
311
+ } else if (obj.type === "table") {
312
+ const off = TABLE_SEAT_OFFSET + SEAT_R;
313
+ const ext = obj.shape === "round" ? (obj.radius ?? 40) + off : Math.max((obj.width ?? 80) / 2, (obj.height ?? 50) / 2) + off;
314
+ acc(obj.center.x - ext, obj.center.y - ext);
315
+ acc(obj.center.x + ext, obj.center.y + ext);
316
+ } else if (obj.type === "booth") {
317
+ const ext = Math.max(obj.width, obj.height) / 2;
318
+ acc(obj.center.x - ext, obj.center.y - ext);
319
+ acc(obj.center.x + ext, obj.center.y + ext);
320
+ } else if (obj.type === "text") {
321
+ const w = obj.fontSize * obj.text.length * 0.6;
322
+ acc(obj.position.x, obj.position.y);
323
+ acc(obj.position.x + w, obj.position.y + obj.fontSize);
324
+ }
325
+ }
326
+ if (doc.backgroundImage) {
327
+ const { center, width } = doc.backgroundImage;
328
+ const bh = width * 3 / 4;
329
+ acc(center.x - width / 2, center.y - bh / 2);
330
+ acc(center.x + width / 2, center.y + bh / 2);
331
+ }
332
+ if (!isFinite(minX)) {
333
+ const f = doc.focalPoint ?? { x: 0, y: 0 };
334
+ return { x: f.x - 200, y: f.y - 200, width: 400, height: 400 };
335
+ }
336
+ return {
337
+ x: minX - PAD,
338
+ y: minY - PAD,
339
+ width: maxX - minX + PAD * 2,
340
+ height: maxY - minY + PAD * 2
341
+ };
342
+ }
343
+
344
+ // src/engine/SeatmapRenderer.ts
345
+ var import_Core = require("konva/lib/Core");
346
+ var import_Stage = require("konva/lib/Stage");
347
+ var import_Layer = require("konva/lib/Layer");
348
+ var import_Group = require("konva/lib/Group");
349
+ var import_Circle = require("konva/lib/shapes/Circle");
350
+ var import_Rect = require("konva/lib/shapes/Rect");
351
+ var import_Ellipse = require("konva/lib/shapes/Ellipse");
352
+ var import_Line = require("konva/lib/shapes/Line");
353
+ var import_Text = require("konva/lib/shapes/Text");
354
+ var import_Image = require("konva/lib/shapes/Image");
355
+ var SEAT_RADIUS = 9;
356
+ var SEAT_LEGIBLE_SCALE = 0.9;
357
+ var CACHE_THRESHOLD = 0.55 * SEAT_LEGIBLE_SCALE;
358
+ var LABEL_SCALE = 1;
359
+ var SECTION_PROMINENT_SCALE = 0.35 * SEAT_LEGIBLE_SCALE;
360
+ var ZONE_PROMINENT_SCALE = 0.55 * SECTION_PROMINENT_SCALE;
361
+ var MAX_LABELS = 700;
362
+ var ISO_ANGLE_DEG = -11.5;
363
+ var ISO_SQUASH = 0.58;
364
+ var LIFT_PER_STEP = 46;
365
+ var ISO_TWEEN_MS = 320;
366
+ var BLOCK_FILL_ALPHA = 0.85;
367
+ var SOLD_DARKEN = 0.5;
368
+ var SECTION_LABEL_PX = 18;
369
+ var SECTION_SUB_PX = 11;
370
+ var ZONE_LABEL_PX = 30;
371
+ var ZONE_SUB_PX = 12;
372
+ var HELD_FILL = "#6b7280";
373
+ var TAKEN_FILL = "#374151";
374
+ var NFS_STROKE = "#4b5563";
375
+ var ACCESS_RING = {
376
+ wheelchair: "#3b82f6",
377
+ companion: "#8b5cf6",
378
+ "semi-ambulatory": "#0ea5e9",
379
+ hearing: "#14b8a6",
380
+ "sign-language": "#f59e0b",
381
+ "plus-size": "#ec4899",
382
+ "lift-armrest": "#22c55e"
383
+ };
384
+ function seatMatchesAccess(seat, filter) {
385
+ if (filter.length === 0) return !!seat.accessible;
386
+ return !!seat.accessibility?.some((t) => filter.includes(t));
387
+ }
388
+ var DEF_SEAT_LABEL = "#0b1220";
389
+ var DEF_SELECTION = "#ffffff";
390
+ var DEF_DECOR_FILL = "#232c40";
391
+ var DEF_TEXT = "#8b93a7";
392
+ var clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
393
+ function seatIdOf(target) {
394
+ const n = target;
395
+ return n?.getAttr?.("seatId") ?? void 0;
396
+ }
397
+ function lighten(hex, amt) {
398
+ const m = /^#?([\da-f]{6})$/i.exec(hex.trim());
399
+ if (!m) return hex;
400
+ const n = parseInt(m[1], 16);
401
+ const r = n >> 16 & 255;
402
+ const g = n >> 8 & 255;
403
+ const b = n & 255;
404
+ const mix = (c) => Math.round(c + (255 - c) * amt);
405
+ return `#${(1 << 24 | mix(r) << 16 | mix(g) << 8 | mix(b)).toString(16).slice(1)}`;
406
+ }
407
+ function darken(hex, amt) {
408
+ const m = /^#?([\da-f]{6})$/i.exec(hex.trim());
409
+ if (!m) return hex;
410
+ const n = parseInt(m[1], 16);
411
+ const mix = (c) => Math.round(c * (1 - amt));
412
+ return `#${(1 << 24 | mix(n >> 16 & 255) << 16 | mix(n >> 8 & 255) << 8 | mix(n & 255)).toString(16).slice(1)}`;
413
+ }
414
+ function polyBounds(pts) {
415
+ let minX = Infinity;
416
+ let minY = Infinity;
417
+ let maxX = -Infinity;
418
+ let maxY = -Infinity;
419
+ for (const p of pts) {
420
+ if (p.x < minX) minX = p.x;
421
+ if (p.y < minY) minY = p.y;
422
+ if (p.x > maxX) maxX = p.x;
423
+ if (p.y > maxY) maxY = p.y;
424
+ }
425
+ return { x: minX, y: minY, width: Math.max(1, maxX - minX), height: Math.max(1, maxY - minY) };
426
+ }
427
+ function rgba(hex, a) {
428
+ const m = /^#?([\da-f]{6})$/i.exec(hex.trim());
429
+ if (!m) return hex;
430
+ const n = parseInt(m[1], 16);
431
+ return `rgba(${n >> 16 & 255},${n >> 8 & 255},${n & 255},${a})`;
432
+ }
433
+ function hexToRgb(hex) {
434
+ const m = /^#?([\da-f]{6})$/i.exec(hex.trim());
435
+ if (!m) return null;
436
+ const n = parseInt(m[1], 16);
437
+ return [n >> 16 & 255, n >> 8 & 255, n & 255];
438
+ }
439
+ var toHex = (r, g, b) => `#${(1 << 24 | Math.round(r) << 16 | Math.round(g) << 8 | Math.round(b)).toString(16).slice(1)}`;
440
+ function mixColors(parts, fallback) {
441
+ let r = 0;
442
+ let g = 0;
443
+ let b = 0;
444
+ let tw = 0;
445
+ for (const p of parts) {
446
+ const rgb = hexToRgb(p.hex);
447
+ if (!rgb || p.w <= 0) continue;
448
+ r += rgb[0] * p.w;
449
+ g += rgb[1] * p.w;
450
+ b += rgb[2] * p.w;
451
+ tw += p.w;
452
+ }
453
+ return tw > 0 ? toHex(r / tw, g / tw, b / tw) : fallback;
454
+ }
455
+ function lerpColor(a, b, t) {
456
+ const ca = hexToRgb(a);
457
+ const cb = hexToRgb(b);
458
+ if (!ca || !cb) return a;
459
+ return toHex(ca[0] + (cb[0] - ca[0]) * t, ca[1] + (cb[1] - ca[1]) * t, ca[2] + (cb[2] - ca[2]) * t);
460
+ }
461
+ var _SeatmapRenderer = class _SeatmapRenderer {
462
+ constructor(container, options = {}) {
463
+ this.seats = [];
464
+ this.seatById = /* @__PURE__ */ new Map();
465
+ /** Interactive node per seat/booth — a Circle for seats, a Rect for booths. */
466
+ this.circleById = /* @__PURE__ */ new Map();
467
+ /** Booth block geometry, keyed by booth id (= the unit's rowId). */
468
+ this.boothDims = /* @__PURE__ */ new Map();
469
+ this.statusById = /* @__PURE__ */ new Map();
470
+ this.catColor = /* @__PURE__ */ new Map();
471
+ this.theme = {};
472
+ this.selection = /* @__PURE__ */ new Set();
473
+ this.selectionRings = /* @__PURE__ */ new Map();
474
+ this.focusedId = null;
475
+ /**
476
+ * Accessibility filter: `null` = off; `[]` = dim all non-accessible free seats;
477
+ * a type list = dim free seats lacking any of those accommodations.
478
+ */
479
+ this.accessFilter = null;
480
+ /** Category highlight (legend hover): dims free seats NOT of this category. */
481
+ this.categoryHighlight = null;
482
+ // Section/zone overlays (bgLayer) — the 3-rung LOD: seats → section blocks →
483
+ // zone blocks. Kept for the melt restyle and for hit-testing a zoomed-out tap.
484
+ this.sections = [];
485
+ this.zones = [];
486
+ this.seatSection = /* @__PURE__ */ new Map();
487
+ this.catPrice = /* @__PURE__ */ new Map();
488
+ /** Zone id → colour (drives extruded side faces in iso view). */
489
+ this.zoneColor = /* @__PURE__ */ new Map();
490
+ this.hasSections = false;
491
+ /** seatLayer carries Text (booth labels) — gate the upright-label scan. */
492
+ this.hasBoothText = false;
493
+ // Isometric ("3D") view — an affine skew/rotate + elevation lift, tweened.
494
+ /** 0 = flat (default), 1 = full isometric; animated by setViewMode. */
495
+ this.isoT = 0;
496
+ this.isoTarget = 0;
497
+ this.isoRaf = 0;
498
+ /** Chart centre the iso projection pivots about (bounds centre). */
499
+ this.isoCentre = { x: 0, y: 0 };
500
+ /** Set in destroy() so an in-flight iso tween bails. */
501
+ this.destroyed = false;
502
+ /** Cached scale the section/zone labels were last sized for (scale-compensation). */
503
+ this.lodScale = 0;
504
+ /** prefers-reduced-motion → hard-swap rungs instead of cross-fading. */
505
+ this.reducedMotion = typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
506
+ this.fitScale = 1;
507
+ /** Effective seat radius (base × theme.seatScale), set per chart in setChart. */
508
+ this.seatR = SEAT_RADIUS;
509
+ this.bounds = { x: 0, y: 0, width: 1, height: 1 };
510
+ this.cached = false;
511
+ this.dpr = Math.min(typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1, 2);
512
+ this.rafId = 0;
513
+ this.frames = 0;
514
+ this.lastFpsAt = 0;
515
+ this.recacheTimer = null;
516
+ this.resizeObs = null;
517
+ /** Coalesces bursty view-change sources (pointermove, wheel) into ≤1 callback/frame. */
518
+ this.viewChangeRaf = 0;
519
+ // Gesture state — pan/pinch are handled with raw pointer events on the
520
+ // container (Konva stage dragging is off; its touch pipeline proved
521
+ // unreliable for multi-touch on real devices).
522
+ this.pointers = /* @__PURE__ */ new Map();
523
+ this.pinch = null;
524
+ this.panLast = null;
525
+ /** Cumulative gesture movement in px — clicks are suppressed after a real pan/pinch. */
526
+ this.moved = 0;
527
+ // ---- keyboard navigation (accessibility) ----------------------------------
528
+ this.onKeyDown = (e) => {
529
+ const dir = e.key === "ArrowLeft" ? { x: -1, y: 0 } : e.key === "ArrowRight" ? { x: 1, y: 0 } : e.key === "ArrowUp" ? { x: 0, y: -1 } : e.key === "ArrowDown" ? { x: 0, y: 1 } : null;
530
+ if (dir) {
531
+ e.preventDefault();
532
+ const next = this.focusedId ? this.nearestSeat(this.focusedId, dir) : this.seats[0]?.id ?? null;
533
+ if (next) this.focusSeat(next);
534
+ return;
535
+ }
536
+ if ((e.key === "Enter" || e.key === " ") && this.focusedId) {
537
+ e.preventDefault();
538
+ this.toggleSeat(this.focusedId);
539
+ const s = this.seatById.get(this.focusedId);
540
+ if (s) this.opts.onFocusSeat?.(s);
541
+ }
542
+ };
543
+ // NOTE: no setPointerCapture here — capturing on the container retargets
544
+ // pointer events away from Konva's canvas, killing its click/tap/hover
545
+ // pipeline. We rely on bubbling instead.
546
+ this.onPointerDown = (e) => {
547
+ this.pointers.set(e.pointerId, this.toLocal(e));
548
+ if (this.pointers.size === 1) {
549
+ this.moved = 0;
550
+ this.panLast = this.toLocal(e);
551
+ this.pinch = null;
552
+ } else if (this.pointers.size === 2) {
553
+ const [a, b] = [...this.pointers.values()];
554
+ const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
555
+ const s = this.stage.scaleX();
556
+ this.pinch = {
557
+ startDist: Math.hypot(b.x - a.x, b.y - a.y),
558
+ startScale: s,
559
+ worldMid: { x: (mid.x - this.stage.x()) / s, y: (mid.y - this.stage.y()) / s }
560
+ };
561
+ this.panLast = null;
562
+ }
563
+ };
564
+ this.onPointerMove = (e) => {
565
+ if (!this.pointers.has(e.pointerId)) return;
566
+ e.preventDefault();
567
+ const p = this.toLocal(e);
568
+ const prev = this.pointers.get(e.pointerId);
569
+ this.moved += Math.hypot(p.x - prev.x, p.y - prev.y);
570
+ this.pointers.set(e.pointerId, p);
571
+ if (this.pinch && this.pointers.size >= 2) {
572
+ const [a, b] = [...this.pointers.values()];
573
+ const dist = Math.hypot(b.x - a.x, b.y - a.y);
574
+ if (this.pinch.startDist < 1) return;
575
+ const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
576
+ const { min, max } = this.zoomBounds();
577
+ const scale = clamp(this.pinch.startScale * (dist / this.pinch.startDist), min, max);
578
+ this.stage.scale({ x: scale, y: scale });
579
+ this.stage.position({
580
+ x: mid.x - this.pinch.worldMid.x * scale,
581
+ y: mid.y - this.pinch.worldMid.y * scale
582
+ });
583
+ this.stage.batchDraw();
584
+ this.scheduleViewChange();
585
+ } else if (this.panLast && this.pointers.size === 1) {
586
+ this.stage.position({
587
+ x: this.stage.x() + (p.x - this.panLast.x),
588
+ y: this.stage.y() + (p.y - this.panLast.y)
589
+ });
590
+ this.panLast = p;
591
+ this.stage.batchDraw();
592
+ this.scheduleViewChange();
593
+ }
594
+ };
595
+ this.onPointerEnd = (e) => {
596
+ this.pointers.delete(e.pointerId);
597
+ if (this.pointers.size < 2) this.pinch = null;
598
+ if (this.pointers.size === 1) {
599
+ this.panLast = [...this.pointers.values()][0];
600
+ }
601
+ if (this.pointers.size === 0) {
602
+ this.panLast = null;
603
+ this.afterViewChange();
604
+ }
605
+ };
606
+ this.container = container;
607
+ this.opts = { maxSelection: 10, selectableStatuses: ["free"], ...options };
608
+ import_Core.Konva.pixelRatio = this.dpr;
609
+ this.stage = new import_Stage.Stage({
610
+ container,
611
+ width: container.clientWidth || 1,
612
+ height: container.clientHeight || 1,
613
+ draggable: false
614
+ // pan/pinch are ours, via pointer events
615
+ });
616
+ container.style.touchAction = "none";
617
+ container.addEventListener("pointerdown", this.onPointerDown, { passive: false });
618
+ container.addEventListener("pointermove", this.onPointerMove, { passive: false });
619
+ container.addEventListener("pointerup", this.onPointerEnd, { passive: false });
620
+ container.addEventListener("pointercancel", this.onPointerEnd, { passive: false });
621
+ if (container.tabIndex < 0) container.tabIndex = 0;
622
+ container.setAttribute("role", "application");
623
+ if (!container.getAttribute("aria-label")) {
624
+ container.setAttribute("aria-label", "Seating map. Use arrow keys to move between seats, Enter to select.");
625
+ }
626
+ container.addEventListener("keydown", this.onKeyDown);
627
+ this.bgLayer = new import_Layer.Layer({ listening: true });
628
+ this.seatLayer = new import_Layer.Layer({ listening: true });
629
+ this.overlayLayer = new import_Layer.Layer({ listening: false });
630
+ this.labelGroup = new import_Group.Group({ listening: false });
631
+ this.overlayLayer.add(this.labelGroup);
632
+ this.hoverRing = new import_Circle.Circle({
633
+ radius: SEAT_RADIUS + 2,
634
+ stroke: "#ffffff",
635
+ strokeWidth: 2,
636
+ opacity: 0.85,
637
+ listening: false,
638
+ visible: false,
639
+ perfectDrawEnabled: false,
640
+ shadowForStrokeEnabled: false
641
+ });
642
+ this.overlayLayer.add(this.hoverRing);
643
+ this.focusRing = new import_Circle.Circle({
644
+ radius: SEAT_RADIUS + 3,
645
+ stroke: "#38bdf8",
646
+ strokeWidth: 2.5,
647
+ dash: [4, 3],
648
+ opacity: 0.95,
649
+ listening: false,
650
+ visible: false,
651
+ perfectDrawEnabled: false,
652
+ shadowForStrokeEnabled: false
653
+ });
654
+ this.overlayLayer.add(this.focusRing);
655
+ this.stage.add(this.bgLayer, this.seatLayer, this.overlayLayer);
656
+ this.wireInteraction();
657
+ this.startFpsLoop();
658
+ if (false) {
659
+ window.__seatmap = this;
660
+ }
661
+ if (typeof ResizeObserver !== "undefined") {
662
+ this.resizeObs = new ResizeObserver(() => this.handleResize());
663
+ this.resizeObs.observe(container);
664
+ }
665
+ }
666
+ // ---- ISeatmapRenderer -----------------------------------------------------
667
+ setChart(doc) {
668
+ this.focusedId = null;
669
+ this.focusRing.visible(false);
670
+ this.bgLayer.destroyChildren();
671
+ this.seatLayer.destroyChildren();
672
+ this.labelGroup.destroyChildren();
673
+ this.circleById.clear();
674
+ this.boothDims.clear();
675
+ this.selectionRings.clear();
676
+ this.statusById.clear();
677
+ this.selection.clear();
678
+ this.seatById.clear();
679
+ this.cached = false;
680
+ this.accessFilter = null;
681
+ this.sections = [];
682
+ this.zones = [];
683
+ this.seatSection.clear();
684
+ this.catPrice.clear();
685
+ this.zoneColor.clear();
686
+ this.lodScale = 0;
687
+ this.hasBoothText = false;
688
+ if (this.isoRaf) {
689
+ cancelAnimationFrame(this.isoRaf);
690
+ this.isoRaf = 0;
691
+ }
692
+ this.isoT = 0;
693
+ this.isoTarget = 0;
694
+ this.resetLayerTransforms();
695
+ this.hasSections = doc.objects.some((o) => o.type === "section");
696
+ for (const z of doc.zones ?? []) if (z.color) this.zoneColor.set(z.id, z.color);
697
+ this.seatLayer.opacity(1);
698
+ this.hoverRing.visible(false);
699
+ this.theme = doc.theme ?? {};
700
+ this.seatR = clamp(this.theme.seatScale ?? 1, 0.7, 1.6) * SEAT_RADIUS;
701
+ this.container.style.background = this.theme.background ?? "";
702
+ this.hoverRing.stroke(this.theme.selectionColor ?? DEF_SELECTION);
703
+ this.hoverRing.radius(this.seatR + 2);
704
+ this.catColor.clear();
705
+ this.catPrice.clear();
706
+ for (const c of doc.categories) {
707
+ this.catColor.set(c.key, c.color);
708
+ if (typeof c.price === "number") this.catPrice.set(c.key, c.price);
709
+ }
710
+ for (const obj of doc.objects) {
711
+ if (obj.type === "booth") {
712
+ this.boothDims.set(obj.id, { width: obj.width, height: obj.height, rotation: obj.rotation });
713
+ }
714
+ }
715
+ this.seats = expandChart(doc);
716
+ for (const s of this.seats) {
717
+ this.seatById.set(s.id, s);
718
+ this.statusById.set(s.id, "free");
719
+ }
720
+ this.renderBackground(doc);
721
+ this.renderSeats();
722
+ this.overlayLayer.add(this.labelGroup);
723
+ this.overlayLayer.add(this.hoverRing);
724
+ this.bounds = chartBounds(doc);
725
+ this.isoCentre = { x: this.bounds.x + this.bounds.width / 2, y: this.bounds.y + this.bounds.height / 2 };
726
+ this.zoomToFit();
727
+ }
728
+ setStatus(seatIds, status) {
729
+ let touched = false;
730
+ const affected = this.hasSections ? /* @__PURE__ */ new Set() : null;
731
+ for (const id of seatIds) {
732
+ if (!this.statusById.has(id)) continue;
733
+ const prev = this.statusById.get(id);
734
+ this.statusById.set(id, status);
735
+ const c = this.circleById.get(id);
736
+ if (c) {
737
+ this.paintSeat(c, id);
738
+ touched = true;
739
+ }
740
+ if (affected) {
741
+ const sec = this.seatSection.get(id);
742
+ if (sec) {
743
+ if (prev === "free" !== (status === "free")) {
744
+ sec.free += status === "free" ? 1 : -1;
745
+ }
746
+ affected.add(sec);
747
+ }
748
+ }
749
+ }
750
+ if (affected && affected.size) {
751
+ for (const sec of affected) this.refreshSectionFill(sec);
752
+ this.bgLayer.batchDraw();
753
+ }
754
+ if (!touched) return;
755
+ if (this.cached) {
756
+ if (this.recacheTimer) clearTimeout(this.recacheTimer);
757
+ this.recacheTimer = setTimeout(() => this.cacheSeatLayer(), 150);
758
+ } else {
759
+ this.seatLayer.batchDraw();
760
+ }
761
+ }
762
+ getStatus(seatId) {
763
+ return this.statusById.get(seatId) ?? "free";
764
+ }
765
+ getSelection() {
766
+ const out = [];
767
+ for (const id of this.selection) {
768
+ const s = this.seatById.get(id);
769
+ if (s) out.push(s);
770
+ }
771
+ return out;
772
+ }
773
+ clearSelection() {
774
+ const ids = [...this.selection];
775
+ for (const id of ids) this.setSelected(id, false, true);
776
+ this.overlayLayer.batchDraw();
777
+ }
778
+ deselect(seatIds) {
779
+ let changed = false;
780
+ for (const id of seatIds) {
781
+ if (this.selection.has(id)) {
782
+ this.setSelected(id, false, true);
783
+ changed = true;
784
+ }
785
+ }
786
+ if (changed) this.overlayLayer.batchDraw();
787
+ }
788
+ flashSeat(seatId, color = "#f43f5e") {
789
+ const seat = this.seatById.get(seatId);
790
+ if (!seat) return;
791
+ const ring = new import_Circle.Circle({
792
+ x: seat.x,
793
+ y: seat.y,
794
+ radius: this.seatR,
795
+ stroke: color,
796
+ strokeWidth: 3,
797
+ opacity: 0.9,
798
+ listening: false,
799
+ perfectDrawEnabled: false,
800
+ shadowForStrokeEnabled: false
801
+ });
802
+ this.overlayLayer.add(ring);
803
+ const start = performance.now();
804
+ const dur = 620;
805
+ const step = (now) => {
806
+ if (!ring.getLayer()) return;
807
+ const t = Math.min(1, (now - start) / dur);
808
+ ring.radius(this.seatR * (1 + t * 1.8));
809
+ ring.opacity(0.9 * (1 - t));
810
+ this.overlayLayer.batchDraw();
811
+ if (t < 1) requestAnimationFrame(step);
812
+ else {
813
+ ring.destroy();
814
+ this.overlayLayer.batchDraw();
815
+ }
816
+ };
817
+ requestAnimationFrame(step);
818
+ }
819
+ /** Nearest seat from `fromId` in a cardinal direction (aligned + close wins). */
820
+ nearestSeat(fromId, dir) {
821
+ const from = this.seatById.get(fromId);
822
+ if (!from) return null;
823
+ let best = null;
824
+ let bestScore = Infinity;
825
+ for (const s of this.seats) {
826
+ if (s.id === fromId) continue;
827
+ const dx = s.x - from.x;
828
+ const dy = s.y - from.y;
829
+ const proj = dx * dir.x + dy * dir.y;
830
+ if (proj <= 0.5) continue;
831
+ const perp = Math.abs(dx * dir.y - dy * dir.x);
832
+ const score = proj + perp * 2.5;
833
+ if (score < bestScore) {
834
+ bestScore = score;
835
+ best = s.id;
836
+ }
837
+ }
838
+ return best;
839
+ }
840
+ focusSeat(id) {
841
+ const seat = this.seatById.get(id);
842
+ if (!seat) return;
843
+ this.focusedId = id;
844
+ this.focusRing.radius(this.seatR + 3);
845
+ this.focusRing.position({ x: seat.x, y: seat.y });
846
+ this.focusRing.visible(true);
847
+ this.ensureVisible(seat);
848
+ this.overlayLayer.batchDraw();
849
+ this.opts.onFocusSeat?.(seat);
850
+ }
851
+ /** Pan/zoom so a seat sits on-screen at a legible scale (for keyboard focus). */
852
+ ensureVisible(seat) {
853
+ const w = this.stage.width();
854
+ const h = this.stage.height();
855
+ const target = Math.max(this.stage.scaleX(), SEAT_LEGIBLE_SCALE * 1.2);
856
+ const p = this.worldToScreen(seat);
857
+ const margin = 70;
858
+ const offscreen = p.x < margin || p.x > w - margin || p.y < margin || p.y > h - margin;
859
+ if (this.stage.scaleX() < target || offscreen) {
860
+ const ip = this.isoT === 0 ? seat : this.isoForward(seat);
861
+ this.stage.scale({ x: target, y: target });
862
+ this.stage.position({ x: w / 2 - ip.x * target, y: h / 2 - ip.y * target });
863
+ this.afterViewChange();
864
+ this.stage.batchDraw();
865
+ }
866
+ }
867
+ zoomToFit() {
868
+ const w = this.stage.width();
869
+ const h = this.stage.height();
870
+ const b = this.bounds;
871
+ this.fitScale = Math.min(w / b.width, h / b.height) || 1;
872
+ const s = this.fitScale;
873
+ this.stage.scale({ x: s, y: s });
874
+ this.stage.position({
875
+ x: (w - b.width * s) / 2 - b.x * s,
876
+ y: (h - b.height * s) / 2 - b.y * s
877
+ });
878
+ this.afterViewChange();
879
+ this.stage.batchDraw();
880
+ }
881
+ zoomIn() {
882
+ const center = { x: this.stage.width() / 2, y: this.stage.height() / 2 };
883
+ this.zoomAbout(this.stage.scaleX() * _SeatmapRenderer.ZOOM_STEP, center);
884
+ }
885
+ zoomOut() {
886
+ const center = { x: this.stage.width() / 2, y: this.stage.height() / 2 };
887
+ this.zoomAbout(this.stage.scaleX() / _SeatmapRenderer.ZOOM_STEP, center);
888
+ }
889
+ seatCount() {
890
+ return this.seats.length;
891
+ }
892
+ worldToScreen(point) {
893
+ const s = this.stage.scaleX();
894
+ const p = this.isoT === 0 ? point : this.isoForward(point);
895
+ return { x: p.x * s + this.stage.x(), y: p.y * s + this.stage.y() };
896
+ }
897
+ setAccessibleFilter(on) {
898
+ this.setAccessibilityFilter(on ? [] : null);
899
+ }
900
+ setAccessibilityFilter(types) {
901
+ const next = types === null ? null : [...types];
902
+ if (this.sameAccessFilter(next)) return;
903
+ this.accessFilter = next;
904
+ for (const seat of this.seats) {
905
+ const c = this.circleById.get(seat.id);
906
+ if (c) this.paintSeat(c, seat.id);
907
+ }
908
+ if (this.cached) {
909
+ this.seatLayer.clearCache();
910
+ this.cacheSeatLayer();
911
+ } else {
912
+ this.seatLayer.batchDraw();
913
+ }
914
+ }
915
+ sameAccessFilter(next) {
916
+ const cur = this.accessFilter;
917
+ if (cur === null || next === null) return cur === next;
918
+ return cur.length === next.length && cur.every((t, i) => t === next[i]);
919
+ }
920
+ // ---- isometric ("3D") view mode -------------------------------------------
921
+ /**
922
+ * Switch the projection between flat top-down and the isometric "3D" view
923
+ * (rotate + y-squash about the chart centre, plus per-elevation lift). Tweens
924
+ * `isoT` 0⇄1 over ~0.32s (ease in-out); reduced-motion snaps. Purely visual —
925
+ * geometry stays flat, so hit-testing (Konva's own, plus the manual section
926
+ * inverse) keeps landing on the projected seats/sections.
927
+ */
928
+ setViewMode(mode) {
929
+ const target = mode === "isometric" ? 1 : 0;
930
+ this.isoTarget = target;
931
+ if (this.isoRaf) {
932
+ cancelAnimationFrame(this.isoRaf);
933
+ this.isoRaf = 0;
934
+ }
935
+ if (this.reducedMotion) {
936
+ this.isoT = target;
937
+ this.applyIso();
938
+ this.afterViewChange();
939
+ return;
940
+ }
941
+ const from = this.isoT;
942
+ if (from === target) {
943
+ this.applyIso();
944
+ this.afterViewChange();
945
+ return;
946
+ }
947
+ const start = performance.now();
948
+ const step = (now) => {
949
+ if (this.destroyed) return;
950
+ const raw = Math.min(1, (now - start) / ISO_TWEEN_MS);
951
+ const e = raw < 0.5 ? 4 * raw * raw * raw : 1 - Math.pow(-2 * raw + 2, 3) / 2;
952
+ this.isoT = from + (this.isoTarget - from) * e;
953
+ this.applyIso();
954
+ this.scheduleViewChange();
955
+ if (raw < 1) {
956
+ this.isoRaf = requestAnimationFrame(step);
957
+ } else {
958
+ this.isoT = this.isoTarget;
959
+ this.isoRaf = 0;
960
+ this.applyIso();
961
+ this.afterViewChange();
962
+ }
963
+ };
964
+ this.isoRaf = requestAnimationFrame(step);
965
+ }
966
+ /** Iso angle (rad) + y-squash for the current isoT. */
967
+ isoParams() {
968
+ return { th: ISO_ANGLE_DEG * Math.PI / 180 * this.isoT, sg: 1 - (1 - ISO_SQUASH) * this.isoT };
969
+ }
970
+ /** Effective vertical scale = stage scale × iso squash — legibility math uses this. */
971
+ effScale() {
972
+ return this.stage.scaleX() * (1 - (1 - ISO_SQUASH) * this.isoT);
973
+ }
974
+ /** Project a world point through the iso affine about the chart centre (→ iso-world). */
975
+ isoForward(p) {
976
+ const { th, sg } = this.isoParams();
977
+ const c = this.isoCentre;
978
+ const dx = p.x - c.x;
979
+ const dy = p.y - c.y;
980
+ const rx = dx * Math.cos(th) - dy * Math.sin(th);
981
+ const ry = (dx * Math.sin(th) + dy * Math.cos(th)) * sg;
982
+ return { x: c.x + rx, y: c.y + ry };
983
+ }
984
+ /** Inverse of isoForward (iso-world → world) for screen-space hit-testing. */
985
+ isoInverse(p) {
986
+ const { th, sg } = this.isoParams();
987
+ const c = this.isoCentre;
988
+ const dx = p.x - c.x;
989
+ const dy = p.y - c.y;
990
+ const uy = dy / sg;
991
+ const wx = dx * Math.cos(th) + uy * Math.sin(th);
992
+ const wy = -dx * Math.sin(th) + uy * Math.cos(th);
993
+ return { x: c.x + wx, y: c.y + wy };
994
+ }
995
+ /**
996
+ * Local-space offset that, once the layer applies the iso affine, lifts an
997
+ * object straight UP in iso-world by `elevation × LIFT_PER_STEP × isoT`
998
+ * (= inverse-linear of the pure vertical lift). Zero at isoT=0.
999
+ */
1000
+ isoLiftLocal(elevation) {
1001
+ const { th, sg } = this.isoParams();
1002
+ const delta = elevation * LIFT_PER_STEP * this.isoT;
1003
+ return { x: -(delta / sg) * Math.sin(th), y: -(delta / sg) * Math.cos(th) };
1004
+ }
1005
+ /** Restore the three layers to identity (flat) — byte-for-byte the original. */
1006
+ resetLayerTransforms() {
1007
+ for (const layer of [this.bgLayer, this.seatLayer, this.overlayLayer]) {
1008
+ layer.position({ x: 0, y: 0 });
1009
+ layer.offset({ x: 0, y: 0 });
1010
+ layer.scale({ x: 1, y: 1 });
1011
+ layer.rotation(0);
1012
+ layer.skewX(0);
1013
+ layer.skewY(0);
1014
+ }
1015
+ }
1016
+ /**
1017
+ * Apply the current isoT to the scene: the base rotate+squash as a decomposed
1018
+ * layer transform (so seats/décor/rings project together and Konva's own
1019
+ * hit-testing follows), upright counter-transforms on text, and the elevation
1020
+ * lift + extruded side faces on elevated sections.
1021
+ */
1022
+ applyIso() {
1023
+ const t = this.isoT;
1024
+ if (t === 0) {
1025
+ this.resetLayerTransforms();
1026
+ } else {
1027
+ const { th, sg } = this.isoParams();
1028
+ const a = Math.cos(th);
1029
+ const b = sg * Math.sin(th);
1030
+ const cc = -Math.sin(th);
1031
+ const d = sg * Math.cos(th);
1032
+ const r = Math.sqrt(a * a + b * b);
1033
+ const delta = a * d - b * cc;
1034
+ const rotationDeg = Math.atan2(b, a) * 180 / Math.PI;
1035
+ const scaleX = r;
1036
+ const scaleY = delta / r;
1037
+ const skewX = (a * cc + b * d) / delta;
1038
+ const c = this.isoCentre;
1039
+ for (const layer of [this.bgLayer, this.seatLayer, this.overlayLayer]) {
1040
+ layer.position({ x: c.x, y: c.y });
1041
+ layer.offset({ x: c.x, y: c.y });
1042
+ layer.rotation(rotationDeg);
1043
+ layer.scaleX(scaleX);
1044
+ layer.scaleY(scaleY);
1045
+ layer.skewX(skewX);
1046
+ layer.skewY(0);
1047
+ }
1048
+ }
1049
+ this.applyUprightLabels();
1050
+ this.applyElevation();
1051
+ this.bgLayer.batchDraw();
1052
+ this.seatLayer.batchDraw();
1053
+ this.overlayLayer.batchDraw();
1054
+ }
1055
+ /** Counter-skew every visible label so it renders upright at its projected anchor. */
1056
+ applyUprightLabels() {
1057
+ const thDeg = ISO_ANGLE_DEG * this.isoT;
1058
+ const invScaleY = 1 / (1 - (1 - ISO_SQUASH) * this.isoT);
1059
+ const layers = this.hasBoothText ? [this.bgLayer, this.seatLayer, this.overlayLayer] : [this.bgLayer, this.overlayLayer];
1060
+ for (const layer of layers) {
1061
+ const texts = layer.find("Text");
1062
+ for (const tn of texts) {
1063
+ let base = tn.getAttr("uprightBase");
1064
+ if (base == null) {
1065
+ base = tn.rotation();
1066
+ tn.setAttr("uprightBase", base);
1067
+ }
1068
+ tn.rotation(base - thDeg);
1069
+ tn.scaleX(1);
1070
+ tn.scaleY(invScaleY);
1071
+ tn.skewX(0);
1072
+ }
1073
+ }
1074
+ }
1075
+ /** Lift elevated sections (+ their members) and update their extruded side faces. */
1076
+ applyElevation() {
1077
+ const t = this.isoT;
1078
+ for (const sec of this.sections) {
1079
+ if (sec.elevation <= 0) continue;
1080
+ const off = this.isoLiftLocal(sec.elevation);
1081
+ sec.liftGroupBg?.position(off);
1082
+ sec.liftGroupSeat?.position(off);
1083
+ const alpha = 0.9 * t;
1084
+ for (let i = 0; i < sec.sideFaces.length; i++) {
1085
+ const face = sec.sideFaces[i];
1086
+ const p0 = sec.outline[i];
1087
+ const p1 = sec.outline[(i + 1) % sec.outline.length];
1088
+ face.points([p0.x, p0.y, p1.x, p1.y, p1.x + off.x, p1.y + off.y, p0.x + off.x, p0.y + off.y]);
1089
+ face.opacity(alpha);
1090
+ }
1091
+ }
1092
+ }
1093
+ destroy() {
1094
+ this.destroyed = true;
1095
+ if (this.rafId) cancelAnimationFrame(this.rafId);
1096
+ if (this.isoRaf) cancelAnimationFrame(this.isoRaf);
1097
+ if (this.viewChangeRaf) cancelAnimationFrame(this.viewChangeRaf);
1098
+ if (this.recacheTimer) clearTimeout(this.recacheTimer);
1099
+ this.resizeObs?.disconnect();
1100
+ this.container.removeEventListener("pointerdown", this.onPointerDown);
1101
+ this.container.removeEventListener("pointermove", this.onPointerMove);
1102
+ this.container.removeEventListener("pointerup", this.onPointerEnd);
1103
+ this.container.removeEventListener("pointercancel", this.onPointerEnd);
1104
+ this.container.removeEventListener("keydown", this.onKeyDown);
1105
+ this.stage.destroy();
1106
+ }
1107
+ // ---- rendering ------------------------------------------------------------
1108
+ /** Theme font stack for all rendered text (falls back to Inter). */
1109
+ labelFont() {
1110
+ return this.theme.fontFamily || "Inter, sans-serif";
1111
+ }
1112
+ /**
1113
+ * Where a seat's nodes live: an elevated section's lift group (so the whole
1114
+ * tier shifts with one offset in iso view) or the seat layer directly.
1115
+ */
1116
+ seatContainer(id) {
1117
+ return this.seatSection.get(id)?.liftGroupSeat ?? this.seatLayer;
1118
+ }
1119
+ renderSeats() {
1120
+ for (const seat of this.seats) {
1121
+ if (seat.kind === "booth") {
1122
+ this.renderBoothUnit(seat);
1123
+ continue;
1124
+ }
1125
+ const target = this.seatContainer(seat.id);
1126
+ const c = new import_Circle.Circle({
1127
+ x: seat.x,
1128
+ y: seat.y,
1129
+ radius: this.seatR,
1130
+ perfectDrawEnabled: false,
1131
+ shadowForStrokeEnabled: false,
1132
+ hitStrokeWidth: 0
1133
+ });
1134
+ c.setAttr("seatId", seat.id);
1135
+ this.circleById.set(seat.id, c);
1136
+ this.paintSeat(c, seat.id);
1137
+ target.add(c);
1138
+ if (seat.accessible) {
1139
+ const primary = seat.accessibility?.[0];
1140
+ target.add(
1141
+ new import_Circle.Circle({
1142
+ x: seat.x,
1143
+ y: seat.y,
1144
+ radius: this.seatR + 1,
1145
+ stroke: primary && ACCESS_RING[primary] || "#3b82f6",
1146
+ strokeWidth: 2,
1147
+ listening: false,
1148
+ perfectDrawEnabled: false,
1149
+ shadowForStrokeEnabled: false
1150
+ })
1151
+ );
1152
+ }
1153
+ }
1154
+ }
1155
+ /** A booth renders as a click-selectable rounded block (dims from the doc). */
1156
+ renderBoothUnit(seat) {
1157
+ const target = this.seatContainer(seat.id);
1158
+ const dims = this.boothDims.get(seat.rowId) ?? { width: 40, height: 30, rotation: 0 };
1159
+ const rect = new import_Rect.Rect({
1160
+ x: seat.x,
1161
+ y: seat.y,
1162
+ width: dims.width,
1163
+ height: dims.height,
1164
+ offsetX: dims.width / 2,
1165
+ offsetY: dims.height / 2,
1166
+ rotation: dims.rotation,
1167
+ cornerRadius: 4,
1168
+ perfectDrawEnabled: false,
1169
+ shadowForStrokeEnabled: false,
1170
+ hitStrokeWidth: 0
1171
+ });
1172
+ rect.setAttr("seatId", seat.id);
1173
+ this.circleById.set(seat.id, rect);
1174
+ this.paintSeat(rect, seat.id);
1175
+ target.add(rect);
1176
+ const t = new import_Text.Text({
1177
+ x: seat.x,
1178
+ y: seat.y,
1179
+ text: seat.label,
1180
+ fontSize: 10,
1181
+ fontStyle: "600",
1182
+ fontFamily: this.labelFont(),
1183
+ fill: this.theme.seatLabelColor ?? DEF_SEAT_LABEL,
1184
+ listening: false,
1185
+ perfectDrawEnabled: false
1186
+ });
1187
+ t.offsetX(t.width() / 2);
1188
+ t.offsetY(t.height() / 2);
1189
+ this.hasBoothText = true;
1190
+ target.add(t);
1191
+ }
1192
+ /** Apply fill/stroke/opacity for a seat's current status + selection. */
1193
+ paintSeat(c, id) {
1194
+ const seat = this.seatById.get(id);
1195
+ const status = this.statusById.get(id) ?? "free";
1196
+ const selected = this.selection.has(id);
1197
+ const base = this.catColor.get(seat.categoryKey) ?? "#6e7bff";
1198
+ c.dash([]);
1199
+ c.strokeWidth(0);
1200
+ c.stroke("");
1201
+ c.opacity(1);
1202
+ switch (status) {
1203
+ case "free":
1204
+ c.fill(selected ? lighten(base, 0.28) : base);
1205
+ break;
1206
+ case "held":
1207
+ c.fill(HELD_FILL);
1208
+ break;
1209
+ case "booked":
1210
+ c.fill(TAKEN_FILL);
1211
+ c.opacity(0.45);
1212
+ break;
1213
+ case "not_for_sale":
1214
+ c.fill(TAKEN_FILL);
1215
+ c.stroke(NFS_STROKE);
1216
+ c.strokeWidth(1);
1217
+ c.dash([2, 2]);
1218
+ break;
1219
+ }
1220
+ if (this.accessFilter && status === "free" && !selected && !seatMatchesAccess(seat, this.accessFilter)) {
1221
+ c.opacity(0.25);
1222
+ }
1223
+ if (this.categoryHighlight && status === "free" && !selected && seat.categoryKey !== this.categoryHighlight) {
1224
+ c.opacity(0.25);
1225
+ }
1226
+ }
1227
+ /** Legend hover: highlight one category (dim the rest), or null to clear. */
1228
+ setCategoryHighlight(key) {
1229
+ if (this.categoryHighlight === key) return;
1230
+ this.categoryHighlight = key;
1231
+ for (const seat of this.seats) {
1232
+ const c = this.circleById.get(seat.id);
1233
+ if (c) this.paintSeat(c, seat.id);
1234
+ }
1235
+ if (this.cached) {
1236
+ this.seatLayer.clearCache();
1237
+ this.cacheSeatLayer();
1238
+ } else {
1239
+ this.seatLayer.batchDraw();
1240
+ }
1241
+ }
1242
+ renderBackground(doc) {
1243
+ if (doc.backgroundImage) this.renderBackgroundImage(doc.backgroundImage);
1244
+ for (const obj of doc.objects) if (obj.type === "section") this.renderSection(obj);
1245
+ this.renderZones(doc);
1246
+ for (const obj of doc.objects) {
1247
+ if (obj.type === "shape") {
1248
+ this.renderShape(obj);
1249
+ } else if (obj.type === "gaArea") {
1250
+ this.renderGA(obj);
1251
+ } else if (obj.type === "table") {
1252
+ this.renderTable(obj);
1253
+ } else if (obj.type === "text") {
1254
+ this.renderText(obj);
1255
+ }
1256
+ }
1257
+ const f = doc.focalPoint;
1258
+ if (f) {
1259
+ const size = 14;
1260
+ const cross = new import_Group.Group({ listening: false });
1261
+ cross.add(
1262
+ new import_Line.Line({ points: [f.x - size, f.y, f.x + size, f.y], stroke: "#4b5563", strokeWidth: 1.5 }),
1263
+ new import_Line.Line({ points: [f.x, f.y - size, f.x, f.y + size], stroke: "#4b5563", strokeWidth: 1.5 }),
1264
+ new import_Circle.Circle({ x: f.x, y: f.y, radius: 3, fill: "#4b5563" })
1265
+ );
1266
+ this.bgLayer.add(cross);
1267
+ }
1268
+ }
1269
+ /** Organizer floor-plan photo, dimmed, at the very bottom of the bg layer. */
1270
+ renderBackgroundImage(bg) {
1271
+ const img = new window.Image();
1272
+ img.onload = () => {
1273
+ const natW = img.naturalWidth || 4;
1274
+ const natH = img.naturalHeight || 3;
1275
+ const w = bg.width;
1276
+ const h = w * (natH / natW);
1277
+ const node = new import_Image.Image({
1278
+ image: img,
1279
+ x: bg.center.x - w / 2,
1280
+ y: bg.center.y - h / 2,
1281
+ width: w,
1282
+ height: h,
1283
+ opacity: bg.opacity,
1284
+ listening: false
1285
+ });
1286
+ this.bgLayer.add(node);
1287
+ node.moveToBottom();
1288
+ this.bgLayer.batchDraw();
1289
+ };
1290
+ img.src = bg.url;
1291
+ }
1292
+ renderTable(obj) {
1293
+ if (obj.shape === "round") {
1294
+ this.bgLayer.add(
1295
+ new import_Circle.Circle({
1296
+ x: obj.center.x,
1297
+ y: obj.center.y,
1298
+ radius: obj.radius ?? 40,
1299
+ fill: "#232c40",
1300
+ stroke: "#2a3348",
1301
+ strokeWidth: 1.5,
1302
+ listening: false
1303
+ })
1304
+ );
1305
+ } else {
1306
+ const w = obj.width ?? 80;
1307
+ const h = obj.height ?? 50;
1308
+ this.bgLayer.add(
1309
+ new import_Rect.Rect({
1310
+ x: obj.center.x,
1311
+ y: obj.center.y,
1312
+ width: w,
1313
+ height: h,
1314
+ offsetX: w / 2,
1315
+ offsetY: h / 2,
1316
+ rotation: obj.rotation,
1317
+ fill: "#232c40",
1318
+ stroke: "#2a3348",
1319
+ strokeWidth: 1.5,
1320
+ cornerRadius: 4,
1321
+ listening: false
1322
+ })
1323
+ );
1324
+ }
1325
+ this.addCentredLabel(this.bgLayer, obj.label, obj.center.x, obj.center.y, "#cbd5e1", 12, true);
1326
+ }
1327
+ renderText(obj) {
1328
+ this.bgLayer.add(
1329
+ new import_Text.Text({
1330
+ x: obj.position.x,
1331
+ y: obj.position.y,
1332
+ text: obj.text,
1333
+ fontSize: obj.fontSize,
1334
+ rotation: obj.rotation,
1335
+ fill: obj.color ?? this.theme.textColor ?? DEF_TEXT,
1336
+ fontFamily: this.labelFont(),
1337
+ listening: false,
1338
+ perfectDrawEnabled: false
1339
+ })
1340
+ );
1341
+ }
1342
+ renderShape(obj) {
1343
+ const fill = obj.fill ?? this.theme.decorFill ?? DEF_DECOR_FILL;
1344
+ const isStage = obj.role === "stage";
1345
+ const isDecor = !!obj.role && !isStage;
1346
+ const stroke = isStage ? lighten(fill, 0.28) : void 0;
1347
+ let cx = 0;
1348
+ let cy = 0;
1349
+ if (obj.kind === "rect" && obj.x != null && obj.y != null && obj.width != null && obj.height != null) {
1350
+ const grad = isStage ? {
1351
+ fillLinearGradientStartPoint: { x: 0, y: 0 },
1352
+ fillLinearGradientEndPoint: { x: 0, y: obj.height },
1353
+ fillLinearGradientColorStops: [0, darken(fill, 0.3), 1, lighten(fill, 0.12)]
1354
+ } : { fill };
1355
+ cx = obj.x + obj.width / 2;
1356
+ cy = obj.y + obj.height / 2;
1357
+ this.bgLayer.add(
1358
+ new import_Rect.Rect({
1359
+ x: cx,
1360
+ y: cy,
1361
+ offsetX: obj.width / 2,
1362
+ offsetY: obj.height / 2,
1363
+ rotation: obj.rotation ?? 0,
1364
+ width: obj.width,
1365
+ height: obj.height,
1366
+ ...grad,
1367
+ stroke,
1368
+ strokeWidth: isStage ? 1 : 0,
1369
+ cornerRadius: 4,
1370
+ listening: false
1371
+ })
1372
+ );
1373
+ } else if (obj.kind === "ellipse" && obj.x != null && obj.y != null && obj.width != null && obj.height != null) {
1374
+ cx = obj.x + obj.width / 2;
1375
+ cy = obj.y + obj.height / 2;
1376
+ const grad = isStage ? {
1377
+ fillLinearGradientStartPoint: { x: 0, y: -obj.height / 2 },
1378
+ fillLinearGradientEndPoint: { x: 0, y: obj.height / 2 },
1379
+ fillLinearGradientColorStops: [0, darken(fill, 0.3), 1, lighten(fill, 0.12)]
1380
+ } : { fill };
1381
+ this.bgLayer.add(
1382
+ new import_Ellipse.Ellipse({ x: cx, y: cy, rotation: obj.rotation ?? 0, radiusX: obj.width / 2, radiusY: obj.height / 2, ...grad, stroke, strokeWidth: isStage ? 1 : 0, listening: false })
1383
+ );
1384
+ } else if (obj.kind === "polygon" && obj.points && obj.points.length) {
1385
+ const pts = obj.points.flatMap((p) => [p.x, p.y]);
1386
+ const b = polyBounds(obj.points);
1387
+ cx = obj.points.reduce((a, p) => a + p.x, 0) / obj.points.length;
1388
+ cy = obj.points.reduce((a, p) => a + p.y, 0) / obj.points.length;
1389
+ const grad = isStage ? {
1390
+ // Line points are absolute chart coords; the gradient endpoints share
1391
+ // that space and rotate with the node (pivot at the centroid below).
1392
+ fillLinearGradientStartPoint: { x: 0, y: b.y },
1393
+ fillLinearGradientEndPoint: { x: 0, y: b.y + b.height },
1394
+ fillLinearGradientColorStops: [0, darken(fill, 0.3), 1, lighten(fill, 0.12)]
1395
+ } : { fill };
1396
+ this.bgLayer.add(
1397
+ new import_Line.Line({ points: pts, closed: true, x: cx, y: cy, offsetX: cx, offsetY: cy, rotation: obj.rotation ?? 0, ...grad, stroke, strokeWidth: isStage ? 1 : 0, listening: false })
1398
+ );
1399
+ }
1400
+ if (obj.label) {
1401
+ if (isStage) this.addStageLabel(cx, cy, obj.label);
1402
+ else if (isDecor) this.addCentredLabel(this.bgLayer, obj.label, cx, cy, "#9aa3b5", 12, false);
1403
+ else this.addCentredLabel(this.bgLayer, obj.label, cx, cy, "#cbd5e1", 16, true);
1404
+ }
1405
+ }
1406
+ /** Prominent stage caption: uppercase, letter-spaced, larger, softly dimmed. */
1407
+ addStageLabel(x, y, text) {
1408
+ const t = new import_Text.Text({
1409
+ x,
1410
+ y,
1411
+ text: text.toUpperCase(),
1412
+ fontSize: 22,
1413
+ fontStyle: "700",
1414
+ letterSpacing: 4,
1415
+ fontFamily: this.labelFont(),
1416
+ fill: rgba("#e6e9f0", 0.62),
1417
+ listening: false,
1418
+ perfectDrawEnabled: false
1419
+ });
1420
+ t.offsetX(t.width() / 2);
1421
+ t.offsetY(t.height() / 2);
1422
+ this.bgLayer.add(t);
1423
+ }
1424
+ renderGA(obj) {
1425
+ const color = this.catColor.get(obj.categoryKey) ?? "#6e7bff";
1426
+ const pts = obj.points.flatMap((p) => [p.x, p.y]);
1427
+ const poly = new import_Line.Line({
1428
+ points: pts,
1429
+ closed: true,
1430
+ fill: color,
1431
+ opacity: 0.22,
1432
+ stroke: color,
1433
+ strokeWidth: 1.5
1434
+ });
1435
+ poly.setAttr("gaId", obj.id);
1436
+ poly.on("click tap", () => this.opts.onGAClick?.(obj.id));
1437
+ poly.on("mouseenter", () => {
1438
+ this.container.style.cursor = "pointer";
1439
+ });
1440
+ poly.on("mouseleave", () => {
1441
+ this.container.style.cursor = "default";
1442
+ });
1443
+ this.bgLayer.add(poly);
1444
+ const cx = obj.points.reduce((a, p) => a + p.x, 0) / obj.points.length;
1445
+ const cy = obj.points.reduce((a, p) => a + p.y, 0) / obj.points.length;
1446
+ this.addCentredLabel(this.bgLayer, obj.label, cx, cy - 8, "#e6e9f0", 15, false);
1447
+ this.addCentredLabel(this.bgLayer, `cap ${obj.capacity}`, cx, cy + 10, "#8b93a7", 11, false);
1448
+ }
1449
+ /**
1450
+ * A section renders in three coordinated layers driven by the LOD melt:
1451
+ * • a faint outline (the existing near-zoom look, untouched),
1452
+ * • a solid category-mix block that fades in at the block rung, and
1453
+ * • a name + "N LEFT" sublabel.
1454
+ * Membership (which seats live inside the outline) + the mix fill + the live
1455
+ * availability count are precomputed here (once), not per frame.
1456
+ */
1457
+ renderSection(obj) {
1458
+ const pts = obj.outline.flatMap((p) => [p.x, p.y]);
1459
+ const centroid = {
1460
+ x: obj.outline.reduce((a, p) => a + p.x, 0) / obj.outline.length,
1461
+ y: obj.outline.reduce((a, p) => a + p.y, 0) / obj.outline.length
1462
+ };
1463
+ const memberIds = [];
1464
+ const catCounts = /* @__PURE__ */ new Map();
1465
+ let free = 0;
1466
+ for (const seat of this.seats) {
1467
+ if (this.seatSection.has(seat.id)) continue;
1468
+ if (!pointInPolygon(seat, obj.outline)) continue;
1469
+ memberIds.push(seat.id);
1470
+ catCounts.set(seat.categoryKey, (catCounts.get(seat.categoryKey) ?? 0) + 1);
1471
+ if ((this.statusById.get(seat.id) ?? "free") === "free") free++;
1472
+ }
1473
+ const baseFill = obj.color ?? mixColors(
1474
+ [...catCounts].map(([key, w]) => ({ hex: this.catColor.get(key) ?? "#6e7bff", w })),
1475
+ "#3a4358"
1476
+ );
1477
+ const elevation = Math.max(0, Math.round(obj.elevation ?? 0));
1478
+ let liftGroupBg = null;
1479
+ let liftGroupSeat = null;
1480
+ const sideFaces = [];
1481
+ if (elevation > 0) {
1482
+ const faceFill = darken(this.zoneColor.get(obj.zone ?? "") ?? baseFill, 0.42);
1483
+ for (let i = 0; i < obj.outline.length; i++) {
1484
+ const face = new import_Line.Line({
1485
+ points: [],
1486
+ closed: true,
1487
+ fill: faceFill,
1488
+ stroke: rgba("#000000", 0.25),
1489
+ strokeWidth: 1,
1490
+ opacity: 0,
1491
+ listening: false,
1492
+ perfectDrawEnabled: false
1493
+ });
1494
+ sideFaces.push(face);
1495
+ this.bgLayer.add(face);
1496
+ }
1497
+ liftGroupBg = new import_Group.Group({ listening: false });
1498
+ this.bgLayer.add(liftGroupBg);
1499
+ liftGroupSeat = new import_Group.Group({ listening: false });
1500
+ this.seatLayer.add(liftGroupSeat);
1501
+ }
1502
+ const bgTarget = liftGroupBg ?? this.bgLayer;
1503
+ const outlinePoly = new import_Line.Line({
1504
+ points: pts,
1505
+ closed: true,
1506
+ stroke: "#3a4358",
1507
+ strokeWidth: 1.5,
1508
+ fill: rgba(obj.color ?? "#3a4358", 0.06),
1509
+ listening: false,
1510
+ perfectDrawEnabled: false
1511
+ });
1512
+ bgTarget.add(outlinePoly);
1513
+ const blockPoly = new import_Line.Line({
1514
+ points: pts,
1515
+ closed: true,
1516
+ fill: baseFill,
1517
+ stroke: rgba("#ffffff", 0.12),
1518
+ strokeWidth: 1,
1519
+ opacity: 0,
1520
+ listening: false,
1521
+ perfectDrawEnabled: false
1522
+ });
1523
+ bgTarget.add(blockPoly);
1524
+ const nameLabel = new import_Text.Text({
1525
+ x: centroid.x,
1526
+ y: centroid.y,
1527
+ text: obj.label,
1528
+ fontSize: 22,
1529
+ fontStyle: "700",
1530
+ fontFamily: this.labelFont(),
1531
+ fill: "#8b93a7",
1532
+ listening: false,
1533
+ perfectDrawEnabled: false
1534
+ });
1535
+ nameLabel.offsetX(nameLabel.width() / 2);
1536
+ nameLabel.offsetY(nameLabel.height() / 2);
1537
+ bgTarget.add(nameLabel);
1538
+ const subLabel = new import_Text.Text({
1539
+ x: centroid.x,
1540
+ y: centroid.y,
1541
+ text: `${free} LEFT`,
1542
+ fontSize: 12,
1543
+ fontStyle: "600",
1544
+ fontFamily: "JetBrains Mono, ui-monospace, monospace",
1545
+ fill: rgba("#e6e9f0", 0.8),
1546
+ opacity: 0,
1547
+ listening: false,
1548
+ perfectDrawEnabled: false
1549
+ });
1550
+ subLabel.offsetX(subLabel.width() / 2);
1551
+ bgTarget.add(subLabel);
1552
+ const sec = {
1553
+ id: obj.id,
1554
+ label: obj.label,
1555
+ outline: obj.outline,
1556
+ centroid,
1557
+ zone: obj.zone,
1558
+ memberIds,
1559
+ total: memberIds.length,
1560
+ free,
1561
+ baseFill,
1562
+ outlinePoly,
1563
+ blockPoly,
1564
+ nameLabel,
1565
+ subLabel,
1566
+ elevation,
1567
+ liftGroupBg,
1568
+ liftGroupSeat,
1569
+ sideFaces
1570
+ };
1571
+ for (const id of memberIds) this.seatSection.set(id, sec);
1572
+ this.refreshSectionFill(sec);
1573
+ this.sections.push(sec);
1574
+ }
1575
+ /** Recompute a section's availability-tinted fill + "N LEFT" (cheap; on status change). */
1576
+ refreshSectionFill(sec) {
1577
+ const sold = sec.total > 0 ? (sec.total - sec.free) / sec.total : 0;
1578
+ sec.blockPoly.fill(darken(sec.baseFill, sold * SOLD_DARKEN));
1579
+ sec.subLabel.text(`${sec.free} LEFT`);
1580
+ sec.subLabel.offsetX(sec.subLabel.width() / 2);
1581
+ }
1582
+ /**
1583
+ * Zone rung: one giant screen-constant label per zone (+ optional "FROM $n"),
1584
+ * shown at the farthest zoom in place of per-section detail. Skipped entirely
1585
+ * when the doc declares no zones — sections then stay the far rung (graceful).
1586
+ */
1587
+ renderZones(doc) {
1588
+ if (!doc.zones?.length || !this.sections.length) return;
1589
+ const byZone = /* @__PURE__ */ new Map();
1590
+ for (const sec of this.sections) {
1591
+ if (!sec.zone) continue;
1592
+ (byZone.get(sec.zone) ?? byZone.set(sec.zone, []).get(sec.zone)).push(sec);
1593
+ }
1594
+ for (const z of doc.zones) {
1595
+ const members = byZone.get(z.id);
1596
+ if (!members || !members.length) continue;
1597
+ const cx = members.reduce((a, s) => a + s.centroid.x, 0) / members.length;
1598
+ const cy = members.reduce((a, s) => a + s.centroid.y, 0) / members.length;
1599
+ let minPrice = Infinity;
1600
+ for (const sec of members) {
1601
+ for (const id of sec.memberIds) {
1602
+ const seat = this.seatById.get(id);
1603
+ const p = seat && this.catPrice.get(seat.categoryKey);
1604
+ if (typeof p === "number" && p < minPrice) minPrice = p;
1605
+ }
1606
+ }
1607
+ const label = new import_Text.Text({
1608
+ x: cx,
1609
+ y: cy,
1610
+ text: z.label.toUpperCase(),
1611
+ fontSize: 34,
1612
+ fontStyle: "800",
1613
+ letterSpacing: 2,
1614
+ fontFamily: this.labelFont(),
1615
+ fill: z.color ?? "#f2f4f8",
1616
+ opacity: 0,
1617
+ listening: false,
1618
+ perfectDrawEnabled: false
1619
+ });
1620
+ label.offsetX(label.width() / 2);
1621
+ label.offsetY(label.height() / 2);
1622
+ this.bgLayer.add(label);
1623
+ let sub = null;
1624
+ if (isFinite(minPrice)) {
1625
+ sub = new import_Text.Text({
1626
+ x: cx,
1627
+ y: cy,
1628
+ text: `FROM $${minPrice}`,
1629
+ fontSize: 14,
1630
+ fontStyle: "600",
1631
+ fontFamily: "JetBrains Mono, ui-monospace, monospace",
1632
+ fill: rgba("#e6e9f0", 0.75),
1633
+ opacity: 0,
1634
+ listening: false,
1635
+ perfectDrawEnabled: false
1636
+ });
1637
+ sub.offsetX(sub.width() / 2);
1638
+ this.bgLayer.add(sub);
1639
+ }
1640
+ this.zones.push({ id: z.id, label, sub });
1641
+ }
1642
+ }
1643
+ /**
1644
+ * THE MELT — a continuous, scale-driven cross-fade across the three rungs:
1645
+ * seats ⇄ section blocks ⇄ zone blocks.
1646
+ * `blockT` ramps 0→1 as scale falls through [SECTION_PROMINENT, CACHE_THRESHOLD]
1647
+ * so the solid fill is fully present exactly as seats become the cached bitmap
1648
+ * and fade out; `zoneT` ramps 0→1 across [ZONE_PROMINENT, SECTION_PROMINENT] so
1649
+ * per-section detail hands off to giant zone labels. Reduced-motion hard-swaps.
1650
+ * Section/zone labels are scale-compensated to hold a roughly constant screen size.
1651
+ */
1652
+ applySectionLod(scale) {
1653
+ let blockT;
1654
+ let zoneT;
1655
+ if (this.reducedMotion) {
1656
+ blockT = scale <= SECTION_PROMINENT_SCALE ? 1 : 0;
1657
+ zoneT = scale <= ZONE_PROMINENT_SCALE ? 1 : 0;
1658
+ } else {
1659
+ blockT = clamp((CACHE_THRESHOLD - scale) / (CACHE_THRESHOLD - SECTION_PROMINENT_SCALE), 0, 1);
1660
+ zoneT = clamp((SECTION_PROMINENT_SCALE - scale) / (SECTION_PROMINENT_SCALE - ZONE_PROMINENT_SCALE), 0, 1);
1661
+ }
1662
+ if (!this.zones.length) zoneT = 0;
1663
+ this.seatLayer.opacity(1 - blockT);
1664
+ const sx = this.stage.scaleX();
1665
+ const rescale = this.lodScale === 0 || Math.abs(scale - this.lodScale) / (this.lodScale || 1) > 0.02;
1666
+ if (rescale) this.lodScale = scale;
1667
+ for (const sec of this.sections) {
1668
+ sec.blockPoly.opacity(BLOCK_FILL_ALPHA * blockT);
1669
+ sec.nameLabel.fill(lerpColor("#8b93a7", "#f2f4f8", blockT));
1670
+ sec.nameLabel.opacity(1 - zoneT);
1671
+ sec.subLabel.opacity(blockT * (1 - zoneT));
1672
+ if (rescale) {
1673
+ this.sizeLabel(sec.nameLabel, SECTION_LABEL_PX / sx, sec.centroid.y - SECTION_SUB_PX / sx);
1674
+ this.sizeLabel(sec.subLabel, SECTION_SUB_PX / sx, sec.centroid.y + SECTION_LABEL_PX / sx);
1675
+ }
1676
+ }
1677
+ for (const zone of this.zones) {
1678
+ zone.label.opacity(zoneT);
1679
+ if (zone.sub) zone.sub.opacity(zoneT);
1680
+ if (rescale) {
1681
+ const cy = zone.label.y();
1682
+ this.sizeLabel(zone.label, ZONE_LABEL_PX / sx, cy);
1683
+ if (zone.sub) this.sizeLabel(zone.sub, ZONE_SUB_PX / sx, cy + ZONE_LABEL_PX / sx);
1684
+ }
1685
+ }
1686
+ this.bgLayer.batchDraw();
1687
+ }
1688
+ /** Set a centred label's world fontSize (for a target screen px) and re-anchor it. */
1689
+ sizeLabel(t, fontSize, y) {
1690
+ t.fontSize(Math.max(1, fontSize));
1691
+ t.offsetX(t.width() / 2);
1692
+ t.offsetY(t.height() / 2);
1693
+ t.y(y);
1694
+ }
1695
+ /**
1696
+ * Map a container-relative screen point back to world coords. Inverts the
1697
+ * stage (scale/pos) and, in iso view, the iso affine — so screen-space taps
1698
+ * test against the flat world outlines/geometry. Identity-equivalent at isoT=0.
1699
+ */
1700
+ screenToWorld(clientPoint) {
1701
+ const s = this.stage.scaleX();
1702
+ const iso = { x: (clientPoint.x - this.stage.x()) / s, y: (clientPoint.y - this.stage.y()) / s };
1703
+ return this.isoT === 0 ? iso : this.isoInverse(iso);
1704
+ }
1705
+ /** Section id under a container-relative screen point, or null (Slice 5 tap-to-zoom). */
1706
+ sectionAt(clientPoint) {
1707
+ if (!this.sections.length) return null;
1708
+ const world = this.screenToWorld(clientPoint);
1709
+ const hit = this.sections.find((sec) => pointInPolygon(world, sec.outline));
1710
+ return hit ? hit.id : null;
1711
+ }
1712
+ /** Seat ids belonging to a section (Slice 5 section-summary card). */
1713
+ sectionMembers(id) {
1714
+ return this.sections.find((s) => s.id === id)?.memberIds.slice() ?? [];
1715
+ }
1716
+ addCentredLabel(layer, text, x, y, fill, fontSize, bold) {
1717
+ const t = new import_Text.Text({
1718
+ x,
1719
+ y,
1720
+ text,
1721
+ fontSize,
1722
+ fontStyle: bold ? "700" : "500",
1723
+ fontFamily: this.labelFont(),
1724
+ fill,
1725
+ listening: false,
1726
+ perfectDrawEnabled: false
1727
+ });
1728
+ t.offsetX(t.width() / 2);
1729
+ t.offsetY(t.height() / 2);
1730
+ layer.add(t);
1731
+ }
1732
+ // ---- selection ------------------------------------------------------------
1733
+ isSelectable(id) {
1734
+ const statuses = this.opts.selectableStatuses ?? ["free"];
1735
+ return statuses.includes(this.statusById.get(id) ?? "free");
1736
+ }
1737
+ toggleSeat(id) {
1738
+ if (this.selection.has(id)) {
1739
+ this.setSelected(id, false);
1740
+ const seat = this.seatById.get(id);
1741
+ if (seat) this.opts.onDeselect?.(seat);
1742
+ } else {
1743
+ if (!this.isSelectable(id)) return;
1744
+ if (this.selection.size >= this.opts.maxSelection) return;
1745
+ this.setSelected(id, true);
1746
+ const seat = this.seatById.get(id);
1747
+ if (seat) this.opts.onSelect?.(seat);
1748
+ }
1749
+ this.overlayLayer.batchDraw();
1750
+ }
1751
+ setSelected(id, on, silent = false) {
1752
+ const c = this.circleById.get(id);
1753
+ if (on) {
1754
+ this.selection.add(id);
1755
+ const seat = this.seatById.get(id);
1756
+ const ring = new import_Circle.Circle({
1757
+ x: seat.x,
1758
+ y: seat.y,
1759
+ radius: this.seatR,
1760
+ stroke: this.theme.selectionColor ?? DEF_SELECTION,
1761
+ strokeWidth: 3,
1762
+ listening: false,
1763
+ perfectDrawEnabled: false,
1764
+ shadowForStrokeEnabled: false
1765
+ });
1766
+ this.selectionRings.set(id, ring);
1767
+ this.overlayLayer.add(ring);
1768
+ } else {
1769
+ this.selection.delete(id);
1770
+ const ring = this.selectionRings.get(id);
1771
+ ring?.destroy();
1772
+ this.selectionRings.delete(id);
1773
+ }
1774
+ if (c) {
1775
+ this.paintSeat(c, id);
1776
+ if (!silent && !this.cached) this.seatLayer.batchDraw();
1777
+ }
1778
+ }
1779
+ // ---- interaction ----------------------------------------------------------
1780
+ wireInteraction() {
1781
+ this.seatLayer.on("click tap", (e) => {
1782
+ if (this.moved > 8) return;
1783
+ const id = seatIdOf(e.target);
1784
+ if (id) this.toggleSeat(id);
1785
+ });
1786
+ this.seatLayer.on("mouseover", (e) => {
1787
+ const id = seatIdOf(e.target);
1788
+ if (!id) return;
1789
+ const seat = this.seatById.get(id);
1790
+ if (!seat) return;
1791
+ this.hoverRing.position({ x: seat.x, y: seat.y });
1792
+ this.hoverRing.visible(true);
1793
+ this.overlayLayer.batchDraw();
1794
+ this.container.style.cursor = "pointer";
1795
+ this.opts.onHover?.(seat);
1796
+ });
1797
+ this.seatLayer.on("mouseout", () => {
1798
+ this.hoverRing.visible(false);
1799
+ this.overlayLayer.batchDraw();
1800
+ this.container.style.cursor = "default";
1801
+ this.opts.onHover?.(null);
1802
+ });
1803
+ this.stage.on("wheel", (e) => {
1804
+ e.evt.preventDefault();
1805
+ const r = this.container.getBoundingClientRect();
1806
+ const pointer = { x: e.evt.clientX - r.left, y: e.evt.clientY - r.top };
1807
+ const factor = Math.exp(-e.evt.deltaY * 2e-3);
1808
+ this.zoomAbout(this.stage.scaleX() * clamp(factor, 0.5, 2), pointer);
1809
+ });
1810
+ this.stage.on("click tap", () => {
1811
+ if (!this.cached || this.moved > 8) return;
1812
+ const pointer = this.stage.getPointerPosition();
1813
+ if (!pointer) return;
1814
+ if (this.sections.length) {
1815
+ const world = this.screenToWorld(pointer);
1816
+ const hit = this.sections.find((sn) => pointInPolygon(world, sn.outline));
1817
+ if (hit) {
1818
+ this.zoomToBounds(polyBounds(hit.outline));
1819
+ return;
1820
+ }
1821
+ }
1822
+ const target = Math.max(SEAT_LEGIBLE_SCALE * 1.2, this.stage.scaleX() * 2.5);
1823
+ this.zoomAbout(target, pointer);
1824
+ });
1825
+ }
1826
+ // ---- Pan & pinch (raw pointer events — mouse, touch and pen alike) --------
1827
+ toLocal(e) {
1828
+ const r = this.container.getBoundingClientRect();
1829
+ return { x: e.clientX - r.left, y: e.clientY - r.top };
1830
+ }
1831
+ /**
1832
+ * Min is chart-relative (never lose the room); max is absolute so seats
1833
+ * reach a readable size on any chart, however large the venue.
1834
+ */
1835
+ zoomBounds() {
1836
+ return { min: 0.5 * this.fitScale, max: Math.max(10 * this.fitScale, 4) };
1837
+ }
1838
+ /** Zoom so `clientPoint` (stage-relative px) stays fixed under the cursor. */
1839
+ zoomAbout(nextScale, clientPoint) {
1840
+ const { min, max } = this.zoomBounds();
1841
+ const scale = clamp(nextScale, min, max);
1842
+ const old = this.stage.scaleX();
1843
+ if (scale === old) return;
1844
+ const worldX = (clientPoint.x - this.stage.x()) / old;
1845
+ const worldY = (clientPoint.y - this.stage.y()) / old;
1846
+ this.stage.scale({ x: scale, y: scale });
1847
+ this.stage.position({
1848
+ x: clientPoint.x - worldX * scale,
1849
+ y: clientPoint.y - worldY * scale
1850
+ });
1851
+ this.afterViewChange();
1852
+ this.stage.batchDraw();
1853
+ }
1854
+ /** Zoom + pan so world-rect `b` fills the viewport (with a small margin). */
1855
+ zoomToBounds(b) {
1856
+ const w = this.stage.width();
1857
+ const h = this.stage.height();
1858
+ const { min, max } = this.zoomBounds();
1859
+ const margin = 1.12;
1860
+ const scale = clamp(Math.min(w / (b.width * margin), h / (b.height * margin)), min, max);
1861
+ this.stage.scale({ x: scale, y: scale });
1862
+ this.stage.position({
1863
+ x: w / 2 - (b.x + b.width / 2) * scale,
1864
+ y: h / 2 - (b.y + b.height / 2) * scale
1865
+ });
1866
+ this.afterViewChange();
1867
+ this.stage.batchDraw();
1868
+ }
1869
+ /** Recompute LOD (cache/labels) after any pan/zoom settles. */
1870
+ afterViewChange() {
1871
+ this.updateLOD();
1872
+ this.updateLabels();
1873
+ this.scheduleViewChange();
1874
+ }
1875
+ /** rAF-coalesced `onViewChange` — at most one host callback per animation frame. */
1876
+ scheduleViewChange() {
1877
+ if (this.viewChangeRaf) return;
1878
+ this.viewChangeRaf = requestAnimationFrame(() => {
1879
+ this.viewChangeRaf = 0;
1880
+ this.opts.onViewChange?.();
1881
+ });
1882
+ }
1883
+ updateLOD() {
1884
+ const scale = this.effScale();
1885
+ if (this.hasSections) this.applySectionLod(scale);
1886
+ const shouldCache = scale < CACHE_THRESHOLD;
1887
+ if (shouldCache && !this.cached) {
1888
+ this.cacheSeatLayer();
1889
+ } else if (!shouldCache && this.cached) {
1890
+ this.seatLayer.clearCache();
1891
+ this.seatLayer.listening(true);
1892
+ this.cached = false;
1893
+ this.seatLayer.batchDraw();
1894
+ }
1895
+ }
1896
+ cacheSeatLayer() {
1897
+ const pr = clamp(this.stage.scaleX() * this.dpr, 0.15, 2);
1898
+ this.seatLayer.clearCache();
1899
+ this.seatLayer.cache({ pixelRatio: pr });
1900
+ this.seatLayer.listening(false);
1901
+ this.cached = true;
1902
+ this.seatLayer.batchDraw();
1903
+ }
1904
+ updateLabels() {
1905
+ const show = this.effScale() > LABEL_SCALE;
1906
+ this.labelGroup.destroyChildren();
1907
+ if (!show) {
1908
+ this.overlayLayer.batchDraw();
1909
+ return;
1910
+ }
1911
+ const s = this.stage.scaleX();
1912
+ const x0 = -this.stage.x() / s;
1913
+ const y0 = -this.stage.y() / s;
1914
+ const x1 = (this.stage.width() - this.stage.x()) / s;
1915
+ const y1 = (this.stage.height() - this.stage.y()) / s;
1916
+ let count = 0;
1917
+ for (const seat of this.seats) {
1918
+ if (seat.x < x0 || seat.x > x1 || seat.y < y0 || seat.y > y1) continue;
1919
+ const t = new import_Text.Text({
1920
+ x: seat.x,
1921
+ y: seat.y,
1922
+ text: seat.label,
1923
+ fontSize: 7,
1924
+ fontStyle: "600",
1925
+ fontFamily: this.labelFont(),
1926
+ fill: this.theme.seatLabelColor ?? DEF_SEAT_LABEL,
1927
+ listening: false,
1928
+ perfectDrawEnabled: false
1929
+ });
1930
+ const maxW = this.seatR * 2 - 3;
1931
+ if (t.width() > maxW) t.fontSize(Math.max(4, 7 * maxW / t.width()));
1932
+ if (t.fontSize() < 4.2) {
1933
+ t.destroy();
1934
+ continue;
1935
+ }
1936
+ t.offsetX(t.width() / 2);
1937
+ t.offsetY(t.height() / 2);
1938
+ this.labelGroup.add(t);
1939
+ if (++count >= MAX_LABELS) break;
1940
+ }
1941
+ if (this.isoT > 0) this.applyUprightLabels();
1942
+ this.overlayLayer.batchDraw();
1943
+ }
1944
+ handleResize() {
1945
+ const w = this.container.clientWidth || 1;
1946
+ const h = this.container.clientHeight || 1;
1947
+ if (w === this.stage.width() && h === this.stage.height()) return;
1948
+ this.stage.size({ width: w, height: h });
1949
+ this.zoomToFit();
1950
+ }
1951
+ startFpsLoop() {
1952
+ const tick = (now) => {
1953
+ if (!this.lastFpsAt) this.lastFpsAt = now;
1954
+ this.frames++;
1955
+ const elapsed = now - this.lastFpsAt;
1956
+ if (elapsed >= 1e3) {
1957
+ this.opts.onFps?.(Math.round(this.frames * 1e3 / elapsed));
1958
+ this.frames = 0;
1959
+ this.lastFpsAt = now;
1960
+ }
1961
+ this.rafId = requestAnimationFrame(tick);
1962
+ };
1963
+ this.rafId = requestAnimationFrame(tick);
1964
+ }
1965
+ };
1966
+ /** Step factor for the on-screen +/− buttons (B1) — same clamp path as pinch/wheel. */
1967
+ _SeatmapRenderer.ZOOM_STEP = 1.4;
1968
+ var SeatmapRenderer = _SeatmapRenderer;
1969
+ function createRenderer(container, opts) {
1970
+ return new SeatmapRenderer(container, opts);
1971
+ }
1972
+
1973
+ // src/picker/PickerController.ts
1974
+ var DEFAULT_MAX_SELECTION = 10;
1975
+ var MAX_BACKOFF_MS = 15e3;
1976
+ function errInfo(err) {
1977
+ const e = err ?? {};
1978
+ return { status: e.status, conflicts: e.conflicts, reason: e.reason };
1979
+ }
1980
+ function mapStatus(s) {
1981
+ if (s === "blocked") return "not_for_sale";
1982
+ if (s === "held" || s === "booked" || s === "free" || s === "not_for_sale") return s;
1983
+ return "free";
1984
+ }
1985
+ var PickerController = class {
1986
+ constructor(options) {
1987
+ this.renderer = null;
1988
+ this._doc = null;
1989
+ /** label ⇄ id maps — backend speaks labels, the engine speaks ids. */
1990
+ this.labelToId = /* @__PURE__ */ new Map();
1991
+ this.labelToSeat = /* @__PURE__ */ new Map();
1992
+ this.allIds = [];
1993
+ // realtime socket
1994
+ this.ws = null;
1995
+ this.reconnectTimer = null;
1996
+ this.attempt = 0;
1997
+ this.closed = false;
1998
+ // hold state
1999
+ this.hold_ = null;
2000
+ this.expiryTimer = null;
2001
+ this.opts = options;
2002
+ this.api = options.transport;
2003
+ this.key = options.eventKey;
2004
+ this.maxSelection = options.maxSelection ?? DEFAULT_MAX_SELECTION;
2005
+ }
2006
+ get doc() {
2007
+ return this._doc;
2008
+ }
2009
+ currentHold() {
2010
+ return this.hold_;
2011
+ }
2012
+ getRenderer() {
2013
+ return this.renderer;
2014
+ }
2015
+ seatByLabel(label) {
2016
+ return this.labelToSeat.get(label);
2017
+ }
2018
+ idForLabel(label) {
2019
+ return this.labelToId.get(label);
2020
+ }
2021
+ /** Fetch chart, build label maps, mount the renderer, seed statuses, go live. */
2022
+ async render(host) {
2023
+ if (this.renderer) return null;
2024
+ this.closed = false;
2025
+ let res;
2026
+ try {
2027
+ res = await this.api.chart(this.key);
2028
+ } catch (err) {
2029
+ this.emitError(err);
2030
+ return null;
2031
+ }
2032
+ if (this.closed) return null;
2033
+ this._doc = res.doc;
2034
+ this.labelToId = /* @__PURE__ */ new Map();
2035
+ this.labelToSeat = /* @__PURE__ */ new Map();
2036
+ this.allIds = [];
2037
+ for (const s of expandChart(res.doc)) {
2038
+ this.labelToId.set(s.label, s.id);
2039
+ this.labelToSeat.set(s.label, s);
2040
+ this.allIds.push(s.id);
2041
+ }
2042
+ const renderer = createRenderer(host, {
2043
+ maxSelection: this.maxSelection,
2044
+ confirmSelection: this.opts.confirmSelection,
2045
+ onSelect: (seat) => {
2046
+ this.opts.onSelect?.(seat);
2047
+ this.emitSelectionChange();
2048
+ },
2049
+ onDeselect: (seat) => {
2050
+ this.opts.onDeselect?.(seat);
2051
+ this.emitSelectionChange();
2052
+ },
2053
+ onHover: this.opts.onHover,
2054
+ onFocusSeat: this.opts.onFocusSeat,
2055
+ onViewChange: this.opts.onViewChange,
2056
+ onGAClick: this.opts.onGAClick,
2057
+ onFps: this.opts.onFps
2058
+ });
2059
+ if (this.closed) {
2060
+ renderer.destroy();
2061
+ return null;
2062
+ }
2063
+ this.renderer = renderer;
2064
+ renderer.setChart(res.doc);
2065
+ await this.resnapshot();
2066
+ this.connect();
2067
+ return {
2068
+ doc: res.doc,
2069
+ salesClosed: !!res.event.salesClosed,
2070
+ eventName: res.event.name,
2071
+ venue: res.event.venue,
2072
+ startsAt: res.event.startsAt
2073
+ };
2074
+ }
2075
+ // ---- selection ------------------------------------------------------------
2076
+ getSelection() {
2077
+ if (!this.renderer) return [];
2078
+ return this.renderer.getSelection().map((s) => this.toSeat(s));
2079
+ }
2080
+ clearSelection() {
2081
+ this.renderer?.clearSelection();
2082
+ this.emitSelectionChange();
2083
+ }
2084
+ deselect(ids) {
2085
+ this.renderer?.deselect(ids);
2086
+ this.emitSelectionChange();
2087
+ }
2088
+ // ---- booking machine ------------------------------------------------------
2089
+ /**
2090
+ * Hold the current selection (or a given label set). The controller does the
2091
+ * renderer/hold side (409 → deselect + repaint the taken seats held) and then
2092
+ * re-throws so the caller can choose the banner copy. Returns null only when
2093
+ * there's nothing to hold.
2094
+ */
2095
+ async hold(labelsArg) {
2096
+ const r = this.renderer;
2097
+ if (!r) return null;
2098
+ const labels = labelsArg ?? r.getSelection().map((s) => s.label);
2099
+ if (!labels.length) return null;
2100
+ if (this.hold_ && this.holdCovers(labels)) return this.hold_;
2101
+ try {
2102
+ const result = await this.api.hold(this.key, labels);
2103
+ this.setHold({ holdId: result.holdId, labels: [...labels], expiresAt: result.expiresAt });
2104
+ return this.hold_;
2105
+ } catch (err) {
2106
+ this.handle409Conflicts(err);
2107
+ throw err;
2108
+ }
2109
+ }
2110
+ /** Server-picks `qty` best free seats and holds them atomically. Throws on failure. */
2111
+ async bestAvailable(qty, categoryKey) {
2112
+ const r = this.renderer;
2113
+ if (!r) return null;
2114
+ if (this.hold_) await this.release();
2115
+ try {
2116
+ const result = await this.api.bestAvailable(this.key, qty, categoryKey);
2117
+ r.clearSelection();
2118
+ const ids = result.labels.map((l) => this.labelToId.get(l)).filter((v) => !!v);
2119
+ if (ids.length) r.setStatus(ids, "held");
2120
+ this.setHold({ holdId: result.holdId, labels: [...result.labels], expiresAt: result.expiresAt });
2121
+ const seats = result.labels.map((l) => this.labelToSeat.get(l)).filter((s) => !!s).map((s) => this.toSeat(s));
2122
+ this.opts.onSelectionChange?.(seats);
2123
+ return this.hold_;
2124
+ } catch (err) {
2125
+ const { status, reason } = errInfo(err);
2126
+ if (status === 409 && reason === "event_closed") this.opts.onSalesClosed?.();
2127
+ throw err;
2128
+ }
2129
+ }
2130
+ /**
2131
+ * Complete a booking. Requires a transport that supports book() (the SDK
2132
+ * deliberately does not). `bookingRef` MUST be a real reference from the caller.
2133
+ * `labelsArg` lets the caller book its own cart (the live page's confirm-flow
2134
+ * cart can hold best-available seats that aren't in the renderer selection);
2135
+ * defaults to the renderer selection. Reuses an existing exact-match hold, else
2136
+ * holds first. Returns the labels booked, or null on failure (conflicts painted).
2137
+ */
2138
+ async book(bookingRef, labelsArg) {
2139
+ const r = this.renderer;
2140
+ if (!r) return null;
2141
+ if (!this.api.book) throw new Error("picker: transport has no book() \u2014 hold-only mode");
2142
+ const labels = labelsArg ?? r.getSelection().map((s) => s.label);
2143
+ if (!labels.length) return null;
2144
+ let holdId;
2145
+ try {
2146
+ if (this.hold_ && this.holdCovers(labels)) {
2147
+ holdId = this.hold_.holdId;
2148
+ } else {
2149
+ const h = await this.api.hold(this.key, labels);
2150
+ holdId = h.holdId;
2151
+ this.setHold({ holdId, labels: [...labels], expiresAt: h.expiresAt });
2152
+ }
2153
+ await this.api.book(this.key, labels, holdId, bookingRef);
2154
+ } catch (err) {
2155
+ const { status, conflicts, reason } = errInfo(err);
2156
+ this.clearHold();
2157
+ if (status === 409 && reason === "event_closed") {
2158
+ this.opts.onSalesClosed?.();
2159
+ } else if (status === 409 && conflicts?.length) {
2160
+ const takenLabels = new Set(conflicts.map((c) => c.label));
2161
+ const takenIds = [...takenLabels].map((l) => this.labelToId.get(l)).filter((v) => !!v);
2162
+ if (takenIds.length) {
2163
+ r.setStatus(takenIds, "booked");
2164
+ r.deselect(takenIds);
2165
+ }
2166
+ const stillFree = labels.filter((l) => !takenLabels.has(l));
2167
+ if (stillFree.length && holdId) void this.api.release(this.key, stillFree, holdId).catch(() => {
2168
+ });
2169
+ this.emitSelectionChange();
2170
+ } else if (holdId) {
2171
+ void this.api.release(this.key, labels, holdId).catch(() => {
2172
+ });
2173
+ }
2174
+ throw err;
2175
+ }
2176
+ const ids = labels.map((l) => this.labelToId.get(l)).filter((v) => !!v);
2177
+ if (ids.length) {
2178
+ r.setStatus(ids, "booked");
2179
+ r.deselect(ids);
2180
+ }
2181
+ this.clearHold();
2182
+ this.emitSelectionChange();
2183
+ this.opts.onBook?.(bookingRef);
2184
+ return labels;
2185
+ }
2186
+ /** Release the whole open hold (if any), repaint those seats free. */
2187
+ async release() {
2188
+ const hold = this.hold_;
2189
+ if (!hold) return;
2190
+ this.clearHold();
2191
+ const ids = hold.labels.map((l) => this.labelToId.get(l)).filter((v) => !!v);
2192
+ if (ids.length) this.renderer?.setStatus(ids, "free");
2193
+ try {
2194
+ await this.api.release(this.key, hold.labels, hold.holdId);
2195
+ } catch (err) {
2196
+ this.emitError(err);
2197
+ }
2198
+ }
2199
+ /**
2200
+ * Release just some labels from the open hold, keeping the rest held (used when
2201
+ * a buyer removes one seat chip). Clears the hold entirely once it empties.
2202
+ */
2203
+ async releaseLabels(labels) {
2204
+ const hold = this.hold_;
2205
+ if (!hold) return;
2206
+ const drop = labels.filter((l) => hold.labels.includes(l));
2207
+ if (!drop.length) return;
2208
+ const remaining = hold.labels.filter((l) => !drop.includes(l));
2209
+ if (remaining.length) this.setHold({ ...hold, labels: remaining });
2210
+ else this.clearHold();
2211
+ const ids = drop.map((l) => this.labelToId.get(l)).filter((v) => !!v);
2212
+ if (ids.length) this.renderer?.setStatus(ids, "free");
2213
+ try {
2214
+ await this.api.release(this.key, drop, hold.holdId);
2215
+ } catch (err) {
2216
+ this.emitError(err);
2217
+ }
2218
+ }
2219
+ // ---- renderer proxies (so consumers don't reach through) ------------------
2220
+ setStatus(ids, status) {
2221
+ this.renderer?.setStatus(ids, status);
2222
+ }
2223
+ getStatus(id) {
2224
+ return this.renderer?.getStatus(id);
2225
+ }
2226
+ flashSeat(id, color) {
2227
+ this.renderer?.flashSeat(id, color);
2228
+ }
2229
+ zoomIn() {
2230
+ this.renderer?.zoomIn();
2231
+ }
2232
+ zoomOut() {
2233
+ this.renderer?.zoomOut();
2234
+ }
2235
+ zoomToFit() {
2236
+ this.renderer?.zoomToFit();
2237
+ }
2238
+ worldToScreen(p) {
2239
+ return this.renderer?.worldToScreen(p) ?? { x: 0, y: 0 };
2240
+ }
2241
+ setAccessibilityFilter(types) {
2242
+ this.renderer?.setAccessibilityFilter?.(types);
2243
+ }
2244
+ destroy() {
2245
+ this.closed = true;
2246
+ if (this.reconnectTimer) {
2247
+ clearTimeout(this.reconnectTimer);
2248
+ this.reconnectTimer = null;
2249
+ }
2250
+ if (this.expiryTimer) {
2251
+ clearTimeout(this.expiryTimer);
2252
+ this.expiryTimer = null;
2253
+ }
2254
+ if (this.ws) {
2255
+ try {
2256
+ this.ws.close();
2257
+ } catch {
2258
+ }
2259
+ this.ws = null;
2260
+ }
2261
+ this.renderer?.destroy();
2262
+ this.renderer = null;
2263
+ }
2264
+ // ---- internals ------------------------------------------------------------
2265
+ toSeat(s) {
2266
+ return { id: s.id, label: s.label, categoryKey: s.categoryKey, price: this.priceFor(s.categoryKey) };
2267
+ }
2268
+ priceFor(categoryKey) {
2269
+ return this._doc?.categories.find((c) => c.key === categoryKey)?.price ?? 0;
2270
+ }
2271
+ emitSelectionChange() {
2272
+ this.opts.onSelectionChange?.(this.getSelection());
2273
+ }
2274
+ emitError(err) {
2275
+ if (this.opts.onError) this.opts.onError(err);
2276
+ else console.error("[picker]", err);
2277
+ }
2278
+ holdCovers(labels) {
2279
+ const h = this.hold_;
2280
+ return !!h && h.labels.length === labels.length && labels.every((l) => h.labels.includes(l));
2281
+ }
2282
+ handle409Conflicts(err) {
2283
+ const { status, conflicts } = errInfo(err);
2284
+ if (status !== 409 || !conflicts?.length) return;
2285
+ const r = this.renderer;
2286
+ if (!r) return;
2287
+ const takenIds = conflicts.map((c) => this.labelToId.get(c.label)).filter((v) => !!v);
2288
+ if (takenIds.length) {
2289
+ r.deselect(takenIds);
2290
+ r.setStatus(takenIds, "held");
2291
+ }
2292
+ this.emitSelectionChange();
2293
+ }
2294
+ /** Set the open hold + (re)arm the server-authoritative expiry timer. */
2295
+ setHold(hold) {
2296
+ this.hold_ = hold;
2297
+ if (this.expiryTimer) clearTimeout(this.expiryTimer);
2298
+ const ms = Math.max(0, hold.expiresAt - Date.now());
2299
+ this.expiryTimer = setTimeout(() => this.onHoldExpired(), ms);
2300
+ this.opts.onHold?.(hold);
2301
+ }
2302
+ clearHold() {
2303
+ this.hold_ = null;
2304
+ if (this.expiryTimer) {
2305
+ clearTimeout(this.expiryTimer);
2306
+ this.expiryTimer = null;
2307
+ }
2308
+ }
2309
+ onHoldExpired() {
2310
+ const hold = this.hold_;
2311
+ this.clearHold();
2312
+ if (hold) {
2313
+ const ids = hold.labels.map((l) => this.labelToId.get(l)).filter((v) => !!v);
2314
+ if (ids.length) this.renderer?.setStatus(ids, "free");
2315
+ }
2316
+ this.opts.onHoldExpired?.();
2317
+ }
2318
+ applySeatsMap(seats) {
2319
+ const r = this.renderer;
2320
+ if (!r) return;
2321
+ if (this.allIds.length) r.setStatus(this.allIds, "free");
2322
+ const byStatus = { free: [], held: [], booked: [], not_for_sale: [] };
2323
+ for (const [label, st] of Object.entries(seats)) {
2324
+ const id = this.labelToId.get(label);
2325
+ if (id) byStatus[mapStatus(st)].push(id);
2326
+ }
2327
+ ["held", "booked", "not_for_sale"].forEach((st) => {
2328
+ if (byStatus[st].length) r.setStatus(byStatus[st], st);
2329
+ });
2330
+ this.opts.onStatusChange?.();
2331
+ }
2332
+ async resnapshot() {
2333
+ try {
2334
+ const objs = await this.api.objects(this.key);
2335
+ this.applySeatsMap(objs.seats);
2336
+ } catch {
2337
+ }
2338
+ }
2339
+ // ---- realtime socket ------------------------------------------------------
2340
+ connect() {
2341
+ if (this.closed) return;
2342
+ let ws;
2343
+ try {
2344
+ ws = new WebSocket(this.api.socketUrl(this.key));
2345
+ } catch {
2346
+ this.scheduleReconnect();
2347
+ return;
2348
+ }
2349
+ this.ws = ws;
2350
+ ws.onopen = () => {
2351
+ this.attempt = 0;
2352
+ void this.resnapshot();
2353
+ };
2354
+ ws.onmessage = (e) => {
2355
+ let msg;
2356
+ try {
2357
+ msg = JSON.parse(typeof e.data === "string" ? e.data : "");
2358
+ } catch {
2359
+ return;
2360
+ }
2361
+ const r = this.renderer;
2362
+ if (!r || !msg || typeof msg !== "object") return;
2363
+ const m = msg;
2364
+ if (m.seats && typeof m.seats === "object") {
2365
+ this.applySeatsMap(m.seats);
2366
+ } else if (Array.isArray(m.changes)) {
2367
+ for (const ch of m.changes) {
2368
+ const id = this.labelToId.get(ch.label);
2369
+ if (!id) continue;
2370
+ const next = mapStatus(ch.status);
2371
+ if (this.opts.flashOnLiveChange && next !== "free" && r.getStatus(id) === "free" && !this.hold_?.labels.includes(ch.label)) {
2372
+ r.flashSeat(id);
2373
+ }
2374
+ r.setStatus([id], next);
2375
+ }
2376
+ this.opts.onStatusChange?.();
2377
+ }
2378
+ };
2379
+ ws.onclose = () => {
2380
+ if (this.ws === ws) this.ws = null;
2381
+ this.scheduleReconnect();
2382
+ };
2383
+ ws.onerror = () => {
2384
+ try {
2385
+ ws.close();
2386
+ } catch {
2387
+ }
2388
+ };
2389
+ }
2390
+ scheduleReconnect() {
2391
+ if (this.closed || this.reconnectTimer) return;
2392
+ const attempt = Math.min(this.attempt++, 5);
2393
+ const delay = Math.min(1e3 * 2 ** attempt, MAX_BACKOFF_MS);
2394
+ this.reconnectTimer = setTimeout(() => {
2395
+ this.reconnectTimer = null;
2396
+ this.connect();
2397
+ }, delay);
2398
+ }
2399
+ };
2400
+ // Annotate the CommonJS export names for ESM import in node:
2401
+ 0 && (module.exports = {
2402
+ ACCESSIBILITY_TYPES,
2403
+ CHART_STORAGE_KEY,
2404
+ PickerController,
2405
+ SeatmapRenderer,
2406
+ accessibilityMeta,
2407
+ chartBounds,
2408
+ createRenderer,
2409
+ expandBooth,
2410
+ expandChart,
2411
+ expandRow,
2412
+ expandRowSlots,
2413
+ expandTable,
2414
+ layerOf,
2415
+ objectCenter,
2416
+ pointInPolygon
2417
+ });
2418
+ //# sourceMappingURL=index.cjs.map