@seatlayer/core 0.29.0 → 0.30.1

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,832 +1,41 @@
1
1
  import {
2
+ ACCESSIBILITY_RING_COLOR,
3
+ ACCESSIBILITY_TYPES,
4
+ CHART_STORAGE_KEY,
2
5
  CHART_UNITS_PER_METRE,
6
+ LABEL_STYLE_MAX_SIZE,
7
+ LABEL_STYLE_MIN_SIZE,
3
8
  METRES_PER_CHART_UNIT,
4
9
  SEATED_EYE_HEIGHT_M,
10
+ SURROUNDINGS_SHAPE_ROLES,
5
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,
6
33
  sectionElevationTier,
7
- sectionGeometry
8
- } from "./chunk-5MLADB2N.js";
9
-
10
- // src/core/types.ts
11
- var ACCESSIBILITY_TYPES = [
12
- { key: "wheelchair", label: "Wheelchair space", short: "Wheelchair", icon: "\u267F" },
13
- { key: "companion", label: "Companion seat", short: "Companion", icon: "\u{1F9D1}\u200D\u{1F91D}\u200D\u{1F9D1}" },
14
- { key: "semi-ambulatory", label: "Semi-ambulatory (limited mobility)", short: "Limited mobility", icon: "\u{1F9AF}" },
15
- { key: "hearing", label: "Assistive listening", short: "Hearing", icon: "\u{1F9BB}" },
16
- { key: "cart", label: "CART live-caption view", short: "CART captions", icon: "CC" },
17
- { key: "sign-language", label: "Sign-language view", short: "Sign language", icon: "\u{1F91F}" },
18
- { key: "plus-size", label: "Plus-size seat", short: "Plus-size", icon: "\u{1F4BA}" },
19
- { key: "lift-armrest", label: "Lift-up armrest", short: "Lift armrest", icon: "\u2195\uFE0F" }
20
- ];
21
- var ACCESSIBILITY_LABEL = new Map(ACCESSIBILITY_TYPES.map((a) => [a.key, a]));
22
- function accessibilityMeta(key) {
23
- return ACCESSIBILITY_LABEL.get(key);
24
- }
25
- var ACCESSIBILITY_RING_COLOR = {
26
- wheelchair: "#3b82f6",
27
- companion: "#8b5cf6",
28
- "semi-ambulatory": "#0ea5e9",
29
- hearing: "#14b8a6",
30
- cart: "#7c3aed",
31
- "sign-language": "#f59e0b",
32
- "plus-size": "#ec4899",
33
- "lift-armrest": "#22c55e"
34
- };
35
- function accessibilityRingColor(types) {
36
- const primary = types?.[0];
37
- return primary && ACCESSIBILITY_RING_COLOR[primary] || "#3b82f6";
38
- }
39
- var LABEL_STYLE_MIN_SIZE = 8;
40
- var LABEL_STYLE_MAX_SIZE = 24;
41
- var SURROUNDINGS_SHAPE_ROLES = [
42
- "reference-focal",
43
- "bar",
44
- "entrance",
45
- "exit",
46
- "restroom",
47
- "screen",
48
- "sound",
49
- "concession",
50
- "coat",
51
- "wall"
52
- ];
53
- var SURROUNDINGS_SHAPE_ROLE_SET = new Set(SURROUNDINGS_SHAPE_ROLES);
54
- function layerOf(obj) {
55
- switch (obj.type) {
56
- case "row":
57
- case "table":
58
- case "gaArea":
59
- case "booth":
60
- case "section":
61
- return "interactive";
62
- // Raster décor follows its explicit authored z-layer. Absence retains the
63
- // legacy Background default; bitmap content is never guessed semantically.
64
- case "decorImage":
65
- return obj.layer === "foreground" ? "foreground" : "background";
66
- // Source-backed focal geometry and curated venue landmarks help an author
67
- // orient around the sellable plan. A stage and an ordinary authored shape
68
- // remain Background even when they carry an arbitrary/custom role.
69
- case "shape":
70
- return obj.role && SURROUNDINGS_SHAPE_ROLE_SET.has(obj.role) ? "surroundings" : "background";
71
- // Free text — including semantic icon text — is background furniture.
72
- case "text":
73
- return "background";
74
- default:
75
- return "interactive";
76
- }
77
- }
78
- var CHART_STORAGE_KEY = "seatmap.chart";
79
-
80
- // src/core/complexGeometry.ts
81
- function cubicPoint(path, t2) {
82
- const u = 1 - t2;
83
- const a = u * u * u;
84
- const b = 3 * u * u * t2;
85
- const c = 3 * u * t2 * t2;
86
- const d = t2 * t2 * t2;
87
- return {
88
- x: a * path.start.x + b * path.control1.x + c * path.control2.x + d * path.end.x,
89
- y: a * path.start.y + b * path.control1.y + c * path.control2.y + d * path.end.y
90
- };
91
- }
92
- function distributeAlongCubic(path, count, resolution = 192) {
93
- if (!Number.isInteger(count) || count < 1) throw new Error("Path point count must be a positive integer");
94
- if (count === 1) return [cubicPoint(path, 0.5)];
95
- const samples = Array.from({ length: resolution + 1 }, (_, index) => cubicPoint(path, index / resolution));
96
- const lengths = new Float64Array(samples.length);
97
- for (let index = 1; index < samples.length; index += 1) {
98
- const dx = samples[index].x - samples[index - 1].x;
99
- const dy = samples[index].y - samples[index - 1].y;
100
- lengths[index] = lengths[index - 1] + Math.hypot(dx, dy);
101
- }
102
- const total = lengths[lengths.length - 1];
103
- if (total <= 1e-9) return Array.from({ length: count }, () => ({ ...path.start }));
104
- const output = [];
105
- let segment = 1;
106
- for (let index = 0; index < count; index += 1) {
107
- const target = total * index / (count - 1);
108
- while (segment < lengths.length - 1 && lengths[segment] < target) segment += 1;
109
- const before = lengths[segment - 1];
110
- const after = lengths[segment];
111
- const ratio = after === before ? 0 : (target - before) / (after - before);
112
- output.push({
113
- x: samples[segment - 1].x + (samples[segment].x - samples[segment - 1].x) * ratio,
114
- y: samples[segment - 1].y + (samples[segment].y - samples[segment - 1].y) * ratio
115
- });
116
- }
117
- return output;
118
- }
119
-
120
- // src/core/sectionPath.ts
121
- var TAU = Math.PI * 2;
122
- function translateSectionOutlinePath(path, dx, dy) {
123
- const translate = (point) => ({ x: point.x + dx, y: point.y + dy });
124
- return transformSectionOutlinePath(path, translate);
125
- }
126
- function transformSectionOutlinePath(path, transform, radiusScale = 1, reflected = false) {
127
- return {
128
- ...path,
129
- start: transform(path.start),
130
- segments: path.segments.map((segment) => segment.kind === "line" ? { ...segment, end: transform(segment.end) } : segment.kind === "arc" ? {
131
- ...segment,
132
- center: transform(segment.center),
133
- radius: segment.radius * Math.abs(radiusScale),
134
- clockwise: reflected ? !segment.clockwise : segment.clockwise,
135
- end: transform(segment.end)
136
- } : {
137
- ...segment,
138
- control1: transform(segment.control1),
139
- control2: transform(segment.control2),
140
- end: transform(segment.end)
141
- })
142
- };
143
- }
144
-
145
- // src/core/labeling.ts
146
- var FULL_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
147
- var LOWER_ALPHABET = "abcdefghijklmnopqrstuvwxyz";
148
- function toBijectiveBase(index, alphabet) {
149
- const base = alphabet.length;
150
- let n = index + 1;
151
- let out = "";
152
- while (n > 0) {
153
- n -= 1;
154
- const rem = n % base;
155
- out = alphabet[rem] + out;
156
- n = Math.floor(n / base);
157
- }
158
- return out;
159
- }
160
- function toLetters(value, lower = false) {
161
- const alphabet = lower ? LOWER_ALPHABET : FULL_ALPHABET;
162
- return toBijectiveBase(Math.max(0, Math.floor(value) - 1), alphabet);
163
- }
164
- var ROMAN_TABLE = [
165
- [1e3, "M"],
166
- [900, "CM"],
167
- [500, "D"],
168
- [400, "CD"],
169
- [100, "C"],
170
- [90, "XC"],
171
- [50, "L"],
172
- [40, "XL"],
173
- [10, "X"],
174
- [9, "IX"],
175
- [5, "V"],
176
- [4, "IV"],
177
- [1, "I"]
178
- ];
179
- function toRoman(value) {
180
- if (!Number.isFinite(value) || value <= 0) return String(value);
181
- let remaining = Math.floor(value);
182
- let out = "";
183
- for (const [n, sym] of ROMAN_TABLE) {
184
- while (remaining >= n) {
185
- out += sym;
186
- remaining -= n;
187
- }
188
- }
189
- return out;
190
- }
191
-
192
- // src/core/layout.ts
193
- function overrideAccessibility(o) {
194
- if (!o) return [];
195
- if (o.accessibility && o.accessibility.length) {
196
- return o.wheelchairSpaceType && !o.accessibility.includes("wheelchair") ? ["wheelchair", ...o.accessibility] : o.accessibility;
197
- }
198
- if (o.wheelchairSpaceType) return ["wheelchair"];
199
- return o.accessible ? ["wheelchair"] : [];
200
- }
201
- var DEG = Math.PI / 180;
202
- var SEAT_R = 9;
203
- var TABLE_SEAT_OFFSET = 16;
204
- function place(lx, ly, deg, origin) {
205
- const a = deg * DEG;
206
- const cos = Math.cos(a);
207
- const sin = Math.sin(a);
208
- return {
209
- x: origin.x + lx * cos - ly * sin,
210
- y: origin.y + lx * sin + ly * cos
211
- };
212
- }
213
- function rowSeatPositions(row) {
214
- const { seatCount, seatSpacing, curve, rotation, origin } = row;
215
- const out = [];
216
- if (row.path) return distributeAlongCubic(row.path, seatCount);
217
- if (seatCount <= 1) {
218
- if (seatCount === 1) out.push({ x: origin.x, y: origin.y });
219
- return out;
220
- }
221
- if (curve === 0) {
222
- for (let i = 0; i < seatCount; i++) out.push(place(i * seatSpacing, 0, rotation, origin));
223
- return out;
224
- }
225
- const arcStep = curve / (seatCount - 1) * DEG;
226
- const radius = seatSpacing / (2 * Math.sin(Math.abs(arcStep) / 2));
227
- for (let i = 0; i < seatCount; i++) {
228
- const phi = i * arcStep;
229
- const lx = radius * Math.sin(phi);
230
- const ly = -radius + radius * Math.cos(phi);
231
- out.push(place(lx, ly, rotation, origin));
232
- }
233
- return out;
234
- }
235
- function overrideMap(row) {
236
- const m = /* @__PURE__ */ new Map();
237
- if (row.overrides) for (const o of row.overrides) m.set(o.index, o);
238
- return m;
239
- }
240
- function rowInventoryCount(row) {
241
- 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));
242
- return Math.max(0, row.seatCount - skipped.size);
243
- }
244
- function centerRank(n) {
245
- const rank = new Array(n);
246
- 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);
247
- return rank;
248
- }
249
- function seatLabelPart(row, i) {
250
- const rawStart = row.seatLabelStart ?? 1;
251
- const dir = row.seatNumbering?.direction ?? "ltr";
252
- const step = row.seatNumbering?.step ?? 1;
253
- const scheme = row.seatNumbering?.scheme ?? "decimal";
254
- const prefix = row.seatNumbering?.prefix ?? "";
255
- const endAt = row.seatNumbering?.endAt;
256
- const n = row.seatCount;
257
- if (scheme === "updown" || scheme === "updown-descending") {
258
- const half = Math.ceil(n / 2);
259
- 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);
260
- return `${prefix}${core2}`;
261
- }
262
- const effStep = scheme === "odd" || scheme === "even" ? 2 : step;
263
- const start = endAt != null && Number.isFinite(endAt) ? endAt - (n - 1) * effStep : rawStart;
264
- const p = dir === "center" ? centerRank(n)[i] : dir === "rtl" ? n - 1 - i : i;
265
- let core;
266
- switch (scheme) {
267
- case "odd": {
268
- const firstOdd = start % 2 === 1 ? start : start + 1;
269
- core = String(firstOdd + p * 2);
270
- break;
271
- }
272
- case "even": {
273
- const firstEven = start % 2 === 0 ? start : start + 1;
274
- core = String(firstEven + p * 2);
275
- break;
276
- }
277
- case "roman":
278
- core = toRoman(start + p * step);
279
- break;
280
- case "letters-upper":
281
- core = toLetters(start + p * step, false);
282
- break;
283
- case "letters-lower":
284
- core = toLetters(start + p * step, true);
285
- break;
286
- case "decimal":
287
- default:
288
- core = String(start + p * step);
289
- break;
290
- }
291
- return `${prefix}${core}`;
292
- }
293
- function expandRowSlots(row) {
294
- const ov = overrideMap(row);
295
- return rowSeatPositions(row).map((p, i) => {
296
- const o = ov.get(i);
297
- const accessibility = overrideAccessibility(o);
298
- const part = seatLabelPart(row, i);
299
- const inventoryLabel = o?.label ?? `${row.label}-${part}`;
300
- const displayPrefix = row.displayLabel ?? row.label;
301
- const commercial = { ...row.commercial, ...o?.commercial };
302
- return {
303
- index: i,
304
- x: p.x + (o?.dx ?? 0),
305
- y: p.y + (o?.dy ?? 0),
306
- label: inventoryLabel,
307
- displayLabel: o?.displayLabel ?? `${displayPrefix}-${part}`,
308
- categoryKey: o?.categoryKey ?? row.categoryKey,
309
- skipped: !!o?.skip,
310
- accessible: accessibility.length > 0,
311
- accessibility,
312
- wheelchairSpaceType: o?.wheelchairSpaceType,
313
- commercial: Object.values(commercial).some((value) => value !== void 0 && value !== false && value !== "") ? commercial : void 0,
314
- viewUrl: o?.viewFromSeatUrl ?? row.viewFromSeatUrl,
315
- labelStyle: o?.labelStyle
316
- };
317
- });
318
- }
319
- function expandRow(row) {
320
- const seats = [];
321
- for (const slot of expandRowSlots(row)) {
322
- if (slot.skipped) continue;
323
- seats.push({
324
- id: `${row.id}:${slot.index}`,
325
- label: slot.label,
326
- displayLabel: slot.displayLabel === slot.label ? void 0 : slot.displayLabel,
327
- x: slot.x,
328
- y: slot.y,
329
- rowId: row.id,
330
- categoryKey: slot.categoryKey,
331
- accessible: slot.accessible || void 0,
332
- accessibility: slot.accessibility.length ? slot.accessibility : void 0,
333
- wheelchairSpaceType: slot.wheelchairSpaceType,
334
- commercial: slot.commercial,
335
- viewUrl: slot.viewUrl,
336
- labelStyle: slot.labelStyle
337
- });
338
- }
339
- return seats;
340
- }
341
- function tableSeatCountsBySide(t2) {
342
- if (t2.seatCountsBySide) return { ...t2.seatCountsBySide };
343
- const enabled = t2.sides && t2.sides.length ? t2.sides : ["top", "bottom"];
344
- const order = ["top", "bottom", "left", "right"].filter((side) => enabled.includes(side));
345
- const counts = { top: 0, bottom: 0, left: 0, right: 0 };
346
- if (!order.length) return counts;
347
- const n = Math.max(0, Math.round(t2.seatCount));
348
- for (let index = 0; index < n; index++) counts[order[index % order.length]] += 1;
349
- return counts;
350
- }
351
- function expandTableSlots(t2) {
352
- const seats = [];
353
- const n = Math.max(0, Math.round(t2.seatCount));
354
- if (n === 0) return seats;
355
- const overrides2 = new Map((t2.overrides ?? []).map((override) => [override.index, override]));
356
- const mk = (index, x, y, side) => {
357
- const override = overrides2.get(index);
358
- const accessibility = overrideAccessibility(override);
359
- const label = override?.label ?? `${t2.label}-${index + 1}`;
360
- const displayPrefix = t2.displayLabel ?? t2.label;
361
- return {
362
- index,
363
- label,
364
- displayLabel: override?.displayLabel ?? `${displayPrefix}-${index + 1}`,
365
- x: x + (override?.dx ?? 0),
366
- y: y + (override?.dy ?? 0),
367
- categoryKey: override?.categoryKey ?? t2.categoryKey,
368
- skipped: !!override?.skip,
369
- accessible: accessibility.length > 0,
370
- accessibility,
371
- wheelchairSpaceType: override?.wheelchairSpaceType,
372
- commercial: override?.commercial,
373
- viewUrl: override?.viewFromSeatUrl,
374
- labelStyle: override?.labelStyle,
375
- ...side ? { side } : {}
376
- };
377
- };
378
- if (t2.shape === "round") {
379
- const R = (t2.radius ?? 40) + TABLE_SEAT_OFFSET;
380
- const base = t2.rotation * DEG;
381
- const arc = Math.max(0, Math.min(360, t2.seatArc ?? 360));
382
- if (arc >= 360 || n === 1) {
383
- for (let i = 0; i < n; i++) {
384
- const a = base + i / n * 2 * Math.PI;
385
- seats.push(mk(i, t2.center.x + R * Math.cos(a), t2.center.y + R * Math.sin(a)));
386
- }
387
- return seats;
388
- }
389
- const arcRad = arc * DEG;
390
- const halfGap = (2 * Math.PI - arcRad) / 2;
391
- const start = base + halfGap;
392
- for (let i = 0; i < n; i++) {
393
- const a = start + i / (n - 1) * arcRad;
394
- seats.push(mk(i, t2.center.x + R * Math.cos(a), t2.center.y + R * Math.sin(a)));
395
- }
396
- return seats;
397
- }
398
- const w = t2.width ?? 80;
399
- const h = t2.height ?? 50;
400
- const counts = tableSeatCountsBySide(t2);
401
- const order = ["top", "bottom", "left", "right"];
402
- let idx = 0;
403
- for (const side of order) {
404
- const count = counts[side];
405
- for (let j = 0; j < count; j++) {
406
- let localX;
407
- let localY;
408
- if (side === "top") {
409
- localX = -w / 2 + (j + 0.5) * w / count;
410
- localY = -h / 2 - TABLE_SEAT_OFFSET;
411
- } else if (side === "bottom") {
412
- localX = -w / 2 + (j + 0.5) * w / count;
413
- localY = h / 2 + TABLE_SEAT_OFFSET;
414
- } else if (side === "left") {
415
- localX = -w / 2 - TABLE_SEAT_OFFSET;
416
- localY = -h / 2 + (j + 0.5) * h / count;
417
- } else {
418
- localX = w / 2 + TABLE_SEAT_OFFSET;
419
- localY = -h / 2 + (j + 0.5) * h / count;
420
- }
421
- const point = place(localX, localY, t2.rotation, t2.center);
422
- seats.push(mk(idx++, point.x, point.y, side));
423
- }
424
- }
425
- return seats;
426
- }
427
- function tableInventoryCount(t2) {
428
- if (t2.bookAsWhole || t2.variableOccupancy) return Math.max(0, Math.round(t2.seatCount));
429
- return expandTableSlots(t2).filter((slot) => !slot.skipped).length;
430
- }
431
- function expandTable(t2) {
432
- return expandTableSlots(t2).filter((slot) => !slot.skipped).map((slot) => ({
433
- id: `${t2.id}:${slot.index}`,
434
- label: slot.label,
435
- ...slot.displayLabel !== slot.label ? { displayLabel: slot.displayLabel } : {},
436
- x: slot.x,
437
- y: slot.y,
438
- rowId: t2.id,
439
- categoryKey: slot.categoryKey,
440
- ...slot.accessible ? { accessible: true } : {},
441
- ...slot.accessibility.length ? { accessibility: slot.accessibility } : {},
442
- ...slot.wheelchairSpaceType ? { wheelchairSpaceType: slot.wheelchairSpaceType } : {},
443
- ...slot.commercial ? { commercial: slot.commercial } : {},
444
- ...slot.viewUrl ? { viewUrl: slot.viewUrl } : {},
445
- ...slot.labelStyle ? { labelStyle: slot.labelStyle } : {}
446
- }));
447
- }
448
- function expandBooth(b) {
449
- return [
450
- {
451
- id: `${b.id}:0`,
452
- label: b.label,
453
- ...b.displayLabel && b.displayLabel !== b.label ? { displayLabel: b.displayLabel } : {},
454
- x: b.center.x,
455
- y: b.center.y,
456
- rowId: b.id,
457
- categoryKey: b.categoryKey,
458
- kind: "booth"
459
- }
460
- ];
461
- }
462
- function pointInPolygon(p, poly) {
463
- let inside = false;
464
- for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
465
- const xi = poly[i].x;
466
- const yi = poly[i].y;
467
- const xj = poly[j].x;
468
- const yj = poly[j].y;
469
- const hit = yi > p.y !== yj > p.y && p.x < (xj - xi) * (p.y - yi) / (yj - yi) + xi;
470
- if (hit) inside = !inside;
471
- }
472
- return inside;
473
- }
474
- function pointOnPolygonBoundary(p, poly) {
475
- return poly.some((start, index) => {
476
- const end = poly[(index + 1) % poly.length];
477
- const cross = (p.y - start.y) * (end.x - start.x) - (p.x - start.x) * (end.y - start.y);
478
- if (Math.abs(cross) > 1e-7) return false;
479
- const dot = (p.x - start.x) * (end.x - start.x) + (p.y - start.y) * (end.y - start.y);
480
- const lengthSquared = (end.x - start.x) ** 2 + (end.y - start.y) ** 2;
481
- return dot >= -1e-7 && dot <= lengthSquared + 1e-7;
482
- });
483
- }
484
- function pointInPolygonWithHoles(p, outer, holes) {
485
- return pointInPolygon(p, outer) && !(holes ?? []).some((hole) => pointInPolygon(p, hole) || pointOnPolygonBoundary(p, hole));
486
- }
487
- function polygonLabelPoint(outer, holes) {
488
- if (!outer.length) return { x: 0, y: 0 };
489
- const xs = outer.map((point) => point.x);
490
- const ys = outer.map((point) => point.y);
491
- const bounds2 = { minX: Math.min(...xs), maxX: Math.max(...xs), minY: Math.min(...ys), maxY: Math.max(...ys) };
492
- const centroid2 = polygonCentroid(outer);
493
- if (pointInPolygonWithHoles(centroid2, outer, holes)) return centroid2;
494
- let best = outer[0];
495
- let bestScore = -Infinity;
496
- const rings = [outer, ...holes ?? []];
497
- for (let row = 1; row < 24; row += 1) {
498
- for (let column = 1; column < 24; column += 1) {
499
- const point = {
500
- x: bounds2.minX + (bounds2.maxX - bounds2.minX) * column / 24,
501
- y: bounds2.minY + (bounds2.maxY - bounds2.minY) * row / 24
502
- };
503
- if (!pointInPolygonWithHoles(point, outer, holes)) continue;
504
- const score = Math.min(...rings.flatMap((ring) => ring.map((start, index) => {
505
- const end = ring[(index + 1) % ring.length];
506
- const dx = end.x - start.x;
507
- const dy = end.y - start.y;
508
- const denominator = dx * dx + dy * dy;
509
- const projection = denominator ? ((point.x - start.x) * dx + (point.y - start.y) * dy) / denominator : 0;
510
- const t2 = Math.max(0, Math.min(1, projection));
511
- return Math.hypot(point.x - (start.x + t2 * dx), point.y - (start.y + t2 * dy));
512
- })));
513
- if (score > bestScore) {
514
- best = point;
515
- bestScore = score;
516
- }
517
- }
518
- }
519
- return best;
520
- }
521
- function polygonCentroid(pts) {
522
- if (!pts.length) return { x: 0, y: 0 };
523
- let x = 0;
524
- let y = 0;
525
- for (const p of pts) {
526
- x += p.x;
527
- y += p.y;
528
- }
529
- return { x: x / pts.length, y: y / pts.length };
530
- }
531
- function objectCenter(o) {
532
- switch (o.type) {
533
- case "row": {
534
- const seats = expandRow(o);
535
- if (!seats.length) return { x: o.origin.x, y: o.origin.y };
536
- let x = 0;
537
- let y = 0;
538
- for (const s of seats) {
539
- x += s.x;
540
- y += s.y;
541
- }
542
- return { x: x / seats.length, y: y / seats.length };
543
- }
544
- case "table":
545
- case "booth":
546
- return { x: o.center.x, y: o.center.y };
547
- case "gaArea":
548
- return polygonCentroid(o.points);
549
- case "section":
550
- return polygonCentroid(o.outline);
551
- case "text":
552
- return { x: o.position.x, y: o.position.y };
553
- case "shape":
554
- if (o.points && o.points.length) return polygonCentroid(o.points);
555
- if (o.x != null && o.y != null && o.width != null && o.height != null) {
556
- return { x: o.x + o.width / 2, y: o.y + o.height / 2 };
557
- }
558
- return { x: o.x ?? 0, y: o.y ?? 0 };
559
- case "decorImage":
560
- return { x: o.x + o.width / 2, y: o.y + o.height / 2 };
561
- }
562
- }
563
- function samePoints(left, right) {
564
- return left.length === right.length && left.every((point, index) => point.x === right[index].x && point.y === right[index].y);
565
- }
566
- function sameGASurfaceAsSection(object, section) {
567
- if (object.type !== "gaArea" || !samePoints(object.points, section.outline)) return false;
568
- const objectHoles = object.holes ?? [];
569
- const sectionHoles = section.holes ?? [];
570
- return objectHoles.length === sectionHoles.length && objectHoles.every((hole, index) => samePoints(hole, sectionHoles[index]));
571
- }
572
- function owningSectionForObject(objects, object) {
573
- const sections = objects.filter((candidate) => candidate.type === "section");
574
- const referencedLogicalId = "referenceInventorySource" in object ? object.referenceInventorySource?.logicalSectionId : void 0;
575
- const center = objectCenter(object);
576
- const referencedOwner = referencedLogicalId ? sections.find((section) => (section.logicalSectionId ?? section.id) === referencedLogicalId && (sameGASurfaceAsSection(object, section) || pointInPolygonWithHoles(center, section.outline, section.holes))) : void 0;
577
- return referencedOwner ?? sections.find((section) => pointInPolygonWithHoles(center, section.outline, section.holes));
578
- }
579
- function floorsOf(doc) {
580
- if (doc.floors && doc.floors.length) return doc.floors;
581
- return [{
582
- id: "floor-0",
583
- name: "Main",
584
- objects: doc.objects,
585
- focalPoint: doc.focalPoint,
586
- referenceImage: doc.referenceImage,
587
- backgroundImage: doc.backgroundImage
588
- }];
589
- }
590
- function floorObjects(doc, floorId) {
591
- const floors = floorsOf(doc);
592
- return (floorId ? floors.find((f) => f.id === floorId) : floors[0])?.objects ?? [];
593
- }
594
- function allObjects(doc) {
595
- return doc.floors && doc.floors.length ? doc.floors.flatMap((f) => f.objects) : doc.objects;
596
- }
597
- function translateObject(o, dx, dy) {
598
- const p = (pt) => ({ x: pt.x + dx, y: pt.y + dy });
599
- const pts = (a) => a.map(p);
600
- switch (o.type) {
601
- case "row":
602
- return { ...o, origin: p(o.origin) };
603
- case "table":
604
- case "booth":
605
- return { ...o, center: p(o.center) };
606
- case "gaArea":
607
- return { ...o, points: pts(o.points), ...o.holes ? { holes: o.holes.map(pts) } : {} };
608
- case "section":
609
- return {
610
- ...o,
611
- outline: pts(o.outline),
612
- ...o.outlinePath ? { outlinePath: translateSectionOutlinePath(o.outlinePath, dx, dy) } : {},
613
- ...o.holes ? { holes: o.holes.map(pts) } : {}
614
- };
615
- case "text":
616
- return { ...o, position: p(o.position) };
617
- case "shape":
618
- return {
619
- ...o,
620
- ...o.points ? { points: pts(o.points) } : {},
621
- ...o.x != null ? { x: o.x + dx } : {},
622
- ...o.y != null ? { y: o.y + dy } : {}
623
- };
624
- case "decorImage":
625
- return { ...o, x: o.x + dx, y: o.y + dy };
626
- }
627
- }
628
- function stackFloors(doc, spread = 900) {
629
- if (!doc.floors || doc.floors.length < 2) return doc;
630
- const objects = [];
631
- doc.floors.forEach((f, i) => {
632
- const dy = -i * spread;
633
- for (const o of f.objects) objects.push(dy === 0 ? o : translateObject(o, 0, dy));
634
- });
635
- return { ...doc, objects, floors: void 0 };
636
- }
637
- function expandFloorObjects(objects, zones, fallbackFocal) {
638
- const out = [];
639
- const segmented = /* @__PURE__ */ new Map();
640
- const grouped = /* @__PURE__ */ new Map();
641
- for (const object of objects) {
642
- if (object.type !== "row" || !object.segmentedRow) continue;
643
- const list = grouped.get(object.segmentedRow.groupId) ?? [];
644
- list.push(object);
645
- grouped.set(object.segmentedRow.groupId, list);
646
- }
647
- for (const [groupId, members] of grouped) {
648
- const ordered = members.slice().sort((left, right) => left.segmentedRow.componentIndex - right.segmentedRow.componentIndex);
649
- const expectedCount = ordered[0]?.segmentedRow?.componentCount ?? 0;
650
- const first = ordered[0]?.segmentedRow;
651
- if (!first) continue;
652
- 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);
653
- if (!valid) continue;
654
- const totalSeats = ordered.reduce((sum, row) => sum + row.seatCount, 0);
655
- let adjacencyOffset = 0;
656
- let displayOffset = 0;
657
- for (const row of ordered) {
658
- if (row.segmentedRow.boundaryBefore === "break") adjacencyOffset += 1;
659
- segmented.set(row.id, {
660
- groupId,
661
- adjacencyOffset,
662
- displayOffset,
663
- displayLabel: first.displayLabel,
664
- totalSeats,
665
- canonical: ordered[0],
666
- viewFromSeatUrl: first.viewFromSeatUrl
667
- });
668
- adjacencyOffset += row.seatCount;
669
- displayOffset += row.seatCount;
670
- }
671
- }
672
- for (const obj of objects) {
673
- let seats = [];
674
- if (obj.type === "row") seats = expandRow(obj);
675
- else if (obj.type === "table") seats = expandTable(obj);
676
- else if (obj.type === "booth") seats = expandBooth(obj);
677
- if (!seats.length) continue;
678
- if (obj.type === "row") {
679
- const logical = segmented.get(obj.id);
680
- if (logical) {
681
- const overrides2 = new Map((obj.overrides ?? []).map((override) => [override.index, override]));
682
- for (const seat of seats) {
683
- const physicalIndex = Number(seat.id.slice(seat.id.lastIndexOf(":") + 1));
684
- if (!Number.isInteger(physicalIndex)) continue;
685
- const displayOrdinal = logical.displayOffset + physicalIndex;
686
- seat.logicalRowId = logical.groupId;
687
- seat.logicalSeatIndex = logical.adjacencyOffset + physicalIndex;
688
- if (!overrides2.get(physicalIndex)?.displayLabel) {
689
- const numberingRow = {
690
- ...logical.canonical,
691
- seatCount: logical.totalSeats,
692
- label: logical.displayLabel,
693
- displayLabel: logical.displayLabel
694
- };
695
- seat.displayLabel = `${logical.displayLabel}-${seatLabelPart(numberingRow, displayOrdinal)}`;
696
- }
697
- seat.viewUrl ??= logical.viewFromSeatUrl;
698
- }
699
- }
700
- }
701
- const owner = owningSectionForObject(objects, obj);
702
- const inheritedView = owner?.viewFromSeatUrl;
703
- const zone = owner?.zone ? zones?.find((candidate) => candidate.id === owner.zone) : void 0;
704
- const resolvedFocal = zone?.focalPoint ?? fallbackFocal;
705
- for (const seat of seats) {
706
- if (inheritedView) seat.viewUrl ??= inheritedView;
707
- if (owner) seat.sectionId = owner.logicalSectionId ?? owner.id;
708
- if (owner?.zone) seat.zoneId = owner.zone;
709
- if (resolvedFocal) seat.focalPoint = { ...resolvedFocal };
710
- }
711
- out.push(...seats);
712
- }
713
- return out;
714
- }
715
- function expandChart(doc, options = {}) {
716
- if (doc.floors?.length) {
717
- const out2 = [];
718
- for (const floor of doc.floors) {
719
- const floorFocal = floor.focalPoint ?? doc.focalPoint;
720
- const seats = expandFloorObjects(floor.objects, doc.zones, floorFocal);
721
- assignEyeHeights(floor.objects, floor.focalPoint ?? doc.focalPoint, floor.baseHeightM ?? 0, seats);
722
- out2.push(...seats);
723
- }
724
- return out2;
725
- }
726
- const out = expandFloorObjects(doc.objects, doc.zones, doc.focalPoint);
727
- assignEyeHeights(doc.objects, doc.focalPoint, options.floorBaseHeightM ?? 0, out);
728
- return out;
729
- }
730
- function assignEyeHeights(objects, focal, floorBaseHeightM, seats) {
731
- const sections = objects.filter((o) => o.type === "section");
732
- const hasGeometry = floorBaseHeightM > 0 || sections.some((s) => s.height !== void 0 || s.rake !== void 0 || (s.elevation ?? 0) > 0);
733
- if (!sections.length || !hasGeometry || !focal) {
734
- for (const seat of seats) seat.eyeHeightM = floorBaseHeightM + SEATED_EYE_HEIGHT_M;
735
- return;
736
- }
737
- const owner = new Array(seats.length);
738
- const geo = /* @__PURE__ */ new Map();
739
- const frontDistU = /* @__PURE__ */ new Map();
740
- for (let i = 0; i < seats.length; i++) {
741
- const seat = seats[i];
742
- const sec = sections.find((s) => pointInPolygonWithHoles({ x: seat.x, y: seat.y }, s.outline, s.holes)) ?? null;
743
- owner[i] = sec;
744
- if (!sec) continue;
745
- if (!geo.has(sec.id)) geo.set(sec.id, sectionGeometry(sec, { floorBaseHeightM }));
746
- const seatFocal = seat.focalPoint ?? focal;
747
- const d = Math.hypot(seat.x - seatFocal.x, seat.y - seatFocal.y);
748
- const cur = frontDistU.get(sec.id);
749
- if (cur === void 0 || d < cur) frontDistU.set(sec.id, d);
750
- }
751
- for (let i = 0; i < seats.length; i++) {
752
- const seat = seats[i];
753
- const sec = owner[i];
754
- if (!sec) {
755
- seat.eyeHeightM = floorBaseHeightM + SEATED_EYE_HEIGHT_M;
756
- continue;
757
- }
758
- const g = geo.get(sec.id);
759
- let riseM = 0;
760
- if (g.rake > 0) {
761
- const seatFocal = seat.focalPoint ?? focal;
762
- const d = Math.hypot(seat.x - seatFocal.x, seat.y - seatFocal.y);
763
- const depthU = Math.max(0, d - (frontDistU.get(sec.id) ?? d));
764
- riseM = depthU * METRES_PER_CHART_UNIT * Math.tan(g.rake * Math.PI / 180);
765
- }
766
- seat.eyeHeightM = g.height + riseM + SEATED_EYE_HEIGHT_M;
767
- }
768
- }
769
- var PAD = 40;
770
- function chartBounds(doc) {
771
- let minX = Infinity;
772
- let minY = Infinity;
773
- let maxX = -Infinity;
774
- let maxY = -Infinity;
775
- const acc = (x, y) => {
776
- if (x < minX) minX = x;
777
- if (y < minY) minY = y;
778
- if (x > maxX) maxX = x;
779
- if (y > maxY) maxY = y;
780
- };
781
- for (const s of expandChart(doc)) acc(s.x, s.y);
782
- for (const obj of allObjects(doc)) {
783
- if (obj.type === "gaArea") {
784
- for (const p of obj.points) acc(p.x, p.y);
785
- } else if (obj.type === "section") {
786
- for (const p of obj.outline) acc(p.x, p.y);
787
- } else if (obj.type === "decorImage") {
788
- acc(obj.x, obj.y);
789
- acc(obj.x + obj.width, obj.y + obj.height);
790
- } else if (obj.type === "shape") {
791
- if (obj.points && obj.points.length) {
792
- for (const p of obj.points) acc(p.x, p.y);
793
- } else if (obj.x != null && obj.y != null && obj.width != null && obj.height != null) {
794
- acc(obj.x, obj.y);
795
- acc(obj.x + obj.width, obj.y + obj.height);
796
- }
797
- } else if (obj.type === "table") {
798
- const off = TABLE_SEAT_OFFSET + SEAT_R;
799
- const ext = obj.shape === "round" ? (obj.radius ?? 40) + off : Math.max((obj.width ?? 80) / 2, (obj.height ?? 50) / 2) + off;
800
- acc(obj.center.x - ext, obj.center.y - ext);
801
- acc(obj.center.x + ext, obj.center.y + ext);
802
- } else if (obj.type === "booth") {
803
- const ext = Math.max(obj.width, obj.height) / 2;
804
- acc(obj.center.x - ext, obj.center.y - ext);
805
- acc(obj.center.x + ext, obj.center.y + ext);
806
- } else if (obj.type === "text") {
807
- const w = obj.fontSize * obj.text.length * 0.6;
808
- acc(obj.position.x, obj.position.y);
809
- acc(obj.position.x + w, obj.position.y + obj.fontSize);
810
- }
811
- }
812
- for (const image of [doc.referenceImage, doc.backgroundImage]) {
813
- if (!image) continue;
814
- const { center, width } = image;
815
- const bh = width * 3 / 4;
816
- acc(center.x - width / 2, center.y - bh / 2);
817
- acc(center.x + width / 2, center.y + bh / 2);
818
- }
819
- if (!isFinite(minX)) {
820
- const f = doc.focalPoint ?? { x: 0, y: 0 };
821
- return { x: f.x - 200, y: f.y - 200, width: 400, height: 400 };
822
- }
823
- return {
824
- x: minX - PAD,
825
- y: minY - PAD,
826
- width: maxX - minX + PAD * 2,
827
- height: maxY - minY + PAD * 2
828
- };
829
- }
34
+ sectionGeometry,
35
+ stackFloors,
36
+ tableInventoryCount,
37
+ tableSeatCountsBySide
38
+ } from "./chunk-FGS67GT3.js";
830
39
 
831
40
  // src/core/ga.ts
832
41
  var PREFIX = "__sl_ga__";
@@ -8474,7 +7683,7 @@ function stageSightlinePitch(eyeHeightM, distM) {
8474
7683
  var W = 2048;
8475
7684
  var H = 1024;
8476
7685
  var UNIT = METRES_PER_CHART_UNIT;
8477
- var TAU2 = Math.PI * 2;
7686
+ var TAU = Math.PI * 2;
8478
7687
  var PX_PER_DEG = H / 180;
8479
7688
  var yawToX = (yawDeg) => (yawDeg + 180) / 360 * W;
8480
7689
  var pitchToY = (pitchDeg) => (90 - pitchDeg) / 180 * H;
@@ -8642,7 +7851,7 @@ function classifyScene(_seat, focal, neighbors) {
8642
7851
  const dM = Math.hypot(dx, dy) * UNIT;
8643
7852
  if (dM < 0.5) continue;
8644
7853
  const bearing = Math.atan2(dx, -dy);
8645
- let idx = Math.floor((bearing + Math.PI) / TAU2 * NB);
7854
+ let idx = Math.floor((bearing + Math.PI) / TAU * NB);
8646
7855
  if (idx < 0) idx = 0;
8647
7856
  else if (idx >= NB) idx = NB - 1;
8648
7857
  occ[idx] = true;
@@ -8672,34 +7881,34 @@ function drawHair(ctx, r, kind) {
8672
7881
  switch (kind) {
8673
7882
  case 1:
8674
7883
  ctx.beginPath();
8675
- ctx.ellipse(0, -r * 0.14, r * 1.16, r * 1.16, 0, 0, TAU2);
7884
+ ctx.ellipse(0, -r * 0.14, r * 1.16, r * 1.16, 0, 0, TAU);
8676
7885
  ctx.fill();
8677
7886
  break;
8678
7887
  case 2:
8679
7888
  ctx.beginPath();
8680
- ctx.ellipse(0, -r * 1.02, r * 0.42, r * 0.42, 0, 0, TAU2);
7889
+ ctx.ellipse(0, -r * 1.02, r * 0.42, r * 0.42, 0, 0, TAU);
8681
7890
  ctx.fill();
8682
7891
  ctx.beginPath();
8683
- ctx.ellipse(0, -r * 0.2, r * 0.98, r * 1.02, 0, 0, TAU2);
7892
+ ctx.ellipse(0, -r * 0.2, r * 0.98, r * 1.02, 0, 0, TAU);
8684
7893
  ctx.fill();
8685
7894
  break;
8686
7895
  case 3:
8687
7896
  ctx.beginPath();
8688
- ctx.ellipse(0, -r * 0.36, r * 1.04, r * 0.6, 0, Math.PI, TAU2);
7897
+ ctx.ellipse(0, -r * 0.36, r * 1.04, r * 0.6, 0, Math.PI, TAU);
8689
7898
  ctx.fill();
8690
7899
  ctx.beginPath();
8691
- ctx.ellipse(0, -r * 0.52, r * 1.22, r * 0.24, 0, 0, TAU2);
7900
+ ctx.ellipse(0, -r * 0.52, r * 1.22, r * 0.24, 0, 0, TAU);
8692
7901
  ctx.fill();
8693
7902
  break;
8694
7903
  case 4:
8695
7904
  ctx.beginPath();
8696
- ctx.ellipse(-r * 0.66, r * 0.5, r * 0.5, r * 1.35, 0, 0, TAU2);
7905
+ ctx.ellipse(-r * 0.66, r * 0.5, r * 0.5, r * 1.35, 0, 0, TAU);
8697
7906
  ctx.fill();
8698
7907
  ctx.beginPath();
8699
- ctx.ellipse(r * 0.66, r * 0.5, r * 0.5, r * 1.35, 0, 0, TAU2);
7908
+ ctx.ellipse(r * 0.66, r * 0.5, r * 0.5, r * 1.35, 0, 0, TAU);
8700
7909
  ctx.fill();
8701
7910
  ctx.beginPath();
8702
- ctx.ellipse(0, -r * 0.08, r * 1.06, r * 1.08, 0, 0, TAU2);
7911
+ ctx.ellipse(0, -r * 0.08, r * 1.06, r * 1.08, 0, 0, TAU);
8703
7912
  ctx.fill();
8704
7913
  break;
8705
7914
  default:
@@ -8716,24 +7925,44 @@ function drawSeated(ctx, hx, hy, r, o) {
8716
7925
  const skin = `rgba(${o.tone[0]},${o.tone[1]},${o.tone[2]},${o.alpha})`;
8717
7926
  ctx.save();
8718
7927
  ctx.translate(hx, hy);
7928
+ const leanPx = o.lean * r;
8719
7929
  ctx.fillStyle = clo;
8720
7930
  ctx.beginPath();
8721
7931
  ctx.moveTo(-neckHalf, neckTopY);
8722
- ctx.quadraticCurveTo(-neckHalf * 1.18, shoulderY - r * 0.6, -shHalf, shoulderY);
7932
+ ctx.quadraticCurveTo(-neckHalf * 1.18, shoulderY - leanPx - r * 0.6, -shHalf, shoulderY - leanPx);
8723
7933
  ctx.quadraticCurveTo(-shHalf * 1.05, shoulderY + r * 1.2, -shHalf * 0.94, botY);
8724
7934
  ctx.lineTo(shHalf * 0.94, botY);
8725
- ctx.quadraticCurveTo(shHalf * 1.05, shoulderY + r * 1.2, shHalf, shoulderY);
8726
- ctx.quadraticCurveTo(neckHalf * 1.18, shoulderY - r * 0.6, neckHalf, neckTopY);
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);
8727
7937
  ctx.closePath();
8728
7938
  ctx.fill();
8729
7939
  ctx.fillStyle = skin;
8730
7940
  ctx.fillRect(-neckHalf * 0.82, r * 0.5, neckHalf * 1.64, r * 0.9);
8731
7941
  ctx.save();
8732
7942
  ctx.rotate(o.tilt);
7943
+ ctx.translate(o.turn * r, 0);
8733
7944
  ctx.fillStyle = skin;
8734
- ctx.beginPath();
8735
- ctx.ellipse(0, 0, r * 0.9, r * 1.06, 0, 0, TAU2);
8736
- ctx.fill();
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
+ }
8737
7966
  drawHair(ctx, r, o.hair);
8738
7967
  if (o.rim > 0.01) {
8739
7968
  ctx.strokeStyle = `rgba(245,205,150,${(0.5 * o.rim).toFixed(3)})`;
@@ -8757,15 +7986,17 @@ function drawSeated(ctx, hx, hy, r, o) {
8757
7986
  function drawSeatedMid(ctx, hx, hy, r, clothing, tone, alpha) {
8758
7987
  ctx.fillStyle = `rgba(${clothing[0]},${clothing[1]},${clothing[2]},${alpha})`;
8759
7988
  ctx.beginPath();
8760
- ctx.moveTo(hx - r * 1.9, hy + r * 4);
8761
- ctx.quadraticCurveTo(hx - r * 1.95, hy + r * 1.2, hx - r * 0.5, hy + r * 0.9);
8762
- ctx.quadraticCurveTo(hx, hy + r * 0.4, hx + r * 0.5, hy + r * 0.9);
8763
- ctx.quadraticCurveTo(hx + r * 1.95, hy + r * 1.2, hx + r * 1.9, hy + r * 4);
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);
8764
7995
  ctx.closePath();
8765
7996
  ctx.fill();
8766
7997
  ctx.fillStyle = `rgba(${tone[0]},${tone[1]},${tone[2]},${alpha})`;
8767
7998
  ctx.beginPath();
8768
- ctx.ellipse(hx, hy, r * 0.92, r * 1.05, 0, 0, TAU2);
7999
+ ctx.ellipse(hx, hy, r * 0.9, r * 1.05, 0, 0, TAU);
8769
8000
  ctx.fill();
8770
8001
  }
8771
8002
  function drawPerformer(ctx, x, footY, h, o) {
@@ -8783,7 +8014,7 @@ function drawPerformer(ctx, x, footY, h, o) {
8783
8014
  ctx.scale(1, 0.28);
8784
8015
  ctx.fillStyle = sh;
8785
8016
  ctx.beginPath();
8786
- ctx.ellipse(x, footY / 0.28, h * 0.24, h * 0.24, 0, 0, TAU2);
8017
+ ctx.ellipse(x, footY / 0.28, h * 0.24, h * 0.24, 0, 0, TAU);
8787
8018
  ctx.fill();
8788
8019
  ctx.restore();
8789
8020
  ctx.fillStyle = skin;
@@ -8827,7 +8058,7 @@ function drawPerformer(ctx, x, footY, h, o) {
8827
8058
  ctx.stroke();
8828
8059
  ctx.fillStyle = skin;
8829
8060
  ctx.beginPath();
8830
- ctx.ellipse(x + hipHalf * 1.5, hipY - h * 0.04, h * 0.07, h * 0.09, -0.5, 0, TAU2);
8061
+ ctx.ellipse(x + hipHalf * 1.5, hipY - h * 0.04, h * 0.07, h * 0.09, -0.5, 0, TAU);
8831
8062
  ctx.fill();
8832
8063
  } else if (o.pose === 3) {
8833
8064
  ctx.beginPath();
@@ -8852,7 +8083,7 @@ function drawPerformer(ctx, x, footY, h, o) {
8852
8083
  ctx.fillStyle = skin;
8853
8084
  ctx.fillRect(x - headR * 0.34, headCy + headR * 0.6, headR * 0.68, headR * 0.9);
8854
8085
  ctx.beginPath();
8855
- ctx.ellipse(x, headCy, headR * 0.86, headR * 1.05, 0, 0, TAU2);
8086
+ ctx.ellipse(x, headCy, headR * 0.86, headR * 1.05, 0, 0, TAU);
8856
8087
  ctx.fill();
8857
8088
  if (o.rimStrength > 0.01) {
8858
8089
  ctx.strokeStyle = `rgba(${o.rim[0]},${o.rim[1]},${o.rim[2]},${(0.6 * o.rimStrength).toFixed(3)})`;
@@ -8889,11 +8120,11 @@ function drawFixture(ctx, x, y, r, tint) {
8889
8120
  g.addColorStop(1, `rgba(${tint},0)`);
8890
8121
  ctx.fillStyle = g;
8891
8122
  ctx.beginPath();
8892
- ctx.arc(x, y, r * 3, 0, TAU2);
8123
+ ctx.arc(x, y, r * 3, 0, TAU);
8893
8124
  ctx.fill();
8894
8125
  ctx.fillStyle = `rgba(${tint},1)`;
8895
8126
  ctx.beginPath();
8896
- ctx.arc(x, y, r, 0, TAU2);
8127
+ ctx.arc(x, y, r, 0, TAU);
8897
8128
  ctx.fill();
8898
8129
  }
8899
8130
  function generateSeatPanorama(seat, focalPoint, neighborSeats) {
@@ -8948,7 +8179,7 @@ function generateSeatPanorama(seat, focalPoint, neighborSeats) {
8948
8179
  const x = (i + 0.5) / rigCount * W;
8949
8180
  const y = pitchToY(scene.mode === "sport" ? 58 : 54 + i * 7 % 3 * 6);
8950
8181
  ctx.beginPath();
8951
- ctx.arc(x, y, scene.mode === "sport" ? 2.6 : 3.2, 0, TAU2);
8182
+ ctx.arc(x, y, scene.mode === "sport" ? 2.6 : 3.2, 0, TAU);
8952
8183
  ctx.fill();
8953
8184
  }
8954
8185
  drawAudience(ctx, seat, neighborSeats, focalPoint, stageBearing, eyeM, dx, dy, hasRelief, scene);
@@ -9107,40 +8338,73 @@ function drawArenaScene(ctx, distM, eyeM, scene, nearGlow, rigTintStr, rand) {
9107
8338
  riser.addColorStop(1, "rgba(0,0,0,0)");
9108
8339
  ctx.fillStyle = riser;
9109
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);
9110
8355
  const deckTop = ctx.createLinearGradient(0, topCy - topRy, 0, topCy + topRy);
9111
- deckTop.addColorStop(0, "#2b3350");
9112
- deckTop.addColorStop(1, "#161b2c");
8356
+ deckTop.addColorStop(0, "#3d4a72");
8357
+ deckTop.addColorStop(1, "#232b47");
9113
8358
  ctx.fillStyle = deckTop;
9114
8359
  ctx.beginPath();
9115
- ctx.ellipse(cx, topCy, halfW, topRy, 0, 0, TAU2);
8360
+ ctx.ellipse(cx, topCy, halfW, topRy, 0, 0, TAU);
9116
8361
  ctx.fill();
9117
- const deckGlow = ctx.createRadialGradient(cx, topCy, 2, cx, topCy, halfW);
9118
- deckGlow.addColorStop(0, `rgba(255,214,160,${(0.28 * nearGlow).toFixed(3)})`);
8362
+ const deckGlow = ctx.createRadialGradient(leadX, topCy, 2, leadX, topCy, halfW);
8363
+ deckGlow.addColorStop(0, `rgba(255,214,160,${(0.5 * nearGlow).toFixed(3)})`);
9119
8364
  deckGlow.addColorStop(1, "rgba(255,214,160,0)");
9120
8365
  ctx.save();
9121
8366
  ctx.globalCompositeOperation = "lighter";
9122
8367
  ctx.fillStyle = deckGlow;
9123
8368
  ctx.beginPath();
9124
- ctx.ellipse(cx, topCy, halfW, topRy, 0, 0, TAU2);
8369
+ ctx.ellipse(cx, topCy, halfW, topRy, 0, 0, TAU);
9125
8370
  ctx.fill();
9126
8371
  ctx.restore();
9127
8372
  ctx.strokeStyle = `rgba(${rigTintStr},0.7)`;
9128
8373
  ctx.lineWidth = 2;
9129
8374
  ctx.beginPath();
9130
- ctx.ellipse(cx, topCy, halfW, topRy, 0, 0, TAU2);
8375
+ ctx.ellipse(cx, topCy, halfW, topRy, 0, 0, TAU);
9131
8376
  ctx.stroke();
9132
- const perfCount = 2 + Math.floor(rand() * 3);
9133
8377
  const perfH = Math.max(24, (yBase - yFarTop) * 1.3);
9134
- for (let i = 0; i < perfCount; i++) {
9135
- const t2 = perfCount === 1 ? 0.5 : i / (perfCount - 1);
9136
- const px = cx + (t2 - 0.5) * 2 * halfW * 0.66;
9137
- const footY = topCy + topRy * 0.5 * Math.cos((t2 - 0.5) * Math.PI) - topRy * 0.1;
9138
- drawPerformer(ctx, px, footY, perfH * (0.85 + rand() * 0.3), {
9139
- tone: [10, 12, 20],
9140
- alpha: 0.92,
9141
- pose: Math.floor(rand() * 4),
9142
- rim: hslToRgb(40, 0.6, 0.72),
9143
- rimStrength: 0.8
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";
8393
+ ctx.beginPath();
8394
+ ctx.ellipse(drumX, topCy - topRy * 0.34, halfW * 0.08, topRy * 0.1, 0, 0, TAU);
8395
+ ctx.fill();
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
9144
8408
  });
9145
8409
  }
9146
8410
  const trussY = pitchToY(Math.min(60, farTopPitch + 34));
@@ -9148,19 +8412,64 @@ function drawArenaScene(ctx, distM, eyeM, scene, nearGlow, rigTintStr, rand) {
9148
8412
  ctx.strokeStyle = "rgba(60,68,92,0.85)";
9149
8413
  ctx.lineWidth = 3;
9150
8414
  ctx.beginPath();
9151
- ctx.ellipse(cx, trussY, halfW * 1.15, trussRy, 0, 0, TAU2);
8415
+ ctx.ellipse(cx, trussY, halfW * 1.15, trussRy, 0, 0, TAU);
9152
8416
  ctx.stroke();
9153
8417
  const fixtures = 4 + Math.floor(rand() * 3);
9154
8418
  for (let i = 0; i < fixtures; i++) {
9155
- const a = i / fixtures * TAU2;
8419
+ const a = i / fixtures * TAU;
9156
8420
  const fxX = cx + Math.cos(a) * halfW * 1.15;
9157
8421
  const fxY = trussY + Math.sin(a) * trussRy;
9158
8422
  if (Math.sin(a) > -0.2) {
9159
8423
  const tx = cx + (rand() - 0.5) * halfW * 0.8;
9160
- drawBeam(ctx, fxX, fxY, tx, topCy, 3, halfW * 0.32, rigTintStr, 0.14 * (0.7 + nearGlow * 0.4));
8424
+ drawBeam(ctx, fxX, fxY, tx, topCy, 3, halfW * 0.32, rigTintStr, 0.2 * (0.7 + nearGlow * 0.4));
9161
8425
  }
9162
- drawFixture(ctx, fxX, fxY, 2.2, rigTintStr);
8426
+ drawFixture(ctx, fxX, fxY, 3, rigTintStr);
9163
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();
9164
8473
  }
9165
8474
  function drawSportScene(ctx, distM, eyeM, scene, _nearGlow, baseHue, rigTintStr, rand) {
9166
8475
  const cx = W / 2;
@@ -9181,7 +8490,7 @@ function drawSportScene(ctx, distM, eyeM, scene, _nearGlow, baseHue, rigTintStr,
9181
8490
  surfCol.addColorStop(1, "#8fa3c4");
9182
8491
  ctx.save();
9183
8492
  ctx.beginPath();
9184
- ctx.ellipse(cx, cy, halfW, ry, 0, 0, TAU2);
8493
+ ctx.ellipse(cx, cy, halfW, ry, 0, 0, TAU);
9185
8494
  ctx.clip();
9186
8495
  ctx.fillStyle = surfCol;
9187
8496
  ctx.fillRect(cx - halfW, cy - ry, halfW * 2, ry * 2);
@@ -9197,13 +8506,13 @@ function drawSportScene(ctx, distM, eyeM, scene, _nearGlow, baseHue, rigTintStr,
9197
8506
  ctx.stroke();
9198
8507
  ctx.strokeStyle = "rgba(180,90,110,0.45)";
9199
8508
  ctx.beginPath();
9200
- ctx.ellipse(cx, cy, ry * 0.9, ry * 0.5, 0, 0, TAU2);
8509
+ ctx.ellipse(cx, cy, ry * 0.9, ry * 0.5, 0, 0, TAU);
9201
8510
  ctx.stroke();
9202
8511
  ctx.restore();
9203
8512
  ctx.strokeStyle = "rgba(210,225,245,0.6)";
9204
8513
  ctx.lineWidth = 2.5;
9205
8514
  ctx.beginPath();
9206
- ctx.ellipse(cx, cy, halfW, ry, 0, 0, TAU2);
8515
+ ctx.ellipse(cx, cy, halfW, ry, 0, 0, TAU);
9207
8516
  ctx.stroke();
9208
8517
  ctx.fillStyle = "rgba(20,26,40,0.8)";
9209
8518
  for (let i = 0; i < 5; i++) {
@@ -9211,7 +8520,7 @@ function drawSportScene(ctx, distM, eyeM, scene, _nearGlow, baseHue, rigTintStr,
9211
8520
  const py = cy + (rand() - 0.5) * ry * 1.1;
9212
8521
  const s = Math.max(2, ry * 0.06);
9213
8522
  ctx.beginPath();
9214
- ctx.ellipse(px, py, s * 0.6, s, 0, 0, TAU2);
8523
+ ctx.ellipse(px, py, s * 0.6, s, 0, 0, TAU);
9215
8524
  ctx.fill();
9216
8525
  }
9217
8526
  const wash = ctx.createRadialGradient(cx, cy, 4, cx, cy, halfW * 1.4);
@@ -9254,7 +8563,7 @@ function drawSportScene(ctx, distM, eyeM, scene, _nearGlow, baseHue, rigTintStr,
9254
8563
  for (let i = 0; i < 8; i++) {
9255
8564
  const lx = cx - sbW * 0.6 + sbW * 1.2 * i / 7;
9256
8565
  ctx.beginPath();
9257
- ctx.arc(lx, sbY + sbH * 0.62, 2.4, 0, TAU2);
8566
+ ctx.arc(lx, sbY + sbH * 0.62, 2.4, 0, TAU);
9258
8567
  ctx.fill();
9259
8568
  }
9260
8569
  }
@@ -9268,7 +8577,7 @@ function drawAudience(ctx, seat, neighborSeats, _focalPoint, stageBearing, eyeM,
9268
8577
  const oy = other.y - seat.y;
9269
8578
  const d = Math.hypot(ox, oy) * UNIT;
9270
8579
  if (own && other.sectionId === own && d < 3 && ox * dx + oy * dy > 0) seatAhead = true;
9271
- if (d < 0.3 || d > 17) continue;
8580
+ if (d < 0.3 || d > 32) continue;
9272
8581
  const bearing = Math.atan2(ox, -oy) - stageBearing;
9273
8582
  const yaw = (bearing * 180 / Math.PI + 540) % 360 - 180;
9274
8583
  const rise = hasRelief ? (other.eyeHeightM ?? SEATED_EYE_HEIGHT_M) - eyeM : 0;
@@ -9278,40 +8587,91 @@ function drawAudience(ctx, seat, neighborSeats, _focalPoint, stageBearing, eyeM,
9278
8587
  for (let k = 0; k < other.id.length; k++) hh = hh * 31 + other.id.charCodeAt(k) & 65535;
9279
8588
  const jitter = 0.9 + (hh & 15) / 40;
9280
8589
  const r = Math.min(78, Math.atan2(0.16, d) * 180 / Math.PI * PX_PER_DEG * jitter);
9281
- if (r < 2) continue;
8590
+ if (r < 1.2) continue;
9282
8591
  figs.push({ hx: yawToX(yaw), hy: pitchToY(headPitch), r, d, hash: hh });
9283
8592
  }
9284
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);
9285
8614
  const CLOTHES = [[22, 26, 40], [28, 27, 42], [24, 31, 46], [31, 30, 47], [20, 24, 36]];
9286
- for (const f of figs) {
9287
- const alpha = f.d < 12 ? 0.95 : Math.max(0.66, 0.95 - (f.d - 12) / 18);
9288
- const lift = Math.round((1 - Math.min(1, f.d / 16)) * 10);
9289
- const headTone = [14 + (f.hash >> 4 & 6) + lift, 17 + (f.hash >> 4 & 6) + lift, 25 + (f.hash >> 4 & 6) + lift];
9290
- const clo0 = CLOTHES[f.hash % CLOTHES.length];
9291
- const clothing = [clo0[0] + lift, clo0[1] + lift, clo0[2] + lift];
9292
- if (f.r >= 13) {
9293
- const rim = f.d < 9 ? 0.16 * (1 - f.d / 9) + (scene.mode === "proscenium" ? 0.06 : 0) : 0;
9294
- drawSeated(ctx, f.hx, f.hy, f.r, {
9295
- tone: headTone,
9296
- clothing,
9297
- alpha,
9298
- tilt: ((f.hash >> 2 & 7) - 3.5) * 0.018,
9299
- // ±~7°
9300
- shoulderK: 1.72 + (f.hash >> 5 & 7) / 22,
9301
- // ~1.72..2.04
9302
- hair: f.hash % HAIR_KINDS,
9303
- rim
9304
- });
9305
- } else if (f.r >= 5.5) {
9306
- drawSeatedMid(ctx, f.hx, f.hy, f.r, clothing, headTone, alpha);
9307
- } else {
9308
- ctx.fillStyle = `rgba(${headTone[0]},${headTone[1]},${headTone[2]},${alpha.toFixed(3)})`;
9309
- ctx.beginPath();
9310
- ctx.arc(f.hx, f.hy, f.r, 0, TAU2);
9311
- ctx.fill();
9312
- ctx.beginPath();
9313
- ctx.ellipse(f.hx, f.hy + f.r * 1.4, f.r * 1.85, f.r * 0.95, 0, Math.PI, 0, true);
9314
- ctx.fill();
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
+ }
9315
8675
  }
9316
8676
  }
9317
8677
  if (seatAhead) {
@@ -9449,7 +8809,7 @@ function generateSeatThumb(seat, focalPoint, _neighborSeats) {
9449
8809
  ctx.fillStyle = `rgba(${rigTintStr},0.75)`;
9450
8810
  for (let i = 0; i < 8; i++) {
9451
8811
  ctx.beginPath();
9452
- ctx.arc((i + 0.5) / 8 * TW, pitchToTY(HALF_VFOV - 4 - i % 2 * 3), 2.2, 0, TAU2);
8812
+ ctx.arc((i + 0.5) / 8 * TW, pitchToTY(HALF_VFOV - 4 - i % 2 * 3), 2.2, 0, TAU);
9453
8813
  ctx.fill();
9454
8814
  }
9455
8815
  ctx.fillStyle = "rgba(10,13,20,0.9)";