@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.cjs CHANGED
@@ -411,6 +411,7 @@ function transformSectionOutlinePath(path, transform, radiusScale = 1, reflected
411
411
  })
412
412
  };
413
413
  }
414
+ var SAGITTA_TO_CONTROL = 4 / 3;
414
415
 
415
416
  // src/core/labeling.ts
416
417
  var FULL_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
@@ -503,6 +504,345 @@ function sectionGeometry(section, context = {}) {
503
504
  return { height, rake };
504
505
  }
505
506
 
507
+ // src/core/venueStructure.ts
508
+ var BLOCK_LINK_RATIO = 1.8;
509
+ function probesOf(pts) {
510
+ const n = pts.length;
511
+ if (n <= 3) return [...pts];
512
+ return [pts[Math.floor(n * 0.25)], pts[Math.floor(n * 0.5)], pts[Math.floor(n * 0.75)]];
513
+ }
514
+ function distanceToPolyline(pts, x, y) {
515
+ if (!pts.length) return Infinity;
516
+ if (pts.length === 1) return Math.hypot(x - pts[0].x, y - pts[0].y);
517
+ let best = Infinity;
518
+ for (let i = 0; i + 1 < pts.length; i++) {
519
+ const a = pts[i], b = pts[i + 1];
520
+ const vx = b.x - a.x, vy = b.y - a.y;
521
+ const len2 = vx * vx + vy * vy;
522
+ let t2 = len2 > 1e-12 ? ((x - a.x) * vx + (y - a.y) * vy) / len2 : 0;
523
+ if (t2 < 0) t2 = 0;
524
+ else if (t2 > 1) t2 = 1;
525
+ const d = Math.hypot(x - (a.x + t2 * vx), y - (a.y + t2 * vy));
526
+ if (d < best) best = d;
527
+ }
528
+ return best;
529
+ }
530
+ function boundOf(pts) {
531
+ let cx = 0, cy = 0;
532
+ for (const p of pts) {
533
+ cx += p.x;
534
+ cy += p.y;
535
+ }
536
+ const n = pts.length || 1;
537
+ cx /= n;
538
+ cy /= n;
539
+ let r = 0;
540
+ for (const p of pts) {
541
+ const d = Math.hypot(p.x - cx, p.y - cy);
542
+ if (d > r) r = d;
543
+ }
544
+ return { cx, cy, r };
545
+ }
546
+ function boundGap(a, b) {
547
+ return Math.hypot(a.cx - b.cx, a.cy - b.cy) - a.r - b.r;
548
+ }
549
+ function rowGap(a, b) {
550
+ let best = Infinity;
551
+ for (const p of probesOf(a)) {
552
+ const d = distanceToPolyline(b, p.x, p.y);
553
+ if (d < best) best = d;
554
+ }
555
+ for (const p of probesOf(b)) {
556
+ const d = distanceToPolyline(a, p.x, p.y);
557
+ if (d < best) best = d;
558
+ }
559
+ return best;
560
+ }
561
+ function orderAlongRow(pts, indices) {
562
+ if (pts.length < 3) return { pts, seatIndices: indices };
563
+ let cx = 0, cy = 0;
564
+ for (const p of pts) {
565
+ cx += p.x;
566
+ cy += p.y;
567
+ }
568
+ cx /= pts.length;
569
+ cy /= pts.length;
570
+ let far = -1, ax = pts[0];
571
+ for (const p of pts) {
572
+ const d = Math.hypot(p.x - cx, p.y - cy);
573
+ if (d > far) {
574
+ far = d;
575
+ ax = p;
576
+ }
577
+ }
578
+ let dx = ax.x - cx, dy = ax.y - cy;
579
+ const len = Math.hypot(dx, dy);
580
+ if (len > 1e-9) {
581
+ dx /= len;
582
+ dy /= len;
583
+ } else {
584
+ dx = 1;
585
+ dy = 0;
586
+ }
587
+ const order = pts.map((p, i) => ({ p, i, t: (p.x - cx) * dx + (p.y - cy) * dy })).sort((u, v) => u.t - v.t);
588
+ return { pts: order.map((o) => o.p), seatIndices: order.map((o) => indices[o.i]) };
589
+ }
590
+ function fitCircle(pts) {
591
+ const n = pts.length;
592
+ if (n < 3) return null;
593
+ let mx = 0, my = 0;
594
+ for (const p of pts) {
595
+ mx += p.x;
596
+ my += p.y;
597
+ }
598
+ mx /= n;
599
+ my /= n;
600
+ let suu = 0, suv = 0, svv = 0, suuu = 0, svvv = 0, suvv = 0, svuu = 0;
601
+ for (const p of pts) {
602
+ const u = p.x - mx, v = p.y - my;
603
+ suu += u * u;
604
+ svv += v * v;
605
+ suv += u * v;
606
+ suuu += u * u * u;
607
+ svvv += v * v * v;
608
+ suvv += u * v * v;
609
+ svuu += v * u * u;
610
+ }
611
+ const det = suu * svv - suv * suv;
612
+ if (Math.abs(det) < 1e-9) return null;
613
+ const b1 = (suuu + suvv) / 2;
614
+ const b2 = (svvv + svuu) / 2;
615
+ const uc = (b1 * svv - b2 * suv) / det;
616
+ const vc = (b2 * suu - b1 * suv) / det;
617
+ const cx = uc + mx, cy = vc + my;
618
+ if (!Number.isFinite(cx) || !Number.isFinite(cy)) return null;
619
+ return { cx, cy };
620
+ }
621
+ var CIRCLE_ROW_SPREAD_LIMIT = 0.15;
622
+ function fitBlockAxis(group) {
623
+ if (group.length < 2) return null;
624
+ const cents = group.map((r) => {
625
+ let x = 0, y = 0;
626
+ for (const p of r.pts) {
627
+ x += p.x;
628
+ y += p.y;
629
+ }
630
+ return { x: x / r.pts.length, y: y / r.pts.length };
631
+ });
632
+ let ox = 0, oy = 0;
633
+ for (const c of cents) {
634
+ ox += c.x;
635
+ oy += c.y;
636
+ }
637
+ ox /= cents.length;
638
+ oy /= cents.length;
639
+ let sxx = 0, sxy = 0, syy = 0;
640
+ for (const c of cents) {
641
+ const dx = c.x - ox, dy = c.y - oy;
642
+ sxx += dx * dx;
643
+ sxy += dx * dy;
644
+ syy += dy * dy;
645
+ }
646
+ const tr = sxx + syy;
647
+ const det = sxx * syy - sxy * sxy;
648
+ const lambda = tr / 2 + Math.sqrt(Math.max(0, tr * tr / 4 - det));
649
+ let ax, ay;
650
+ if (Math.abs(sxy) > 1e-12) {
651
+ ax = lambda - syy;
652
+ ay = sxy;
653
+ } else if (sxx >= syy) {
654
+ ax = 1;
655
+ ay = 0;
656
+ } else {
657
+ ax = 0;
658
+ ay = 1;
659
+ }
660
+ const len = Math.hypot(ax, ay);
661
+ if (!(len > 1e-12)) return null;
662
+ ax /= len;
663
+ ay /= len;
664
+ let lo = Infinity, hi = -Infinity;
665
+ const widths = [];
666
+ for (const r of group) {
667
+ let rlo = Infinity, rhi = -Infinity;
668
+ for (const p of r.pts) {
669
+ const t2 = p.x * ax + p.y * ay;
670
+ if (t2 < rlo) rlo = t2;
671
+ if (t2 > rhi) rhi = t2;
672
+ if (t2 < lo) lo = t2;
673
+ if (t2 > hi) hi = t2;
674
+ }
675
+ widths.push(rhi - rlo);
676
+ }
677
+ const range = hi - lo;
678
+ if (!(range > 1e-9)) return null;
679
+ widths.sort((a, b) => a - b);
680
+ const p90 = widths[Math.min(widths.length - 1, Math.floor(widths.length * 0.9))];
681
+ return p90 / range <= AXIS_ROW_WIDTH_LIMIT ? { x: ax, y: ay } : null;
682
+ }
683
+ var AXIS_ROW_WIDTH_LIMIT = 0.35;
684
+ function resolveSection(sectionId, seats, seatIndices, focal) {
685
+ const groups = /* @__PURE__ */ new Map();
686
+ for (const i of seatIndices) {
687
+ const s = seats[i];
688
+ const key = s.rowId || `__seat-${i}`;
689
+ let g = groups.get(key);
690
+ if (!g) {
691
+ g = { pts: [], idx: [] };
692
+ groups.set(key, g);
693
+ }
694
+ g.pts.push({ x: s.x, y: s.y });
695
+ g.idx.push(i);
696
+ }
697
+ if (!groups.size) return { sectionId, rows: [], blockCount: 0 };
698
+ const rows = [];
699
+ for (const [id, g] of groups) {
700
+ const ordered = orderAlongRow(g.pts, g.idx);
701
+ let sum = 0;
702
+ for (const p of ordered.pts) sum += Math.hypot(p.x - focal.x, p.y - focal.y);
703
+ rows.push({
704
+ id,
705
+ pts: ordered.pts,
706
+ seatIndices: ordered.seatIndices,
707
+ blockId: -1,
708
+ ordinal: 0,
709
+ blockDepth: 0,
710
+ focalDistance: sum / ordered.pts.length
711
+ });
712
+ }
713
+ const bounds2 = rows.map((r) => boundOf(r.pts));
714
+ const nearest = [];
715
+ for (let i = 0; i < rows.length; i++) {
716
+ let best = Infinity;
717
+ for (let j = 0; j < rows.length; j++) {
718
+ if (i === j) continue;
719
+ if (boundGap(bounds2[i], bounds2[j]) >= best) continue;
720
+ const d = rowGap(rows[i].pts, rows[j].pts);
721
+ if (d < best) best = d;
722
+ }
723
+ if (Number.isFinite(best)) nearest.push(best);
724
+ }
725
+ nearest.sort((a, b) => a - b);
726
+ const typicalGap = nearest.length ? nearest[Math.floor(nearest.length / 2)] : 1;
727
+ const linkDistance = Math.max(typicalGap * BLOCK_LINK_RATIO, 1e-6);
728
+ const adjacency = rows.map(() => []);
729
+ for (let i = 0; i < rows.length; i++) {
730
+ for (let j = i + 1; j < rows.length; j++) {
731
+ if (boundGap(bounds2[i], bounds2[j]) > linkDistance) continue;
732
+ if (rowGap(rows[i].pts, rows[j].pts) <= linkDistance) {
733
+ adjacency[i].push(j);
734
+ adjacency[j].push(i);
735
+ }
736
+ }
737
+ }
738
+ let blockCount = 0;
739
+ for (let i = 0; i < rows.length; i++) {
740
+ if (rows[i].blockId !== -1) continue;
741
+ const id = blockCount++;
742
+ const stack = [i];
743
+ rows[i].blockId = id;
744
+ while (stack.length) {
745
+ const k = stack.pop();
746
+ for (const n of adjacency[k]) {
747
+ if (rows[n].blockId !== -1) continue;
748
+ rows[n].blockId = id;
749
+ stack.push(n);
750
+ }
751
+ }
752
+ }
753
+ const byBlock = /* @__PURE__ */ new Map();
754
+ for (const r of rows) {
755
+ const a = byBlock.get(r.blockId) ?? [];
756
+ a.push(r);
757
+ byBlock.set(r.blockId, a);
758
+ }
759
+ const centres = [];
760
+ for (const r of rows) {
761
+ if (r.pts.length < 4) continue;
762
+ const c = fitCircle(r.pts);
763
+ if (!c) continue;
764
+ if (!Number.isFinite(c.cx) || !Number.isFinite(c.cy)) continue;
765
+ centres.push({ x: c.cx, y: c.cy });
766
+ }
767
+ const centre = centres.length >= 2 ? (() => {
768
+ const xs = centres.map((c) => c.x).sort((a, b) => a - b);
769
+ const ys = centres.map((c) => c.y).sort((a, b) => a - b);
770
+ const mid = Math.floor(centres.length / 2);
771
+ return { cx: xs[mid], cy: ys[mid] };
772
+ })() : null;
773
+ if (centre) {
774
+ const radiusOf = (p) => Math.hypot(p.x - centre.cx, p.y - centre.cy);
775
+ let lo = Infinity, hi = -Infinity;
776
+ const spreads = [];
777
+ for (const r of rows) {
778
+ let rlo = Infinity, rhi = -Infinity;
779
+ for (const p of r.pts) {
780
+ const d = radiusOf(p);
781
+ if (d < rlo) rlo = d;
782
+ if (d > rhi) rhi = d;
783
+ }
784
+ spreads.push(rhi - rlo);
785
+ if (rlo < lo) lo = rlo;
786
+ if (rhi > hi) hi = rhi;
787
+ }
788
+ const range = hi - lo;
789
+ spreads.sort((a, b) => a - b);
790
+ const p90 = spreads[Math.min(spreads.length - 1, Math.floor(spreads.length * 0.9))];
791
+ if (range > 1e-9 && p90 / range <= CIRCLE_ROW_SPREAD_LIMIT) {
792
+ const withRadius = rows.map((r) => {
793
+ let sum = 0;
794
+ for (const p of r.pts) sum += radiusOf(p);
795
+ return { row: r, radius: sum / r.pts.length };
796
+ });
797
+ let minR = Infinity;
798
+ for (const w of withRadius) if (w.radius < minR) minR = w.radius;
799
+ withRadius.sort((a, b) => a.radius - b.radius);
800
+ let ordinal = -1, lastRadius = -Infinity;
801
+ const tolerance = range / Math.max(1, withRadius.length) * 0.5;
802
+ const ordered = [];
803
+ for (const w of withRadius) {
804
+ if (w.radius - lastRadius > tolerance) {
805
+ ordinal++;
806
+ lastRadius = w.radius;
807
+ }
808
+ w.row.ordinal = ordinal;
809
+ w.row.blockDepth = w.radius - minR;
810
+ ordered.push(w.row);
811
+ }
812
+ return { sectionId, rows: ordered, blockCount };
813
+ }
814
+ }
815
+ const out = [];
816
+ for (const [, group] of byBlock) {
817
+ const axis = fitBlockAxis(group);
818
+ if (axis) {
819
+ const key = (r) => {
820
+ let sum = 0;
821
+ for (const p of r.pts) sum += p.x * axis.x + p.y * axis.y;
822
+ return sum / r.pts.length;
823
+ };
824
+ group.sort((a, b) => key(a) - key(b));
825
+ let depth = 0;
826
+ for (let i = 0; i < group.length; i++) {
827
+ if (i > 0) depth += rowGap(group[i - 1].pts, group[i].pts);
828
+ group[i].ordinal = i;
829
+ group[i].blockDepth = depth;
830
+ out.push(group[i]);
831
+ }
832
+ } else {
833
+ let front = Infinity;
834
+ for (const r of group) if (r.focalDistance < front) front = r.focalDistance;
835
+ group.sort((a, b) => a.focalDistance - b.focalDistance);
836
+ for (let i = 0; i < group.length; i++) {
837
+ group[i].ordinal = i;
838
+ group[i].blockDepth = Math.max(0, group[i].focalDistance - front);
839
+ out.push(group[i]);
840
+ }
841
+ }
842
+ }
843
+ return { sectionId, rows: out, blockCount };
844
+ }
845
+
506
846
  // src/core/layout.ts
507
847
  function overrideAccessibility(o) {
508
848
  if (!o) return [];
@@ -948,7 +1288,7 @@ function stackFloors(doc, spread = 900) {
948
1288
  });
949
1289
  return { ...doc, objects, floors: void 0 };
950
1290
  }
951
- function expandFloorObjects(objects, zones, fallbackFocal) {
1291
+ function expandFloorObjects(objects, zones, fallbackFocal, viewFallback) {
952
1292
  const out = [];
953
1293
  const segmented = /* @__PURE__ */ new Map();
954
1294
  const grouped = /* @__PURE__ */ new Map();
@@ -1018,6 +1358,7 @@ function expandFloorObjects(objects, zones, fallbackFocal) {
1018
1358
  const resolvedFocal = zone?.focalPoint ?? fallbackFocal;
1019
1359
  for (const seat of seats) {
1020
1360
  if (inheritedView) seat.viewUrl ??= inheritedView;
1361
+ if (viewFallback) seat.viewUrl ??= viewFallback;
1021
1362
  if (owner) seat.sectionId = owner.logicalSectionId ?? owner.id;
1022
1363
  if (owner?.zone) seat.zoneId = owner.zone;
1023
1364
  if (resolvedFocal) seat.focalPoint = { ...resolvedFocal };
@@ -1031,13 +1372,14 @@ function expandChart(doc, options = {}) {
1031
1372
  const out2 = [];
1032
1373
  for (const floor of doc.floors) {
1033
1374
  const floorFocal = floor.focalPoint ?? doc.focalPoint;
1034
- const seats = expandFloorObjects(floor.objects, doc.zones, floorFocal);
1375
+ const floorView = floor.viewFromSeatUrl ?? doc.viewFromSeatUrl;
1376
+ const seats = expandFloorObjects(floor.objects, doc.zones, floorFocal, floorView);
1035
1377
  assignEyeHeights(floor.objects, floor.focalPoint ?? doc.focalPoint, floor.baseHeightM ?? 0, seats);
1036
1378
  out2.push(...seats);
1037
1379
  }
1038
1380
  return out2;
1039
1381
  }
1040
- const out = expandFloorObjects(doc.objects, doc.zones, doc.focalPoint);
1382
+ const out = expandFloorObjects(doc.objects, doc.zones, doc.focalPoint, doc.viewFromSeatUrl);
1041
1383
  assignEyeHeights(doc.objects, doc.focalPoint, options.floorBaseHeightM ?? 0, out);
1042
1384
  return out;
1043
1385
  }
@@ -1050,17 +1392,28 @@ function assignEyeHeights(objects, focal, floorBaseHeightM, seats) {
1050
1392
  }
1051
1393
  const owner = new Array(seats.length);
1052
1394
  const geo = /* @__PURE__ */ new Map();
1053
- const frontDistU = /* @__PURE__ */ new Map();
1395
+ const seatsBySection = /* @__PURE__ */ new Map();
1054
1396
  for (let i = 0; i < seats.length; i++) {
1055
1397
  const seat = seats[i];
1056
1398
  const sec = sections.find((s) => pointInPolygonWithHoles({ x: seat.x, y: seat.y }, s.outline, s.holes)) ?? null;
1057
1399
  owner[i] = sec;
1058
1400
  if (!sec) continue;
1059
1401
  if (!geo.has(sec.id)) geo.set(sec.id, sectionGeometry(sec, { floorBaseHeightM }));
1060
- const seatFocal = seat.focalPoint ?? focal;
1061
- const d = Math.hypot(seat.x - seatFocal.x, seat.y - seatFocal.y);
1062
- const cur = frontDistU.get(sec.id);
1063
- if (cur === void 0 || d < cur) frontDistU.set(sec.id, d);
1402
+ const list = seatsBySection.get(sec.id);
1403
+ if (list) list.push(i);
1404
+ else seatsBySection.set(sec.id, [i]);
1405
+ }
1406
+ const seatLevel = new Array(seats.length).fill(void 0);
1407
+ for (const [secId, indices] of seatsBySection) {
1408
+ const g = geo.get(secId);
1409
+ if (!g) continue;
1410
+ const rakeTan = g.rake > 0 ? Math.tan(g.rake * Math.PI / 180) : 0;
1411
+ const structure = resolveSection(secId, seats, indices, focal);
1412
+ for (const row of structure.rows) {
1413
+ const riseM = row.blockDepth * METRES_PER_CHART_UNIT * rakeTan;
1414
+ const level = g.height + riseM;
1415
+ for (const si of row.seatIndices) seatLevel[si] = level;
1416
+ }
1064
1417
  }
1065
1418
  for (let i = 0; i < seats.length; i++) {
1066
1419
  const seat = seats[i];
@@ -1070,14 +1423,7 @@ function assignEyeHeights(objects, focal, floorBaseHeightM, seats) {
1070
1423
  continue;
1071
1424
  }
1072
1425
  const g = geo.get(sec.id);
1073
- let riseM = 0;
1074
- if (g.rake > 0) {
1075
- const seatFocal = seat.focalPoint ?? focal;
1076
- const d = Math.hypot(seat.x - seatFocal.x, seat.y - seatFocal.y);
1077
- const depthU = Math.max(0, d - (frontDistU.get(sec.id) ?? d));
1078
- riseM = depthU * METRES_PER_CHART_UNIT * Math.tan(g.rake * Math.PI / 180);
1079
- }
1080
- seat.eyeHeightM = g.height + riseM + SEATED_EYE_HEIGHT_M;
1426
+ seat.eyeHeightM = (seatLevel[i] ?? g.height) + SEATED_EYE_HEIGHT_M;
1081
1427
  }
1082
1428
  }
1083
1429
  var PAD = 40;
@@ -9030,24 +9376,44 @@ function drawSeated(ctx, hx, hy, r, o) {
9030
9376
  const skin = `rgba(${o.tone[0]},${o.tone[1]},${o.tone[2]},${o.alpha})`;
9031
9377
  ctx.save();
9032
9378
  ctx.translate(hx, hy);
9379
+ const leanPx = o.lean * r;
9033
9380
  ctx.fillStyle = clo;
9034
9381
  ctx.beginPath();
9035
9382
  ctx.moveTo(-neckHalf, neckTopY);
9036
- ctx.quadraticCurveTo(-neckHalf * 1.18, shoulderY - r * 0.6, -shHalf, shoulderY);
9383
+ ctx.quadraticCurveTo(-neckHalf * 1.18, shoulderY - leanPx - r * 0.6, -shHalf, shoulderY - leanPx);
9037
9384
  ctx.quadraticCurveTo(-shHalf * 1.05, shoulderY + r * 1.2, -shHalf * 0.94, botY);
9038
9385
  ctx.lineTo(shHalf * 0.94, botY);
9039
- ctx.quadraticCurveTo(shHalf * 1.05, shoulderY + r * 1.2, shHalf, shoulderY);
9040
- ctx.quadraticCurveTo(neckHalf * 1.18, shoulderY - r * 0.6, neckHalf, neckTopY);
9386
+ ctx.quadraticCurveTo(shHalf * 1.05, shoulderY + r * 1.2, shHalf, shoulderY + leanPx);
9387
+ ctx.quadraticCurveTo(neckHalf * 1.18, shoulderY + leanPx - r * 0.6, neckHalf, neckTopY);
9041
9388
  ctx.closePath();
9042
9389
  ctx.fill();
9043
9390
  ctx.fillStyle = skin;
9044
9391
  ctx.fillRect(-neckHalf * 0.82, r * 0.5, neckHalf * 1.64, r * 0.9);
9045
9392
  ctx.save();
9046
9393
  ctx.rotate(o.tilt);
9394
+ ctx.translate(o.turn * r, 0);
9047
9395
  ctx.fillStyle = skin;
9048
- ctx.beginPath();
9049
- ctx.ellipse(0, 0, r * 0.9, r * 1.06, 0, 0, TAU2);
9050
- ctx.fill();
9396
+ if (r >= 18) {
9397
+ ctx.beginPath();
9398
+ ctx.moveTo(-r * 0.9, -r * 0.15);
9399
+ ctx.quadraticCurveTo(-r * 0.96, -r * 1.12, 0, -r * 1.06);
9400
+ ctx.quadraticCurveTo(r * 0.96, -r * 1.12, r * 0.9, -r * 0.15);
9401
+ ctx.quadraticCurveTo(r * 0.86, r * 0.55, r * 0.36, r * 0.98);
9402
+ ctx.quadraticCurveTo(0, r * 1.14, -r * 0.36, r * 0.98);
9403
+ ctx.quadraticCurveTo(-r * 0.86, r * 0.55, -r * 0.9, -r * 0.15);
9404
+ ctx.closePath();
9405
+ ctx.fill();
9406
+ if (r >= 22) {
9407
+ ctx.beginPath();
9408
+ ctx.ellipse(-r * 0.9, r * 0.18, r * 0.13, r * 0.22, 0, 0, TAU2);
9409
+ ctx.ellipse(r * 0.9, r * 0.18, r * 0.13, r * 0.22, 0, 0, TAU2);
9410
+ ctx.fill();
9411
+ }
9412
+ } else {
9413
+ ctx.beginPath();
9414
+ ctx.ellipse(0, 0, r * 0.9, r * 1.06, 0, 0, TAU2);
9415
+ ctx.fill();
9416
+ }
9051
9417
  drawHair(ctx, r, o.hair);
9052
9418
  if (o.rim > 0.01) {
9053
9419
  ctx.strokeStyle = `rgba(245,205,150,${(0.5 * o.rim).toFixed(3)})`;
@@ -9071,15 +9437,17 @@ function drawSeated(ctx, hx, hy, r, o) {
9071
9437
  function drawSeatedMid(ctx, hx, hy, r, clothing, tone, alpha) {
9072
9438
  ctx.fillStyle = `rgba(${clothing[0]},${clothing[1]},${clothing[2]},${alpha})`;
9073
9439
  ctx.beginPath();
9074
- ctx.moveTo(hx - r * 1.9, hy + r * 4);
9075
- ctx.quadraticCurveTo(hx - r * 1.95, hy + r * 1.2, hx - r * 0.5, hy + r * 0.9);
9076
- ctx.quadraticCurveTo(hx, hy + r * 0.4, hx + r * 0.5, hy + r * 0.9);
9077
- ctx.quadraticCurveTo(hx + r * 1.95, hy + r * 1.2, hx + r * 1.9, hy + r * 4);
9440
+ ctx.moveTo(hx - r * 1.72, hy + r * 4);
9441
+ ctx.quadraticCurveTo(hx - r * 1.78, hy + r * 1.55, hx - r * 0.46, hy + r * 1.02);
9442
+ ctx.lineTo(hx - r * 0.4, hy + r * 0.62);
9443
+ ctx.lineTo(hx + r * 0.4, hy + r * 0.62);
9444
+ ctx.lineTo(hx + r * 0.46, hy + r * 1.02);
9445
+ ctx.quadraticCurveTo(hx + r * 1.78, hy + r * 1.55, hx + r * 1.72, hy + r * 4);
9078
9446
  ctx.closePath();
9079
9447
  ctx.fill();
9080
9448
  ctx.fillStyle = `rgba(${tone[0]},${tone[1]},${tone[2]},${alpha})`;
9081
9449
  ctx.beginPath();
9082
- ctx.ellipse(hx, hy, r * 0.92, r * 1.05, 0, 0, TAU2);
9450
+ ctx.ellipse(hx, hy, r * 0.9, r * 1.05, 0, 0, TAU2);
9083
9451
  ctx.fill();
9084
9452
  }
9085
9453
  function drawPerformer(ctx, x, footY, h, o) {
@@ -9421,15 +9789,29 @@ function drawArenaScene(ctx, distM, eyeM, scene, nearGlow, rigTintStr, rand) {
9421
9789
  riser.addColorStop(1, "rgba(0,0,0,0)");
9422
9790
  ctx.fillStyle = riser;
9423
9791
  ctx.fillRect(cx - halfW, topCy, halfW * 2, yBase - topCy);
9792
+ const FORMATIONS = [
9793
+ // frontman + guitarist + one at the back
9794
+ [{ 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 }],
9795
+ // duo — singer forward, instrument behind the shoulder
9796
+ [{ u: 0.2, v: 0.34, lead: true, pose: 3 }, { u: -0.3, v: -0.08, pose: 2 }],
9797
+ // 4-piece — lead just off-centre, band staggered around the far half
9798
+ [{ 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 }]
9799
+ ];
9800
+ const form = FORMATIONS[Math.floor(rand() * FORMATIONS.length)];
9801
+ const mirror = rand() < 0.5 ? -1 : 1;
9802
+ const spotX = (p) => cx + p.u * mirror * halfW * 0.84;
9803
+ const spotY = (p) => topCy + p.v * topRy * 0.8;
9804
+ const lead = form.find((p) => p.lead) ?? form[0];
9805
+ const leadX = spotX(lead);
9424
9806
  const deckTop = ctx.createLinearGradient(0, topCy - topRy, 0, topCy + topRy);
9425
- deckTop.addColorStop(0, "#2b3350");
9426
- deckTop.addColorStop(1, "#161b2c");
9807
+ deckTop.addColorStop(0, "#3d4a72");
9808
+ deckTop.addColorStop(1, "#232b47");
9427
9809
  ctx.fillStyle = deckTop;
9428
9810
  ctx.beginPath();
9429
9811
  ctx.ellipse(cx, topCy, halfW, topRy, 0, 0, TAU2);
9430
9812
  ctx.fill();
9431
- const deckGlow = ctx.createRadialGradient(cx, topCy, 2, cx, topCy, halfW);
9432
- deckGlow.addColorStop(0, `rgba(255,214,160,${(0.28 * nearGlow).toFixed(3)})`);
9813
+ const deckGlow = ctx.createRadialGradient(leadX, topCy, 2, leadX, topCy, halfW);
9814
+ deckGlow.addColorStop(0, `rgba(255,214,160,${(0.5 * nearGlow).toFixed(3)})`);
9433
9815
  deckGlow.addColorStop(1, "rgba(255,214,160,0)");
9434
9816
  ctx.save();
9435
9817
  ctx.globalCompositeOperation = "lighter";
@@ -9443,18 +9825,37 @@ function drawArenaScene(ctx, distM, eyeM, scene, nearGlow, rigTintStr, rand) {
9443
9825
  ctx.beginPath();
9444
9826
  ctx.ellipse(cx, topCy, halfW, topRy, 0, 0, TAU2);
9445
9827
  ctx.stroke();
9446
- const perfCount = 2 + Math.floor(rand() * 3);
9447
9828
  const perfH = Math.max(24, (yBase - yFarTop) * 1.3);
9448
- for (let i = 0; i < perfCount; i++) {
9449
- const t2 = perfCount === 1 ? 0.5 : i / (perfCount - 1);
9450
- const px = cx + (t2 - 0.5) * 2 * halfW * 0.66;
9451
- const footY = topCy + topRy * 0.5 * Math.cos((t2 - 0.5) * Math.PI) - topRy * 0.1;
9452
- drawPerformer(ctx, px, footY, perfH * (0.85 + rand() * 0.3), {
9453
- tone: [10, 12, 20],
9454
- alpha: 0.92,
9455
- pose: Math.floor(rand() * 4),
9456
- rim: hslToRgb(40, 0.6, 0.72),
9457
- rimStrength: 0.8
9829
+ const gearN = form.length >= 3 ? 3 : 2;
9830
+ for (let i = 0; i < gearN; i++) {
9831
+ const gu = (-0.62 + i * 0.52 + (rand() - 0.5) * 0.12) * -mirror;
9832
+ const gx = cx + gu * halfW * 0.7;
9833
+ const gy = topCy - topRy * (0.45 + rand() * 0.18);
9834
+ const gw = halfW * (0.07 + rand() * 0.04);
9835
+ const gh = perfH * (0.26 + rand() * 0.12);
9836
+ ctx.fillStyle = "#0a0e18";
9837
+ ctx.fillRect(gx - gw / 2, gy - gh, gw, gh);
9838
+ ctx.fillStyle = `rgba(${rigTintStr},0.3)`;
9839
+ ctx.fillRect(gx - gw / 2, gy - gh, gw, 1.5);
9840
+ }
9841
+ if (form.length >= 3) {
9842
+ const drumX = cx - lead.u * mirror * halfW * 0.4;
9843
+ ctx.fillStyle = "#0b0f1a";
9844
+ ctx.beginPath();
9845
+ ctx.ellipse(drumX, topCy - topRy * 0.34, halfW * 0.08, topRy * 0.1, 0, 0, TAU2);
9846
+ ctx.fill();
9847
+ ctx.strokeStyle = `rgba(${rigTintStr},0.35)`;
9848
+ ctx.lineWidth = 1;
9849
+ ctx.stroke();
9850
+ }
9851
+ for (const p of [...form].sort((a, b) => a.v - b.v)) {
9852
+ const depthScale = 0.82 + (p.v + 1) * 0.14;
9853
+ drawPerformer(ctx, spotX(p), spotY(p), perfH * depthScale * (p.lead ? 1.06 : 0.92), {
9854
+ tone: [18, 21, 34],
9855
+ alpha: 0.95,
9856
+ pose: p.pose,
9857
+ rim: hslToRgb(40, 0.72, 0.8),
9858
+ rimStrength: p.lead ? 1.4 : 0.9
9458
9859
  });
9459
9860
  }
9460
9861
  const trussY = pitchToY(Math.min(60, farTopPitch + 34));
@@ -9471,10 +9872,55 @@ function drawArenaScene(ctx, distM, eyeM, scene, nearGlow, rigTintStr, rand) {
9471
9872
  const fxY = trussY + Math.sin(a) * trussRy;
9472
9873
  if (Math.sin(a) > -0.2) {
9473
9874
  const tx = cx + (rand() - 0.5) * halfW * 0.8;
9474
- drawBeam(ctx, fxX, fxY, tx, topCy, 3, halfW * 0.32, rigTintStr, 0.14 * (0.7 + nearGlow * 0.4));
9875
+ drawBeam(ctx, fxX, fxY, tx, topCy, 3, halfW * 0.32, rigTintStr, 0.2 * (0.7 + nearGlow * 0.4));
9475
9876
  }
9476
- drawFixture(ctx, fxX, fxY, 2.2, rigTintStr);
9877
+ drawFixture(ctx, fxX, fxY, 3, rigTintStr);
9477
9878
  }
9879
+ const scoreY = pitchToY(Math.min(52, farTopPitch + 24));
9880
+ const scoreW = Math.max(60, halfW * 0.4);
9881
+ const scoreH = Math.max(24, scoreW * 0.34);
9882
+ ctx.strokeStyle = "rgba(70,78,102,0.6)";
9883
+ ctx.lineWidth = 1.5;
9884
+ ctx.beginPath();
9885
+ ctx.moveTo(cx - scoreW * 0.3, scoreY - scoreH / 2);
9886
+ ctx.lineTo(cx - scoreW * 0.16, scoreY - scoreH * 2.2);
9887
+ ctx.moveTo(cx + scoreW * 0.3, scoreY - scoreH / 2);
9888
+ ctx.lineTo(cx + scoreW * 0.16, scoreY - scoreH * 2.2);
9889
+ ctx.stroke();
9890
+ ctx.fillStyle = "#0d1220";
9891
+ ctx.fillRect(cx - scoreW / 2, scoreY - scoreH / 2, scoreW, scoreH);
9892
+ const screen = ctx.createLinearGradient(0, scoreY - scoreH * 0.3, 0, scoreY + scoreH * 0.42);
9893
+ screen.addColorStop(0, `rgba(${rigTintStr},0.5)`);
9894
+ screen.addColorStop(1, "rgba(255,214,160,0.32)");
9895
+ ctx.fillStyle = screen;
9896
+ ctx.fillRect(cx - scoreW * 0.42, scoreY - scoreH * 0.3, scoreW * 0.84, scoreH * 0.72);
9897
+ ctx.fillStyle = "rgba(255,214,160,0.55)";
9898
+ ctx.fillRect(cx - scoreW / 2, scoreY + scoreH / 2 - 2, scoreW, 2);
9899
+ const scoreGlow = ctx.createRadialGradient(cx, scoreY, 4, cx, scoreY, scoreW * 1.1);
9900
+ scoreGlow.addColorStop(0, `rgba(${rigTintStr},0.2)`);
9901
+ scoreGlow.addColorStop(1, "rgba(0,0,0,0)");
9902
+ ctx.save();
9903
+ ctx.globalCompositeOperation = "lighter";
9904
+ ctx.fillStyle = scoreGlow;
9905
+ ctx.fillRect(cx - scoreW * 1.1, scoreY - scoreW * 0.8, scoreW * 2.2, scoreW * 1.6);
9906
+ const airGlow = ctx.createRadialGradient(cx, (trussY + topCy) / 2, 6, cx, (trussY + topCy) / 2, halfW * 1.5);
9907
+ airGlow.addColorStop(0, `rgba(${rigTintStr},${(0.1 * (0.6 + nearGlow * 0.5)).toFixed(3)})`);
9908
+ airGlow.addColorStop(1, "rgba(0,0,0,0)");
9909
+ ctx.fillStyle = airGlow;
9910
+ ctx.fillRect(cx - halfW * 1.5, trussY - 40, halfW * 3, topCy - trussY + 80);
9911
+ ctx.restore();
9912
+ const keyX = cx + Math.sign(leadX - cx || 1) * halfW * 0.5;
9913
+ 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));
9914
+ const pool2 = ctx.createRadialGradient(leadX, spotY(lead), 1, leadX, spotY(lead), halfW * 0.2);
9915
+ pool2.addColorStop(0, `rgba(255,228,185,${(0.4 * nearGlow).toFixed(3)})`);
9916
+ pool2.addColorStop(1, "rgba(255,228,185,0)");
9917
+ ctx.save();
9918
+ ctx.globalCompositeOperation = "lighter";
9919
+ ctx.fillStyle = pool2;
9920
+ ctx.beginPath();
9921
+ ctx.ellipse(leadX, spotY(lead), halfW * 0.2, topRy * 0.24, 0, 0, TAU2);
9922
+ ctx.fill();
9923
+ ctx.restore();
9478
9924
  }
9479
9925
  function drawSportScene(ctx, distM, eyeM, scene, _nearGlow, baseHue, rigTintStr, rand) {
9480
9926
  const cx = W / 2;
@@ -9582,7 +10028,7 @@ function drawAudience(ctx, seat, neighborSeats, _focalPoint, stageBearing, eyeM,
9582
10028
  const oy = other.y - seat.y;
9583
10029
  const d = Math.hypot(ox, oy) * UNIT;
9584
10030
  if (own && other.sectionId === own && d < 3 && ox * dx + oy * dy > 0) seatAhead = true;
9585
- if (d < 0.3 || d > 17) continue;
10031
+ if (d < 0.3 || d > 32) continue;
9586
10032
  const bearing = Math.atan2(ox, -oy) - stageBearing;
9587
10033
  const yaw = (bearing * 180 / Math.PI + 540) % 360 - 180;
9588
10034
  const rise = hasRelief ? (other.eyeHeightM ?? SEATED_EYE_HEIGHT_M) - eyeM : 0;
@@ -9592,40 +10038,91 @@ function drawAudience(ctx, seat, neighborSeats, _focalPoint, stageBearing, eyeM,
9592
10038
  for (let k = 0; k < other.id.length; k++) hh = hh * 31 + other.id.charCodeAt(k) & 65535;
9593
10039
  const jitter = 0.9 + (hh & 15) / 40;
9594
10040
  const r = Math.min(78, Math.atan2(0.16, d) * 180 / Math.PI * PX_PER_DEG * jitter);
9595
- if (r < 2) continue;
10041
+ if (r < 1.2) continue;
9596
10042
  figs.push({ hx: yawToX(yaw), hy: pitchToY(headPitch), r, d, hash: hh });
9597
10043
  }
9598
10044
  figs.sort((a, b) => b.d - a.d);
10045
+ const figRows = [];
10046
+ {
10047
+ const byY = [...figs].sort((a, b) => a.hy - b.hy);
10048
+ let cur = [];
10049
+ const flush = () => {
10050
+ if (!cur.length) return;
10051
+ const d = cur.reduce((s, f) => s + f.d, 0) / cur.length;
10052
+ const hy = cur.reduce((s, f) => s + f.hy, 0) / cur.length;
10053
+ const r = cur.reduce((s, f) => s + f.r, 0) / cur.length;
10054
+ figRows.push({ figs: cur.sort((a, b) => b.d - a.d), d, hy, r });
10055
+ cur = [];
10056
+ };
10057
+ for (const f of byY) {
10058
+ const last = cur[cur.length - 1];
10059
+ if (last && Math.abs(f.hy - last.hy) > Math.max(5, (last.r + f.r) / 2 * 1.7)) flush();
10060
+ cur.push(f);
10061
+ }
10062
+ flush();
10063
+ }
10064
+ figRows.sort((a, b) => b.d - a.d);
9599
10065
  const CLOTHES = [[22, 26, 40], [28, 27, 42], [24, 31, 46], [31, 30, 47], [20, 24, 36]];
9600
- for (const f of figs) {
9601
- const alpha = f.d < 12 ? 0.95 : Math.max(0.66, 0.95 - (f.d - 12) / 18);
9602
- const lift = Math.round((1 - Math.min(1, f.d / 16)) * 10);
9603
- const headTone = [14 + (f.hash >> 4 & 6) + lift, 17 + (f.hash >> 4 & 6) + lift, 25 + (f.hash >> 4 & 6) + lift];
9604
- const clo0 = CLOTHES[f.hash % CLOTHES.length];
9605
- const clothing = [clo0[0] + lift, clo0[1] + lift, clo0[2] + lift];
9606
- if (f.r >= 13) {
9607
- const rim = f.d < 9 ? 0.16 * (1 - f.d / 9) + (scene.mode === "proscenium" ? 0.06 : 0) : 0;
9608
- drawSeated(ctx, f.hx, f.hy, f.r, {
9609
- tone: headTone,
9610
- clothing,
9611
- alpha,
9612
- tilt: ((f.hash >> 2 & 7) - 3.5) * 0.018,
9613
- // ±~7°
9614
- shoulderK: 1.72 + (f.hash >> 5 & 7) / 22,
9615
- // ~1.72..2.04
9616
- hair: f.hash % HAIR_KINDS,
9617
- rim
9618
- });
9619
- } else if (f.r >= 5.5) {
9620
- drawSeatedMid(ctx, f.hx, f.hy, f.r, clothing, headTone, alpha);
9621
- } else {
9622
- ctx.fillStyle = `rgba(${headTone[0]},${headTone[1]},${headTone[2]},${alpha.toFixed(3)})`;
9623
- ctx.beginPath();
9624
- ctx.arc(f.hx, f.hy, f.r, 0, TAU2);
9625
- ctx.fill();
9626
- ctx.beginPath();
9627
- ctx.ellipse(f.hx, f.hy + f.r * 1.4, f.r * 1.85, f.r * 0.95, 0, Math.PI, 0, true);
9628
- ctx.fill();
10066
+ for (const row of figRows) {
10067
+ if (row.figs.length >= 3 && row.r >= 1.6 && row.r < 26) {
10068
+ const runs = [];
10069
+ const byX = [...row.figs].sort((a, b) => a.hx - b.hx);
10070
+ let run = [];
10071
+ for (const f of byX) {
10072
+ const last = run[run.length - 1];
10073
+ if (last && f.hx - last.hx > Math.max(14, f.r * 7)) {
10074
+ if (run.length >= 3) runs.push(run);
10075
+ run = [];
10076
+ }
10077
+ run.push(f);
10078
+ }
10079
+ if (run.length >= 3) runs.push(run);
10080
+ const stripAlpha = Math.min(0.42, 0.5 * (1 - row.d / 42));
10081
+ if (stripAlpha > 0.05) {
10082
+ ctx.fillStyle = `rgba(15, 19, 31, ${stripAlpha.toFixed(3)})`;
10083
+ for (const seg of runs) {
10084
+ const x0 = seg[0].hx - row.r * 1.6;
10085
+ const x1 = seg[seg.length - 1].hx + row.r * 1.6;
10086
+ ctx.beginPath();
10087
+ ctx.roundRect(x0, row.hy + row.r * 0.8, x1 - x0, row.r * 2.4, row.r * 0.8);
10088
+ ctx.fill();
10089
+ }
10090
+ }
10091
+ }
10092
+ for (const f of row.figs) {
10093
+ 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);
10094
+ const lift = Math.round((1 - Math.min(1, f.d / 16)) * 10);
10095
+ const headTone = [14 + (f.hash >> 4 & 6) + lift, 17 + (f.hash >> 4 & 6) + lift, 25 + (f.hash >> 4 & 6) + lift];
10096
+ const clo0 = CLOTHES[f.hash % CLOTHES.length];
10097
+ const clothing = [clo0[0] + lift, clo0[1] + lift, clo0[2] + lift];
10098
+ if (f.r >= 13) {
10099
+ const rim = f.d < 9 ? 0.16 * (1 - f.d / 9) + (scene.mode === "proscenium" ? 0.06 : 0) : 0;
10100
+ drawSeated(ctx, f.hx, f.hy, f.r, {
10101
+ tone: headTone,
10102
+ clothing,
10103
+ alpha,
10104
+ tilt: ((f.hash >> 2 & 7) - 3.5) * 0.018,
10105
+ // ±~7°
10106
+ shoulderK: 1.72 + (f.hash >> 5 & 7) / 22,
10107
+ // ~1.72..2.04
10108
+ hair: f.hash % HAIR_KINDS,
10109
+ rim,
10110
+ turn: ((f.hash >> 8 & 7) - 3.5) * 0.034,
10111
+ // ±~0.12 head-radii sideways
10112
+ lean: ((f.hash >> 10 & 3) - 1.5) * 0.09
10113
+ // one shoulder rides higher
10114
+ });
10115
+ } else if (f.r >= 5.5) {
10116
+ drawSeatedMid(ctx, f.hx, f.hy, f.r, clothing, headTone, alpha);
10117
+ } else {
10118
+ ctx.fillStyle = `rgba(${headTone[0]},${headTone[1]},${headTone[2]},${alpha.toFixed(3)})`;
10119
+ ctx.beginPath();
10120
+ ctx.arc(f.hx, f.hy, f.r, 0, TAU2);
10121
+ ctx.fill();
10122
+ ctx.beginPath();
10123
+ ctx.ellipse(f.hx, f.hy + f.r * 1.4, f.r * 1.85, f.r * 0.95, 0, Math.PI, 0, true);
10124
+ ctx.fill();
10125
+ }
9629
10126
  }
9630
10127
  }
9631
10128
  if (seatAhead) {