@seatlayer/core 0.28.4 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,867 +1,41 @@
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: "cart", label: "CART live-caption view", short: "CART captions", icon: "CC" },
8
- { key: "sign-language", label: "Sign-language view", short: "Sign language", icon: "\u{1F91F}" },
9
- { key: "plus-size", label: "Plus-size seat", short: "Plus-size", icon: "\u{1F4BA}" },
10
- { key: "lift-armrest", label: "Lift-up armrest", short: "Lift armrest", icon: "\u2195\uFE0F" }
11
- ];
12
- var ACCESSIBILITY_LABEL = new Map(ACCESSIBILITY_TYPES.map((a) => [a.key, a]));
13
- function accessibilityMeta(key) {
14
- return ACCESSIBILITY_LABEL.get(key);
15
- }
16
- var ACCESSIBILITY_RING_COLOR = {
17
- wheelchair: "#3b82f6",
18
- companion: "#8b5cf6",
19
- "semi-ambulatory": "#0ea5e9",
20
- hearing: "#14b8a6",
21
- cart: "#7c3aed",
22
- "sign-language": "#f59e0b",
23
- "plus-size": "#ec4899",
24
- "lift-armrest": "#22c55e"
25
- };
26
- function accessibilityRingColor(types) {
27
- const primary = types?.[0];
28
- return primary && ACCESSIBILITY_RING_COLOR[primary] || "#3b82f6";
29
- }
30
- var LABEL_STYLE_MIN_SIZE = 8;
31
- var LABEL_STYLE_MAX_SIZE = 24;
32
- var SURROUNDINGS_SHAPE_ROLES = [
33
- "reference-focal",
34
- "bar",
35
- "entrance",
36
- "exit",
37
- "restroom",
38
- "screen",
39
- "sound",
40
- "concession",
41
- "coat",
42
- "wall"
43
- ];
44
- var SURROUNDINGS_SHAPE_ROLE_SET = new Set(SURROUNDINGS_SHAPE_ROLES);
45
- function layerOf(obj) {
46
- switch (obj.type) {
47
- case "row":
48
- case "table":
49
- case "gaArea":
50
- case "booth":
51
- case "section":
52
- return "interactive";
53
- // Raster décor follows its explicit authored z-layer. Absence retains the
54
- // legacy Background default; bitmap content is never guessed semantically.
55
- case "decorImage":
56
- return obj.layer === "foreground" ? "foreground" : "background";
57
- // Source-backed focal geometry and curated venue landmarks help an author
58
- // orient around the sellable plan. A stage and an ordinary authored shape
59
- // remain Background even when they carry an arbitrary/custom role.
60
- case "shape":
61
- return obj.role && SURROUNDINGS_SHAPE_ROLE_SET.has(obj.role) ? "surroundings" : "background";
62
- // Free text — including semantic icon text — is background furniture.
63
- case "text":
64
- return "background";
65
- default:
66
- return "interactive";
67
- }
68
- }
69
- var CHART_STORAGE_KEY = "seatmap.chart";
70
-
71
- // src/core/complexGeometry.ts
72
- function cubicPoint(path, t2) {
73
- const u = 1 - t2;
74
- const a = u * u * u;
75
- const b = 3 * u * u * t2;
76
- const c = 3 * u * t2 * t2;
77
- const d = t2 * t2 * t2;
78
- return {
79
- x: a * path.start.x + b * path.control1.x + c * path.control2.x + d * path.end.x,
80
- y: a * path.start.y + b * path.control1.y + c * path.control2.y + d * path.end.y
81
- };
82
- }
83
- function distributeAlongCubic(path, count, resolution = 192) {
84
- if (!Number.isInteger(count) || count < 1) throw new Error("Path point count must be a positive integer");
85
- if (count === 1) return [cubicPoint(path, 0.5)];
86
- const samples = Array.from({ length: resolution + 1 }, (_, index) => cubicPoint(path, index / resolution));
87
- const lengths = new Float64Array(samples.length);
88
- for (let index = 1; index < samples.length; index += 1) {
89
- const dx = samples[index].x - samples[index - 1].x;
90
- const dy = samples[index].y - samples[index - 1].y;
91
- lengths[index] = lengths[index - 1] + Math.hypot(dx, dy);
92
- }
93
- const total = lengths[lengths.length - 1];
94
- if (total <= 1e-9) return Array.from({ length: count }, () => ({ ...path.start }));
95
- const output = [];
96
- let segment = 1;
97
- for (let index = 0; index < count; index += 1) {
98
- const target = total * index / (count - 1);
99
- while (segment < lengths.length - 1 && lengths[segment] < target) segment += 1;
100
- const before = lengths[segment - 1];
101
- const after = lengths[segment];
102
- const ratio = after === before ? 0 : (target - before) / (after - before);
103
- output.push({
104
- x: samples[segment - 1].x + (samples[segment].x - samples[segment - 1].x) * ratio,
105
- y: samples[segment - 1].y + (samples[segment].y - samples[segment - 1].y) * ratio
106
- });
107
- }
108
- return output;
109
- }
110
-
111
- // src/core/sectionPath.ts
112
- var TAU = Math.PI * 2;
113
- function translateSectionOutlinePath(path, dx, dy) {
114
- const translate = (point) => ({ x: point.x + dx, y: point.y + dy });
115
- return transformSectionOutlinePath(path, translate);
116
- }
117
- function transformSectionOutlinePath(path, transform, radiusScale = 1, reflected = false) {
118
- return {
119
- ...path,
120
- start: transform(path.start),
121
- segments: path.segments.map((segment) => segment.kind === "line" ? { ...segment, end: transform(segment.end) } : segment.kind === "arc" ? {
122
- ...segment,
123
- center: transform(segment.center),
124
- radius: segment.radius * Math.abs(radiusScale),
125
- clockwise: reflected ? !segment.clockwise : segment.clockwise,
126
- end: transform(segment.end)
127
- } : {
128
- ...segment,
129
- control1: transform(segment.control1),
130
- control2: transform(segment.control2),
131
- end: transform(segment.end)
132
- })
133
- };
134
- }
135
-
136
- // src/core/labeling.ts
137
- var FULL_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
138
- var LOWER_ALPHABET = "abcdefghijklmnopqrstuvwxyz";
139
- function toBijectiveBase(index, alphabet) {
140
- const base = alphabet.length;
141
- let n = index + 1;
142
- let out = "";
143
- while (n > 0) {
144
- n -= 1;
145
- const rem = n % base;
146
- out = alphabet[rem] + out;
147
- n = Math.floor(n / base);
148
- }
149
- return out;
150
- }
151
- function toLetters(value, lower = false) {
152
- const alphabet = lower ? LOWER_ALPHABET : FULL_ALPHABET;
153
- return toBijectiveBase(Math.max(0, Math.floor(value) - 1), alphabet);
154
- }
155
- var ROMAN_TABLE = [
156
- [1e3, "M"],
157
- [900, "CM"],
158
- [500, "D"],
159
- [400, "CD"],
160
- [100, "C"],
161
- [90, "XC"],
162
- [50, "L"],
163
- [40, "XL"],
164
- [10, "X"],
165
- [9, "IX"],
166
- [5, "V"],
167
- [4, "IV"],
168
- [1, "I"]
169
- ];
170
- function toRoman(value) {
171
- if (!Number.isFinite(value) || value <= 0) return String(value);
172
- let remaining = Math.floor(value);
173
- let out = "";
174
- for (const [n, sym] of ROMAN_TABLE) {
175
- while (remaining >= n) {
176
- out += sym;
177
- remaining -= n;
178
- }
179
- }
180
- return out;
181
- }
182
-
183
- // src/core/units.ts
184
- var METRES_PER_CHART_UNIT = 0.55 / 24;
185
- var CHART_UNITS_PER_METRE = 1 / METRES_PER_CHART_UNIT;
186
- var LIFT_PER_STEP_WORLD = 58;
187
- var TIER_HEIGHT_M = LIFT_PER_STEP_WORLD * METRES_PER_CHART_UNIT;
188
- var SECTION_ELEVATION_TIER_MIN = 0;
189
- var SECTION_ELEVATION_TIER_MAX = 3;
190
- var SECTION_HEIGHT_MIN_M = 0;
191
- var SECTION_HEIGHT_MAX_M = 120;
192
- var SECTION_RAKE_MIN_DEG = 0;
193
- var SECTION_RAKE_MAX_DEG = 45;
194
- var LEGACY_SECTION_ELEVATION_TIER_MAX = 7;
195
- var SEATED_EYE_HEIGHT_M = 1.2;
196
- function finiteClamped(value, min, max, fallback) {
197
- return Number.isFinite(value) ? Math.max(min, Math.min(max, value)) : fallback;
198
- }
199
- function sectionElevationTier(value) {
200
- if (!Number.isFinite(value)) return SECTION_ELEVATION_TIER_MIN;
201
- return Math.max(
202
- SECTION_ELEVATION_TIER_MIN,
203
- Math.min(SECTION_ELEVATION_TIER_MAX, Math.round(value))
204
- );
205
- }
206
- function compatibleAutomaticTier(value) {
207
- if (!Number.isFinite(value) || !Number.isInteger(value) || value < 0) return 0;
208
- if (value <= LEGACY_SECTION_ELEVATION_TIER_MAX) return value;
209
- return SECTION_ELEVATION_TIER_MAX;
210
- }
211
- function sectionGeometry(section, context = {}) {
212
- const floorBaseHeightM = finiteClamped(
213
- context.floorBaseHeightM,
214
- SECTION_HEIGHT_MIN_M,
215
- SECTION_HEIGHT_MAX_M,
216
- 0
217
- );
218
- const automaticHeight = Math.min(
219
- SECTION_HEIGHT_MAX_M,
220
- floorBaseHeightM + compatibleAutomaticTier(section.elevation) * TIER_HEIGHT_M
221
- );
222
- const height = section.height === void 0 ? automaticHeight : finiteClamped(section.height, SECTION_HEIGHT_MIN_M, SECTION_HEIGHT_MAX_M, automaticHeight);
223
- const rake = finiteClamped(section.rake, SECTION_RAKE_MIN_DEG, SECTION_RAKE_MAX_DEG, 0);
224
- return { height, rake };
225
- }
226
-
227
- // src/core/layout.ts
228
- function overrideAccessibility(o) {
229
- if (!o) return [];
230
- if (o.accessibility && o.accessibility.length) {
231
- return o.wheelchairSpaceType && !o.accessibility.includes("wheelchair") ? ["wheelchair", ...o.accessibility] : o.accessibility;
232
- }
233
- if (o.wheelchairSpaceType) return ["wheelchair"];
234
- return o.accessible ? ["wheelchair"] : [];
235
- }
236
- var DEG = Math.PI / 180;
237
- var SEAT_R = 9;
238
- var TABLE_SEAT_OFFSET = 16;
239
- function place(lx, ly, deg, origin) {
240
- const a = deg * DEG;
241
- const cos = Math.cos(a);
242
- const sin = Math.sin(a);
243
- return {
244
- x: origin.x + lx * cos - ly * sin,
245
- y: origin.y + lx * sin + ly * cos
246
- };
247
- }
248
- function rowSeatPositions(row) {
249
- const { seatCount, seatSpacing, curve, rotation, origin } = row;
250
- const out = [];
251
- if (row.path) return distributeAlongCubic(row.path, seatCount);
252
- if (seatCount <= 1) {
253
- if (seatCount === 1) out.push({ x: origin.x, y: origin.y });
254
- return out;
255
- }
256
- if (curve === 0) {
257
- for (let i = 0; i < seatCount; i++) out.push(place(i * seatSpacing, 0, rotation, origin));
258
- return out;
259
- }
260
- const arcStep = curve / (seatCount - 1) * DEG;
261
- const radius = seatSpacing / (2 * Math.sin(Math.abs(arcStep) / 2));
262
- for (let i = 0; i < seatCount; i++) {
263
- const phi = i * arcStep;
264
- const lx = radius * Math.sin(phi);
265
- const ly = -radius + radius * Math.cos(phi);
266
- out.push(place(lx, ly, rotation, origin));
267
- }
268
- return out;
269
- }
270
- function overrideMap(row) {
271
- const m = /* @__PURE__ */ new Map();
272
- if (row.overrides) for (const o of row.overrides) m.set(o.index, o);
273
- return m;
274
- }
275
- function rowInventoryCount(row) {
276
- const skipped = new Set((row.overrides ?? []).filter((override) => override.skip && Number.isInteger(override.index) && override.index >= 0 && override.index < row.seatCount).map((override) => override.index));
277
- return Math.max(0, row.seatCount - skipped.size);
278
- }
279
- function centerRank(n) {
280
- const rank = new Array(n);
281
- Array.from({ length: n }, (_, i) => i).sort((a, b) => Math.abs(2 * a - (n - 1)) - Math.abs(2 * b - (n - 1)) || a - b).forEach((idx, k) => rank[idx] = k);
282
- return rank;
283
- }
284
- function seatLabelPart(row, i) {
285
- const rawStart = row.seatLabelStart ?? 1;
286
- const dir = row.seatNumbering?.direction ?? "ltr";
287
- const step = row.seatNumbering?.step ?? 1;
288
- const scheme = row.seatNumbering?.scheme ?? "decimal";
289
- const prefix = row.seatNumbering?.prefix ?? "";
290
- const endAt = row.seatNumbering?.endAt;
291
- const n = row.seatCount;
292
- if (scheme === "updown" || scheme === "updown-descending") {
293
- const half = Math.ceil(n / 2);
294
- const core2 = scheme === "updown" ? i < half ? rawStart + 2 * i : rawStart - 1 + 2 * (n - i) : i < half ? rawStart + 2 * (half - 1 - i) : rawStart + 1 + 2 * (i - half);
295
- return `${prefix}${core2}`;
296
- }
297
- const effStep = scheme === "odd" || scheme === "even" ? 2 : step;
298
- const start = endAt != null && Number.isFinite(endAt) ? endAt - (n - 1) * effStep : rawStart;
299
- const p = dir === "center" ? centerRank(n)[i] : dir === "rtl" ? n - 1 - i : i;
300
- let core;
301
- switch (scheme) {
302
- case "odd": {
303
- const firstOdd = start % 2 === 1 ? start : start + 1;
304
- core = String(firstOdd + p * 2);
305
- break;
306
- }
307
- case "even": {
308
- const firstEven = start % 2 === 0 ? start : start + 1;
309
- core = String(firstEven + p * 2);
310
- break;
311
- }
312
- case "roman":
313
- core = toRoman(start + p * step);
314
- break;
315
- case "letters-upper":
316
- core = toLetters(start + p * step, false);
317
- break;
318
- case "letters-lower":
319
- core = toLetters(start + p * step, true);
320
- break;
321
- case "decimal":
322
- default:
323
- core = String(start + p * step);
324
- break;
325
- }
326
- return `${prefix}${core}`;
327
- }
328
- function expandRowSlots(row) {
329
- const ov = overrideMap(row);
330
- return rowSeatPositions(row).map((p, i) => {
331
- const o = ov.get(i);
332
- const accessibility = overrideAccessibility(o);
333
- const part = seatLabelPart(row, i);
334
- const inventoryLabel = o?.label ?? `${row.label}-${part}`;
335
- const displayPrefix = row.displayLabel ?? row.label;
336
- const commercial = { ...row.commercial, ...o?.commercial };
337
- return {
338
- index: i,
339
- x: p.x + (o?.dx ?? 0),
340
- y: p.y + (o?.dy ?? 0),
341
- label: inventoryLabel,
342
- displayLabel: o?.displayLabel ?? `${displayPrefix}-${part}`,
343
- categoryKey: o?.categoryKey ?? row.categoryKey,
344
- skipped: !!o?.skip,
345
- accessible: accessibility.length > 0,
346
- accessibility,
347
- wheelchairSpaceType: o?.wheelchairSpaceType,
348
- commercial: Object.values(commercial).some((value) => value !== void 0 && value !== false && value !== "") ? commercial : void 0,
349
- viewUrl: o?.viewFromSeatUrl ?? row.viewFromSeatUrl,
350
- labelStyle: o?.labelStyle
351
- };
352
- });
353
- }
354
- function expandRow(row) {
355
- const seats = [];
356
- for (const slot of expandRowSlots(row)) {
357
- if (slot.skipped) continue;
358
- seats.push({
359
- id: `${row.id}:${slot.index}`,
360
- label: slot.label,
361
- displayLabel: slot.displayLabel === slot.label ? void 0 : slot.displayLabel,
362
- x: slot.x,
363
- y: slot.y,
364
- rowId: row.id,
365
- categoryKey: slot.categoryKey,
366
- accessible: slot.accessible || void 0,
367
- accessibility: slot.accessibility.length ? slot.accessibility : void 0,
368
- wheelchairSpaceType: slot.wheelchairSpaceType,
369
- commercial: slot.commercial,
370
- viewUrl: slot.viewUrl,
371
- labelStyle: slot.labelStyle
372
- });
373
- }
374
- return seats;
375
- }
376
- function tableSeatCountsBySide(t2) {
377
- if (t2.seatCountsBySide) return { ...t2.seatCountsBySide };
378
- const enabled = t2.sides && t2.sides.length ? t2.sides : ["top", "bottom"];
379
- const order = ["top", "bottom", "left", "right"].filter((side) => enabled.includes(side));
380
- const counts = { top: 0, bottom: 0, left: 0, right: 0 };
381
- if (!order.length) return counts;
382
- const n = Math.max(0, Math.round(t2.seatCount));
383
- for (let index = 0; index < n; index++) counts[order[index % order.length]] += 1;
384
- return counts;
385
- }
386
- function expandTableSlots(t2) {
387
- const seats = [];
388
- const n = Math.max(0, Math.round(t2.seatCount));
389
- if (n === 0) return seats;
390
- const overrides2 = new Map((t2.overrides ?? []).map((override) => [override.index, override]));
391
- const mk = (index, x, y, side) => {
392
- const override = overrides2.get(index);
393
- const accessibility = overrideAccessibility(override);
394
- const label = override?.label ?? `${t2.label}-${index + 1}`;
395
- const displayPrefix = t2.displayLabel ?? t2.label;
396
- return {
397
- index,
398
- label,
399
- displayLabel: override?.displayLabel ?? `${displayPrefix}-${index + 1}`,
400
- x: x + (override?.dx ?? 0),
401
- y: y + (override?.dy ?? 0),
402
- categoryKey: override?.categoryKey ?? t2.categoryKey,
403
- skipped: !!override?.skip,
404
- accessible: accessibility.length > 0,
405
- accessibility,
406
- wheelchairSpaceType: override?.wheelchairSpaceType,
407
- commercial: override?.commercial,
408
- viewUrl: override?.viewFromSeatUrl,
409
- labelStyle: override?.labelStyle,
410
- ...side ? { side } : {}
411
- };
412
- };
413
- if (t2.shape === "round") {
414
- const R = (t2.radius ?? 40) + TABLE_SEAT_OFFSET;
415
- const base = t2.rotation * DEG;
416
- const arc = Math.max(0, Math.min(360, t2.seatArc ?? 360));
417
- if (arc >= 360 || n === 1) {
418
- for (let i = 0; i < n; i++) {
419
- const a = base + i / n * 2 * Math.PI;
420
- seats.push(mk(i, t2.center.x + R * Math.cos(a), t2.center.y + R * Math.sin(a)));
421
- }
422
- return seats;
423
- }
424
- const arcRad = arc * DEG;
425
- const halfGap = (2 * Math.PI - arcRad) / 2;
426
- const start = base + halfGap;
427
- for (let i = 0; i < n; i++) {
428
- const a = start + i / (n - 1) * arcRad;
429
- seats.push(mk(i, t2.center.x + R * Math.cos(a), t2.center.y + R * Math.sin(a)));
430
- }
431
- return seats;
432
- }
433
- const w = t2.width ?? 80;
434
- const h = t2.height ?? 50;
435
- const counts = tableSeatCountsBySide(t2);
436
- const order = ["top", "bottom", "left", "right"];
437
- let idx = 0;
438
- for (const side of order) {
439
- const count = counts[side];
440
- for (let j = 0; j < count; j++) {
441
- let localX;
442
- let localY;
443
- if (side === "top") {
444
- localX = -w / 2 + (j + 0.5) * w / count;
445
- localY = -h / 2 - TABLE_SEAT_OFFSET;
446
- } else if (side === "bottom") {
447
- localX = -w / 2 + (j + 0.5) * w / count;
448
- localY = h / 2 + TABLE_SEAT_OFFSET;
449
- } else if (side === "left") {
450
- localX = -w / 2 - TABLE_SEAT_OFFSET;
451
- localY = -h / 2 + (j + 0.5) * h / count;
452
- } else {
453
- localX = w / 2 + TABLE_SEAT_OFFSET;
454
- localY = -h / 2 + (j + 0.5) * h / count;
455
- }
456
- const point = place(localX, localY, t2.rotation, t2.center);
457
- seats.push(mk(idx++, point.x, point.y, side));
458
- }
459
- }
460
- return seats;
461
- }
462
- function tableInventoryCount(t2) {
463
- if (t2.bookAsWhole || t2.variableOccupancy) return Math.max(0, Math.round(t2.seatCount));
464
- return expandTableSlots(t2).filter((slot) => !slot.skipped).length;
465
- }
466
- function expandTable(t2) {
467
- return expandTableSlots(t2).filter((slot) => !slot.skipped).map((slot) => ({
468
- id: `${t2.id}:${slot.index}`,
469
- label: slot.label,
470
- ...slot.displayLabel !== slot.label ? { displayLabel: slot.displayLabel } : {},
471
- x: slot.x,
472
- y: slot.y,
473
- rowId: t2.id,
474
- categoryKey: slot.categoryKey,
475
- ...slot.accessible ? { accessible: true } : {},
476
- ...slot.accessibility.length ? { accessibility: slot.accessibility } : {},
477
- ...slot.wheelchairSpaceType ? { wheelchairSpaceType: slot.wheelchairSpaceType } : {},
478
- ...slot.commercial ? { commercial: slot.commercial } : {},
479
- ...slot.viewUrl ? { viewUrl: slot.viewUrl } : {},
480
- ...slot.labelStyle ? { labelStyle: slot.labelStyle } : {}
481
- }));
482
- }
483
- function expandBooth(b) {
484
- return [
485
- {
486
- id: `${b.id}:0`,
487
- label: b.label,
488
- ...b.displayLabel && b.displayLabel !== b.label ? { displayLabel: b.displayLabel } : {},
489
- x: b.center.x,
490
- y: b.center.y,
491
- rowId: b.id,
492
- categoryKey: b.categoryKey,
493
- kind: "booth"
494
- }
495
- ];
496
- }
497
- function pointInPolygon(p, poly) {
498
- let inside = false;
499
- for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
500
- const xi = poly[i].x;
501
- const yi = poly[i].y;
502
- const xj = poly[j].x;
503
- const yj = poly[j].y;
504
- const hit = yi > p.y !== yj > p.y && p.x < (xj - xi) * (p.y - yi) / (yj - yi) + xi;
505
- if (hit) inside = !inside;
506
- }
507
- return inside;
508
- }
509
- function pointOnPolygonBoundary(p, poly) {
510
- return poly.some((start, index) => {
511
- const end = poly[(index + 1) % poly.length];
512
- const cross = (p.y - start.y) * (end.x - start.x) - (p.x - start.x) * (end.y - start.y);
513
- if (Math.abs(cross) > 1e-7) return false;
514
- const dot = (p.x - start.x) * (end.x - start.x) + (p.y - start.y) * (end.y - start.y);
515
- const lengthSquared = (end.x - start.x) ** 2 + (end.y - start.y) ** 2;
516
- return dot >= -1e-7 && dot <= lengthSquared + 1e-7;
517
- });
518
- }
519
- function pointInPolygonWithHoles(p, outer, holes) {
520
- return pointInPolygon(p, outer) && !(holes ?? []).some((hole) => pointInPolygon(p, hole) || pointOnPolygonBoundary(p, hole));
521
- }
522
- function polygonLabelPoint(outer, holes) {
523
- if (!outer.length) return { x: 0, y: 0 };
524
- const xs = outer.map((point) => point.x);
525
- const ys = outer.map((point) => point.y);
526
- const bounds2 = { minX: Math.min(...xs), maxX: Math.max(...xs), minY: Math.min(...ys), maxY: Math.max(...ys) };
527
- const centroid2 = polygonCentroid(outer);
528
- if (pointInPolygonWithHoles(centroid2, outer, holes)) return centroid2;
529
- let best = outer[0];
530
- let bestScore = -Infinity;
531
- const rings = [outer, ...holes ?? []];
532
- for (let row = 1; row < 24; row += 1) {
533
- for (let column = 1; column < 24; column += 1) {
534
- const point = {
535
- x: bounds2.minX + (bounds2.maxX - bounds2.minX) * column / 24,
536
- y: bounds2.minY + (bounds2.maxY - bounds2.minY) * row / 24
537
- };
538
- if (!pointInPolygonWithHoles(point, outer, holes)) continue;
539
- const score = Math.min(...rings.flatMap((ring) => ring.map((start, index) => {
540
- const end = ring[(index + 1) % ring.length];
541
- const dx = end.x - start.x;
542
- const dy = end.y - start.y;
543
- const denominator = dx * dx + dy * dy;
544
- const projection = denominator ? ((point.x - start.x) * dx + (point.y - start.y) * dy) / denominator : 0;
545
- const t2 = Math.max(0, Math.min(1, projection));
546
- return Math.hypot(point.x - (start.x + t2 * dx), point.y - (start.y + t2 * dy));
547
- })));
548
- if (score > bestScore) {
549
- best = point;
550
- bestScore = score;
551
- }
552
- }
553
- }
554
- return best;
555
- }
556
- function polygonCentroid(pts) {
557
- if (!pts.length) return { x: 0, y: 0 };
558
- let x = 0;
559
- let y = 0;
560
- for (const p of pts) {
561
- x += p.x;
562
- y += p.y;
563
- }
564
- return { x: x / pts.length, y: y / pts.length };
565
- }
566
- function objectCenter(o) {
567
- switch (o.type) {
568
- case "row": {
569
- const seats = expandRow(o);
570
- if (!seats.length) return { x: o.origin.x, y: o.origin.y };
571
- let x = 0;
572
- let y = 0;
573
- for (const s of seats) {
574
- x += s.x;
575
- y += s.y;
576
- }
577
- return { x: x / seats.length, y: y / seats.length };
578
- }
579
- case "table":
580
- case "booth":
581
- return { x: o.center.x, y: o.center.y };
582
- case "gaArea":
583
- return polygonCentroid(o.points);
584
- case "section":
585
- return polygonCentroid(o.outline);
586
- case "text":
587
- return { x: o.position.x, y: o.position.y };
588
- case "shape":
589
- if (o.points && o.points.length) return polygonCentroid(o.points);
590
- if (o.x != null && o.y != null && o.width != null && o.height != null) {
591
- return { x: o.x + o.width / 2, y: o.y + o.height / 2 };
592
- }
593
- return { x: o.x ?? 0, y: o.y ?? 0 };
594
- case "decorImage":
595
- return { x: o.x + o.width / 2, y: o.y + o.height / 2 };
596
- }
597
- }
598
- function samePoints(left, right) {
599
- return left.length === right.length && left.every((point, index) => point.x === right[index].x && point.y === right[index].y);
600
- }
601
- function sameGASurfaceAsSection(object, section) {
602
- if (object.type !== "gaArea" || !samePoints(object.points, section.outline)) return false;
603
- const objectHoles = object.holes ?? [];
604
- const sectionHoles = section.holes ?? [];
605
- return objectHoles.length === sectionHoles.length && objectHoles.every((hole, index) => samePoints(hole, sectionHoles[index]));
606
- }
607
- function owningSectionForObject(objects, object) {
608
- const sections = objects.filter((candidate) => candidate.type === "section");
609
- const referencedLogicalId = "referenceInventorySource" in object ? object.referenceInventorySource?.logicalSectionId : void 0;
610
- const center = objectCenter(object);
611
- const referencedOwner = referencedLogicalId ? sections.find((section) => (section.logicalSectionId ?? section.id) === referencedLogicalId && (sameGASurfaceAsSection(object, section) || pointInPolygonWithHoles(center, section.outline, section.holes))) : void 0;
612
- return referencedOwner ?? sections.find((section) => pointInPolygonWithHoles(center, section.outline, section.holes));
613
- }
614
- function floorsOf(doc) {
615
- if (doc.floors && doc.floors.length) return doc.floors;
616
- return [{
617
- id: "floor-0",
618
- name: "Main",
619
- objects: doc.objects,
620
- focalPoint: doc.focalPoint,
621
- referenceImage: doc.referenceImage,
622
- backgroundImage: doc.backgroundImage
623
- }];
624
- }
625
- function floorObjects(doc, floorId) {
626
- const floors = floorsOf(doc);
627
- return (floorId ? floors.find((f) => f.id === floorId) : floors[0])?.objects ?? [];
628
- }
629
- function allObjects(doc) {
630
- return doc.floors && doc.floors.length ? doc.floors.flatMap((f) => f.objects) : doc.objects;
631
- }
632
- function translateObject(o, dx, dy) {
633
- const p = (pt) => ({ x: pt.x + dx, y: pt.y + dy });
634
- const pts = (a) => a.map(p);
635
- switch (o.type) {
636
- case "row":
637
- return { ...o, origin: p(o.origin) };
638
- case "table":
639
- case "booth":
640
- return { ...o, center: p(o.center) };
641
- case "gaArea":
642
- return { ...o, points: pts(o.points), ...o.holes ? { holes: o.holes.map(pts) } : {} };
643
- case "section":
644
- return {
645
- ...o,
646
- outline: pts(o.outline),
647
- ...o.outlinePath ? { outlinePath: translateSectionOutlinePath(o.outlinePath, dx, dy) } : {},
648
- ...o.holes ? { holes: o.holes.map(pts) } : {}
649
- };
650
- case "text":
651
- return { ...o, position: p(o.position) };
652
- case "shape":
653
- return {
654
- ...o,
655
- ...o.points ? { points: pts(o.points) } : {},
656
- ...o.x != null ? { x: o.x + dx } : {},
657
- ...o.y != null ? { y: o.y + dy } : {}
658
- };
659
- case "decorImage":
660
- return { ...o, x: o.x + dx, y: o.y + dy };
661
- }
662
- }
663
- function stackFloors(doc, spread = 900) {
664
- if (!doc.floors || doc.floors.length < 2) return doc;
665
- const objects = [];
666
- doc.floors.forEach((f, i) => {
667
- const dy = -i * spread;
668
- for (const o of f.objects) objects.push(dy === 0 ? o : translateObject(o, 0, dy));
669
- });
670
- return { ...doc, objects, floors: void 0 };
671
- }
672
- function expandFloorObjects(objects, zones, fallbackFocal) {
673
- const out = [];
674
- const segmented = /* @__PURE__ */ new Map();
675
- const grouped = /* @__PURE__ */ new Map();
676
- for (const object of objects) {
677
- if (object.type !== "row" || !object.segmentedRow) continue;
678
- const list = grouped.get(object.segmentedRow.groupId) ?? [];
679
- list.push(object);
680
- grouped.set(object.segmentedRow.groupId, list);
681
- }
682
- for (const [groupId, members] of grouped) {
683
- const ordered = members.slice().sort((left, right) => left.segmentedRow.componentIndex - right.segmentedRow.componentIndex);
684
- const expectedCount = ordered[0]?.segmentedRow?.componentCount ?? 0;
685
- const first = ordered[0]?.segmentedRow;
686
- if (!first) continue;
687
- const valid = expectedCount >= 2 && ordered.length === expectedCount && first?.boundaryBefore === "start" && ordered.every((row, index) => row.segmentedRow?.kind === "segmented-row-v1" && row.segmentedRow.groupId === groupId && row.segmentedRow.componentCount === expectedCount && row.segmentedRow.componentIndex === index && (index === 0 ? row.segmentedRow.boundaryBefore === "start" : row.segmentedRow.boundaryBefore !== "start") && row.segmentedRow.displayLabel === first.displayLabel);
688
- if (!valid) continue;
689
- const totalSeats = ordered.reduce((sum, row) => sum + row.seatCount, 0);
690
- let adjacencyOffset = 0;
691
- let displayOffset = 0;
692
- for (const row of ordered) {
693
- if (row.segmentedRow.boundaryBefore === "break") adjacencyOffset += 1;
694
- segmented.set(row.id, {
695
- groupId,
696
- adjacencyOffset,
697
- displayOffset,
698
- displayLabel: first.displayLabel,
699
- totalSeats,
700
- canonical: ordered[0],
701
- viewFromSeatUrl: first.viewFromSeatUrl
702
- });
703
- adjacencyOffset += row.seatCount;
704
- displayOffset += row.seatCount;
705
- }
706
- }
707
- for (const obj of objects) {
708
- let seats = [];
709
- if (obj.type === "row") seats = expandRow(obj);
710
- else if (obj.type === "table") seats = expandTable(obj);
711
- else if (obj.type === "booth") seats = expandBooth(obj);
712
- if (!seats.length) continue;
713
- if (obj.type === "row") {
714
- const logical = segmented.get(obj.id);
715
- if (logical) {
716
- const overrides2 = new Map((obj.overrides ?? []).map((override) => [override.index, override]));
717
- for (const seat of seats) {
718
- const physicalIndex = Number(seat.id.slice(seat.id.lastIndexOf(":") + 1));
719
- if (!Number.isInteger(physicalIndex)) continue;
720
- const displayOrdinal = logical.displayOffset + physicalIndex;
721
- seat.logicalRowId = logical.groupId;
722
- seat.logicalSeatIndex = logical.adjacencyOffset + physicalIndex;
723
- if (!overrides2.get(physicalIndex)?.displayLabel) {
724
- const numberingRow = {
725
- ...logical.canonical,
726
- seatCount: logical.totalSeats,
727
- label: logical.displayLabel,
728
- displayLabel: logical.displayLabel
729
- };
730
- seat.displayLabel = `${logical.displayLabel}-${seatLabelPart(numberingRow, displayOrdinal)}`;
731
- }
732
- seat.viewUrl ??= logical.viewFromSeatUrl;
733
- }
734
- }
735
- }
736
- const owner = owningSectionForObject(objects, obj);
737
- const inheritedView = owner?.viewFromSeatUrl;
738
- const zone = owner?.zone ? zones?.find((candidate) => candidate.id === owner.zone) : void 0;
739
- const resolvedFocal = zone?.focalPoint ?? fallbackFocal;
740
- for (const seat of seats) {
741
- if (inheritedView) seat.viewUrl ??= inheritedView;
742
- if (owner) seat.sectionId = owner.logicalSectionId ?? owner.id;
743
- if (owner?.zone) seat.zoneId = owner.zone;
744
- if (resolvedFocal) seat.focalPoint = { ...resolvedFocal };
745
- }
746
- out.push(...seats);
747
- }
748
- return out;
749
- }
750
- function expandChart(doc, options = {}) {
751
- if (doc.floors?.length) {
752
- const out2 = [];
753
- for (const floor of doc.floors) {
754
- const floorFocal = floor.focalPoint ?? doc.focalPoint;
755
- const seats = expandFloorObjects(floor.objects, doc.zones, floorFocal);
756
- assignEyeHeights(floor.objects, floor.focalPoint ?? doc.focalPoint, floor.baseHeightM ?? 0, seats);
757
- out2.push(...seats);
758
- }
759
- return out2;
760
- }
761
- const out = expandFloorObjects(doc.objects, doc.zones, doc.focalPoint);
762
- assignEyeHeights(doc.objects, doc.focalPoint, options.floorBaseHeightM ?? 0, out);
763
- return out;
764
- }
765
- function assignEyeHeights(objects, focal, floorBaseHeightM, seats) {
766
- const sections = objects.filter((o) => o.type === "section");
767
- const hasGeometry = floorBaseHeightM > 0 || sections.some((s) => s.height !== void 0 || s.rake !== void 0 || (s.elevation ?? 0) > 0);
768
- if (!sections.length || !hasGeometry || !focal) {
769
- for (const seat of seats) seat.eyeHeightM = floorBaseHeightM + SEATED_EYE_HEIGHT_M;
770
- return;
771
- }
772
- const owner = new Array(seats.length);
773
- const geo = /* @__PURE__ */ new Map();
774
- const frontDistU = /* @__PURE__ */ new Map();
775
- for (let i = 0; i < seats.length; i++) {
776
- const seat = seats[i];
777
- const sec = sections.find((s) => pointInPolygonWithHoles({ x: seat.x, y: seat.y }, s.outline, s.holes)) ?? null;
778
- owner[i] = sec;
779
- if (!sec) continue;
780
- if (!geo.has(sec.id)) geo.set(sec.id, sectionGeometry(sec, { floorBaseHeightM }));
781
- const seatFocal = seat.focalPoint ?? focal;
782
- const d = Math.hypot(seat.x - seatFocal.x, seat.y - seatFocal.y);
783
- const cur = frontDistU.get(sec.id);
784
- if (cur === void 0 || d < cur) frontDistU.set(sec.id, d);
785
- }
786
- for (let i = 0; i < seats.length; i++) {
787
- const seat = seats[i];
788
- const sec = owner[i];
789
- if (!sec) {
790
- seat.eyeHeightM = floorBaseHeightM + SEATED_EYE_HEIGHT_M;
791
- continue;
792
- }
793
- const g = geo.get(sec.id);
794
- let riseM = 0;
795
- if (g.rake > 0) {
796
- const seatFocal = seat.focalPoint ?? focal;
797
- const d = Math.hypot(seat.x - seatFocal.x, seat.y - seatFocal.y);
798
- const depthU = Math.max(0, d - (frontDistU.get(sec.id) ?? d));
799
- riseM = depthU * METRES_PER_CHART_UNIT * Math.tan(g.rake * Math.PI / 180);
800
- }
801
- seat.eyeHeightM = g.height + riseM + SEATED_EYE_HEIGHT_M;
802
- }
803
- }
804
- var PAD = 40;
805
- function chartBounds(doc) {
806
- let minX = Infinity;
807
- let minY = Infinity;
808
- let maxX = -Infinity;
809
- let maxY = -Infinity;
810
- const acc = (x, y) => {
811
- if (x < minX) minX = x;
812
- if (y < minY) minY = y;
813
- if (x > maxX) maxX = x;
814
- if (y > maxY) maxY = y;
815
- };
816
- for (const s of expandChart(doc)) acc(s.x, s.y);
817
- for (const obj of allObjects(doc)) {
818
- if (obj.type === "gaArea") {
819
- for (const p of obj.points) acc(p.x, p.y);
820
- } else if (obj.type === "section") {
821
- for (const p of obj.outline) acc(p.x, p.y);
822
- } else if (obj.type === "decorImage") {
823
- acc(obj.x, obj.y);
824
- acc(obj.x + obj.width, obj.y + obj.height);
825
- } else if (obj.type === "shape") {
826
- if (obj.points && obj.points.length) {
827
- for (const p of obj.points) acc(p.x, p.y);
828
- } else if (obj.x != null && obj.y != null && obj.width != null && obj.height != null) {
829
- acc(obj.x, obj.y);
830
- acc(obj.x + obj.width, obj.y + obj.height);
831
- }
832
- } else if (obj.type === "table") {
833
- const off = TABLE_SEAT_OFFSET + SEAT_R;
834
- const ext = obj.shape === "round" ? (obj.radius ?? 40) + off : Math.max((obj.width ?? 80) / 2, (obj.height ?? 50) / 2) + off;
835
- acc(obj.center.x - ext, obj.center.y - ext);
836
- acc(obj.center.x + ext, obj.center.y + ext);
837
- } else if (obj.type === "booth") {
838
- const ext = Math.max(obj.width, obj.height) / 2;
839
- acc(obj.center.x - ext, obj.center.y - ext);
840
- acc(obj.center.x + ext, obj.center.y + ext);
841
- } else if (obj.type === "text") {
842
- const w = obj.fontSize * obj.text.length * 0.6;
843
- acc(obj.position.x, obj.position.y);
844
- acc(obj.position.x + w, obj.position.y + obj.fontSize);
845
- }
846
- }
847
- for (const image of [doc.referenceImage, doc.backgroundImage]) {
848
- if (!image) continue;
849
- const { center, width } = image;
850
- const bh = width * 3 / 4;
851
- acc(center.x - width / 2, center.y - bh / 2);
852
- acc(center.x + width / 2, center.y + bh / 2);
853
- }
854
- if (!isFinite(minX)) {
855
- const f = doc.focalPoint ?? { x: 0, y: 0 };
856
- return { x: f.x - 200, y: f.y - 200, width: 400, height: 400 };
857
- }
858
- return {
859
- x: minX - PAD,
860
- y: minY - PAD,
861
- width: maxX - minX + PAD * 2,
862
- height: maxY - minY + PAD * 2
863
- };
864
- }
1
+ import {
2
+ ACCESSIBILITY_RING_COLOR,
3
+ ACCESSIBILITY_TYPES,
4
+ CHART_STORAGE_KEY,
5
+ CHART_UNITS_PER_METRE,
6
+ LABEL_STYLE_MAX_SIZE,
7
+ LABEL_STYLE_MIN_SIZE,
8
+ METRES_PER_CHART_UNIT,
9
+ SEATED_EYE_HEIGHT_M,
10
+ SURROUNDINGS_SHAPE_ROLES,
11
+ TIER_HEIGHT_M,
12
+ accessibilityMeta,
13
+ accessibilityRingColor,
14
+ allObjects,
15
+ chartBounds,
16
+ expandBooth,
17
+ expandChart,
18
+ expandRow,
19
+ expandRowSlots,
20
+ expandTable,
21
+ expandTableSlots,
22
+ floorObjects,
23
+ floorsOf,
24
+ layerOf,
25
+ objectCenter,
26
+ owningSectionForObject,
27
+ pointInPolygon,
28
+ pointInPolygonWithHoles,
29
+ polygonLabelPoint,
30
+ rowInventoryCount,
31
+ rowSeatPositions,
32
+ seatLabelPart,
33
+ sectionElevationTier,
34
+ sectionGeometry,
35
+ stackFloors,
36
+ tableInventoryCount,
37
+ tableSeatCountsBySide
38
+ } from "./chunk-FGS67GT3.js";
865
39
 
866
40
  // src/core/ga.ts
867
41
  var PREFIX = "__sl_ga__";
@@ -4359,7 +3533,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4359
3533
  this.accessRingById.set(seat.id, ring);
4360
3534
  target.add(ring);
4361
3535
  }
4362
- if (accessGlyphPath(seat.accessibility ?? [])) {
3536
+ if (seat.accessibility?.includes("wheelchair") && accessGlyphPath(seat.accessibility)) {
4363
3537
  const glyph = this.buildAccessGlyph(seat, typeof c.fill() === "string" ? c.fill() : "");
4364
3538
  this.accessGlyphById.set(seat.id, glyph);
4365
3539
  target.add(glyph);
@@ -8509,8 +7683,450 @@ function stageSightlinePitch(eyeHeightM, distM) {
8509
7683
  var W = 2048;
8510
7684
  var H = 1024;
8511
7685
  var UNIT = METRES_PER_CHART_UNIT;
7686
+ var TAU = Math.PI * 2;
7687
+ var PX_PER_DEG = H / 180;
8512
7688
  var yawToX = (yawDeg) => (yawDeg + 180) / 360 * W;
8513
7689
  var pitchToY = (pitchDeg) => (90 - pitchDeg) / 180 * H;
7690
+ function fnv1a(str) {
7691
+ let h = 2166136261;
7692
+ for (let i = 0; i < str.length; i++) {
7693
+ h ^= str.charCodeAt(i);
7694
+ h = Math.imul(h, 16777619);
7695
+ }
7696
+ return h >>> 0;
7697
+ }
7698
+ function rng(seed) {
7699
+ let a = seed >>> 0;
7700
+ return () => {
7701
+ a |= 0;
7702
+ a = a + 1831565813 | 0;
7703
+ let t2 = Math.imul(a ^ a >>> 15, 1 | a);
7704
+ t2 = t2 + Math.imul(t2 ^ t2 >>> 7, 61 | t2) ^ t2;
7705
+ return ((t2 ^ t2 >>> 14) >>> 0) / 4294967296;
7706
+ };
7707
+ }
7708
+ var NBINS = 96;
7709
+ var BIN_DEG = 360 / NBINS;
7710
+ var FLAT_DELTA_M = 0.6;
7711
+ var HAZE_SKY = [17, 23, 42];
7712
+ function blendToSky(base, t2, a = 1) {
7713
+ const k = Math.max(0, Math.min(0.58, t2));
7714
+ const r = Math.round(base[0] + (HAZE_SKY[0] - base[0]) * k);
7715
+ const g = Math.round(base[1] + (HAZE_SKY[1] - base[1]) * k);
7716
+ const b = Math.round(base[2] + (HAZE_SKY[2] - base[2]) * k);
7717
+ return `rgba(${r},${g},${b},${a})`;
7718
+ }
7719
+ function mixRgb(a, b, t2) {
7720
+ return [
7721
+ Math.round(a[0] + (b[0] - a[0]) * t2),
7722
+ Math.round(a[1] + (b[1] - a[1]) * t2),
7723
+ Math.round(a[2] + (b[2] - a[2]) * t2)
7724
+ ];
7725
+ }
7726
+ function hslToRgb(h, s, l) {
7727
+ const c = (1 - Math.abs(2 * l - 1)) * s;
7728
+ const hp = (h % 360 + 360) % 360 / 60;
7729
+ const x = c * (1 - Math.abs(hp % 2 - 1));
7730
+ let r = 0, g = 0, b = 0;
7731
+ if (hp < 1) [r, g, b] = [c, x, 0];
7732
+ else if (hp < 2) [r, g, b] = [x, c, 0];
7733
+ else if (hp < 3) [r, g, b] = [0, c, x];
7734
+ else if (hp < 4) [r, g, b] = [0, x, c];
7735
+ else if (hp < 5) [r, g, b] = [x, 0, c];
7736
+ else [r, g, b] = [c, 0, x];
7737
+ const m = l - c / 2;
7738
+ return [Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255)];
7739
+ }
7740
+ var tintCache = /* @__PURE__ */ new Map();
7741
+ function tintFromKey(key) {
7742
+ if (!key) return null;
7743
+ const cached = tintCache.get(key);
7744
+ if (cached) return cached;
7745
+ let h = 2166136261;
7746
+ for (let i = 0; i < key.length; i++) {
7747
+ h ^= key.charCodeAt(i);
7748
+ h = Math.imul(h, 16777619);
7749
+ }
7750
+ const hue = (h >>> 0) % 360;
7751
+ const rgb2 = hslToRgb(hue, 0.3, 0.42);
7752
+ tintCache.set(key, rgb2);
7753
+ return rgb2;
7754
+ }
7755
+ function buildStands(seat, neighborSeats, stageBearing, myEyeM) {
7756
+ const bins = new Array(NBINS).fill(null);
7757
+ let maxDelta = 0;
7758
+ const own = seat.sectionId;
7759
+ for (const other of neighborSeats) {
7760
+ if (other.id === seat.id) continue;
7761
+ const os = other.sectionId;
7762
+ if (!os || own && os === own) continue;
7763
+ const ox = other.x - seat.x;
7764
+ const oy = other.y - seat.y;
7765
+ const dM = Math.hypot(ox, oy) * UNIT;
7766
+ if (dM < 1.5 || dM > 95) continue;
7767
+ const otherEye = other.eyeHeightM ?? SEATED_EYE_HEIGHT_M;
7768
+ const dTop = otherEye - myEyeM;
7769
+ if (Math.abs(dTop) > maxDelta) maxDelta = Math.abs(dTop);
7770
+ const top = Math.atan2(dTop, dM) * 180 / Math.PI;
7771
+ const base = Math.atan2(otherEye - SEATED_EYE_HEIGHT_M - myEyeM, dM) * 180 / Math.PI;
7772
+ const bearing = Math.atan2(ox, -oy) - stageBearing;
7773
+ const yaw = (bearing * 180 / Math.PI + 540) % 360 - 180;
7774
+ const idx = Math.min(NBINS - 1, Math.max(0, Math.floor((yaw + 180) / BIN_DEG)));
7775
+ const overhead = top > 34 && dM < 16;
7776
+ const cur = bins[idx];
7777
+ if (!cur) bins[idx] = { top, base, nearM: dM, overhead, tint: tintFromKey(other.categoryKey) };
7778
+ else {
7779
+ if (top > cur.top) cur.top = top;
7780
+ if (base < cur.base) cur.base = base;
7781
+ if (dM < cur.nearM) {
7782
+ cur.nearM = dM;
7783
+ cur.tint = tintFromKey(other.categoryKey);
7784
+ }
7785
+ cur.overhead = cur.overhead || overhead;
7786
+ }
7787
+ }
7788
+ return { bins, maxDelta };
7789
+ }
7790
+ function drawStands(ctx, bins, toX, toY) {
7791
+ for (let i = 0; i < NBINS; i++) {
7792
+ const b = bins[i];
7793
+ if (!b) continue;
7794
+ const yaw0 = -180 + i * BIN_DEG;
7795
+ const x0 = toX(yaw0);
7796
+ const x1 = toX(yaw0 + BIN_DEG);
7797
+ if (!(x1 > x0)) continue;
7798
+ const top = Math.min(72, b.top);
7799
+ const bottom = Math.min(b.base, -4);
7800
+ const yTop = toY(top);
7801
+ const yBot = toY(bottom);
7802
+ if (yBot <= yTop) continue;
7803
+ const haze = b.nearM / 95;
7804
+ const tint = b.tint;
7805
+ const crest = tint ? mixRgb([44, 54, 80], tint, 0.34) : [44, 54, 80];
7806
+ const seating = tint ? mixRgb([24, 30, 48], tint, 0.28) : [24, 30, 48];
7807
+ const grad = ctx.createLinearGradient(0, yTop, 0, yBot);
7808
+ grad.addColorStop(0, blendToSky(crest, haze));
7809
+ grad.addColorStop(0.24, blendToSky(seating, haze));
7810
+ grad.addColorStop(1, blendToSky([10, 13, 22], haze));
7811
+ ctx.fillStyle = grad;
7812
+ ctx.fillRect(x0, yTop, x1 - x0 + 1, yBot - yTop);
7813
+ ctx.fillStyle = `rgba(150,165,205,${0.35 * (1 - haze * 0.55)})`;
7814
+ ctx.fillRect(x0, yTop, x1 - x0 + 1, 1.5);
7815
+ const bandBottom = Math.min(yBot, toY(0));
7816
+ const bandSpan = bandBottom - yTop;
7817
+ if (bandSpan > 8) {
7818
+ const bands = 6;
7819
+ const contrast = 1 - haze * 0.4;
7820
+ for (let r = 1; r < bands; r++) {
7821
+ const y = yTop + bandSpan * r / bands;
7822
+ ctx.fillStyle = `rgba(0,0,0,${0.16 * contrast})`;
7823
+ ctx.fillRect(x0, y, x1 - x0 + 1, 1.5);
7824
+ ctx.fillStyle = `rgba(120,134,170,${0.1 * contrast})`;
7825
+ ctx.fillRect(x0, y + 1.5, x1 - x0 + 1, 1.2);
7826
+ }
7827
+ }
7828
+ if (b.overhead) {
7829
+ const lipY = toY(Math.min(86, top + 12));
7830
+ const g2 = ctx.createLinearGradient(0, lipY, 0, yTop);
7831
+ g2.addColorStop(0, "rgba(3,4,8,0.9)");
7832
+ g2.addColorStop(1, "rgba(3,4,8,0)");
7833
+ ctx.fillStyle = g2;
7834
+ ctx.fillRect(x0, lipY, x1 - x0 + 1, yTop - lipY);
7835
+ ctx.fillStyle = "rgba(120,130,160,0.14)";
7836
+ ctx.fillRect(x0, yTop - 1.5, x1 - x0 + 1, 1.5);
7837
+ }
7838
+ }
7839
+ }
7840
+ function classifyScene(_seat, focal, neighbors) {
7841
+ const NB = 72;
7842
+ const occ = new Array(NB).fill(false);
7843
+ const innerM = new Array(NB).fill(Infinity);
7844
+ const secSet = /* @__PURE__ */ new Set();
7845
+ let n = 0;
7846
+ for (const s of neighbors) {
7847
+ n++;
7848
+ if (s.sectionId) secSet.add(s.sectionId);
7849
+ const dx = s.x - focal.x;
7850
+ const dy = s.y - focal.y;
7851
+ const dM = Math.hypot(dx, dy) * UNIT;
7852
+ if (dM < 0.5) continue;
7853
+ const bearing = Math.atan2(dx, -dy);
7854
+ let idx = Math.floor((bearing + Math.PI) / TAU * NB);
7855
+ if (idx < 0) idx = 0;
7856
+ else if (idx >= NB) idx = NB - 1;
7857
+ occ[idx] = true;
7858
+ if (dM < innerM[idx]) innerM[idx] = dM;
7859
+ }
7860
+ let occN = 0;
7861
+ const inner = [];
7862
+ for (let i = 0; i < NB; i++) if (occ[i]) {
7863
+ occN++;
7864
+ inner.push(innerM[i]);
7865
+ }
7866
+ const coverage = occN / NB * 360;
7867
+ inner.sort((a, b) => a - b);
7868
+ const median = inner.length ? inner[inner.length >> 1] : 0;
7869
+ const lo = inner.length ? inner[Math.floor(inner.length * 0.15)] ?? median : 0;
7870
+ const hi = inner.length ? inner[Math.floor(inner.length * 0.85)] ?? median : 0;
7871
+ const aspect = lo > 0.5 ? hi / lo : 1;
7872
+ const seed = fnv1a(`${n}|${[...secSet].sort().join(",")}|${Math.round(focal.x)},${Math.round(focal.y)}`);
7873
+ let mode;
7874
+ if (coverage >= 300 && median > 11 && aspect > 1.35) mode = "sport";
7875
+ else if (coverage >= 285) mode = "arena";
7876
+ else mode = "proscenium";
7877
+ return { mode, seed, voidRadiusM: median, aspect };
7878
+ }
7879
+ var HAIR_KINDS = 5;
7880
+ function drawHair(ctx, r, kind) {
7881
+ switch (kind) {
7882
+ case 1:
7883
+ ctx.beginPath();
7884
+ ctx.ellipse(0, -r * 0.14, r * 1.16, r * 1.16, 0, 0, TAU);
7885
+ ctx.fill();
7886
+ break;
7887
+ case 2:
7888
+ ctx.beginPath();
7889
+ ctx.ellipse(0, -r * 1.02, r * 0.42, r * 0.42, 0, 0, TAU);
7890
+ ctx.fill();
7891
+ ctx.beginPath();
7892
+ ctx.ellipse(0, -r * 0.2, r * 0.98, r * 1.02, 0, 0, TAU);
7893
+ ctx.fill();
7894
+ break;
7895
+ case 3:
7896
+ ctx.beginPath();
7897
+ ctx.ellipse(0, -r * 0.36, r * 1.04, r * 0.6, 0, Math.PI, TAU);
7898
+ ctx.fill();
7899
+ ctx.beginPath();
7900
+ ctx.ellipse(0, -r * 0.52, r * 1.22, r * 0.24, 0, 0, TAU);
7901
+ ctx.fill();
7902
+ break;
7903
+ case 4:
7904
+ ctx.beginPath();
7905
+ ctx.ellipse(-r * 0.66, r * 0.5, r * 0.5, r * 1.35, 0, 0, TAU);
7906
+ ctx.fill();
7907
+ ctx.beginPath();
7908
+ ctx.ellipse(r * 0.66, r * 0.5, r * 0.5, r * 1.35, 0, 0, TAU);
7909
+ ctx.fill();
7910
+ ctx.beginPath();
7911
+ ctx.ellipse(0, -r * 0.08, r * 1.06, r * 1.08, 0, 0, TAU);
7912
+ ctx.fill();
7913
+ break;
7914
+ default:
7915
+ break;
7916
+ }
7917
+ }
7918
+ function drawSeated(ctx, hx, hy, r, o) {
7919
+ const neckHalf = r * 0.44;
7920
+ const shHalf = r * o.shoulderK;
7921
+ const neckTopY = r * 0.66;
7922
+ const shoulderY = r * 1.68;
7923
+ const botY = r * 5.4;
7924
+ const clo = `rgba(${o.clothing[0]},${o.clothing[1]},${o.clothing[2]},${o.alpha})`;
7925
+ const skin = `rgba(${o.tone[0]},${o.tone[1]},${o.tone[2]},${o.alpha})`;
7926
+ ctx.save();
7927
+ ctx.translate(hx, hy);
7928
+ const leanPx = o.lean * r;
7929
+ ctx.fillStyle = clo;
7930
+ ctx.beginPath();
7931
+ ctx.moveTo(-neckHalf, neckTopY);
7932
+ ctx.quadraticCurveTo(-neckHalf * 1.18, shoulderY - leanPx - r * 0.6, -shHalf, shoulderY - leanPx);
7933
+ ctx.quadraticCurveTo(-shHalf * 1.05, shoulderY + r * 1.2, -shHalf * 0.94, botY);
7934
+ ctx.lineTo(shHalf * 0.94, botY);
7935
+ ctx.quadraticCurveTo(shHalf * 1.05, shoulderY + r * 1.2, shHalf, shoulderY + leanPx);
7936
+ ctx.quadraticCurveTo(neckHalf * 1.18, shoulderY + leanPx - r * 0.6, neckHalf, neckTopY);
7937
+ ctx.closePath();
7938
+ ctx.fill();
7939
+ ctx.fillStyle = skin;
7940
+ ctx.fillRect(-neckHalf * 0.82, r * 0.5, neckHalf * 1.64, r * 0.9);
7941
+ ctx.save();
7942
+ ctx.rotate(o.tilt);
7943
+ ctx.translate(o.turn * r, 0);
7944
+ ctx.fillStyle = skin;
7945
+ if (r >= 18) {
7946
+ ctx.beginPath();
7947
+ ctx.moveTo(-r * 0.9, -r * 0.15);
7948
+ ctx.quadraticCurveTo(-r * 0.96, -r * 1.12, 0, -r * 1.06);
7949
+ ctx.quadraticCurveTo(r * 0.96, -r * 1.12, r * 0.9, -r * 0.15);
7950
+ ctx.quadraticCurveTo(r * 0.86, r * 0.55, r * 0.36, r * 0.98);
7951
+ ctx.quadraticCurveTo(0, r * 1.14, -r * 0.36, r * 0.98);
7952
+ ctx.quadraticCurveTo(-r * 0.86, r * 0.55, -r * 0.9, -r * 0.15);
7953
+ ctx.closePath();
7954
+ ctx.fill();
7955
+ if (r >= 22) {
7956
+ ctx.beginPath();
7957
+ ctx.ellipse(-r * 0.9, r * 0.18, r * 0.13, r * 0.22, 0, 0, TAU);
7958
+ ctx.ellipse(r * 0.9, r * 0.18, r * 0.13, r * 0.22, 0, 0, TAU);
7959
+ ctx.fill();
7960
+ }
7961
+ } else {
7962
+ ctx.beginPath();
7963
+ ctx.ellipse(0, 0, r * 0.9, r * 1.06, 0, 0, TAU);
7964
+ ctx.fill();
7965
+ }
7966
+ drawHair(ctx, r, o.hair);
7967
+ if (o.rim > 0.01) {
7968
+ ctx.strokeStyle = `rgba(245,205,150,${(0.5 * o.rim).toFixed(3)})`;
7969
+ ctx.lineWidth = Math.max(1, r * 0.14);
7970
+ ctx.beginPath();
7971
+ ctx.ellipse(0, -r * 0.06, r * 0.86, r * 1, 0, Math.PI * 1.12, Math.PI * 1.9);
7972
+ ctx.stroke();
7973
+ }
7974
+ ctx.restore();
7975
+ if (o.rim > 0.01) {
7976
+ ctx.strokeStyle = `rgba(240,200,150,${(0.28 * o.rim).toFixed(3)})`;
7977
+ ctx.lineWidth = Math.max(1, r * 0.12);
7978
+ ctx.beginPath();
7979
+ ctx.moveTo(-shHalf * 0.8, shoulderY - r * 0.05);
7980
+ ctx.quadraticCurveTo(-neckHalf, shoulderY - r * 0.75, 0, shoulderY - r * 0.82);
7981
+ ctx.quadraticCurveTo(neckHalf, shoulderY - r * 0.75, shHalf * 0.8, shoulderY - r * 0.05);
7982
+ ctx.stroke();
7983
+ }
7984
+ ctx.restore();
7985
+ }
7986
+ function drawSeatedMid(ctx, hx, hy, r, clothing, tone, alpha) {
7987
+ ctx.fillStyle = `rgba(${clothing[0]},${clothing[1]},${clothing[2]},${alpha})`;
7988
+ ctx.beginPath();
7989
+ ctx.moveTo(hx - r * 1.72, hy + r * 4);
7990
+ ctx.quadraticCurveTo(hx - r * 1.78, hy + r * 1.55, hx - r * 0.46, hy + r * 1.02);
7991
+ ctx.lineTo(hx - r * 0.4, hy + r * 0.62);
7992
+ ctx.lineTo(hx + r * 0.4, hy + r * 0.62);
7993
+ ctx.lineTo(hx + r * 0.46, hy + r * 1.02);
7994
+ ctx.quadraticCurveTo(hx + r * 1.78, hy + r * 1.55, hx + r * 1.72, hy + r * 4);
7995
+ ctx.closePath();
7996
+ ctx.fill();
7997
+ ctx.fillStyle = `rgba(${tone[0]},${tone[1]},${tone[2]},${alpha})`;
7998
+ ctx.beginPath();
7999
+ ctx.ellipse(hx, hy, r * 0.9, r * 1.05, 0, 0, TAU);
8000
+ ctx.fill();
8001
+ }
8002
+ function drawPerformer(ctx, x, footY, h, o) {
8003
+ const headR = h * 0.1;
8004
+ const headCy = footY - h * 0.9;
8005
+ const shoulderY = footY - h * 0.74;
8006
+ const hipY = footY - h * 0.46;
8007
+ const shHalf = h * 0.13;
8008
+ const hipHalf = h * 0.09;
8009
+ const skin = `rgba(${o.tone[0]},${o.tone[1]},${o.tone[2]},${o.alpha})`;
8010
+ const sh = ctx.createRadialGradient(x, footY, 1, x, footY, h * 0.24);
8011
+ sh.addColorStop(0, "rgba(0,0,0,0.4)");
8012
+ sh.addColorStop(1, "rgba(0,0,0,0)");
8013
+ ctx.save();
8014
+ ctx.scale(1, 0.28);
8015
+ ctx.fillStyle = sh;
8016
+ ctx.beginPath();
8017
+ ctx.ellipse(x, footY / 0.28, h * 0.24, h * 0.24, 0, 0, TAU);
8018
+ ctx.fill();
8019
+ ctx.restore();
8020
+ ctx.fillStyle = skin;
8021
+ ctx.beginPath();
8022
+ ctx.moveTo(x - hipHalf, hipY);
8023
+ ctx.lineTo(x - hipHalf * 0.7, footY);
8024
+ ctx.lineTo(x - hipHalf * 0.1, footY);
8025
+ ctx.lineTo(x - hipHalf * 0.1, hipY + h * 0.02);
8026
+ ctx.lineTo(x + hipHalf * 0.1, hipY + h * 0.02);
8027
+ ctx.lineTo(x + hipHalf * 0.1, footY);
8028
+ ctx.lineTo(x + hipHalf * 0.7, footY);
8029
+ ctx.lineTo(x + hipHalf, hipY);
8030
+ ctx.closePath();
8031
+ ctx.fill();
8032
+ ctx.beginPath();
8033
+ ctx.moveTo(x - shHalf, shoulderY);
8034
+ ctx.quadraticCurveTo(x - shHalf * 0.9, hipY - h * 0.12, x - hipHalf, hipY);
8035
+ ctx.lineTo(x + hipHalf, hipY);
8036
+ ctx.quadraticCurveTo(x + shHalf * 0.9, hipY - h * 0.12, x + shHalf, shoulderY);
8037
+ ctx.quadraticCurveTo(x, shoulderY - h * 0.05, x - shHalf, shoulderY);
8038
+ ctx.closePath();
8039
+ ctx.fill();
8040
+ ctx.lineCap = "round";
8041
+ ctx.lineJoin = "round";
8042
+ ctx.strokeStyle = skin;
8043
+ ctx.lineWidth = h * 0.055;
8044
+ const armY = shoulderY + h * 0.02;
8045
+ if (o.pose === 1) {
8046
+ ctx.beginPath();
8047
+ ctx.moveTo(x - shHalf * 0.8, armY);
8048
+ ctx.lineTo(x - shHalf * 1.5, footY - h * 1.02);
8049
+ ctx.moveTo(x + shHalf * 0.8, armY);
8050
+ ctx.lineTo(x + shHalf * 1.5, footY - h * 1.02);
8051
+ ctx.stroke();
8052
+ } else if (o.pose === 2) {
8053
+ ctx.beginPath();
8054
+ ctx.moveTo(x - shHalf * 0.8, armY);
8055
+ ctx.lineTo(x + hipHalf * 1.4, hipY - h * 0.02);
8056
+ ctx.moveTo(x + shHalf * 0.8, armY);
8057
+ ctx.lineTo(x - hipHalf * 0.4, hipY - h * 0.06);
8058
+ ctx.stroke();
8059
+ ctx.fillStyle = skin;
8060
+ ctx.beginPath();
8061
+ ctx.ellipse(x + hipHalf * 1.5, hipY - h * 0.04, h * 0.07, h * 0.09, -0.5, 0, TAU);
8062
+ ctx.fill();
8063
+ } else if (o.pose === 3) {
8064
+ ctx.beginPath();
8065
+ ctx.moveTo(x - shHalf * 0.8, armY);
8066
+ ctx.lineTo(x - shHalf * 0.2, headCy + headR * 0.6);
8067
+ ctx.moveTo(x + shHalf * 0.8, armY);
8068
+ ctx.lineTo(x + shHalf * 1.1, hipY);
8069
+ ctx.stroke();
8070
+ ctx.lineWidth = h * 0.02;
8071
+ ctx.beginPath();
8072
+ ctx.moveTo(x - shHalf * 0.2, headCy + headR * 0.9);
8073
+ ctx.lineTo(x - shHalf * 0.2, footY - h * 0.02);
8074
+ ctx.stroke();
8075
+ } else {
8076
+ ctx.beginPath();
8077
+ ctx.moveTo(x - shHalf * 0.85, armY);
8078
+ ctx.lineTo(x - shHalf * 0.95, hipY + h * 0.02);
8079
+ ctx.moveTo(x + shHalf * 0.85, armY);
8080
+ ctx.lineTo(x + shHalf * 0.95, hipY + h * 0.02);
8081
+ ctx.stroke();
8082
+ }
8083
+ ctx.fillStyle = skin;
8084
+ ctx.fillRect(x - headR * 0.34, headCy + headR * 0.6, headR * 0.68, headR * 0.9);
8085
+ ctx.beginPath();
8086
+ ctx.ellipse(x, headCy, headR * 0.86, headR * 1.05, 0, 0, TAU);
8087
+ ctx.fill();
8088
+ if (o.rimStrength > 0.01) {
8089
+ ctx.strokeStyle = `rgba(${o.rim[0]},${o.rim[1]},${o.rim[2]},${(0.6 * o.rimStrength).toFixed(3)})`;
8090
+ ctx.lineWidth = Math.max(1, h * 0.02);
8091
+ ctx.beginPath();
8092
+ ctx.moveTo(x - shHalf, shoulderY);
8093
+ ctx.lineTo(x - headR * 0.6, headCy + headR * 0.4);
8094
+ ctx.moveTo(x - headR * 0.7, headCy);
8095
+ ctx.ellipse(x, headCy, headR * 0.84, headR * 1.02, 0, Math.PI * 1.05, Math.PI * 1.55);
8096
+ ctx.stroke();
8097
+ }
8098
+ }
8099
+ function drawBeam(ctx, x0, y0, x1, y1, halfTop, halfBot, tint, strength) {
8100
+ ctx.save();
8101
+ ctx.globalCompositeOperation = "lighter";
8102
+ const g = ctx.createLinearGradient(0, y0, 0, y1);
8103
+ g.addColorStop(0, `rgba(${tint},${strength})`);
8104
+ g.addColorStop(0.5, `rgba(${tint},${(strength * 0.4).toFixed(3)})`);
8105
+ g.addColorStop(1, `rgba(${tint},0)`);
8106
+ ctx.fillStyle = g;
8107
+ ctx.beginPath();
8108
+ ctx.moveTo(x0 - halfTop, y0);
8109
+ ctx.lineTo(x1 - halfBot, y1);
8110
+ ctx.lineTo(x1 + halfBot, y1);
8111
+ ctx.lineTo(x0 + halfTop, y0);
8112
+ ctx.closePath();
8113
+ ctx.fill();
8114
+ ctx.restore();
8115
+ }
8116
+ function drawFixture(ctx, x, y, r, tint) {
8117
+ const g = ctx.createRadialGradient(x, y, 0, x, y, r * 3);
8118
+ g.addColorStop(0, `rgba(${tint},0.9)`);
8119
+ g.addColorStop(0.4, `rgba(${tint},0.35)`);
8120
+ g.addColorStop(1, `rgba(${tint},0)`);
8121
+ ctx.fillStyle = g;
8122
+ ctx.beginPath();
8123
+ ctx.arc(x, y, r * 3, 0, TAU);
8124
+ ctx.fill();
8125
+ ctx.fillStyle = `rgba(${tint},1)`;
8126
+ ctx.beginPath();
8127
+ ctx.arc(x, y, r, 0, TAU);
8128
+ ctx.fill();
8129
+ }
8514
8130
  function generateSeatPanorama(seat, focalPoint, neighborSeats) {
8515
8131
  const canvas = document.createElement("canvas");
8516
8132
  canvas.width = W;
@@ -8520,102 +8136,557 @@ function generateSeatPanorama(seat, focalPoint, neighborSeats) {
8520
8136
  const dy = focalPoint.y - seat.y;
8521
8137
  const distU = Math.hypot(dx, dy);
8522
8138
  const distM = Math.max(2, distU * UNIT);
8139
+ const eyeM = seat.eyeHeightM ?? SEATED_EYE_HEIGHT_M;
8140
+ const scene = classifyScene(seat, focalPoint, neighborSeats);
8141
+ const rand = rng(scene.seed);
8142
+ const baseHue = 210 + rand() * 150;
8143
+ const rigTint = hslToRgb(220 + (rand() - 0.5) * 60, 0.55, 0.82);
8144
+ const rigTintStr = `${rigTint[0]},${rigTint[1]},${rigTint[2]}`;
8523
8145
  const sky = ctx.createLinearGradient(0, 0, 0, H);
8524
- sky.addColorStop(0, "#05070c");
8525
- sky.addColorStop(0.42, "#11172a");
8526
- sky.addColorStop(0.5, "#1a2238");
8527
- sky.addColorStop(0.56, "#10141f");
8528
- sky.addColorStop(1, "#07090f");
8146
+ sky.addColorStop(0, "#080b12");
8147
+ sky.addColorStop(0.42, "#12182c");
8148
+ sky.addColorStop(0.5, "#1c2440");
8149
+ sky.addColorStop(0.56, "#151b2c");
8150
+ sky.addColorStop(0.78, "#171e30");
8151
+ sky.addColorStop(1, "#131829");
8529
8152
  ctx.fillStyle = sky;
8530
8153
  ctx.fillRect(0, 0, W, H);
8531
- const eyeM = seat.eyeHeightM ?? SEATED_EYE_HEIGHT_M;
8532
- const stageHalfYaw = Math.min(80, Math.atan2(4, distM) * 180 / Math.PI);
8154
+ const floorAmb = ctx.createLinearGradient(0, pitchToY(-6), 0, H);
8155
+ floorAmb.addColorStop(0, "rgba(30, 37, 56, 0)");
8156
+ floorAmb.addColorStop(0.55, "rgba(30, 37, 56, 0.22)");
8157
+ floorAmb.addColorStop(1, "rgba(22, 28, 44, 0.12)");
8158
+ ctx.fillStyle = floorAmb;
8159
+ ctx.fillRect(0, pitchToY(-6), W, H - pitchToY(-6));
8160
+ const stageBearing = Math.atan2(dx, -dy);
8161
+ const stands = buildStands(seat, neighborSeats, stageBearing, eyeM);
8162
+ const hasRelief = stands.maxDelta >= FLAT_DELTA_M;
8163
+ if (hasRelief) drawStands(ctx, stands.bins, yawToX, pitchToY);
8164
+ const nearGlow = Math.max(0.3, Math.min(1, 1 - (distM - 6) / 60));
8533
8165
  const sightline = stageSightlinePitch(eyeM, distM);
8166
+ const stageHalfYaw = Math.min(80, Math.atan2(4, distM) * 180 / Math.PI);
8534
8167
  const stageTopPitch = Math.min(45, sightline.topPitch);
8535
8168
  const stageBasePitch = sightline.basePitch;
8169
+ if (scene.mode === "sport") {
8170
+ drawSportScene(ctx, distM, eyeM, scene, nearGlow, baseHue, rigTintStr, rand);
8171
+ } else if (scene.mode === "arena") {
8172
+ drawArenaScene(ctx, distM, eyeM, scene, nearGlow, rigTintStr, rand);
8173
+ } else {
8174
+ drawProsceniumScene(ctx, stageHalfYaw, stageTopPitch, stageBasePitch, nearGlow, baseHue, rigTintStr, rand);
8175
+ }
8176
+ const rigCount = scene.mode === "sport" ? 34 : 24 + Math.floor(rand() * 8);
8177
+ ctx.fillStyle = `rgba(${rigTintStr},0.8)`;
8178
+ for (let i = 0; i < rigCount; i++) {
8179
+ const x = (i + 0.5) / rigCount * W;
8180
+ const y = pitchToY(scene.mode === "sport" ? 58 : 54 + i * 7 % 3 * 6);
8181
+ ctx.beginPath();
8182
+ ctx.arc(x, y, scene.mode === "sport" ? 2.6 : 3.2, 0, TAU);
8183
+ ctx.fill();
8184
+ }
8185
+ drawAudience(ctx, seat, neighborSeats, focalPoint, stageBearing, eyeM, dx, dy, hasRelief, scene);
8186
+ const poleFade = ctx.createLinearGradient(0, 0, 0, H);
8187
+ poleFade.addColorStop(0, "rgba(0,0,0,0.85)");
8188
+ poleFade.addColorStop(0.12, "rgba(0,0,0,0)");
8189
+ poleFade.addColorStop(0.9, "rgba(0,0,0,0)");
8190
+ poleFade.addColorStop(1, "rgba(0,0,0,0.55)");
8191
+ ctx.fillStyle = poleFade;
8192
+ ctx.fillRect(0, 0, W, H);
8193
+ return { url: canvas.toDataURL("image/jpeg", 0.82), distanceM: Math.round(distM) };
8194
+ }
8195
+ function drawProsceniumScene(ctx, stageHalfYaw, stageTopPitch, stageBasePitch, nearGlow, baseHue, rigTintStr, rand) {
8536
8196
  const sx0 = yawToX(-stageHalfYaw);
8537
8197
  const sx1 = yawToX(stageHalfYaw);
8538
8198
  const sy0 = pitchToY(stageTopPitch);
8539
8199
  const sy1 = pitchToY(stageBasePitch);
8540
- const glow = ctx.createRadialGradient(W / 2, (sy0 + sy1) / 2, 10, W / 2, (sy0 + sy1) / 2, (sx1 - sx0) * 1.1);
8541
- glow.addColorStop(0, "rgba(129, 140, 248, 0.5)");
8542
- glow.addColorStop(0.5, "rgba(99, 102, 241, 0.16)");
8200
+ const sw = sx1 - sx0;
8201
+ const sh = sy1 - sy0;
8202
+ const archTop = pitchToY(Math.min(50, stageTopPitch + 10));
8203
+ const glowR = sw * (1.1 + (1 - nearGlow) * 0.5);
8204
+ const glow = ctx.createRadialGradient(W / 2, (sy0 + sy1) / 2, 8, W / 2, (sy0 + sy1) / 2, glowR);
8205
+ glow.addColorStop(0, "rgba(255, 214, 150, 0.34)");
8206
+ glow.addColorStop(0.28, "rgba(150, 150, 230, 0.3)");
8207
+ glow.addColorStop(0.6, "rgba(99, 102, 241, 0.12)");
8543
8208
  glow.addColorStop(1, "rgba(99, 102, 241, 0)");
8544
8209
  ctx.fillStyle = glow;
8545
- ctx.fillRect(sx0 - (sx1 - sx0) * 0.6, sy0 - (sy1 - sy0) * 1.2, (sx1 - sx0) * 2.2, (sy1 - sy0) * 3.4);
8546
- ctx.fillStyle = "#242c44";
8547
- ctx.fillRect(sx0, sy0, sx1 - sx0, sy1 - sy0);
8210
+ ctx.fillRect(sx0 - sw * 0.6, sy0 - sh * 1.2, sw * 2.2, sh * 3.4);
8211
+ const topCol = hslToRgb(baseHue, 0.62, 0.55);
8212
+ const midCol = hslToRgb(baseHue + 40, 0.7, 0.5);
8213
+ const botCol = hslToRgb(baseHue + 70, 0.78, 0.52);
8214
+ ctx.save();
8215
+ ctx.beginPath();
8216
+ ctx.rect(sx0, sy0, sw, sh);
8217
+ ctx.clip();
8548
8218
  const backdrop = ctx.createLinearGradient(0, sy0, 0, sy1);
8549
- backdrop.addColorStop(0, "#7c5cff");
8550
- backdrop.addColorStop(0.5, "#e1447a");
8551
- backdrop.addColorStop(1, "#f59e0b");
8552
- ctx.globalAlpha = 0.75;
8219
+ backdrop.addColorStop(0, `rgb(${topCol[0]},${topCol[1]},${topCol[2]})`);
8220
+ backdrop.addColorStop(0.5, `rgb(${midCol[0]},${midCol[1]},${midCol[2]})`);
8221
+ backdrop.addColorStop(1, `rgb(${botCol[0]},${botCol[1]},${botCol[2]})`);
8222
+ ctx.globalAlpha = 0.8;
8553
8223
  ctx.fillStyle = backdrop;
8554
- const inset = (sx1 - sx0) * 0.06;
8555
- ctx.fillRect(sx0 + inset, sy0 + (sy1 - sy0) * 0.12, sx1 - sx0 - inset * 2, (sy1 - sy0) * 0.62);
8224
+ ctx.fillRect(sx0, sy0, sw, sh);
8556
8225
  ctx.globalAlpha = 1;
8557
- ctx.fillStyle = "#0d1119";
8558
- ctx.fillRect(sx0, sy1 - (sy1 - sy0) * 0.1, sx1 - sx0, (sy1 - sy0) * 0.1);
8559
- const performerH = (sy1 - sy0) * 0.45;
8560
- ctx.fillStyle = "rgba(8, 10, 16, 0.85)";
8561
- for (const fx of [0.35, 0.5, 0.65]) {
8562
- const px = sx0 + (sx1 - sx0) * fx;
8563
- const py = sy1 - (sy1 - sy0) * 0.12;
8226
+ const fall = ctx.createRadialGradient(W / 2, (sy0 + sy1) / 2, sw * 0.12, W / 2, (sy0 + sy1) / 2, sw * 0.62);
8227
+ fall.addColorStop(0, "rgba(6,8,14,0)");
8228
+ fall.addColorStop(0.7, "rgba(6,8,14,0.15)");
8229
+ fall.addColorStop(1, "rgba(5,6,11,0.92)");
8230
+ ctx.fillStyle = fall;
8231
+ ctx.fillRect(sx0, sy0, sw, sh);
8232
+ ctx.restore();
8233
+ const perfCount = 3 + Math.floor(rand() * 3);
8234
+ const perfH = sh * 0.46;
8235
+ const floorY = sy1 - sh * 0.08;
8236
+ for (let i = 0; i < perfCount; i++) {
8237
+ const t2 = (i + 1) / (perfCount + 1);
8238
+ const px = sx0 + sw * (0.22 + t2 * 0.56 + (rand() - 0.5) * 0.06);
8239
+ drawPerformer(ctx, px, floorY, perfH * (0.9 + rand() * 0.24), {
8240
+ tone: [8, 10, 16],
8241
+ alpha: 0.9,
8242
+ pose: Math.floor(rand() * 4),
8243
+ rim: hslToRgb(baseHue + 20, 0.5, 0.7),
8244
+ rimStrength: 0.7
8245
+ });
8246
+ }
8247
+ ctx.fillStyle = "#05070d";
8248
+ ctx.fillRect(sx0 - sw * 0.5, archTop, sw * 0.5, sy1 - archTop);
8249
+ ctx.fillRect(sx1, archTop, sw * 0.5, sy1 - archTop);
8250
+ const legBot = sw * 0.055 + 3;
8251
+ const legTop = legBot * 1.35;
8252
+ ctx.fillStyle = "#03050a";
8253
+ ctx.beginPath();
8254
+ ctx.moveTo(sx0, sy1);
8255
+ ctx.lineTo(sx0 - legBot, sy1);
8256
+ ctx.lineTo(sx0 - legTop, archTop);
8257
+ ctx.lineTo(sx0 + sw * 0.012, archTop);
8258
+ ctx.closePath();
8259
+ ctx.fill();
8260
+ ctx.beginPath();
8261
+ ctx.moveTo(sx1, sy1);
8262
+ ctx.lineTo(sx1 + legBot, sy1);
8263
+ ctx.lineTo(sx1 + legTop, archTop);
8264
+ ctx.lineTo(sx1 - sw * 0.012, archTop);
8265
+ ctx.closePath();
8266
+ ctx.fill();
8267
+ ctx.fillRect(sx0 - legTop, archTop, sw + legTop * 2, (sy1 - archTop) * 0.12);
8268
+ const valH = sh * 0.14;
8269
+ const valY = archTop + (sy1 - archTop) * 0.12;
8270
+ ctx.fillStyle = "rgba(4,6,12,0.96)";
8271
+ const scallops = 7;
8272
+ ctx.beginPath();
8273
+ ctx.moveTo(sx0 - legTop, valY);
8274
+ for (let i = 0; i <= scallops; i++) {
8275
+ const xx = sx0 - legTop + (sw + legTop * 2) * i / scallops;
8276
+ ctx.quadraticCurveTo(xx - (sw + legTop * 2) / scallops / 2, valY + valH, xx, valY);
8277
+ }
8278
+ ctx.lineTo(sx1 + legTop, valY);
8279
+ ctx.lineTo(sx1 + legTop, archTop);
8280
+ ctx.lineTo(sx0 - legTop, archTop);
8281
+ ctx.closePath();
8282
+ ctx.fill();
8283
+ ctx.fillStyle = `rgba(${rigTintStr},0.12)`;
8284
+ ctx.fillRect(sx0 - 1.5, valY, 1.5, sy1 - valY);
8285
+ ctx.fillRect(sx1, valY, 1.5, sy1 - valY);
8286
+ ctx.fillStyle = "#0b0f18";
8287
+ ctx.fillRect(sx0 - legBot, sy1 - sh * 0.05, sw + legBot * 2, sh * 0.05 + 3);
8288
+ const foot = ctx.createLinearGradient(0, sy1 - sh * 0.05, 0, sy1 + sh * 0.12);
8289
+ foot.addColorStop(0, `rgba(255,206,140,${(0.5 * nearGlow).toFixed(3)})`);
8290
+ foot.addColorStop(1, "rgba(255,206,140,0)");
8291
+ ctx.fillStyle = foot;
8292
+ ctx.fillRect(sx0 - legBot, sy1 - sh * 0.05, sw + legBot * 2, sh * 0.2);
8293
+ const beams = 3 + Math.floor(rand() * 2);
8294
+ for (let i = 0; i < beams; i++) {
8295
+ const t2 = (i + 1) / (beams + 1);
8296
+ const fx = sx0 + sw * (t2 + (rand() - 0.5) * 0.08);
8297
+ const fy = pitchToY(Math.min(62, stageTopPitch + 26));
8298
+ const skew = (rand() - 0.5) * sw * 0.16;
8299
+ drawBeam(ctx, fx, fy, fx + skew, sy1, 4, sw * 0.09, rigTintStr, 0.16 * (0.7 + nearGlow * 0.5));
8300
+ drawFixture(ctx, fx, fy, 2.4, rigTintStr);
8301
+ }
8302
+ }
8303
+ function drawArenaScene(ctx, distM, eyeM, scene, nearGlow, rigTintStr, rand) {
8304
+ const stageR = Math.min(distM * 0.6, Math.max(3, scene.voidRadiusM));
8305
+ const halfYaw = Math.min(55, Math.atan2(stageR, distM) * 180 / Math.PI);
8306
+ const cx = W / 2;
8307
+ const deckRiserM = 1.2;
8308
+ const nearDist = Math.max(distM * 0.45, distM - stageR);
8309
+ const farDist = distM + stageR;
8310
+ const nearTopPitch = Math.atan2(deckRiserM - eyeM, nearDist) * 180 / Math.PI;
8311
+ const farTopPitch = Math.atan2(deckRiserM - eyeM, farDist) * 180 / Math.PI;
8312
+ const nearBasePitch = Math.atan2(-eyeM, nearDist) * 180 / Math.PI;
8313
+ const yNearTop = pitchToY(nearTopPitch);
8314
+ const yFarTop = pitchToY(farTopPitch);
8315
+ const yBase = pitchToY(nearBasePitch);
8316
+ const halfW = yawToX(halfYaw) - cx;
8317
+ const topRy = Math.max(6, (yNearTop - yFarTop) / 2);
8318
+ const topCy = (yNearTop + yFarTop) / 2;
8319
+ const pool = ctx.createRadialGradient(cx, yBase, 4, cx, yBase, halfW * 2.6);
8320
+ pool.addColorStop(0, `rgba(255,206,150,${(0.2 * nearGlow).toFixed(3)})`);
8321
+ pool.addColorStop(0.5, "rgba(150,140,190,0.08)");
8322
+ pool.addColorStop(1, "rgba(40,44,60,0)");
8323
+ ctx.save();
8324
+ ctx.globalCompositeOperation = "lighter";
8325
+ ctx.fillStyle = pool;
8326
+ ctx.fillRect(cx - halfW * 2.6, yFarTop - topRy, halfW * 5.2, yBase - yFarTop + topRy + 40);
8327
+ ctx.restore();
8328
+ ctx.fillStyle = "#0c1019";
8329
+ ctx.beginPath();
8330
+ ctx.moveTo(cx - halfW, topCy);
8331
+ ctx.ellipse(cx, topCy, halfW, topRy, 0, Math.PI, 0, false);
8332
+ ctx.lineTo(cx + halfW, yBase);
8333
+ ctx.ellipse(cx, yBase, halfW, topRy, 0, 0, Math.PI, false);
8334
+ ctx.closePath();
8335
+ ctx.fill();
8336
+ const riser = ctx.createLinearGradient(0, topCy, 0, yBase);
8337
+ riser.addColorStop(0, `rgba(${rigTintStr},0.14)`);
8338
+ riser.addColorStop(1, "rgba(0,0,0,0)");
8339
+ ctx.fillStyle = riser;
8340
+ ctx.fillRect(cx - halfW, topCy, halfW * 2, yBase - topCy);
8341
+ const FORMATIONS = [
8342
+ // frontman + guitarist + one at the back
8343
+ [{ u: -0.24, v: 0.4, lead: true, pose: 3 }, { u: 0.34, v: -0.04, pose: 2 }, { u: -0.52, v: -0.38, pose: 0 }],
8344
+ // duo — singer forward, instrument behind the shoulder
8345
+ [{ u: 0.2, v: 0.34, lead: true, pose: 3 }, { u: -0.3, v: -0.08, pose: 2 }],
8346
+ // 4-piece — lead just off-centre, band staggered around the far half
8347
+ [{ u: -0.1, v: 0.28, lead: true, pose: 1 }, { u: 0.44, v: -0.2, pose: 2 }, { u: -0.46, v: -0.14, pose: 2 }, { u: 0.08, v: -0.46, pose: 0 }]
8348
+ ];
8349
+ const form = FORMATIONS[Math.floor(rand() * FORMATIONS.length)];
8350
+ const mirror = rand() < 0.5 ? -1 : 1;
8351
+ const spotX = (p) => cx + p.u * mirror * halfW * 0.84;
8352
+ const spotY = (p) => topCy + p.v * topRy * 0.8;
8353
+ const lead = form.find((p) => p.lead) ?? form[0];
8354
+ const leadX = spotX(lead);
8355
+ const deckTop = ctx.createLinearGradient(0, topCy - topRy, 0, topCy + topRy);
8356
+ deckTop.addColorStop(0, "#3d4a72");
8357
+ deckTop.addColorStop(1, "#232b47");
8358
+ ctx.fillStyle = deckTop;
8359
+ ctx.beginPath();
8360
+ ctx.ellipse(cx, topCy, halfW, topRy, 0, 0, TAU);
8361
+ ctx.fill();
8362
+ const deckGlow = ctx.createRadialGradient(leadX, topCy, 2, leadX, topCy, halfW);
8363
+ deckGlow.addColorStop(0, `rgba(255,214,160,${(0.5 * nearGlow).toFixed(3)})`);
8364
+ deckGlow.addColorStop(1, "rgba(255,214,160,0)");
8365
+ ctx.save();
8366
+ ctx.globalCompositeOperation = "lighter";
8367
+ ctx.fillStyle = deckGlow;
8368
+ ctx.beginPath();
8369
+ ctx.ellipse(cx, topCy, halfW, topRy, 0, 0, TAU);
8370
+ ctx.fill();
8371
+ ctx.restore();
8372
+ ctx.strokeStyle = `rgba(${rigTintStr},0.7)`;
8373
+ ctx.lineWidth = 2;
8374
+ ctx.beginPath();
8375
+ ctx.ellipse(cx, topCy, halfW, topRy, 0, 0, TAU);
8376
+ ctx.stroke();
8377
+ const perfH = Math.max(24, (yBase - yFarTop) * 1.3);
8378
+ const gearN = form.length >= 3 ? 3 : 2;
8379
+ for (let i = 0; i < gearN; i++) {
8380
+ const gu = (-0.62 + i * 0.52 + (rand() - 0.5) * 0.12) * -mirror;
8381
+ const gx = cx + gu * halfW * 0.7;
8382
+ const gy = topCy - topRy * (0.45 + rand() * 0.18);
8383
+ const gw = halfW * (0.07 + rand() * 0.04);
8384
+ const gh = perfH * (0.26 + rand() * 0.12);
8385
+ ctx.fillStyle = "#0a0e18";
8386
+ ctx.fillRect(gx - gw / 2, gy - gh, gw, gh);
8387
+ ctx.fillStyle = `rgba(${rigTintStr},0.3)`;
8388
+ ctx.fillRect(gx - gw / 2, gy - gh, gw, 1.5);
8389
+ }
8390
+ if (form.length >= 3) {
8391
+ const drumX = cx - lead.u * mirror * halfW * 0.4;
8392
+ ctx.fillStyle = "#0b0f1a";
8564
8393
  ctx.beginPath();
8565
- ctx.arc(px, py - performerH * 0.82, performerH * 0.16, 0, Math.PI * 2);
8394
+ ctx.ellipse(drumX, topCy - topRy * 0.34, halfW * 0.08, topRy * 0.1, 0, 0, TAU);
8566
8395
  ctx.fill();
8567
- ctx.fillRect(px - performerH * 0.14, py - performerH * 0.68, performerH * 0.28, performerH * 0.68);
8396
+ ctx.strokeStyle = `rgba(${rigTintStr},0.35)`;
8397
+ ctx.lineWidth = 1;
8398
+ ctx.stroke();
8399
+ }
8400
+ for (const p of [...form].sort((a, b) => a.v - b.v)) {
8401
+ const depthScale = 0.82 + (p.v + 1) * 0.14;
8402
+ drawPerformer(ctx, spotX(p), spotY(p), perfH * depthScale * (p.lead ? 1.06 : 0.92), {
8403
+ tone: [18, 21, 34],
8404
+ alpha: 0.95,
8405
+ pose: p.pose,
8406
+ rim: hslToRgb(40, 0.72, 0.8),
8407
+ rimStrength: p.lead ? 1.4 : 0.9
8408
+ });
8568
8409
  }
8569
- ctx.globalAlpha = 0.14;
8570
- ctx.fillStyle = "#c7d2fe";
8571
- for (const fx of [0.3, 0.5, 0.7]) {
8572
- const bx = sx0 + (sx1 - sx0) * fx;
8410
+ const trussY = pitchToY(Math.min(60, farTopPitch + 34));
8411
+ const trussRy = topRy * 1.1 + 6;
8412
+ ctx.strokeStyle = "rgba(60,68,92,0.85)";
8413
+ ctx.lineWidth = 3;
8414
+ ctx.beginPath();
8415
+ ctx.ellipse(cx, trussY, halfW * 1.15, trussRy, 0, 0, TAU);
8416
+ ctx.stroke();
8417
+ const fixtures = 4 + Math.floor(rand() * 3);
8418
+ for (let i = 0; i < fixtures; i++) {
8419
+ const a = i / fixtures * TAU;
8420
+ const fxX = cx + Math.cos(a) * halfW * 1.15;
8421
+ const fxY = trussY + Math.sin(a) * trussRy;
8422
+ if (Math.sin(a) > -0.2) {
8423
+ const tx = cx + (rand() - 0.5) * halfW * 0.8;
8424
+ drawBeam(ctx, fxX, fxY, tx, topCy, 3, halfW * 0.32, rigTintStr, 0.2 * (0.7 + nearGlow * 0.4));
8425
+ }
8426
+ drawFixture(ctx, fxX, fxY, 3, rigTintStr);
8427
+ }
8428
+ const scoreY = pitchToY(Math.min(52, farTopPitch + 24));
8429
+ const scoreW = Math.max(60, halfW * 0.4);
8430
+ const scoreH = Math.max(24, scoreW * 0.34);
8431
+ ctx.strokeStyle = "rgba(70,78,102,0.6)";
8432
+ ctx.lineWidth = 1.5;
8433
+ ctx.beginPath();
8434
+ ctx.moveTo(cx - scoreW * 0.3, scoreY - scoreH / 2);
8435
+ ctx.lineTo(cx - scoreW * 0.16, scoreY - scoreH * 2.2);
8436
+ ctx.moveTo(cx + scoreW * 0.3, scoreY - scoreH / 2);
8437
+ ctx.lineTo(cx + scoreW * 0.16, scoreY - scoreH * 2.2);
8438
+ ctx.stroke();
8439
+ ctx.fillStyle = "#0d1220";
8440
+ ctx.fillRect(cx - scoreW / 2, scoreY - scoreH / 2, scoreW, scoreH);
8441
+ const screen = ctx.createLinearGradient(0, scoreY - scoreH * 0.3, 0, scoreY + scoreH * 0.42);
8442
+ screen.addColorStop(0, `rgba(${rigTintStr},0.5)`);
8443
+ screen.addColorStop(1, "rgba(255,214,160,0.32)");
8444
+ ctx.fillStyle = screen;
8445
+ ctx.fillRect(cx - scoreW * 0.42, scoreY - scoreH * 0.3, scoreW * 0.84, scoreH * 0.72);
8446
+ ctx.fillStyle = "rgba(255,214,160,0.55)";
8447
+ ctx.fillRect(cx - scoreW / 2, scoreY + scoreH / 2 - 2, scoreW, 2);
8448
+ const scoreGlow = ctx.createRadialGradient(cx, scoreY, 4, cx, scoreY, scoreW * 1.1);
8449
+ scoreGlow.addColorStop(0, `rgba(${rigTintStr},0.2)`);
8450
+ scoreGlow.addColorStop(1, "rgba(0,0,0,0)");
8451
+ ctx.save();
8452
+ ctx.globalCompositeOperation = "lighter";
8453
+ ctx.fillStyle = scoreGlow;
8454
+ ctx.fillRect(cx - scoreW * 1.1, scoreY - scoreW * 0.8, scoreW * 2.2, scoreW * 1.6);
8455
+ const airGlow = ctx.createRadialGradient(cx, (trussY + topCy) / 2, 6, cx, (trussY + topCy) / 2, halfW * 1.5);
8456
+ airGlow.addColorStop(0, `rgba(${rigTintStr},${(0.1 * (0.6 + nearGlow * 0.5)).toFixed(3)})`);
8457
+ airGlow.addColorStop(1, "rgba(0,0,0,0)");
8458
+ ctx.fillStyle = airGlow;
8459
+ ctx.fillRect(cx - halfW * 1.5, trussY - 40, halfW * 3, topCy - trussY + 80);
8460
+ ctx.restore();
8461
+ const keyX = cx + Math.sign(leadX - cx || 1) * halfW * 0.5;
8462
+ drawBeam(ctx, keyX, trussY - trussRy * 0.4, leadX, spotY(lead), 2.5, halfW * 0.16, "255,224,178", 0.42 * (0.7 + nearGlow * 0.4));
8463
+ const pool2 = ctx.createRadialGradient(leadX, spotY(lead), 1, leadX, spotY(lead), halfW * 0.2);
8464
+ pool2.addColorStop(0, `rgba(255,228,185,${(0.4 * nearGlow).toFixed(3)})`);
8465
+ pool2.addColorStop(1, "rgba(255,228,185,0)");
8466
+ ctx.save();
8467
+ ctx.globalCompositeOperation = "lighter";
8468
+ ctx.fillStyle = pool2;
8469
+ ctx.beginPath();
8470
+ ctx.ellipse(leadX, spotY(lead), halfW * 0.2, topRy * 0.24, 0, 0, TAU);
8471
+ ctx.fill();
8472
+ ctx.restore();
8473
+ }
8474
+ function drawSportScene(ctx, distM, eyeM, scene, _nearGlow, baseHue, rigTintStr, rand) {
8475
+ const cx = W / 2;
8476
+ const surfR = Math.max(10, scene.voidRadiusM);
8477
+ const nearEdge = Math.max(2, distM - surfR);
8478
+ const farEdge = distM + surfR;
8479
+ const halfYaw = Math.min(78, Math.atan2(surfR * 1.15, distM) * 180 / Math.PI);
8480
+ const nearPitch = Math.atan2(-eyeM, nearEdge) * 180 / Math.PI;
8481
+ const farPitch = Math.atan2(-eyeM, farEdge) * 180 / Math.PI;
8482
+ const yNear = pitchToY(nearPitch);
8483
+ const yFar = pitchToY(farPitch);
8484
+ const halfW = yawToX(halfYaw) - cx;
8485
+ const cy = (yNear + yFar) / 2;
8486
+ const ry = Math.max(10, (yNear - yFar) / 2);
8487
+ const surfCol = ctx.createRadialGradient(cx, cy, 4, cx, cy, halfW);
8488
+ surfCol.addColorStop(0, "#e8f0fb");
8489
+ surfCol.addColorStop(0.7, "#b9c9e2");
8490
+ surfCol.addColorStop(1, "#8fa3c4");
8491
+ ctx.save();
8492
+ ctx.beginPath();
8493
+ ctx.ellipse(cx, cy, halfW, ry, 0, 0, TAU);
8494
+ ctx.clip();
8495
+ ctx.fillStyle = surfCol;
8496
+ ctx.fillRect(cx - halfW, cy - ry, halfW * 2, ry * 2);
8497
+ ctx.strokeStyle = "rgba(90,120,170,0.5)";
8498
+ ctx.lineWidth = 3;
8499
+ ctx.beginPath();
8500
+ ctx.moveTo(cx, cy - ry);
8501
+ ctx.lineTo(cx, cy + ry);
8502
+ ctx.moveTo(cx - halfW * 0.5, cy - ry);
8503
+ ctx.lineTo(cx - halfW * 0.5, cy + ry);
8504
+ ctx.moveTo(cx + halfW * 0.5, cy - ry);
8505
+ ctx.lineTo(cx + halfW * 0.5, cy + ry);
8506
+ ctx.stroke();
8507
+ ctx.strokeStyle = "rgba(180,90,110,0.45)";
8508
+ ctx.beginPath();
8509
+ ctx.ellipse(cx, cy, ry * 0.9, ry * 0.5, 0, 0, TAU);
8510
+ ctx.stroke();
8511
+ ctx.restore();
8512
+ ctx.strokeStyle = "rgba(210,225,245,0.6)";
8513
+ ctx.lineWidth = 2.5;
8514
+ ctx.beginPath();
8515
+ ctx.ellipse(cx, cy, halfW, ry, 0, 0, TAU);
8516
+ ctx.stroke();
8517
+ ctx.fillStyle = "rgba(20,26,40,0.8)";
8518
+ for (let i = 0; i < 5; i++) {
8519
+ const px = cx + (rand() - 0.5) * halfW * 1.4;
8520
+ const py = cy + (rand() - 0.5) * ry * 1.1;
8521
+ const s = Math.max(2, ry * 0.06);
8573
8522
  ctx.beginPath();
8574
- ctx.moveTo(bx - 8, pitchToY(Math.min(60, stageTopPitch + 25)));
8575
- ctx.lineTo(bx - (sx1 - sx0) * 0.09, sy1);
8576
- ctx.lineTo(bx + (sx1 - sx0) * 0.09, sy1);
8577
- ctx.lineTo(bx + 8, pitchToY(Math.min(60, stageTopPitch + 25)));
8578
- ctx.closePath();
8523
+ ctx.ellipse(px, py, s * 0.6, s, 0, 0, TAU);
8579
8524
  ctx.fill();
8580
8525
  }
8581
- ctx.globalAlpha = 1;
8582
- ctx.fillStyle = "rgba(199, 210, 254, 0.8)";
8583
- for (let i = 0; i < 26; i++) {
8584
- const x = i / 26 * W;
8585
- const y = pitchToY(55 + i % 3 * 6);
8526
+ const wash = ctx.createRadialGradient(cx, cy, 4, cx, cy, halfW * 1.4);
8527
+ wash.addColorStop(0, "rgba(200,220,255,0.12)");
8528
+ wash.addColorStop(1, "rgba(200,220,255,0)");
8529
+ ctx.save();
8530
+ ctx.globalCompositeOperation = "lighter";
8531
+ ctx.fillStyle = wash;
8532
+ ctx.fillRect(cx - halfW * 1.4, yFar - ry, halfW * 2.8, yNear - yFar + ry * 2);
8533
+ ctx.restore();
8534
+ const sbY = pitchToY(Math.min(66, farPitch + 60));
8535
+ const sbW = Math.max(80, halfW * 0.7);
8536
+ const sbH = sbW * 0.5;
8537
+ const sbGlow = ctx.createRadialGradient(cx, sbY, 4, cx, sbY, sbW * 1.6);
8538
+ sbGlow.addColorStop(0, `rgba(${rigTintStr},0.3)`);
8539
+ sbGlow.addColorStop(1, `rgba(${rigTintStr},0)`);
8540
+ ctx.save();
8541
+ ctx.globalCompositeOperation = "lighter";
8542
+ ctx.fillStyle = sbGlow;
8543
+ ctx.fillRect(cx - sbW * 1.6, sbY - sbW * 1.2, sbW * 3.2, sbW * 2.4);
8544
+ ctx.restore();
8545
+ ctx.fillStyle = "#080b12";
8546
+ ctx.beginPath();
8547
+ ctx.moveTo(cx - sbW / 2, sbY - sbH / 2);
8548
+ ctx.lineTo(cx + sbW / 2, sbY - sbH / 2);
8549
+ ctx.lineTo(cx + sbW * 0.6, sbY);
8550
+ ctx.lineTo(cx + sbW / 2, sbY + sbH / 2);
8551
+ ctx.lineTo(cx - sbW / 2, sbY + sbH / 2);
8552
+ ctx.lineTo(cx - sbW * 0.6, sbY);
8553
+ ctx.closePath();
8554
+ ctx.fill();
8555
+ const screenHue = baseHue;
8556
+ for (let i = 0; i < 3; i++) {
8557
+ const scol = hslToRgb(screenHue + i * 30, 0.5, 0.5);
8558
+ ctx.fillStyle = `rgba(${scol[0]},${scol[1]},${scol[2]},0.75)`;
8559
+ const swid = sbW * 0.26;
8560
+ ctx.fillRect(cx - sbW * 0.42 + i * (swid + sbW * 0.06), sbY - sbH * 0.32, swid, sbH * 0.64);
8561
+ }
8562
+ ctx.fillStyle = `rgba(${rigTintStr},0.9)`;
8563
+ for (let i = 0; i < 8; i++) {
8564
+ const lx = cx - sbW * 0.6 + sbW * 1.2 * i / 7;
8586
8565
  ctx.beginPath();
8587
- ctx.arc(x, y, 3.2, 0, Math.PI * 2);
8566
+ ctx.arc(lx, sbY + sbH * 0.62, 2.4, 0, TAU);
8588
8567
  ctx.fill();
8589
8568
  }
8590
- const stageBearing = Math.atan2(dx, -dy);
8591
- ctx.fillStyle = "rgba(16, 20, 30, 0.95)";
8569
+ }
8570
+ function drawAudience(ctx, seat, neighborSeats, _focalPoint, stageBearing, eyeM, dx, dy, hasRelief, scene) {
8571
+ const own = seat.sectionId;
8572
+ let seatAhead = false;
8573
+ const figs = [];
8592
8574
  for (const other of neighborSeats) {
8593
8575
  if (other.id === seat.id) continue;
8594
8576
  const ox = other.x - seat.x;
8595
8577
  const oy = other.y - seat.y;
8596
8578
  const d = Math.hypot(ox, oy) * UNIT;
8597
- if (d < 0.3 || d > 14) continue;
8579
+ if (own && other.sectionId === own && d < 3 && ox * dx + oy * dy > 0) seatAhead = true;
8580
+ if (d < 0.3 || d > 32) continue;
8598
8581
  const bearing = Math.atan2(ox, -oy) - stageBearing;
8599
8582
  const yaw = (bearing * 180 / Math.PI + 540) % 360 - 180;
8600
- const headPitch = -Math.atan2(0.35, d) * 180 / Math.PI;
8601
- const headR = Math.min(46, 260 / (d / UNIT / 24 + 1.2) / 4);
8602
- const hx = yawToX(yaw);
8603
- const hy = pitchToY(headPitch);
8604
- ctx.beginPath();
8605
- ctx.arc(hx, hy, headR, 0, Math.PI * 2);
8606
- ctx.fill();
8607
- ctx.beginPath();
8608
- ctx.ellipse(hx, hy + headR * 1.4, headR * 1.7, headR * 0.9, 0, Math.PI, 0, true);
8609
- ctx.fill();
8583
+ const rise = hasRelief ? (other.eyeHeightM ?? SEATED_EYE_HEIGHT_M) - eyeM : 0;
8584
+ const headPitch = Math.atan2(rise - 0.28, d) * 180 / Math.PI;
8585
+ if (headPitch > 10 && d > 5) continue;
8586
+ let hh = 0;
8587
+ for (let k = 0; k < other.id.length; k++) hh = hh * 31 + other.id.charCodeAt(k) & 65535;
8588
+ const jitter = 0.9 + (hh & 15) / 40;
8589
+ const r = Math.min(78, Math.atan2(0.16, d) * 180 / Math.PI * PX_PER_DEG * jitter);
8590
+ if (r < 1.2) continue;
8591
+ figs.push({ hx: yawToX(yaw), hy: pitchToY(headPitch), r, d, hash: hh });
8592
+ }
8593
+ figs.sort((a, b) => b.d - a.d);
8594
+ const figRows = [];
8595
+ {
8596
+ const byY = [...figs].sort((a, b) => a.hy - b.hy);
8597
+ let cur = [];
8598
+ const flush = () => {
8599
+ if (!cur.length) return;
8600
+ const d = cur.reduce((s, f) => s + f.d, 0) / cur.length;
8601
+ const hy = cur.reduce((s, f) => s + f.hy, 0) / cur.length;
8602
+ const r = cur.reduce((s, f) => s + f.r, 0) / cur.length;
8603
+ figRows.push({ figs: cur.sort((a, b) => b.d - a.d), d, hy, r });
8604
+ cur = [];
8605
+ };
8606
+ for (const f of byY) {
8607
+ const last = cur[cur.length - 1];
8608
+ if (last && Math.abs(f.hy - last.hy) > Math.max(5, (last.r + f.r) / 2 * 1.7)) flush();
8609
+ cur.push(f);
8610
+ }
8611
+ flush();
8612
+ }
8613
+ figRows.sort((a, b) => b.d - a.d);
8614
+ const CLOTHES = [[22, 26, 40], [28, 27, 42], [24, 31, 46], [31, 30, 47], [20, 24, 36]];
8615
+ for (const row of figRows) {
8616
+ if (row.figs.length >= 3 && row.r >= 1.6 && row.r < 26) {
8617
+ const runs = [];
8618
+ const byX = [...row.figs].sort((a, b) => a.hx - b.hx);
8619
+ let run = [];
8620
+ for (const f of byX) {
8621
+ const last = run[run.length - 1];
8622
+ if (last && f.hx - last.hx > Math.max(14, f.r * 7)) {
8623
+ if (run.length >= 3) runs.push(run);
8624
+ run = [];
8625
+ }
8626
+ run.push(f);
8627
+ }
8628
+ if (run.length >= 3) runs.push(run);
8629
+ const stripAlpha = Math.min(0.42, 0.5 * (1 - row.d / 42));
8630
+ if (stripAlpha > 0.05) {
8631
+ ctx.fillStyle = `rgba(15, 19, 31, ${stripAlpha.toFixed(3)})`;
8632
+ for (const seg of runs) {
8633
+ const x0 = seg[0].hx - row.r * 1.6;
8634
+ const x1 = seg[seg.length - 1].hx + row.r * 1.6;
8635
+ ctx.beginPath();
8636
+ ctx.roundRect(x0, row.hy + row.r * 0.8, x1 - x0, row.r * 2.4, row.r * 0.8);
8637
+ ctx.fill();
8638
+ }
8639
+ }
8640
+ }
8641
+ for (const f of row.figs) {
8642
+ const alpha = f.d < 12 ? 0.95 : f.d < 17 ? Math.max(0.66, 0.95 - (f.d - 12) / 18) : Math.max(0.3, 0.68 - (f.d - 17) / 34);
8643
+ const lift = Math.round((1 - Math.min(1, f.d / 16)) * 10);
8644
+ const headTone = [14 + (f.hash >> 4 & 6) + lift, 17 + (f.hash >> 4 & 6) + lift, 25 + (f.hash >> 4 & 6) + lift];
8645
+ const clo0 = CLOTHES[f.hash % CLOTHES.length];
8646
+ const clothing = [clo0[0] + lift, clo0[1] + lift, clo0[2] + lift];
8647
+ if (f.r >= 13) {
8648
+ const rim = f.d < 9 ? 0.16 * (1 - f.d / 9) + (scene.mode === "proscenium" ? 0.06 : 0) : 0;
8649
+ drawSeated(ctx, f.hx, f.hy, f.r, {
8650
+ tone: headTone,
8651
+ clothing,
8652
+ alpha,
8653
+ tilt: ((f.hash >> 2 & 7) - 3.5) * 0.018,
8654
+ // ±~7°
8655
+ shoulderK: 1.72 + (f.hash >> 5 & 7) / 22,
8656
+ // ~1.72..2.04
8657
+ hair: f.hash % HAIR_KINDS,
8658
+ rim,
8659
+ turn: ((f.hash >> 8 & 7) - 3.5) * 0.034,
8660
+ // ±~0.12 head-radii sideways
8661
+ lean: ((f.hash >> 10 & 3) - 1.5) * 0.09
8662
+ // one shoulder rides higher
8663
+ });
8664
+ } else if (f.r >= 5.5) {
8665
+ drawSeatedMid(ctx, f.hx, f.hy, f.r, clothing, headTone, alpha);
8666
+ } else {
8667
+ ctx.fillStyle = `rgba(${headTone[0]},${headTone[1]},${headTone[2]},${alpha.toFixed(3)})`;
8668
+ ctx.beginPath();
8669
+ ctx.arc(f.hx, f.hy, f.r, 0, TAU);
8670
+ ctx.fill();
8671
+ ctx.beginPath();
8672
+ ctx.ellipse(f.hx, f.hy + f.r * 1.4, f.r * 1.85, f.r * 0.95, 0, Math.PI, 0, true);
8673
+ ctx.fill();
8674
+ }
8675
+ }
8676
+ }
8677
+ if (seatAhead) {
8678
+ const railTop = pitchToY(-34);
8679
+ ctx.fillStyle = "rgba(6, 8, 14, 0.92)";
8680
+ ctx.fillRect(0, railTop, W, H - railTop);
8681
+ ctx.fillStyle = "rgba(24, 30, 46, 0.9)";
8682
+ const humps = 16;
8683
+ for (let i = 0; i < humps; i++) {
8684
+ const cx = (i + 0.5) / humps * W;
8685
+ ctx.beginPath();
8686
+ ctx.ellipse(cx, railTop + 6, W / humps * 0.42, 22, 0, Math.PI, 0, true);
8687
+ ctx.fill();
8688
+ }
8610
8689
  }
8611
- const poleFade = ctx.createLinearGradient(0, 0, 0, H);
8612
- poleFade.addColorStop(0, "rgba(0,0,0,0.85)");
8613
- poleFade.addColorStop(0.12, "rgba(0,0,0,0)");
8614
- poleFade.addColorStop(0.88, "rgba(0,0,0,0)");
8615
- poleFade.addColorStop(1, "rgba(0,0,0,0.85)");
8616
- ctx.fillStyle = poleFade;
8617
- ctx.fillRect(0, 0, W, H);
8618
- return { url: canvas.toDataURL("image/jpeg", 0.82), distanceM: Math.round(distM) };
8619
8690
  }
8620
8691
  var TW = 480;
8621
8692
  var TH = 270;
@@ -8631,12 +8702,16 @@ function generateSeatThumb(seat, focalPoint, _neighborSeats) {
8631
8702
  const dx = focalPoint.x - seat.x;
8632
8703
  const dy = focalPoint.y - seat.y;
8633
8704
  const distM = Math.max(2, Math.hypot(dx, dy) * UNIT);
8705
+ const rand = rng(fnv1a(`${seat.id}|${Math.round(focalPoint.x)},${Math.round(focalPoint.y)}`));
8706
+ const baseHue = 210 + rand() * 150;
8707
+ const rigTint = hslToRgb(220 + (rand() - 0.5) * 60, 0.55, 0.82);
8708
+ const rigTintStr = `${rigTint[0]},${rigTint[1]},${rigTint[2]}`;
8634
8709
  const sky = ctx.createLinearGradient(0, 0, 0, TH);
8635
- sky.addColorStop(0, "#05070c");
8636
- sky.addColorStop(0.44, "#141b30");
8637
- sky.addColorStop(0.5, "#1b2440");
8638
- sky.addColorStop(0.58, "#10141f");
8639
- sky.addColorStop(1, "#070910");
8710
+ sky.addColorStop(0, "#080b12");
8711
+ sky.addColorStop(0.44, "#151c32");
8712
+ sky.addColorStop(0.5, "#1d2644");
8713
+ sky.addColorStop(0.58, "#161c2e");
8714
+ sky.addColorStop(1, "#131829");
8640
8715
  ctx.fillStyle = sky;
8641
8716
  ctx.fillRect(0, 0, TW, TH);
8642
8717
  const eyeM = seat.eyeHeightM ?? SEATED_EYE_HEIGHT_M;
@@ -8650,58 +8725,102 @@ function generateSeatThumb(seat, focalPoint, _neighborSeats) {
8650
8725
  const sy1 = pitchToTY(stageBasePitch);
8651
8726
  const sw = sx1 - sx0;
8652
8727
  const sh = sy1 - sy0;
8653
- const glow = ctx.createRadialGradient(TW / 2, (sy0 + sy1) / 2, 4, TW / 2, (sy0 + sy1) / 2, sw * 1.1);
8654
- glow.addColorStop(0, "rgba(129,140,248,0.5)");
8655
- glow.addColorStop(0.5, "rgba(99,102,241,0.15)");
8728
+ const nearGlow = Math.max(0.35, Math.min(1, 1 - (distM - 6) / 60));
8729
+ const archTop = pitchToTY(Math.min(HALF_VFOV - 1, stageTopPitch + 8));
8730
+ const glow = ctx.createRadialGradient(TW / 2, (sy0 + sy1) / 2, 4, TW / 2, (sy0 + sy1) / 2, sw * 1.15);
8731
+ glow.addColorStop(0, "rgba(255,214,150,0.34)");
8732
+ glow.addColorStop(0.28, "rgba(150,150,230,0.3)");
8733
+ glow.addColorStop(0.6, "rgba(99,102,241,0.12)");
8656
8734
  glow.addColorStop(1, "rgba(99,102,241,0)");
8657
8735
  ctx.fillStyle = glow;
8658
8736
  ctx.fillRect(sx0 - sw * 0.6, sy0 - sh * 1.2, sw * 2.2, sh * 3.4);
8659
- ctx.fillStyle = "#242c44";
8660
- ctx.fillRect(sx0, sy0, sw, sh);
8737
+ const topCol = hslToRgb(baseHue, 0.62, 0.55);
8738
+ const midCol = hslToRgb(baseHue + 40, 0.7, 0.5);
8739
+ const botCol = hslToRgb(baseHue + 70, 0.78, 0.52);
8740
+ ctx.save();
8741
+ ctx.beginPath();
8742
+ ctx.rect(sx0, sy0, sw, sh);
8743
+ ctx.clip();
8661
8744
  const backdrop = ctx.createLinearGradient(0, sy0, 0, sy1);
8662
- backdrop.addColorStop(0, "#7c5cff");
8663
- backdrop.addColorStop(0.5, "#e1447a");
8664
- backdrop.addColorStop(1, "#f59e0b");
8665
- ctx.globalAlpha = 0.78;
8745
+ backdrop.addColorStop(0, `rgb(${topCol[0]},${topCol[1]},${topCol[2]})`);
8746
+ backdrop.addColorStop(0.5, `rgb(${midCol[0]},${midCol[1]},${midCol[2]})`);
8747
+ backdrop.addColorStop(1, `rgb(${botCol[0]},${botCol[1]},${botCol[2]})`);
8748
+ ctx.globalAlpha = 0.8;
8666
8749
  ctx.fillStyle = backdrop;
8667
- const inset = sw * 0.06;
8668
- ctx.fillRect(sx0 + inset, sy0 + sh * 0.12, sw - inset * 2, sh * 0.62);
8750
+ ctx.fillRect(sx0, sy0, sw, sh);
8669
8751
  ctx.globalAlpha = 1;
8670
- ctx.fillStyle = "#0d1119";
8671
- ctx.fillRect(sx0, sy1 - sh * 0.1, sw, sh * 0.1);
8672
- const ph = sh * 0.45;
8673
- ctx.fillStyle = "rgba(8,10,16,0.85)";
8674
- for (const fx of [0.35, 0.5, 0.65]) {
8675
- const px = sx0 + sw * fx;
8676
- const py = sy1 - sh * 0.12;
8677
- ctx.beginPath();
8678
- ctx.arc(px, py - ph * 0.82, ph * 0.16, 0, Math.PI * 2);
8679
- ctx.fill();
8680
- ctx.fillRect(px - ph * 0.14, py - ph * 0.68, ph * 0.28, ph * 0.68);
8681
- }
8682
- ctx.globalAlpha = 0.15;
8683
- ctx.fillStyle = "#c7d2fe";
8684
- for (const fx of [0.3, 0.5, 0.7]) {
8685
- const bx = sx0 + sw * fx;
8686
- ctx.beginPath();
8687
- ctx.moveTo(bx - 5, pitchToTY(Math.min(HALF_VFOV, stageTopPitch + 18)));
8688
- ctx.lineTo(bx - sw * 0.09, sy1);
8689
- ctx.lineTo(bx + sw * 0.09, sy1);
8690
- ctx.lineTo(bx + 5, pitchToTY(Math.min(HALF_VFOV, stageTopPitch + 18)));
8691
- ctx.closePath();
8692
- ctx.fill();
8752
+ const fall = ctx.createRadialGradient(TW / 2, (sy0 + sy1) / 2, sw * 0.12, TW / 2, (sy0 + sy1) / 2, sw * 0.6);
8753
+ fall.addColorStop(0, "rgba(6,8,14,0)");
8754
+ fall.addColorStop(0.7, "rgba(6,8,14,0.15)");
8755
+ fall.addColorStop(1, "rgba(5,6,11,0.9)");
8756
+ ctx.fillStyle = fall;
8757
+ ctx.fillRect(sx0, sy0, sw, sh);
8758
+ ctx.restore();
8759
+ const perfCount = 3;
8760
+ const floorY = sy1 - sh * 0.08;
8761
+ for (let i = 0; i < perfCount; i++) {
8762
+ const t2 = (i + 1) / (perfCount + 1);
8763
+ drawPerformer(ctx, sx0 + sw * (0.24 + t2 * 0.52), floorY, sh * 0.46 * (0.9 + rand() * 0.24), {
8764
+ tone: [8, 10, 16],
8765
+ alpha: 0.9,
8766
+ pose: Math.floor(rand() * 4),
8767
+ rim: hslToRgb(baseHue + 20, 0.5, 0.7),
8768
+ rimStrength: 0.7
8769
+ });
8693
8770
  }
8694
- ctx.globalAlpha = 1;
8695
- ctx.fillStyle = "rgba(199,210,254,0.75)";
8771
+ ctx.fillStyle = "#05070d";
8772
+ ctx.fillRect(sx0 - sw * 0.5, archTop, sw * 0.5, sy1 - archTop);
8773
+ ctx.fillRect(sx1, archTop, sw * 0.5, sy1 - archTop);
8774
+ const legBot = sw * 0.05 + 2;
8775
+ const legTop = legBot * 1.35;
8776
+ ctx.fillStyle = "#03050a";
8777
+ ctx.beginPath();
8778
+ ctx.moveTo(sx0, sy1);
8779
+ ctx.lineTo(sx0 - legBot, sy1);
8780
+ ctx.lineTo(sx0 - legTop, archTop);
8781
+ ctx.lineTo(sx0 + sw * 0.012, archTop);
8782
+ ctx.closePath();
8783
+ ctx.fill();
8784
+ ctx.beginPath();
8785
+ ctx.moveTo(sx1, sy1);
8786
+ ctx.lineTo(sx1 + legBot, sy1);
8787
+ ctx.lineTo(sx1 + legTop, archTop);
8788
+ ctx.lineTo(sx1 - sw * 0.012, archTop);
8789
+ ctx.closePath();
8790
+ ctx.fill();
8791
+ ctx.fillRect(sx0 - legTop, archTop, sw + legTop * 2, (sy1 - archTop) * 0.13);
8792
+ ctx.fillStyle = `rgba(${rigTintStr},0.12)`;
8793
+ ctx.fillRect(sx0 - 1.2, archTop, 1.2, sy1 - archTop);
8794
+ ctx.fillRect(sx1, archTop, 1.2, sy1 - archTop);
8795
+ ctx.fillStyle = "#0b0f18";
8796
+ ctx.fillRect(sx0 - legBot, sy1 - sh * 0.05, sw + legBot * 2, sh * 0.05 + 2);
8797
+ const foot = ctx.createLinearGradient(0, sy1 - sh * 0.05, 0, sy1 + sh * 0.14);
8798
+ foot.addColorStop(0, `rgba(255,206,140,${(0.5 * nearGlow).toFixed(3)})`);
8799
+ foot.addColorStop(1, "rgba(255,206,140,0)");
8800
+ ctx.fillStyle = foot;
8801
+ ctx.fillRect(sx0 - legBot, sy1 - sh * 0.05, sw + legBot * 2, sh * 0.22);
8802
+ for (let i = 0; i < 3; i++) {
8803
+ const t2 = (i + 1) / 4;
8804
+ const fx = sx0 + sw * t2;
8805
+ const fy = pitchToTY(Math.min(HALF_VFOV - 1, stageTopPitch + 20));
8806
+ drawBeam(ctx, fx, fy, fx + (rand() - 0.5) * sw * 0.14, sy1, 3, sw * 0.09, rigTintStr, 0.17 * nearGlow);
8807
+ drawFixture(ctx, fx, fy, 1.8, rigTintStr);
8808
+ }
8809
+ ctx.fillStyle = `rgba(${rigTintStr},0.75)`;
8696
8810
  for (let i = 0; i < 8; i++) {
8697
8811
  ctx.beginPath();
8698
- ctx.arc((i + 0.5) / 8 * TW, pitchToTY(HALF_VFOV - 4 - i % 2 * 3), 2.2, 0, Math.PI * 2);
8812
+ ctx.arc((i + 0.5) / 8 * TW, pitchToTY(HALF_VFOV - 4 - i % 2 * 3), 2.2, 0, TAU);
8699
8813
  ctx.fill();
8700
8814
  }
8701
8815
  ctx.fillStyle = "rgba(10,13,20,0.9)";
8702
8816
  ctx.fillRect(0, TH - 26, TW, 26);
8703
8817
  ctx.fillStyle = "rgba(30,37,54,0.9)";
8704
- for (let i = 0; i < 9; i++) ctx.fillRect(i * (TW / 9) + 6, TH - 22, TW / 9 - 12, 14);
8818
+ for (let i = 0; i < 9; i++) {
8819
+ const bx = i * (TW / 9) + TW / 18;
8820
+ ctx.beginPath();
8821
+ ctx.ellipse(bx, TH - 22, TW / 9 * 0.4, 15, 0, Math.PI, 0, true);
8822
+ ctx.fill();
8823
+ }
8705
8824
  return { url: canvas.toDataURL("image/jpeg", 0.8), distanceM: Math.round(distM) };
8706
8825
  }
8707
8826