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