@seatlayer/core 0.28.3 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -4638,7 +4638,7 @@ var _SeatmapRenderer = class _SeatmapRenderer {
4638
4638
  this.accessRingById.set(seat.id, ring);
4639
4639
  target.add(ring);
4640
4640
  }
4641
- if (accessGlyphPath(seat.accessibility ?? [])) {
4641
+ if (seat.accessibility?.includes("wheelchair") && accessGlyphPath(seat.accessibility)) {
4642
4642
  const glyph = this.buildAccessGlyph(seat, typeof c.fill() === "string" ? c.fill() : "");
4643
4643
  this.accessGlyphById.set(seat.id, glyph);
4644
4644
  target.add(glyph);
@@ -7685,8 +7685,12 @@ var PickerController = class {
7685
7685
  this.emitSelectionChange();
7686
7686
  return true;
7687
7687
  }
7688
- /** Atomically replace an active variable-table hold with a new guest count. */
7689
- async replaceTableQuantity(label, quantity) {
7688
+ /**
7689
+ * Atomically replace an active variable-table hold with a new guest count.
7690
+ * `ttlMs` keeps the host's checkout window across the replacement — without it
7691
+ * a guest-count change silently re-based the countdown on the server default.
7692
+ */
7693
+ async replaceTableQuantity(label, quantity, ttlMs) {
7690
7694
  const table = this.groupedTablesByLabel.get(label);
7691
7695
  const hold = this.hold_;
7692
7696
  if (!table || table.mode !== "variable" || !hold?.labels.includes(label)) return null;
@@ -7703,7 +7707,7 @@ var PickerController = class {
7703
7707
  quantity: item.label === label ? q : item.quantity
7704
7708
  }));
7705
7709
  if (!selections.some((item) => item.label === label)) return null;
7706
- const result = await this.api.hold(this.key, selections, void 0, hold.holdId);
7710
+ const result = await this.api.hold(this.key, selections, ttlMs, hold.holdId);
7707
7711
  this.tableQuantities.set(label, q);
7708
7712
  this.setHold({
7709
7713
  holdId: result.holdId,
@@ -7987,6 +7991,10 @@ var PickerController = class {
7987
7991
  * failure. With `preferPremium`, first tries to hold the best PREMIUM block
7988
7992
  * locally; when none matches the requested quantity it falls back to the
7989
7993
  * normal server pick (the widget surfaces a subtle note on that fallback).
7994
+ *
7995
+ * `ttlMs` is the host's checkout window and MUST reach both branches: a hold
7996
+ * born here is the same buyer commitment as one made by clicking seats, so
7997
+ * omitting it silently handed the buyer the server default instead.
7990
7998
  */
7991
7999
  async bestAvailable(qty, categoryKey, opts = {}) {
7992
8000
  const r = this.renderer;
@@ -8001,7 +8009,7 @@ var PickerController = class {
8001
8009
  const resolved = seat ? this.toSeat(seat) : null;
8002
8010
  return { label, ...resolved?.tierId ? { tierId: resolved.tierId } : {} };
8003
8011
  });
8004
- const result = await this.api.hold(this.key, selection);
8012
+ const result = await this.api.hold(this.key, selection, opts.ttlMs);
8005
8013
  r.clearSelection();
8006
8014
  const ids = this.idsForLabels(block);
8007
8015
  if (ids.length) r.setStatus(ids, "held");
@@ -8020,7 +8028,7 @@ var PickerController = class {
8020
8028
  }
8021
8029
  if (this.hold_ && !await this.release()) return null;
8022
8030
  try {
8023
- const result = await this.api.bestAvailable(this.key, qty, categoryKey, opts.zoneId);
8031
+ const result = await this.api.bestAvailable(this.key, qty, categoryKey, opts.zoneId, opts.ttlMs);
8024
8032
  r.clearSelection();
8025
8033
  const ids = this.idsForLabels(result.labels);
8026
8034
  if (ids.length) r.setStatus(ids, "held");
@@ -8041,8 +8049,10 @@ var PickerController = class {
8041
8049
  * cart can hold best-available seats that aren't in the renderer selection);
8042
8050
  * defaults to the renderer selection. Reuses an existing exact-match hold, else
8043
8051
  * holds first. Returns the labels booked, or null on failure (conflicts painted).
8052
+ * `ttlMs` only matters on the hold-first path: if the book call then fails, the
8053
+ * seats sit held for the host's window rather than the longer server default.
8044
8054
  */
8045
- async book(bookingRef, labelsArg) {
8055
+ async book(bookingRef, labelsArg, ttlMs) {
8046
8056
  const r = this.renderer;
8047
8057
  if (!r) return null;
8048
8058
  if (!this.api.book) throw new Error("picker: transport has no book() \u2014 hold-only mode");
@@ -8065,7 +8075,7 @@ var PickerController = class {
8065
8075
  ...this.tableQuantityRequest(resolved)
8066
8076
  };
8067
8077
  }),
8068
- void 0,
8078
+ ttlMs,
8069
8079
  replaceHoldId
8070
8080
  );
8071
8081
  holdId = h.holdId;
@@ -8778,8 +8788,428 @@ function stageSightlinePitch(eyeHeightM, distM) {
8778
8788
  var W = 2048;
8779
8789
  var H = 1024;
8780
8790
  var UNIT = METRES_PER_CHART_UNIT;
8791
+ var TAU2 = Math.PI * 2;
8792
+ var PX_PER_DEG = H / 180;
8781
8793
  var yawToX = (yawDeg) => (yawDeg + 180) / 360 * W;
8782
8794
  var pitchToY = (pitchDeg) => (90 - pitchDeg) / 180 * H;
8795
+ function fnv1a(str) {
8796
+ let h = 2166136261;
8797
+ for (let i = 0; i < str.length; i++) {
8798
+ h ^= str.charCodeAt(i);
8799
+ h = Math.imul(h, 16777619);
8800
+ }
8801
+ return h >>> 0;
8802
+ }
8803
+ function rng(seed) {
8804
+ let a = seed >>> 0;
8805
+ return () => {
8806
+ a |= 0;
8807
+ a = a + 1831565813 | 0;
8808
+ let t2 = Math.imul(a ^ a >>> 15, 1 | a);
8809
+ t2 = t2 + Math.imul(t2 ^ t2 >>> 7, 61 | t2) ^ t2;
8810
+ return ((t2 ^ t2 >>> 14) >>> 0) / 4294967296;
8811
+ };
8812
+ }
8813
+ var NBINS = 96;
8814
+ var BIN_DEG = 360 / NBINS;
8815
+ var FLAT_DELTA_M = 0.6;
8816
+ var HAZE_SKY = [17, 23, 42];
8817
+ function blendToSky(base, t2, a = 1) {
8818
+ const k = Math.max(0, Math.min(0.58, t2));
8819
+ const r = Math.round(base[0] + (HAZE_SKY[0] - base[0]) * k);
8820
+ const g = Math.round(base[1] + (HAZE_SKY[1] - base[1]) * k);
8821
+ const b = Math.round(base[2] + (HAZE_SKY[2] - base[2]) * k);
8822
+ return `rgba(${r},${g},${b},${a})`;
8823
+ }
8824
+ function mixRgb(a, b, t2) {
8825
+ return [
8826
+ Math.round(a[0] + (b[0] - a[0]) * t2),
8827
+ Math.round(a[1] + (b[1] - a[1]) * t2),
8828
+ Math.round(a[2] + (b[2] - a[2]) * t2)
8829
+ ];
8830
+ }
8831
+ function hslToRgb(h, s, l) {
8832
+ const c = (1 - Math.abs(2 * l - 1)) * s;
8833
+ const hp = (h % 360 + 360) % 360 / 60;
8834
+ const x = c * (1 - Math.abs(hp % 2 - 1));
8835
+ let r = 0, g = 0, b = 0;
8836
+ if (hp < 1) [r, g, b] = [c, x, 0];
8837
+ else if (hp < 2) [r, g, b] = [x, c, 0];
8838
+ else if (hp < 3) [r, g, b] = [0, c, x];
8839
+ else if (hp < 4) [r, g, b] = [0, x, c];
8840
+ else if (hp < 5) [r, g, b] = [x, 0, c];
8841
+ else [r, g, b] = [c, 0, x];
8842
+ const m = l - c / 2;
8843
+ return [Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255)];
8844
+ }
8845
+ var tintCache = /* @__PURE__ */ new Map();
8846
+ function tintFromKey(key) {
8847
+ if (!key) return null;
8848
+ const cached = tintCache.get(key);
8849
+ if (cached) return cached;
8850
+ let h = 2166136261;
8851
+ for (let i = 0; i < key.length; i++) {
8852
+ h ^= key.charCodeAt(i);
8853
+ h = Math.imul(h, 16777619);
8854
+ }
8855
+ const hue = (h >>> 0) % 360;
8856
+ const rgb2 = hslToRgb(hue, 0.3, 0.42);
8857
+ tintCache.set(key, rgb2);
8858
+ return rgb2;
8859
+ }
8860
+ function buildStands(seat, neighborSeats, stageBearing, myEyeM) {
8861
+ const bins = new Array(NBINS).fill(null);
8862
+ let maxDelta = 0;
8863
+ const own = seat.sectionId;
8864
+ for (const other of neighborSeats) {
8865
+ if (other.id === seat.id) continue;
8866
+ const os = other.sectionId;
8867
+ if (!os || own && os === own) continue;
8868
+ const ox = other.x - seat.x;
8869
+ const oy = other.y - seat.y;
8870
+ const dM = Math.hypot(ox, oy) * UNIT;
8871
+ if (dM < 1.5 || dM > 95) continue;
8872
+ const otherEye = other.eyeHeightM ?? SEATED_EYE_HEIGHT_M;
8873
+ const dTop = otherEye - myEyeM;
8874
+ if (Math.abs(dTop) > maxDelta) maxDelta = Math.abs(dTop);
8875
+ const top = Math.atan2(dTop, dM) * 180 / Math.PI;
8876
+ const base = Math.atan2(otherEye - SEATED_EYE_HEIGHT_M - myEyeM, dM) * 180 / Math.PI;
8877
+ const bearing = Math.atan2(ox, -oy) - stageBearing;
8878
+ const yaw = (bearing * 180 / Math.PI + 540) % 360 - 180;
8879
+ const idx = Math.min(NBINS - 1, Math.max(0, Math.floor((yaw + 180) / BIN_DEG)));
8880
+ const overhead = top > 34 && dM < 16;
8881
+ const cur = bins[idx];
8882
+ if (!cur) bins[idx] = { top, base, nearM: dM, overhead, tint: tintFromKey(other.categoryKey) };
8883
+ else {
8884
+ if (top > cur.top) cur.top = top;
8885
+ if (base < cur.base) cur.base = base;
8886
+ if (dM < cur.nearM) {
8887
+ cur.nearM = dM;
8888
+ cur.tint = tintFromKey(other.categoryKey);
8889
+ }
8890
+ cur.overhead = cur.overhead || overhead;
8891
+ }
8892
+ }
8893
+ return { bins, maxDelta };
8894
+ }
8895
+ function drawStands(ctx, bins, toX, toY) {
8896
+ for (let i = 0; i < NBINS; i++) {
8897
+ const b = bins[i];
8898
+ if (!b) continue;
8899
+ const yaw0 = -180 + i * BIN_DEG;
8900
+ const x0 = toX(yaw0);
8901
+ const x1 = toX(yaw0 + BIN_DEG);
8902
+ if (!(x1 > x0)) continue;
8903
+ const top = Math.min(72, b.top);
8904
+ const bottom = Math.min(b.base, -4);
8905
+ const yTop = toY(top);
8906
+ const yBot = toY(bottom);
8907
+ if (yBot <= yTop) continue;
8908
+ const haze = b.nearM / 95;
8909
+ const tint = b.tint;
8910
+ const crest = tint ? mixRgb([44, 54, 80], tint, 0.34) : [44, 54, 80];
8911
+ const seating = tint ? mixRgb([24, 30, 48], tint, 0.28) : [24, 30, 48];
8912
+ const grad = ctx.createLinearGradient(0, yTop, 0, yBot);
8913
+ grad.addColorStop(0, blendToSky(crest, haze));
8914
+ grad.addColorStop(0.24, blendToSky(seating, haze));
8915
+ grad.addColorStop(1, blendToSky([10, 13, 22], haze));
8916
+ ctx.fillStyle = grad;
8917
+ ctx.fillRect(x0, yTop, x1 - x0 + 1, yBot - yTop);
8918
+ ctx.fillStyle = `rgba(150,165,205,${0.35 * (1 - haze * 0.55)})`;
8919
+ ctx.fillRect(x0, yTop, x1 - x0 + 1, 1.5);
8920
+ const bandBottom = Math.min(yBot, toY(0));
8921
+ const bandSpan = bandBottom - yTop;
8922
+ if (bandSpan > 8) {
8923
+ const bands = 6;
8924
+ const contrast = 1 - haze * 0.4;
8925
+ for (let r = 1; r < bands; r++) {
8926
+ const y = yTop + bandSpan * r / bands;
8927
+ ctx.fillStyle = `rgba(0,0,0,${0.16 * contrast})`;
8928
+ ctx.fillRect(x0, y, x1 - x0 + 1, 1.5);
8929
+ ctx.fillStyle = `rgba(120,134,170,${0.1 * contrast})`;
8930
+ ctx.fillRect(x0, y + 1.5, x1 - x0 + 1, 1.2);
8931
+ }
8932
+ }
8933
+ if (b.overhead) {
8934
+ const lipY = toY(Math.min(86, top + 12));
8935
+ const g2 = ctx.createLinearGradient(0, lipY, 0, yTop);
8936
+ g2.addColorStop(0, "rgba(3,4,8,0.9)");
8937
+ g2.addColorStop(1, "rgba(3,4,8,0)");
8938
+ ctx.fillStyle = g2;
8939
+ ctx.fillRect(x0, lipY, x1 - x0 + 1, yTop - lipY);
8940
+ ctx.fillStyle = "rgba(120,130,160,0.14)";
8941
+ ctx.fillRect(x0, yTop - 1.5, x1 - x0 + 1, 1.5);
8942
+ }
8943
+ }
8944
+ }
8945
+ function classifyScene(_seat, focal, neighbors) {
8946
+ const NB = 72;
8947
+ const occ = new Array(NB).fill(false);
8948
+ const innerM = new Array(NB).fill(Infinity);
8949
+ const secSet = /* @__PURE__ */ new Set();
8950
+ let n = 0;
8951
+ for (const s of neighbors) {
8952
+ n++;
8953
+ if (s.sectionId) secSet.add(s.sectionId);
8954
+ const dx = s.x - focal.x;
8955
+ const dy = s.y - focal.y;
8956
+ const dM = Math.hypot(dx, dy) * UNIT;
8957
+ if (dM < 0.5) continue;
8958
+ const bearing = Math.atan2(dx, -dy);
8959
+ let idx = Math.floor((bearing + Math.PI) / TAU2 * NB);
8960
+ if (idx < 0) idx = 0;
8961
+ else if (idx >= NB) idx = NB - 1;
8962
+ occ[idx] = true;
8963
+ if (dM < innerM[idx]) innerM[idx] = dM;
8964
+ }
8965
+ let occN = 0;
8966
+ const inner = [];
8967
+ for (let i = 0; i < NB; i++) if (occ[i]) {
8968
+ occN++;
8969
+ inner.push(innerM[i]);
8970
+ }
8971
+ const coverage = occN / NB * 360;
8972
+ inner.sort((a, b) => a - b);
8973
+ const median = inner.length ? inner[inner.length >> 1] : 0;
8974
+ const lo = inner.length ? inner[Math.floor(inner.length * 0.15)] ?? median : 0;
8975
+ const hi = inner.length ? inner[Math.floor(inner.length * 0.85)] ?? median : 0;
8976
+ const aspect = lo > 0.5 ? hi / lo : 1;
8977
+ const seed = fnv1a(`${n}|${[...secSet].sort().join(",")}|${Math.round(focal.x)},${Math.round(focal.y)}`);
8978
+ let mode;
8979
+ if (coverage >= 300 && median > 11 && aspect > 1.35) mode = "sport";
8980
+ else if (coverage >= 285) mode = "arena";
8981
+ else mode = "proscenium";
8982
+ return { mode, seed, voidRadiusM: median, aspect };
8983
+ }
8984
+ var HAIR_KINDS = 5;
8985
+ function drawHair(ctx, r, kind) {
8986
+ switch (kind) {
8987
+ case 1:
8988
+ ctx.beginPath();
8989
+ ctx.ellipse(0, -r * 0.14, r * 1.16, r * 1.16, 0, 0, TAU2);
8990
+ ctx.fill();
8991
+ break;
8992
+ case 2:
8993
+ ctx.beginPath();
8994
+ ctx.ellipse(0, -r * 1.02, r * 0.42, r * 0.42, 0, 0, TAU2);
8995
+ ctx.fill();
8996
+ ctx.beginPath();
8997
+ ctx.ellipse(0, -r * 0.2, r * 0.98, r * 1.02, 0, 0, TAU2);
8998
+ ctx.fill();
8999
+ break;
9000
+ case 3:
9001
+ ctx.beginPath();
9002
+ ctx.ellipse(0, -r * 0.36, r * 1.04, r * 0.6, 0, Math.PI, TAU2);
9003
+ ctx.fill();
9004
+ ctx.beginPath();
9005
+ ctx.ellipse(0, -r * 0.52, r * 1.22, r * 0.24, 0, 0, TAU2);
9006
+ ctx.fill();
9007
+ break;
9008
+ case 4:
9009
+ ctx.beginPath();
9010
+ ctx.ellipse(-r * 0.66, r * 0.5, r * 0.5, r * 1.35, 0, 0, TAU2);
9011
+ ctx.fill();
9012
+ ctx.beginPath();
9013
+ ctx.ellipse(r * 0.66, r * 0.5, r * 0.5, r * 1.35, 0, 0, TAU2);
9014
+ ctx.fill();
9015
+ ctx.beginPath();
9016
+ ctx.ellipse(0, -r * 0.08, r * 1.06, r * 1.08, 0, 0, TAU2);
9017
+ ctx.fill();
9018
+ break;
9019
+ default:
9020
+ break;
9021
+ }
9022
+ }
9023
+ function drawSeated(ctx, hx, hy, r, o) {
9024
+ const neckHalf = r * 0.44;
9025
+ const shHalf = r * o.shoulderK;
9026
+ const neckTopY = r * 0.66;
9027
+ const shoulderY = r * 1.68;
9028
+ const botY = r * 5.4;
9029
+ const clo = `rgba(${o.clothing[0]},${o.clothing[1]},${o.clothing[2]},${o.alpha})`;
9030
+ const skin = `rgba(${o.tone[0]},${o.tone[1]},${o.tone[2]},${o.alpha})`;
9031
+ ctx.save();
9032
+ ctx.translate(hx, hy);
9033
+ ctx.fillStyle = clo;
9034
+ ctx.beginPath();
9035
+ ctx.moveTo(-neckHalf, neckTopY);
9036
+ ctx.quadraticCurveTo(-neckHalf * 1.18, shoulderY - r * 0.6, -shHalf, shoulderY);
9037
+ ctx.quadraticCurveTo(-shHalf * 1.05, shoulderY + r * 1.2, -shHalf * 0.94, botY);
9038
+ 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);
9041
+ ctx.closePath();
9042
+ ctx.fill();
9043
+ ctx.fillStyle = skin;
9044
+ ctx.fillRect(-neckHalf * 0.82, r * 0.5, neckHalf * 1.64, r * 0.9);
9045
+ ctx.save();
9046
+ ctx.rotate(o.tilt);
9047
+ ctx.fillStyle = skin;
9048
+ ctx.beginPath();
9049
+ ctx.ellipse(0, 0, r * 0.9, r * 1.06, 0, 0, TAU2);
9050
+ ctx.fill();
9051
+ drawHair(ctx, r, o.hair);
9052
+ if (o.rim > 0.01) {
9053
+ ctx.strokeStyle = `rgba(245,205,150,${(0.5 * o.rim).toFixed(3)})`;
9054
+ ctx.lineWidth = Math.max(1, r * 0.14);
9055
+ ctx.beginPath();
9056
+ ctx.ellipse(0, -r * 0.06, r * 0.86, r * 1, 0, Math.PI * 1.12, Math.PI * 1.9);
9057
+ ctx.stroke();
9058
+ }
9059
+ ctx.restore();
9060
+ if (o.rim > 0.01) {
9061
+ ctx.strokeStyle = `rgba(240,200,150,${(0.28 * o.rim).toFixed(3)})`;
9062
+ ctx.lineWidth = Math.max(1, r * 0.12);
9063
+ ctx.beginPath();
9064
+ ctx.moveTo(-shHalf * 0.8, shoulderY - r * 0.05);
9065
+ ctx.quadraticCurveTo(-neckHalf, shoulderY - r * 0.75, 0, shoulderY - r * 0.82);
9066
+ ctx.quadraticCurveTo(neckHalf, shoulderY - r * 0.75, shHalf * 0.8, shoulderY - r * 0.05);
9067
+ ctx.stroke();
9068
+ }
9069
+ ctx.restore();
9070
+ }
9071
+ function drawSeatedMid(ctx, hx, hy, r, clothing, tone, alpha) {
9072
+ ctx.fillStyle = `rgba(${clothing[0]},${clothing[1]},${clothing[2]},${alpha})`;
9073
+ 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);
9078
+ ctx.closePath();
9079
+ ctx.fill();
9080
+ ctx.fillStyle = `rgba(${tone[0]},${tone[1]},${tone[2]},${alpha})`;
9081
+ ctx.beginPath();
9082
+ ctx.ellipse(hx, hy, r * 0.92, r * 1.05, 0, 0, TAU2);
9083
+ ctx.fill();
9084
+ }
9085
+ function drawPerformer(ctx, x, footY, h, o) {
9086
+ const headR = h * 0.1;
9087
+ const headCy = footY - h * 0.9;
9088
+ const shoulderY = footY - h * 0.74;
9089
+ const hipY = footY - h * 0.46;
9090
+ const shHalf = h * 0.13;
9091
+ const hipHalf = h * 0.09;
9092
+ const skin = `rgba(${o.tone[0]},${o.tone[1]},${o.tone[2]},${o.alpha})`;
9093
+ const sh = ctx.createRadialGradient(x, footY, 1, x, footY, h * 0.24);
9094
+ sh.addColorStop(0, "rgba(0,0,0,0.4)");
9095
+ sh.addColorStop(1, "rgba(0,0,0,0)");
9096
+ ctx.save();
9097
+ ctx.scale(1, 0.28);
9098
+ ctx.fillStyle = sh;
9099
+ ctx.beginPath();
9100
+ ctx.ellipse(x, footY / 0.28, h * 0.24, h * 0.24, 0, 0, TAU2);
9101
+ ctx.fill();
9102
+ ctx.restore();
9103
+ ctx.fillStyle = skin;
9104
+ ctx.beginPath();
9105
+ ctx.moveTo(x - hipHalf, hipY);
9106
+ ctx.lineTo(x - hipHalf * 0.7, footY);
9107
+ ctx.lineTo(x - hipHalf * 0.1, footY);
9108
+ ctx.lineTo(x - hipHalf * 0.1, hipY + h * 0.02);
9109
+ ctx.lineTo(x + hipHalf * 0.1, hipY + h * 0.02);
9110
+ ctx.lineTo(x + hipHalf * 0.1, footY);
9111
+ ctx.lineTo(x + hipHalf * 0.7, footY);
9112
+ ctx.lineTo(x + hipHalf, hipY);
9113
+ ctx.closePath();
9114
+ ctx.fill();
9115
+ ctx.beginPath();
9116
+ ctx.moveTo(x - shHalf, shoulderY);
9117
+ ctx.quadraticCurveTo(x - shHalf * 0.9, hipY - h * 0.12, x - hipHalf, hipY);
9118
+ ctx.lineTo(x + hipHalf, hipY);
9119
+ ctx.quadraticCurveTo(x + shHalf * 0.9, hipY - h * 0.12, x + shHalf, shoulderY);
9120
+ ctx.quadraticCurveTo(x, shoulderY - h * 0.05, x - shHalf, shoulderY);
9121
+ ctx.closePath();
9122
+ ctx.fill();
9123
+ ctx.lineCap = "round";
9124
+ ctx.lineJoin = "round";
9125
+ ctx.strokeStyle = skin;
9126
+ ctx.lineWidth = h * 0.055;
9127
+ const armY = shoulderY + h * 0.02;
9128
+ if (o.pose === 1) {
9129
+ ctx.beginPath();
9130
+ ctx.moveTo(x - shHalf * 0.8, armY);
9131
+ ctx.lineTo(x - shHalf * 1.5, footY - h * 1.02);
9132
+ ctx.moveTo(x + shHalf * 0.8, armY);
9133
+ ctx.lineTo(x + shHalf * 1.5, footY - h * 1.02);
9134
+ ctx.stroke();
9135
+ } else if (o.pose === 2) {
9136
+ ctx.beginPath();
9137
+ ctx.moveTo(x - shHalf * 0.8, armY);
9138
+ ctx.lineTo(x + hipHalf * 1.4, hipY - h * 0.02);
9139
+ ctx.moveTo(x + shHalf * 0.8, armY);
9140
+ ctx.lineTo(x - hipHalf * 0.4, hipY - h * 0.06);
9141
+ ctx.stroke();
9142
+ ctx.fillStyle = skin;
9143
+ ctx.beginPath();
9144
+ ctx.ellipse(x + hipHalf * 1.5, hipY - h * 0.04, h * 0.07, h * 0.09, -0.5, 0, TAU2);
9145
+ ctx.fill();
9146
+ } else if (o.pose === 3) {
9147
+ ctx.beginPath();
9148
+ ctx.moveTo(x - shHalf * 0.8, armY);
9149
+ ctx.lineTo(x - shHalf * 0.2, headCy + headR * 0.6);
9150
+ ctx.moveTo(x + shHalf * 0.8, armY);
9151
+ ctx.lineTo(x + shHalf * 1.1, hipY);
9152
+ ctx.stroke();
9153
+ ctx.lineWidth = h * 0.02;
9154
+ ctx.beginPath();
9155
+ ctx.moveTo(x - shHalf * 0.2, headCy + headR * 0.9);
9156
+ ctx.lineTo(x - shHalf * 0.2, footY - h * 0.02);
9157
+ ctx.stroke();
9158
+ } else {
9159
+ ctx.beginPath();
9160
+ ctx.moveTo(x - shHalf * 0.85, armY);
9161
+ ctx.lineTo(x - shHalf * 0.95, hipY + h * 0.02);
9162
+ ctx.moveTo(x + shHalf * 0.85, armY);
9163
+ ctx.lineTo(x + shHalf * 0.95, hipY + h * 0.02);
9164
+ ctx.stroke();
9165
+ }
9166
+ ctx.fillStyle = skin;
9167
+ ctx.fillRect(x - headR * 0.34, headCy + headR * 0.6, headR * 0.68, headR * 0.9);
9168
+ ctx.beginPath();
9169
+ ctx.ellipse(x, headCy, headR * 0.86, headR * 1.05, 0, 0, TAU2);
9170
+ ctx.fill();
9171
+ if (o.rimStrength > 0.01) {
9172
+ ctx.strokeStyle = `rgba(${o.rim[0]},${o.rim[1]},${o.rim[2]},${(0.6 * o.rimStrength).toFixed(3)})`;
9173
+ ctx.lineWidth = Math.max(1, h * 0.02);
9174
+ ctx.beginPath();
9175
+ ctx.moveTo(x - shHalf, shoulderY);
9176
+ ctx.lineTo(x - headR * 0.6, headCy + headR * 0.4);
9177
+ ctx.moveTo(x - headR * 0.7, headCy);
9178
+ ctx.ellipse(x, headCy, headR * 0.84, headR * 1.02, 0, Math.PI * 1.05, Math.PI * 1.55);
9179
+ ctx.stroke();
9180
+ }
9181
+ }
9182
+ function drawBeam(ctx, x0, y0, x1, y1, halfTop, halfBot, tint, strength) {
9183
+ ctx.save();
9184
+ ctx.globalCompositeOperation = "lighter";
9185
+ const g = ctx.createLinearGradient(0, y0, 0, y1);
9186
+ g.addColorStop(0, `rgba(${tint},${strength})`);
9187
+ g.addColorStop(0.5, `rgba(${tint},${(strength * 0.4).toFixed(3)})`);
9188
+ g.addColorStop(1, `rgba(${tint},0)`);
9189
+ ctx.fillStyle = g;
9190
+ ctx.beginPath();
9191
+ ctx.moveTo(x0 - halfTop, y0);
9192
+ ctx.lineTo(x1 - halfBot, y1);
9193
+ ctx.lineTo(x1 + halfBot, y1);
9194
+ ctx.lineTo(x0 + halfTop, y0);
9195
+ ctx.closePath();
9196
+ ctx.fill();
9197
+ ctx.restore();
9198
+ }
9199
+ function drawFixture(ctx, x, y, r, tint) {
9200
+ const g = ctx.createRadialGradient(x, y, 0, x, y, r * 3);
9201
+ g.addColorStop(0, `rgba(${tint},0.9)`);
9202
+ g.addColorStop(0.4, `rgba(${tint},0.35)`);
9203
+ g.addColorStop(1, `rgba(${tint},0)`);
9204
+ ctx.fillStyle = g;
9205
+ ctx.beginPath();
9206
+ ctx.arc(x, y, r * 3, 0, TAU2);
9207
+ ctx.fill();
9208
+ ctx.fillStyle = `rgba(${tint},1)`;
9209
+ ctx.beginPath();
9210
+ ctx.arc(x, y, r, 0, TAU2);
9211
+ ctx.fill();
9212
+ }
8783
9213
  function generateSeatPanorama(seat, focalPoint, neighborSeats) {
8784
9214
  const canvas = document.createElement("canvas");
8785
9215
  canvas.width = W;
@@ -8789,102 +9219,428 @@ function generateSeatPanorama(seat, focalPoint, neighborSeats) {
8789
9219
  const dy = focalPoint.y - seat.y;
8790
9220
  const distU = Math.hypot(dx, dy);
8791
9221
  const distM = Math.max(2, distU * UNIT);
9222
+ const eyeM = seat.eyeHeightM ?? SEATED_EYE_HEIGHT_M;
9223
+ const scene = classifyScene(seat, focalPoint, neighborSeats);
9224
+ const rand = rng(scene.seed);
9225
+ const baseHue = 210 + rand() * 150;
9226
+ const rigTint = hslToRgb(220 + (rand() - 0.5) * 60, 0.55, 0.82);
9227
+ const rigTintStr = `${rigTint[0]},${rigTint[1]},${rigTint[2]}`;
8792
9228
  const sky = ctx.createLinearGradient(0, 0, 0, H);
8793
- sky.addColorStop(0, "#05070c");
8794
- sky.addColorStop(0.42, "#11172a");
8795
- sky.addColorStop(0.5, "#1a2238");
8796
- sky.addColorStop(0.56, "#10141f");
8797
- sky.addColorStop(1, "#07090f");
9229
+ sky.addColorStop(0, "#080b12");
9230
+ sky.addColorStop(0.42, "#12182c");
9231
+ sky.addColorStop(0.5, "#1c2440");
9232
+ sky.addColorStop(0.56, "#151b2c");
9233
+ sky.addColorStop(0.78, "#171e30");
9234
+ sky.addColorStop(1, "#131829");
8798
9235
  ctx.fillStyle = sky;
8799
9236
  ctx.fillRect(0, 0, W, H);
8800
- const eyeM = seat.eyeHeightM ?? SEATED_EYE_HEIGHT_M;
8801
- const stageHalfYaw = Math.min(80, Math.atan2(4, distM) * 180 / Math.PI);
9237
+ const floorAmb = ctx.createLinearGradient(0, pitchToY(-6), 0, H);
9238
+ floorAmb.addColorStop(0, "rgba(30, 37, 56, 0)");
9239
+ floorAmb.addColorStop(0.55, "rgba(30, 37, 56, 0.22)");
9240
+ floorAmb.addColorStop(1, "rgba(22, 28, 44, 0.12)");
9241
+ ctx.fillStyle = floorAmb;
9242
+ ctx.fillRect(0, pitchToY(-6), W, H - pitchToY(-6));
9243
+ const stageBearing = Math.atan2(dx, -dy);
9244
+ const stands = buildStands(seat, neighborSeats, stageBearing, eyeM);
9245
+ const hasRelief = stands.maxDelta >= FLAT_DELTA_M;
9246
+ if (hasRelief) drawStands(ctx, stands.bins, yawToX, pitchToY);
9247
+ const nearGlow = Math.max(0.3, Math.min(1, 1 - (distM - 6) / 60));
8802
9248
  const sightline = stageSightlinePitch(eyeM, distM);
9249
+ const stageHalfYaw = Math.min(80, Math.atan2(4, distM) * 180 / Math.PI);
8803
9250
  const stageTopPitch = Math.min(45, sightline.topPitch);
8804
9251
  const stageBasePitch = sightline.basePitch;
9252
+ if (scene.mode === "sport") {
9253
+ drawSportScene(ctx, distM, eyeM, scene, nearGlow, baseHue, rigTintStr, rand);
9254
+ } else if (scene.mode === "arena") {
9255
+ drawArenaScene(ctx, distM, eyeM, scene, nearGlow, rigTintStr, rand);
9256
+ } else {
9257
+ drawProsceniumScene(ctx, stageHalfYaw, stageTopPitch, stageBasePitch, nearGlow, baseHue, rigTintStr, rand);
9258
+ }
9259
+ const rigCount = scene.mode === "sport" ? 34 : 24 + Math.floor(rand() * 8);
9260
+ ctx.fillStyle = `rgba(${rigTintStr},0.8)`;
9261
+ for (let i = 0; i < rigCount; i++) {
9262
+ const x = (i + 0.5) / rigCount * W;
9263
+ const y = pitchToY(scene.mode === "sport" ? 58 : 54 + i * 7 % 3 * 6);
9264
+ ctx.beginPath();
9265
+ ctx.arc(x, y, scene.mode === "sport" ? 2.6 : 3.2, 0, TAU2);
9266
+ ctx.fill();
9267
+ }
9268
+ drawAudience(ctx, seat, neighborSeats, focalPoint, stageBearing, eyeM, dx, dy, hasRelief, scene);
9269
+ const poleFade = ctx.createLinearGradient(0, 0, 0, H);
9270
+ poleFade.addColorStop(0, "rgba(0,0,0,0.85)");
9271
+ poleFade.addColorStop(0.12, "rgba(0,0,0,0)");
9272
+ poleFade.addColorStop(0.9, "rgba(0,0,0,0)");
9273
+ poleFade.addColorStop(1, "rgba(0,0,0,0.55)");
9274
+ ctx.fillStyle = poleFade;
9275
+ ctx.fillRect(0, 0, W, H);
9276
+ return { url: canvas.toDataURL("image/jpeg", 0.82), distanceM: Math.round(distM) };
9277
+ }
9278
+ function drawProsceniumScene(ctx, stageHalfYaw, stageTopPitch, stageBasePitch, nearGlow, baseHue, rigTintStr, rand) {
8805
9279
  const sx0 = yawToX(-stageHalfYaw);
8806
9280
  const sx1 = yawToX(stageHalfYaw);
8807
9281
  const sy0 = pitchToY(stageTopPitch);
8808
9282
  const sy1 = pitchToY(stageBasePitch);
8809
- const glow = ctx.createRadialGradient(W / 2, (sy0 + sy1) / 2, 10, W / 2, (sy0 + sy1) / 2, (sx1 - sx0) * 1.1);
8810
- glow.addColorStop(0, "rgba(129, 140, 248, 0.5)");
8811
- glow.addColorStop(0.5, "rgba(99, 102, 241, 0.16)");
9283
+ const sw = sx1 - sx0;
9284
+ const sh = sy1 - sy0;
9285
+ const archTop = pitchToY(Math.min(50, stageTopPitch + 10));
9286
+ const glowR = sw * (1.1 + (1 - nearGlow) * 0.5);
9287
+ const glow = ctx.createRadialGradient(W / 2, (sy0 + sy1) / 2, 8, W / 2, (sy0 + sy1) / 2, glowR);
9288
+ glow.addColorStop(0, "rgba(255, 214, 150, 0.34)");
9289
+ glow.addColorStop(0.28, "rgba(150, 150, 230, 0.3)");
9290
+ glow.addColorStop(0.6, "rgba(99, 102, 241, 0.12)");
8812
9291
  glow.addColorStop(1, "rgba(99, 102, 241, 0)");
8813
9292
  ctx.fillStyle = glow;
8814
- ctx.fillRect(sx0 - (sx1 - sx0) * 0.6, sy0 - (sy1 - sy0) * 1.2, (sx1 - sx0) * 2.2, (sy1 - sy0) * 3.4);
8815
- ctx.fillStyle = "#242c44";
8816
- ctx.fillRect(sx0, sy0, sx1 - sx0, sy1 - sy0);
9293
+ ctx.fillRect(sx0 - sw * 0.6, sy0 - sh * 1.2, sw * 2.2, sh * 3.4);
9294
+ const topCol = hslToRgb(baseHue, 0.62, 0.55);
9295
+ const midCol = hslToRgb(baseHue + 40, 0.7, 0.5);
9296
+ const botCol = hslToRgb(baseHue + 70, 0.78, 0.52);
9297
+ ctx.save();
9298
+ ctx.beginPath();
9299
+ ctx.rect(sx0, sy0, sw, sh);
9300
+ ctx.clip();
8817
9301
  const backdrop = ctx.createLinearGradient(0, sy0, 0, sy1);
8818
- backdrop.addColorStop(0, "#7c5cff");
8819
- backdrop.addColorStop(0.5, "#e1447a");
8820
- backdrop.addColorStop(1, "#f59e0b");
8821
- ctx.globalAlpha = 0.75;
9302
+ backdrop.addColorStop(0, `rgb(${topCol[0]},${topCol[1]},${topCol[2]})`);
9303
+ backdrop.addColorStop(0.5, `rgb(${midCol[0]},${midCol[1]},${midCol[2]})`);
9304
+ backdrop.addColorStop(1, `rgb(${botCol[0]},${botCol[1]},${botCol[2]})`);
9305
+ ctx.globalAlpha = 0.8;
8822
9306
  ctx.fillStyle = backdrop;
8823
- const inset = (sx1 - sx0) * 0.06;
8824
- ctx.fillRect(sx0 + inset, sy0 + (sy1 - sy0) * 0.12, sx1 - sx0 - inset * 2, (sy1 - sy0) * 0.62);
9307
+ ctx.fillRect(sx0, sy0, sw, sh);
8825
9308
  ctx.globalAlpha = 1;
8826
- ctx.fillStyle = "#0d1119";
8827
- ctx.fillRect(sx0, sy1 - (sy1 - sy0) * 0.1, sx1 - sx0, (sy1 - sy0) * 0.1);
8828
- const performerH = (sy1 - sy0) * 0.45;
8829
- ctx.fillStyle = "rgba(8, 10, 16, 0.85)";
8830
- for (const fx of [0.35, 0.5, 0.65]) {
8831
- const px = sx0 + (sx1 - sx0) * fx;
8832
- const py = sy1 - (sy1 - sy0) * 0.12;
8833
- ctx.beginPath();
8834
- ctx.arc(px, py - performerH * 0.82, performerH * 0.16, 0, Math.PI * 2);
8835
- ctx.fill();
8836
- ctx.fillRect(px - performerH * 0.14, py - performerH * 0.68, performerH * 0.28, performerH * 0.68);
9309
+ const fall = ctx.createRadialGradient(W / 2, (sy0 + sy1) / 2, sw * 0.12, W / 2, (sy0 + sy1) / 2, sw * 0.62);
9310
+ fall.addColorStop(0, "rgba(6,8,14,0)");
9311
+ fall.addColorStop(0.7, "rgba(6,8,14,0.15)");
9312
+ fall.addColorStop(1, "rgba(5,6,11,0.92)");
9313
+ ctx.fillStyle = fall;
9314
+ ctx.fillRect(sx0, sy0, sw, sh);
9315
+ ctx.restore();
9316
+ const perfCount = 3 + Math.floor(rand() * 3);
9317
+ const perfH = sh * 0.46;
9318
+ const floorY = sy1 - sh * 0.08;
9319
+ for (let i = 0; i < perfCount; i++) {
9320
+ const t2 = (i + 1) / (perfCount + 1);
9321
+ const px = sx0 + sw * (0.22 + t2 * 0.56 + (rand() - 0.5) * 0.06);
9322
+ drawPerformer(ctx, px, floorY, perfH * (0.9 + rand() * 0.24), {
9323
+ tone: [8, 10, 16],
9324
+ alpha: 0.9,
9325
+ pose: Math.floor(rand() * 4),
9326
+ rim: hslToRgb(baseHue + 20, 0.5, 0.7),
9327
+ rimStrength: 0.7
9328
+ });
9329
+ }
9330
+ ctx.fillStyle = "#05070d";
9331
+ ctx.fillRect(sx0 - sw * 0.5, archTop, sw * 0.5, sy1 - archTop);
9332
+ ctx.fillRect(sx1, archTop, sw * 0.5, sy1 - archTop);
9333
+ const legBot = sw * 0.055 + 3;
9334
+ const legTop = legBot * 1.35;
9335
+ ctx.fillStyle = "#03050a";
9336
+ ctx.beginPath();
9337
+ ctx.moveTo(sx0, sy1);
9338
+ ctx.lineTo(sx0 - legBot, sy1);
9339
+ ctx.lineTo(sx0 - legTop, archTop);
9340
+ ctx.lineTo(sx0 + sw * 0.012, archTop);
9341
+ ctx.closePath();
9342
+ ctx.fill();
9343
+ ctx.beginPath();
9344
+ ctx.moveTo(sx1, sy1);
9345
+ ctx.lineTo(sx1 + legBot, sy1);
9346
+ ctx.lineTo(sx1 + legTop, archTop);
9347
+ ctx.lineTo(sx1 - sw * 0.012, archTop);
9348
+ ctx.closePath();
9349
+ ctx.fill();
9350
+ ctx.fillRect(sx0 - legTop, archTop, sw + legTop * 2, (sy1 - archTop) * 0.12);
9351
+ const valH = sh * 0.14;
9352
+ const valY = archTop + (sy1 - archTop) * 0.12;
9353
+ ctx.fillStyle = "rgba(4,6,12,0.96)";
9354
+ const scallops = 7;
9355
+ ctx.beginPath();
9356
+ ctx.moveTo(sx0 - legTop, valY);
9357
+ for (let i = 0; i <= scallops; i++) {
9358
+ const xx = sx0 - legTop + (sw + legTop * 2) * i / scallops;
9359
+ ctx.quadraticCurveTo(xx - (sw + legTop * 2) / scallops / 2, valY + valH, xx, valY);
9360
+ }
9361
+ ctx.lineTo(sx1 + legTop, valY);
9362
+ ctx.lineTo(sx1 + legTop, archTop);
9363
+ ctx.lineTo(sx0 - legTop, archTop);
9364
+ ctx.closePath();
9365
+ ctx.fill();
9366
+ ctx.fillStyle = `rgba(${rigTintStr},0.12)`;
9367
+ ctx.fillRect(sx0 - 1.5, valY, 1.5, sy1 - valY);
9368
+ ctx.fillRect(sx1, valY, 1.5, sy1 - valY);
9369
+ ctx.fillStyle = "#0b0f18";
9370
+ ctx.fillRect(sx0 - legBot, sy1 - sh * 0.05, sw + legBot * 2, sh * 0.05 + 3);
9371
+ const foot = ctx.createLinearGradient(0, sy1 - sh * 0.05, 0, sy1 + sh * 0.12);
9372
+ foot.addColorStop(0, `rgba(255,206,140,${(0.5 * nearGlow).toFixed(3)})`);
9373
+ foot.addColorStop(1, "rgba(255,206,140,0)");
9374
+ ctx.fillStyle = foot;
9375
+ ctx.fillRect(sx0 - legBot, sy1 - sh * 0.05, sw + legBot * 2, sh * 0.2);
9376
+ const beams = 3 + Math.floor(rand() * 2);
9377
+ for (let i = 0; i < beams; i++) {
9378
+ const t2 = (i + 1) / (beams + 1);
9379
+ const fx = sx0 + sw * (t2 + (rand() - 0.5) * 0.08);
9380
+ const fy = pitchToY(Math.min(62, stageTopPitch + 26));
9381
+ const skew = (rand() - 0.5) * sw * 0.16;
9382
+ drawBeam(ctx, fx, fy, fx + skew, sy1, 4, sw * 0.09, rigTintStr, 0.16 * (0.7 + nearGlow * 0.5));
9383
+ drawFixture(ctx, fx, fy, 2.4, rigTintStr);
9384
+ }
9385
+ }
9386
+ function drawArenaScene(ctx, distM, eyeM, scene, nearGlow, rigTintStr, rand) {
9387
+ const stageR = Math.min(distM * 0.6, Math.max(3, scene.voidRadiusM));
9388
+ const halfYaw = Math.min(55, Math.atan2(stageR, distM) * 180 / Math.PI);
9389
+ const cx = W / 2;
9390
+ const deckRiserM = 1.2;
9391
+ const nearDist = Math.max(distM * 0.45, distM - stageR);
9392
+ const farDist = distM + stageR;
9393
+ const nearTopPitch = Math.atan2(deckRiserM - eyeM, nearDist) * 180 / Math.PI;
9394
+ const farTopPitch = Math.atan2(deckRiserM - eyeM, farDist) * 180 / Math.PI;
9395
+ const nearBasePitch = Math.atan2(-eyeM, nearDist) * 180 / Math.PI;
9396
+ const yNearTop = pitchToY(nearTopPitch);
9397
+ const yFarTop = pitchToY(farTopPitch);
9398
+ const yBase = pitchToY(nearBasePitch);
9399
+ const halfW = yawToX(halfYaw) - cx;
9400
+ const topRy = Math.max(6, (yNearTop - yFarTop) / 2);
9401
+ const topCy = (yNearTop + yFarTop) / 2;
9402
+ const pool = ctx.createRadialGradient(cx, yBase, 4, cx, yBase, halfW * 2.6);
9403
+ pool.addColorStop(0, `rgba(255,206,150,${(0.2 * nearGlow).toFixed(3)})`);
9404
+ pool.addColorStop(0.5, "rgba(150,140,190,0.08)");
9405
+ pool.addColorStop(1, "rgba(40,44,60,0)");
9406
+ ctx.save();
9407
+ ctx.globalCompositeOperation = "lighter";
9408
+ ctx.fillStyle = pool;
9409
+ ctx.fillRect(cx - halfW * 2.6, yFarTop - topRy, halfW * 5.2, yBase - yFarTop + topRy + 40);
9410
+ ctx.restore();
9411
+ ctx.fillStyle = "#0c1019";
9412
+ ctx.beginPath();
9413
+ ctx.moveTo(cx - halfW, topCy);
9414
+ ctx.ellipse(cx, topCy, halfW, topRy, 0, Math.PI, 0, false);
9415
+ ctx.lineTo(cx + halfW, yBase);
9416
+ ctx.ellipse(cx, yBase, halfW, topRy, 0, 0, Math.PI, false);
9417
+ ctx.closePath();
9418
+ ctx.fill();
9419
+ const riser = ctx.createLinearGradient(0, topCy, 0, yBase);
9420
+ riser.addColorStop(0, `rgba(${rigTintStr},0.14)`);
9421
+ riser.addColorStop(1, "rgba(0,0,0,0)");
9422
+ ctx.fillStyle = riser;
9423
+ ctx.fillRect(cx - halfW, topCy, halfW * 2, yBase - topCy);
9424
+ const deckTop = ctx.createLinearGradient(0, topCy - topRy, 0, topCy + topRy);
9425
+ deckTop.addColorStop(0, "#2b3350");
9426
+ deckTop.addColorStop(1, "#161b2c");
9427
+ ctx.fillStyle = deckTop;
9428
+ ctx.beginPath();
9429
+ ctx.ellipse(cx, topCy, halfW, topRy, 0, 0, TAU2);
9430
+ 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)})`);
9433
+ deckGlow.addColorStop(1, "rgba(255,214,160,0)");
9434
+ ctx.save();
9435
+ ctx.globalCompositeOperation = "lighter";
9436
+ ctx.fillStyle = deckGlow;
9437
+ ctx.beginPath();
9438
+ ctx.ellipse(cx, topCy, halfW, topRy, 0, 0, TAU2);
9439
+ ctx.fill();
9440
+ ctx.restore();
9441
+ ctx.strokeStyle = `rgba(${rigTintStr},0.7)`;
9442
+ ctx.lineWidth = 2;
9443
+ ctx.beginPath();
9444
+ ctx.ellipse(cx, topCy, halfW, topRy, 0, 0, TAU2);
9445
+ ctx.stroke();
9446
+ const perfCount = 2 + Math.floor(rand() * 3);
9447
+ 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
9458
+ });
8837
9459
  }
8838
- ctx.globalAlpha = 0.14;
8839
- ctx.fillStyle = "#c7d2fe";
8840
- for (const fx of [0.3, 0.5, 0.7]) {
8841
- const bx = sx0 + (sx1 - sx0) * fx;
9460
+ const trussY = pitchToY(Math.min(60, farTopPitch + 34));
9461
+ const trussRy = topRy * 1.1 + 6;
9462
+ ctx.strokeStyle = "rgba(60,68,92,0.85)";
9463
+ ctx.lineWidth = 3;
9464
+ ctx.beginPath();
9465
+ ctx.ellipse(cx, trussY, halfW * 1.15, trussRy, 0, 0, TAU2);
9466
+ ctx.stroke();
9467
+ const fixtures = 4 + Math.floor(rand() * 3);
9468
+ for (let i = 0; i < fixtures; i++) {
9469
+ const a = i / fixtures * TAU2;
9470
+ const fxX = cx + Math.cos(a) * halfW * 1.15;
9471
+ const fxY = trussY + Math.sin(a) * trussRy;
9472
+ if (Math.sin(a) > -0.2) {
9473
+ 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));
9475
+ }
9476
+ drawFixture(ctx, fxX, fxY, 2.2, rigTintStr);
9477
+ }
9478
+ }
9479
+ function drawSportScene(ctx, distM, eyeM, scene, _nearGlow, baseHue, rigTintStr, rand) {
9480
+ const cx = W / 2;
9481
+ const surfR = Math.max(10, scene.voidRadiusM);
9482
+ const nearEdge = Math.max(2, distM - surfR);
9483
+ const farEdge = distM + surfR;
9484
+ const halfYaw = Math.min(78, Math.atan2(surfR * 1.15, distM) * 180 / Math.PI);
9485
+ const nearPitch = Math.atan2(-eyeM, nearEdge) * 180 / Math.PI;
9486
+ const farPitch = Math.atan2(-eyeM, farEdge) * 180 / Math.PI;
9487
+ const yNear = pitchToY(nearPitch);
9488
+ const yFar = pitchToY(farPitch);
9489
+ const halfW = yawToX(halfYaw) - cx;
9490
+ const cy = (yNear + yFar) / 2;
9491
+ const ry = Math.max(10, (yNear - yFar) / 2);
9492
+ const surfCol = ctx.createRadialGradient(cx, cy, 4, cx, cy, halfW);
9493
+ surfCol.addColorStop(0, "#e8f0fb");
9494
+ surfCol.addColorStop(0.7, "#b9c9e2");
9495
+ surfCol.addColorStop(1, "#8fa3c4");
9496
+ ctx.save();
9497
+ ctx.beginPath();
9498
+ ctx.ellipse(cx, cy, halfW, ry, 0, 0, TAU2);
9499
+ ctx.clip();
9500
+ ctx.fillStyle = surfCol;
9501
+ ctx.fillRect(cx - halfW, cy - ry, halfW * 2, ry * 2);
9502
+ ctx.strokeStyle = "rgba(90,120,170,0.5)";
9503
+ ctx.lineWidth = 3;
9504
+ ctx.beginPath();
9505
+ ctx.moveTo(cx, cy - ry);
9506
+ ctx.lineTo(cx, cy + ry);
9507
+ ctx.moveTo(cx - halfW * 0.5, cy - ry);
9508
+ ctx.lineTo(cx - halfW * 0.5, cy + ry);
9509
+ ctx.moveTo(cx + halfW * 0.5, cy - ry);
9510
+ ctx.lineTo(cx + halfW * 0.5, cy + ry);
9511
+ ctx.stroke();
9512
+ ctx.strokeStyle = "rgba(180,90,110,0.45)";
9513
+ ctx.beginPath();
9514
+ ctx.ellipse(cx, cy, ry * 0.9, ry * 0.5, 0, 0, TAU2);
9515
+ ctx.stroke();
9516
+ ctx.restore();
9517
+ ctx.strokeStyle = "rgba(210,225,245,0.6)";
9518
+ ctx.lineWidth = 2.5;
9519
+ ctx.beginPath();
9520
+ ctx.ellipse(cx, cy, halfW, ry, 0, 0, TAU2);
9521
+ ctx.stroke();
9522
+ ctx.fillStyle = "rgba(20,26,40,0.8)";
9523
+ for (let i = 0; i < 5; i++) {
9524
+ const px = cx + (rand() - 0.5) * halfW * 1.4;
9525
+ const py = cy + (rand() - 0.5) * ry * 1.1;
9526
+ const s = Math.max(2, ry * 0.06);
8842
9527
  ctx.beginPath();
8843
- ctx.moveTo(bx - 8, pitchToY(Math.min(60, stageTopPitch + 25)));
8844
- ctx.lineTo(bx - (sx1 - sx0) * 0.09, sy1);
8845
- ctx.lineTo(bx + (sx1 - sx0) * 0.09, sy1);
8846
- ctx.lineTo(bx + 8, pitchToY(Math.min(60, stageTopPitch + 25)));
8847
- ctx.closePath();
9528
+ ctx.ellipse(px, py, s * 0.6, s, 0, 0, TAU2);
8848
9529
  ctx.fill();
8849
9530
  }
8850
- ctx.globalAlpha = 1;
8851
- ctx.fillStyle = "rgba(199, 210, 254, 0.8)";
8852
- for (let i = 0; i < 26; i++) {
8853
- const x = i / 26 * W;
8854
- const y = pitchToY(55 + i % 3 * 6);
9531
+ const wash = ctx.createRadialGradient(cx, cy, 4, cx, cy, halfW * 1.4);
9532
+ wash.addColorStop(0, "rgba(200,220,255,0.12)");
9533
+ wash.addColorStop(1, "rgba(200,220,255,0)");
9534
+ ctx.save();
9535
+ ctx.globalCompositeOperation = "lighter";
9536
+ ctx.fillStyle = wash;
9537
+ ctx.fillRect(cx - halfW * 1.4, yFar - ry, halfW * 2.8, yNear - yFar + ry * 2);
9538
+ ctx.restore();
9539
+ const sbY = pitchToY(Math.min(66, farPitch + 60));
9540
+ const sbW = Math.max(80, halfW * 0.7);
9541
+ const sbH = sbW * 0.5;
9542
+ const sbGlow = ctx.createRadialGradient(cx, sbY, 4, cx, sbY, sbW * 1.6);
9543
+ sbGlow.addColorStop(0, `rgba(${rigTintStr},0.3)`);
9544
+ sbGlow.addColorStop(1, `rgba(${rigTintStr},0)`);
9545
+ ctx.save();
9546
+ ctx.globalCompositeOperation = "lighter";
9547
+ ctx.fillStyle = sbGlow;
9548
+ ctx.fillRect(cx - sbW * 1.6, sbY - sbW * 1.2, sbW * 3.2, sbW * 2.4);
9549
+ ctx.restore();
9550
+ ctx.fillStyle = "#080b12";
9551
+ ctx.beginPath();
9552
+ ctx.moveTo(cx - sbW / 2, sbY - sbH / 2);
9553
+ ctx.lineTo(cx + sbW / 2, sbY - sbH / 2);
9554
+ ctx.lineTo(cx + sbW * 0.6, sbY);
9555
+ ctx.lineTo(cx + sbW / 2, sbY + sbH / 2);
9556
+ ctx.lineTo(cx - sbW / 2, sbY + sbH / 2);
9557
+ ctx.lineTo(cx - sbW * 0.6, sbY);
9558
+ ctx.closePath();
9559
+ ctx.fill();
9560
+ const screenHue = baseHue;
9561
+ for (let i = 0; i < 3; i++) {
9562
+ const scol = hslToRgb(screenHue + i * 30, 0.5, 0.5);
9563
+ ctx.fillStyle = `rgba(${scol[0]},${scol[1]},${scol[2]},0.75)`;
9564
+ const swid = sbW * 0.26;
9565
+ ctx.fillRect(cx - sbW * 0.42 + i * (swid + sbW * 0.06), sbY - sbH * 0.32, swid, sbH * 0.64);
9566
+ }
9567
+ ctx.fillStyle = `rgba(${rigTintStr},0.9)`;
9568
+ for (let i = 0; i < 8; i++) {
9569
+ const lx = cx - sbW * 0.6 + sbW * 1.2 * i / 7;
8855
9570
  ctx.beginPath();
8856
- ctx.arc(x, y, 3.2, 0, Math.PI * 2);
9571
+ ctx.arc(lx, sbY + sbH * 0.62, 2.4, 0, TAU2);
8857
9572
  ctx.fill();
8858
9573
  }
8859
- const stageBearing = Math.atan2(dx, -dy);
8860
- ctx.fillStyle = "rgba(16, 20, 30, 0.95)";
9574
+ }
9575
+ function drawAudience(ctx, seat, neighborSeats, _focalPoint, stageBearing, eyeM, dx, dy, hasRelief, scene) {
9576
+ const own = seat.sectionId;
9577
+ let seatAhead = false;
9578
+ const figs = [];
8861
9579
  for (const other of neighborSeats) {
8862
9580
  if (other.id === seat.id) continue;
8863
9581
  const ox = other.x - seat.x;
8864
9582
  const oy = other.y - seat.y;
8865
9583
  const d = Math.hypot(ox, oy) * UNIT;
8866
- if (d < 0.3 || d > 14) continue;
9584
+ if (own && other.sectionId === own && d < 3 && ox * dx + oy * dy > 0) seatAhead = true;
9585
+ if (d < 0.3 || d > 17) continue;
8867
9586
  const bearing = Math.atan2(ox, -oy) - stageBearing;
8868
9587
  const yaw = (bearing * 180 / Math.PI + 540) % 360 - 180;
8869
- const headPitch = -Math.atan2(0.35, d) * 180 / Math.PI;
8870
- const headR = Math.min(46, 260 / (d / UNIT / 24 + 1.2) / 4);
8871
- const hx = yawToX(yaw);
8872
- const hy = pitchToY(headPitch);
8873
- ctx.beginPath();
8874
- ctx.arc(hx, hy, headR, 0, Math.PI * 2);
8875
- ctx.fill();
8876
- ctx.beginPath();
8877
- ctx.ellipse(hx, hy + headR * 1.4, headR * 1.7, headR * 0.9, 0, Math.PI, 0, true);
8878
- ctx.fill();
9588
+ const rise = hasRelief ? (other.eyeHeightM ?? SEATED_EYE_HEIGHT_M) - eyeM : 0;
9589
+ const headPitch = Math.atan2(rise - 0.28, d) * 180 / Math.PI;
9590
+ if (headPitch > 10 && d > 5) continue;
9591
+ let hh = 0;
9592
+ for (let k = 0; k < other.id.length; k++) hh = hh * 31 + other.id.charCodeAt(k) & 65535;
9593
+ const jitter = 0.9 + (hh & 15) / 40;
9594
+ const r = Math.min(78, Math.atan2(0.16, d) * 180 / Math.PI * PX_PER_DEG * jitter);
9595
+ if (r < 2) continue;
9596
+ figs.push({ hx: yawToX(yaw), hy: pitchToY(headPitch), r, d, hash: hh });
9597
+ }
9598
+ figs.sort((a, b) => b.d - a.d);
9599
+ 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();
9629
+ }
9630
+ }
9631
+ if (seatAhead) {
9632
+ const railTop = pitchToY(-34);
9633
+ ctx.fillStyle = "rgba(6, 8, 14, 0.92)";
9634
+ ctx.fillRect(0, railTop, W, H - railTop);
9635
+ ctx.fillStyle = "rgba(24, 30, 46, 0.9)";
9636
+ const humps = 16;
9637
+ for (let i = 0; i < humps; i++) {
9638
+ const cx = (i + 0.5) / humps * W;
9639
+ ctx.beginPath();
9640
+ ctx.ellipse(cx, railTop + 6, W / humps * 0.42, 22, 0, Math.PI, 0, true);
9641
+ ctx.fill();
9642
+ }
8879
9643
  }
8880
- const poleFade = ctx.createLinearGradient(0, 0, 0, H);
8881
- poleFade.addColorStop(0, "rgba(0,0,0,0.85)");
8882
- poleFade.addColorStop(0.12, "rgba(0,0,0,0)");
8883
- poleFade.addColorStop(0.88, "rgba(0,0,0,0)");
8884
- poleFade.addColorStop(1, "rgba(0,0,0,0.85)");
8885
- ctx.fillStyle = poleFade;
8886
- ctx.fillRect(0, 0, W, H);
8887
- return { url: canvas.toDataURL("image/jpeg", 0.82), distanceM: Math.round(distM) };
8888
9644
  }
8889
9645
  var TW = 480;
8890
9646
  var TH = 270;
@@ -8900,12 +9656,16 @@ function generateSeatThumb(seat, focalPoint, _neighborSeats) {
8900
9656
  const dx = focalPoint.x - seat.x;
8901
9657
  const dy = focalPoint.y - seat.y;
8902
9658
  const distM = Math.max(2, Math.hypot(dx, dy) * UNIT);
9659
+ const rand = rng(fnv1a(`${seat.id}|${Math.round(focalPoint.x)},${Math.round(focalPoint.y)}`));
9660
+ const baseHue = 210 + rand() * 150;
9661
+ const rigTint = hslToRgb(220 + (rand() - 0.5) * 60, 0.55, 0.82);
9662
+ const rigTintStr = `${rigTint[0]},${rigTint[1]},${rigTint[2]}`;
8903
9663
  const sky = ctx.createLinearGradient(0, 0, 0, TH);
8904
- sky.addColorStop(0, "#05070c");
8905
- sky.addColorStop(0.44, "#141b30");
8906
- sky.addColorStop(0.5, "#1b2440");
8907
- sky.addColorStop(0.58, "#10141f");
8908
- sky.addColorStop(1, "#070910");
9664
+ sky.addColorStop(0, "#080b12");
9665
+ sky.addColorStop(0.44, "#151c32");
9666
+ sky.addColorStop(0.5, "#1d2644");
9667
+ sky.addColorStop(0.58, "#161c2e");
9668
+ sky.addColorStop(1, "#131829");
8909
9669
  ctx.fillStyle = sky;
8910
9670
  ctx.fillRect(0, 0, TW, TH);
8911
9671
  const eyeM = seat.eyeHeightM ?? SEATED_EYE_HEIGHT_M;
@@ -8919,58 +9679,102 @@ function generateSeatThumb(seat, focalPoint, _neighborSeats) {
8919
9679
  const sy1 = pitchToTY(stageBasePitch);
8920
9680
  const sw = sx1 - sx0;
8921
9681
  const sh = sy1 - sy0;
8922
- const glow = ctx.createRadialGradient(TW / 2, (sy0 + sy1) / 2, 4, TW / 2, (sy0 + sy1) / 2, sw * 1.1);
8923
- glow.addColorStop(0, "rgba(129,140,248,0.5)");
8924
- glow.addColorStop(0.5, "rgba(99,102,241,0.15)");
9682
+ const nearGlow = Math.max(0.35, Math.min(1, 1 - (distM - 6) / 60));
9683
+ const archTop = pitchToTY(Math.min(HALF_VFOV - 1, stageTopPitch + 8));
9684
+ const glow = ctx.createRadialGradient(TW / 2, (sy0 + sy1) / 2, 4, TW / 2, (sy0 + sy1) / 2, sw * 1.15);
9685
+ glow.addColorStop(0, "rgba(255,214,150,0.34)");
9686
+ glow.addColorStop(0.28, "rgba(150,150,230,0.3)");
9687
+ glow.addColorStop(0.6, "rgba(99,102,241,0.12)");
8925
9688
  glow.addColorStop(1, "rgba(99,102,241,0)");
8926
9689
  ctx.fillStyle = glow;
8927
9690
  ctx.fillRect(sx0 - sw * 0.6, sy0 - sh * 1.2, sw * 2.2, sh * 3.4);
8928
- ctx.fillStyle = "#242c44";
8929
- ctx.fillRect(sx0, sy0, sw, sh);
9691
+ const topCol = hslToRgb(baseHue, 0.62, 0.55);
9692
+ const midCol = hslToRgb(baseHue + 40, 0.7, 0.5);
9693
+ const botCol = hslToRgb(baseHue + 70, 0.78, 0.52);
9694
+ ctx.save();
9695
+ ctx.beginPath();
9696
+ ctx.rect(sx0, sy0, sw, sh);
9697
+ ctx.clip();
8930
9698
  const backdrop = ctx.createLinearGradient(0, sy0, 0, sy1);
8931
- backdrop.addColorStop(0, "#7c5cff");
8932
- backdrop.addColorStop(0.5, "#e1447a");
8933
- backdrop.addColorStop(1, "#f59e0b");
8934
- ctx.globalAlpha = 0.78;
9699
+ backdrop.addColorStop(0, `rgb(${topCol[0]},${topCol[1]},${topCol[2]})`);
9700
+ backdrop.addColorStop(0.5, `rgb(${midCol[0]},${midCol[1]},${midCol[2]})`);
9701
+ backdrop.addColorStop(1, `rgb(${botCol[0]},${botCol[1]},${botCol[2]})`);
9702
+ ctx.globalAlpha = 0.8;
8935
9703
  ctx.fillStyle = backdrop;
8936
- const inset = sw * 0.06;
8937
- ctx.fillRect(sx0 + inset, sy0 + sh * 0.12, sw - inset * 2, sh * 0.62);
9704
+ ctx.fillRect(sx0, sy0, sw, sh);
8938
9705
  ctx.globalAlpha = 1;
8939
- ctx.fillStyle = "#0d1119";
8940
- ctx.fillRect(sx0, sy1 - sh * 0.1, sw, sh * 0.1);
8941
- const ph = sh * 0.45;
8942
- ctx.fillStyle = "rgba(8,10,16,0.85)";
8943
- for (const fx of [0.35, 0.5, 0.65]) {
8944
- const px = sx0 + sw * fx;
8945
- const py = sy1 - sh * 0.12;
8946
- ctx.beginPath();
8947
- ctx.arc(px, py - ph * 0.82, ph * 0.16, 0, Math.PI * 2);
8948
- ctx.fill();
8949
- ctx.fillRect(px - ph * 0.14, py - ph * 0.68, ph * 0.28, ph * 0.68);
8950
- }
8951
- ctx.globalAlpha = 0.15;
8952
- ctx.fillStyle = "#c7d2fe";
8953
- for (const fx of [0.3, 0.5, 0.7]) {
8954
- const bx = sx0 + sw * fx;
8955
- ctx.beginPath();
8956
- ctx.moveTo(bx - 5, pitchToTY(Math.min(HALF_VFOV, stageTopPitch + 18)));
8957
- ctx.lineTo(bx - sw * 0.09, sy1);
8958
- ctx.lineTo(bx + sw * 0.09, sy1);
8959
- ctx.lineTo(bx + 5, pitchToTY(Math.min(HALF_VFOV, stageTopPitch + 18)));
8960
- ctx.closePath();
8961
- ctx.fill();
9706
+ const fall = ctx.createRadialGradient(TW / 2, (sy0 + sy1) / 2, sw * 0.12, TW / 2, (sy0 + sy1) / 2, sw * 0.6);
9707
+ fall.addColorStop(0, "rgba(6,8,14,0)");
9708
+ fall.addColorStop(0.7, "rgba(6,8,14,0.15)");
9709
+ fall.addColorStop(1, "rgba(5,6,11,0.9)");
9710
+ ctx.fillStyle = fall;
9711
+ ctx.fillRect(sx0, sy0, sw, sh);
9712
+ ctx.restore();
9713
+ const perfCount = 3;
9714
+ const floorY = sy1 - sh * 0.08;
9715
+ for (let i = 0; i < perfCount; i++) {
9716
+ const t2 = (i + 1) / (perfCount + 1);
9717
+ drawPerformer(ctx, sx0 + sw * (0.24 + t2 * 0.52), floorY, sh * 0.46 * (0.9 + rand() * 0.24), {
9718
+ tone: [8, 10, 16],
9719
+ alpha: 0.9,
9720
+ pose: Math.floor(rand() * 4),
9721
+ rim: hslToRgb(baseHue + 20, 0.5, 0.7),
9722
+ rimStrength: 0.7
9723
+ });
8962
9724
  }
8963
- ctx.globalAlpha = 1;
8964
- ctx.fillStyle = "rgba(199,210,254,0.75)";
9725
+ ctx.fillStyle = "#05070d";
9726
+ ctx.fillRect(sx0 - sw * 0.5, archTop, sw * 0.5, sy1 - archTop);
9727
+ ctx.fillRect(sx1, archTop, sw * 0.5, sy1 - archTop);
9728
+ const legBot = sw * 0.05 + 2;
9729
+ const legTop = legBot * 1.35;
9730
+ ctx.fillStyle = "#03050a";
9731
+ ctx.beginPath();
9732
+ ctx.moveTo(sx0, sy1);
9733
+ ctx.lineTo(sx0 - legBot, sy1);
9734
+ ctx.lineTo(sx0 - legTop, archTop);
9735
+ ctx.lineTo(sx0 + sw * 0.012, archTop);
9736
+ ctx.closePath();
9737
+ ctx.fill();
9738
+ ctx.beginPath();
9739
+ ctx.moveTo(sx1, sy1);
9740
+ ctx.lineTo(sx1 + legBot, sy1);
9741
+ ctx.lineTo(sx1 + legTop, archTop);
9742
+ ctx.lineTo(sx1 - sw * 0.012, archTop);
9743
+ ctx.closePath();
9744
+ ctx.fill();
9745
+ ctx.fillRect(sx0 - legTop, archTop, sw + legTop * 2, (sy1 - archTop) * 0.13);
9746
+ ctx.fillStyle = `rgba(${rigTintStr},0.12)`;
9747
+ ctx.fillRect(sx0 - 1.2, archTop, 1.2, sy1 - archTop);
9748
+ ctx.fillRect(sx1, archTop, 1.2, sy1 - archTop);
9749
+ ctx.fillStyle = "#0b0f18";
9750
+ ctx.fillRect(sx0 - legBot, sy1 - sh * 0.05, sw + legBot * 2, sh * 0.05 + 2);
9751
+ const foot = ctx.createLinearGradient(0, sy1 - sh * 0.05, 0, sy1 + sh * 0.14);
9752
+ foot.addColorStop(0, `rgba(255,206,140,${(0.5 * nearGlow).toFixed(3)})`);
9753
+ foot.addColorStop(1, "rgba(255,206,140,0)");
9754
+ ctx.fillStyle = foot;
9755
+ ctx.fillRect(sx0 - legBot, sy1 - sh * 0.05, sw + legBot * 2, sh * 0.22);
9756
+ for (let i = 0; i < 3; i++) {
9757
+ const t2 = (i + 1) / 4;
9758
+ const fx = sx0 + sw * t2;
9759
+ const fy = pitchToTY(Math.min(HALF_VFOV - 1, stageTopPitch + 20));
9760
+ drawBeam(ctx, fx, fy, fx + (rand() - 0.5) * sw * 0.14, sy1, 3, sw * 0.09, rigTintStr, 0.17 * nearGlow);
9761
+ drawFixture(ctx, fx, fy, 1.8, rigTintStr);
9762
+ }
9763
+ ctx.fillStyle = `rgba(${rigTintStr},0.75)`;
8965
9764
  for (let i = 0; i < 8; i++) {
8966
9765
  ctx.beginPath();
8967
- ctx.arc((i + 0.5) / 8 * TW, pitchToTY(HALF_VFOV - 4 - i % 2 * 3), 2.2, 0, Math.PI * 2);
9766
+ ctx.arc((i + 0.5) / 8 * TW, pitchToTY(HALF_VFOV - 4 - i % 2 * 3), 2.2, 0, TAU2);
8968
9767
  ctx.fill();
8969
9768
  }
8970
9769
  ctx.fillStyle = "rgba(10,13,20,0.9)";
8971
9770
  ctx.fillRect(0, TH - 26, TW, 26);
8972
9771
  ctx.fillStyle = "rgba(30,37,54,0.9)";
8973
- for (let i = 0; i < 9; i++) ctx.fillRect(i * (TW / 9) + 6, TH - 22, TW / 9 - 12, 14);
9772
+ for (let i = 0; i < 9; i++) {
9773
+ const bx = i * (TW / 9) + TW / 18;
9774
+ ctx.beginPath();
9775
+ ctx.ellipse(bx, TH - 22, TW / 9 * 0.4, 15, 0, Math.PI, 0, true);
9776
+ ctx.fill();
9777
+ }
8974
9778
  return { url: canvas.toDataURL("image/jpeg", 0.8), distanceM: Math.round(distM) };
8975
9779
  }
8976
9780