@rydr/game-sdk 8.11.0 → 8.13.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.
Files changed (135) hide show
  1. package/README.md +26 -2
  2. package/dist/client/PlatformClient.d.ts.map +1 -1
  3. package/dist/client/PlatformClient.js +14 -2
  4. package/dist/client/PlatformClient.js.map +1 -1
  5. package/dist/client/Room.d.ts +9 -4
  6. package/dist/client/Room.d.ts.map +1 -1
  7. package/dist/client/Room.js +8 -5
  8. package/dist/client/Room.js.map +1 -1
  9. package/dist/host/PlatformHost.d.ts +4 -2
  10. package/dist/host/PlatformHost.d.ts.map +1 -1
  11. package/dist/host/PlatformHost.js +5 -3
  12. package/dist/host/PlatformHost.js.map +1 -1
  13. package/dist/protocol/messages.d.ts +11 -4
  14. package/dist/protocol/messages.d.ts.map +1 -1
  15. package/dist/protocol/room.d.ts +26 -0
  16. package/dist/protocol/room.d.ts.map +1 -1
  17. package/dist/protocol/version.d.ts +2 -2
  18. package/dist/protocol/version.d.ts.map +1 -1
  19. package/dist/protocol/version.js +23 -2
  20. package/dist/protocol/version.js.map +1 -1
  21. package/dist/three/README.md +111 -0
  22. package/dist/three/character/index.d.ts +14 -0
  23. package/dist/three/character/index.d.ts.map +1 -0
  24. package/dist/three/character/index.js +14 -0
  25. package/dist/three/character/index.js.map +1 -0
  26. package/dist/three/character/kit.d.ts +74 -0
  27. package/dist/three/character/kit.d.ts.map +1 -0
  28. package/dist/three/character/kit.js +254 -0
  29. package/dist/three/character/kit.js.map +1 -0
  30. package/dist/three/character/riders.d.ts +20 -0
  31. package/dist/three/character/riders.d.ts.map +1 -0
  32. package/dist/three/character/riders.js +383 -0
  33. package/dist/three/character/riders.js.map +1 -0
  34. package/dist/three/character/skeleton.d.ts +105 -0
  35. package/dist/three/character/skeleton.d.ts.map +1 -0
  36. package/dist/three/character/skeleton.js +57 -0
  37. package/dist/three/character/skeleton.js.map +1 -0
  38. package/dist/three/controller/glyph-texture.d.ts +29 -0
  39. package/dist/three/controller/glyph-texture.d.ts.map +1 -0
  40. package/dist/three/controller/glyph-texture.js +68 -0
  41. package/dist/three/controller/glyph-texture.js.map +1 -0
  42. package/dist/three/controller/index.d.ts +12 -0
  43. package/dist/three/controller/index.d.ts.map +1 -0
  44. package/dist/three/controller/index.js +12 -0
  45. package/dist/three/controller/index.js.map +1 -0
  46. package/dist/three/controller/joycon-designs.d.ts +49 -0
  47. package/dist/three/controller/joycon-designs.d.ts.map +1 -0
  48. package/dist/three/controller/joycon-designs.js +742 -0
  49. package/dist/three/controller/joycon-designs.js.map +1 -0
  50. package/dist/three/controller/joycon-shoulder-mesh.d.ts +41 -0
  51. package/dist/three/controller/joycon-shoulder-mesh.d.ts.map +1 -0
  52. package/dist/three/controller/joycon-shoulder-mesh.js +527 -0
  53. package/dist/three/controller/joycon-shoulder-mesh.js.map +1 -0
  54. package/dist/three/controller/joycon-spec.d.ts +233 -0
  55. package/dist/three/controller/joycon-spec.d.ts.map +1 -0
  56. package/dist/three/controller/joycon-spec.js +285 -0
  57. package/dist/three/controller/joycon-spec.js.map +1 -0
  58. package/dist/three/controller/three-pad-renderer.d.ts +81 -0
  59. package/dist/three/controller/three-pad-renderer.d.ts.map +1 -0
  60. package/dist/three/controller/three-pad-renderer.js +433 -0
  61. package/dist/three/controller/three-pad-renderer.js.map +1 -0
  62. package/dist/three/index.d.ts +2 -0
  63. package/dist/three/index.d.ts.map +1 -1
  64. package/dist/three/index.js +2 -0
  65. package/dist/three/index.js.map +1 -1
  66. package/dist/three/rider/cyclist-pose.d.ts +62 -0
  67. package/dist/three/rider/cyclist-pose.d.ts.map +1 -0
  68. package/dist/three/rider/cyclist-pose.js +173 -0
  69. package/dist/three/rider/cyclist-pose.js.map +1 -0
  70. package/dist/three/rider/index.d.ts +8 -0
  71. package/dist/three/rider/index.d.ts.map +1 -0
  72. package/dist/three/rider/index.js +8 -0
  73. package/dist/three/rider/index.js.map +1 -0
  74. package/dist/three/rider/three-rider-rig.d.ts +80 -0
  75. package/dist/three/rider/three-rider-rig.d.ts.map +1 -0
  76. package/dist/three/rider/three-rider-rig.js +418 -0
  77. package/dist/three/rider/three-rider-rig.js.map +1 -0
  78. package/dist/ui/README.md +153 -1
  79. package/dist/ui/controller/combo-demo.d.ts +9 -0
  80. package/dist/ui/controller/combo-demo.d.ts.map +1 -1
  81. package/dist/ui/controller/combo-demo.js +1 -0
  82. package/dist/ui/controller/combo-demo.js.map +1 -1
  83. package/dist/ui/controller/control-spotlight.d.ts +10 -1
  84. package/dist/ui/controller/control-spotlight.d.ts.map +1 -1
  85. package/dist/ui/controller/control-spotlight.js +42 -42
  86. package/dist/ui/controller/control-spotlight.js.map +1 -1
  87. package/dist/ui/controller/control-trainer.d.ts +14 -0
  88. package/dist/ui/controller/control-trainer.d.ts.map +1 -1
  89. package/dist/ui/controller/control-trainer.js +6 -0
  90. package/dist/ui/controller/control-trainer.js.map +1 -1
  91. package/dist/ui/controller/controller-map.d.ts +9 -0
  92. package/dist/ui/controller/controller-map.d.ts.map +1 -1
  93. package/dist/ui/controller/controller-map.js +8 -6
  94. package/dist/ui/controller/controller-map.js.map +1 -1
  95. package/dist/ui/controller/controller-pad.d.ts +35 -2
  96. package/dist/ui/controller/controller-pad.d.ts.map +1 -1
  97. package/dist/ui/controller/controller-pad.js +47 -7
  98. package/dist/ui/controller/controller-pad.js.map +1 -1
  99. package/dist/ui/controller/index.d.ts +1 -0
  100. package/dist/ui/controller/index.d.ts.map +1 -1
  101. package/dist/ui/controller/index.js +1 -0
  102. package/dist/ui/controller/index.js.map +1 -1
  103. package/dist/ui/controller/renderer.d.ts +118 -0
  104. package/dist/ui/controller/renderer.d.ts.map +1 -0
  105. package/dist/ui/controller/renderer.js +93 -0
  106. package/dist/ui/controller/renderer.js.map +1 -0
  107. package/dist/ui/controller/styles.js +30 -0
  108. package/dist/ui/controller/styles.js.map +1 -1
  109. package/dist/ui/index.d.ts +7 -0
  110. package/dist/ui/index.d.ts.map +1 -1
  111. package/dist/ui/index.js +7 -0
  112. package/dist/ui/index.js.map +1 -1
  113. package/dist/ui/keycap.d.ts +11 -0
  114. package/dist/ui/keycap.d.ts.map +1 -1
  115. package/dist/ui/keycap.js +3 -0
  116. package/dist/ui/keycap.js.map +1 -1
  117. package/dist/ui/pad-hints.d.ts +78 -0
  118. package/dist/ui/pad-hints.d.ts.map +1 -0
  119. package/dist/ui/pad-hints.js +103 -0
  120. package/dist/ui/pad-hints.js.map +1 -0
  121. package/dist/ui/rider-rig.d.ts +315 -0
  122. package/dist/ui/rider-rig.d.ts.map +1 -0
  123. package/dist/ui/rider-rig.js +1000 -0
  124. package/dist/ui/rider-rig.js.map +1 -0
  125. package/dist/ui/showcase/index.d.ts +11 -0
  126. package/dist/ui/showcase/index.d.ts.map +1 -1
  127. package/dist/ui/showcase/index.js +109 -1
  128. package/dist/ui/showcase/index.js.map +1 -1
  129. package/dist/ui/solo-labeled-diamond.d.ts +7 -0
  130. package/dist/ui/solo-labeled-diamond.d.ts.map +1 -1
  131. package/dist/ui/solo-labeled-diamond.js +1 -1
  132. package/dist/ui/solo-labeled-diamond.js.map +1 -1
  133. package/dist/ui/styles.js +9 -0
  134. package/dist/ui/styles.js.map +1 -1
  135. package/package.json +8 -4
@@ -0,0 +1,1000 @@
1
+ /**
2
+ * RIDER RIG — a rider on their trainer, pedalling, with their watts rising out of the machine.
3
+ *
4
+ * The one drawing in this kit whose subject is **the bike itself**. Everything else in
5
+ * `@rydr/game-sdk/ui` teaches the rider about the *pad* — which button, which combination, which
6
+ * lesson — and a game built from those pieces alone would tell a new player that the controller is
7
+ * the thing in their hands. It isn't, or not only: the trainer is half of it. This component is the
8
+ * picture that says so, and it exists to be put on the screens where that has to land — an
9
+ * onboarding beat, a "how this game works" panel, a store page, the pause screen of a game whose
10
+ * whole mechanic is effort.
11
+ *
12
+ * ```ts
13
+ * const rig = mountRiderRig(panel, { design: "solid", ftp: session.identity.ftp });
14
+ *
15
+ * // each frame:
16
+ * rig.setPower(session.hardware.current.power);
17
+ * rig.setCadence(session.hardware.current.cadence);
18
+ * rig.tick(dt);
19
+ * ```
20
+ *
21
+ * ⚠️ **Not to be confused with {@link mountControlTrainer}**, which lives one folder over in
22
+ * `./controller/`. English "trainer" means both the *turbo trainer* and the *coach*, and the SDK has
23
+ * one of each: `mountControlTrainer` is the COACH — a guided lesson that watches the rider perform a
24
+ * combination. This is the TURBO TRAINER — the physical machine, drawn. They share no code and
25
+ * answer different questions. If you want to teach a control, you want the other one.
26
+ *
27
+ * ## There is no rear wheel, and that is the point
28
+ *
29
+ * This is a **direct-drive** trainer, the kind RYDR runs on. The rear wheel comes OFF and the bike's
30
+ * dropouts bolt straight onto the trainer's own axle, so the chain drives the trainer's cassette
31
+ * directly — that junction is where the connection to the platform physically happens. Drawing a
32
+ * wheel-on trainer instead (a tyre pressed onto a spinning roller) would be a picture of a different,
33
+ * older machine, and would quietly teach a newcomer that the trainer is an accessory the bike sits on
34
+ * top of rather than a part of the drivetrain.
35
+ *
36
+ * It also settles what may move. **The bike is still.** It is bolted to a fixed machine: the frame
37
+ * does not sway, and the front wheel — parked on the floor, driven by nothing — does not turn. The
38
+ * only things that rotate are the ones the rider's legs actually drive: the crank, the trainer's
39
+ * cassette, and its flywheel. A drawing whose frame rocked or whose front wheel spun would be
40
+ * describing a bike going somewhere, which is the one thing this bike is not doing.
41
+ *
42
+ * ## Two engines, deliberately separate
43
+ *
44
+ * **Cadence turns the legs. Power fills the gauge.** Nothing crosses over, and that separation is the
45
+ * entire pedagogical content of the animation:
46
+ *
47
+ * · `setCadence(rpm)` drives the crank, the legs, the cassette and the flywheel. It is the
48
+ * *movement*.
49
+ * · `setPower(watts)` drives the gauge and the rider's posture — the torso folding down onto the
50
+ * bars, and past ~110% FTP a rise out of the saddle. It is the *effort*.
51
+ *
52
+ * Driving both from watts would be less code and would teach the opposite of the truth: that a rider
53
+ * spinning fast against no resistance is doing something. Two riders at 90 rpm, one at 100 W and one
54
+ * at 300 W, have identical legs and nothing else in common — and which of them the game is reading is
55
+ * exactly what a newcomer has to understand. So the legs say "you are pedalling" and only the gauge
56
+ * and the posture say "you are pushing".
57
+ *
58
+ * ## The gauge is FTP-relative, and it leaves the machine
59
+ *
60
+ * The scale runs `0 → 1.5 × ftp`, so the rider's own threshold sits at two thirds of the way up with
61
+ * headroom above it for a sprint, and it carries a labelled FTP reference mark. A 150 W rider and a
62
+ * 350 W rider therefore see the same gauge do the same thing for the same effort — never hard-code a
63
+ * raw-watt scale (the same rule as {@link createPowerRace}). Zone colours are computed from %FTP and
64
+ * are **local to this component**: they are deliberately NOT `@rydr/game-sdk/difficulty`'s ramp, which
65
+ * colours the rider's *chosen calibration level* and would be a different quantity wearing the same
66
+ * paint.
67
+ *
68
+ * It is presented as a **glass tooltip leaving the machine** — a bubble with a pointer into the
69
+ * trainer's shoulder, the watts printed large at the top and the bar below them. The bar's
70
+ * orientation drives the shape of the box: a landscape bubble where it fills left → right, a portrait
71
+ * one where it fills bottom → top. Two reasons, and
72
+ * neither is decoration. Where the number comes from is part of what the picture says: these watts
73
+ * are measured by *that* machine, not floating beside the bike. And the bubble is what gives every
74
+ * label somewhere to live: the number, its unit and the FTP mark all sit INSIDE the glass, so none of
75
+ * them is ever printed over the frame, the rider or the trainer. The version before it stood a bare
76
+ * column next to the machine and hung the FTP label off its side, where it landed on the gauge's own
77
+ * rungs.
78
+ *
79
+ * Feed it raw `power`, not `smoothedPower`. It keeps both: the numeric readout shows the true
80
+ * instantaneous value, while the bar itself follows an internal EMA so it climbs smoothly between the
81
+ * trainer's sparse (~1–4 Hz) updates. That is the split `HardwareSnapshot.power` documents, resolved
82
+ * here once so no caller has to hold two numbers.
83
+ *
84
+ * ## One geometry, three paints
85
+ *
86
+ * `design` picks the look; the skeleton, the joint solving and the timing are shared. Three art
87
+ * directions because a component this decorative has to sit inside games that look nothing alike:
88
+ *
89
+ * Every gauge is a CONTINUOUS fill; what changes is the paint and the tooltip's orientation.
90
+ *
91
+ * · `solid` (default) — flat opaque silhouette; a landscape tooltip with the figure beside the
92
+ * title and the bar under both. Most legible; start here.
93
+ * · `blueprint` — thin technical linework; a PORTRAIT tooltip whose bar runs vertically and which
94
+ * carries NO figure at all, only the title and a tall bar marked at FTP. On that variant the bar
95
+ * IS the reading: a rider glancing at a trainer screen reads a fill long before three digits, and
96
+ * a number beside it competes with the thing it annotates.
97
+ * · `neon` — glowing outline; a landscape tooltip whose fill carries a bright leading edge.
98
+ *
99
+ * ## SVG for the drawing, CSS for the tooltip
100
+ *
101
+ * The rider, the bike and the trainer are one inline SVG: they are a picture, made of coordinates and
102
+ * joints. The watts tooltip is an HTML element positioned over it, because it is a box with rows of
103
+ * text — and that is the kit's own split, the one `controller-map.ts` already makes when it draws its
104
+ * callout cards in HTML and only the LINES between them in SVG. Boxes are CSS; shapes are SVG.
105
+ *
106
+ * The tooltip being CSS is what buys real `backdrop-filter` glass, and it removes a whole class of
107
+ * bug: its FTP label is placed off one custom property with a -50% translate, so it is centred on its
108
+ * own tick by construction rather than by a coordinate computed twice.
109
+ *
110
+ * Self-contained like the rest of the kit: it injects its own scoped `.rrig-*` stylesheet once into
111
+ * `<head>`, pulls in no three.js, and reads no global CSS. It also never renders per frame — the SVG
112
+ * is built once and a driver mutates cached nodes behind a dirty guard, exactly as `pad-svg.ts` does,
113
+ * so an idle rig costs no DOM writes at 60 Hz.
114
+ *
115
+ * ── Sizing contract (same as the rest of the kit) ──────────────────────────────────────────────
116
+ * Tuned for a trainer screen read from a few feet away while pedalling. Pick a `size` preset; don't
117
+ * override the `.rrig-*` dimensions or wrap it in `transform: scale()`.
118
+ * ────────────────────────────────────────────────────────────────────────────────────────────────
119
+ */
120
+ const STYLE_ID = "rydr-rider-rig-styles";
121
+ const NS = "http://www.w3.org/2000/svg";
122
+ /**
123
+ * Width presets, in px, and the type unit that goes with each.
124
+ *
125
+ * The tooltip is HTML, so its type does not scale with the SVG on its own — and left at a fixed px
126
+ * size it swamped the drawing at `sm` and looked lost at `lg`. `u` is the em base every tooltip
127
+ * dimension is expressed against, so a preset scales the box and its contents together. The presets
128
+ * are the sanctioned sizes; nothing else should be scaling this component (see the sizing contract).
129
+ */
130
+ const SIZES = {
131
+ sm: { w: 240, u: 7.5 },
132
+ md: { w: 330, u: 10 },
133
+ lg: { w: 440, u: 13 },
134
+ };
135
+ /* ───────────────────────────── geometry ─────────────────────────────
136
+ * One coordinate space, shared by all three designs, in a 200×200 viewBox.
137
+ *
138
+ * Proportioned off the front wheel, which is the only wheel left: wheelbase ≈ 1.55 × wheel diameter,
139
+ * bottom bracket ≈ 0.79 × wheel radius above the ground, trainer axle level with the front hub. Those
140
+ * ratios are what make a drawing read as "a bike" before any detail is examined; an earlier pass with
141
+ * a wheelbase near 2× the diameter looked like a lowrider, and no arm length could reach the bars
142
+ * from a plausible torso angle.
143
+ */
144
+ const VB_W = 200;
145
+ const VB_H = 200;
146
+ const GROUND = 178;
147
+ const WHEEL_R = 26;
148
+ /**
149
+ * The trainer's axle — where the bike's dropouts bolt on, and where a rear wheel would have been.
150
+ *
151
+ * Level with the front hub: a direct-drive unit reproduces the rear axle's height, so the bike sits
152
+ * flat rather than nose-up.
153
+ */
154
+ const AXLE = { x: 78, y: 152 };
155
+ /** Front hub. Its tyre is on the ground (`y + WHEEL_R === GROUND`), and it never turns. */
156
+ const FRONT = { x: 158, y: 152 };
157
+ /** Bottom bracket — the crank's centre of rotation, and the anchor the whole rider hangs off. */
158
+ const BB = { x: 112, y: 157 };
159
+ const CRANK_R = 15;
160
+ const CHAINRING_R = 8;
161
+ /** The trainer's cassette, on its axle. This is what the chain drives. */
162
+ const CASSETTE_R = 7;
163
+ /** Top of the seat tube, where the seat stay and the top tube meet it. */
164
+ const SEAT_CLUSTER = { x: 101, y: 121 };
165
+ const SADDLE = { x: 96, y: 106 };
166
+ const HEADTUBE_TOP = { x: 144, y: 112 };
167
+ const HEADTUBE_BOT = { x: 148, y: 128 };
168
+ /** Where the hands go. */
169
+ const BAR = { x: 152, y: 108 };
170
+ /** The resistance flywheel, housed inside the trainer body. */
171
+ const FLY = { x: 70, y: 165, r: 12 };
172
+ /** Rider segment lengths, in the same units. */
173
+ const THIGH = 38;
174
+ const SHIN = 40;
175
+ const UPPER_ARM = 25;
176
+ const FOREARM = 25;
177
+ /**
178
+ * Hip at rest — just above and behind the saddle. Effort lifts it.
179
+ *
180
+ * Chosen so the leg is near full extension at the bottom of the stroke and properly folded at the
181
+ * top, which is what makes the pedalling read as pedalling rather than as a leg waving.
182
+ */
183
+ const HIP0 = { x: 94, y: 100 };
184
+ /** Shoulder at rest — a moderate road position, ~42° of back angle. Effort folds it down and forward. */
185
+ const SHOULDER0 = { x: 136, y: 62 };
186
+ /**
187
+ * Where each layout hangs, in percentages of the square viewBox.
188
+ *
189
+ * How far along its own bottom edge each layout hangs its pointer. The landscape box is wide, so its
190
+ * pointer sits well in from the left; the portrait box is narrow and hangs its own nearer the corner.
191
+ */
192
+ const TIP_ANCHOR = {
193
+ horizontal: { x: 0.32, y: 0.695, arrow: 6.2 },
194
+ vertical: { x: 0.325, y: 0.72, arrow: 6.3 },
195
+ };
196
+ /**
197
+ * The top of the gauge, as a multiple of FTP.
198
+ *
199
+ * 1.5 puts threshold at two thirds height: high enough that a rider at FTP reads as "working hard",
200
+ * with real headroom left so a sprint has somewhere to go. A scale that topped out AT FTP would peg
201
+ * for most of any actual game.
202
+ */
203
+ const GAUGE_MAX_FTP_PCT = 1.5;
204
+ /** Where the FTP reference mark lands, as a fraction of the gauge. */
205
+ const FTP_FRAC = 1 / GAUGE_MAX_FTP_PCT;
206
+ /**
207
+ * Gauge smoothing time constant, seconds. Short — this is here to absorb the trainer's 1–4 Hz update
208
+ * steps, not to lag behind a real surge, which the rider must see land.
209
+ */
210
+ const POWER_TAU_S = 0.22;
211
+ /** Cadence is smoothed harder: the legs must never visibly stutter on a sparse update. */
212
+ const CADENCE_TAU_S = 0.45;
213
+ /**
214
+ * Visual gear-down for the driven parts.
215
+ *
216
+ * A trainer's cassette turns several times crank speed and its flywheel faster again — at 90 rpm
217
+ * that's past the frame rate, where a drawn spoke stops reading as rotation and starts strobing
218
+ * backwards. So they turn at a fraction of the truth and **motion streaks carry the rest of the
219
+ * speed**, fading in as cadence rises. A drawing that flickers tells the rider less than an honest
220
+ * blur.
221
+ */
222
+ const CASSETTE_GEAR = 1.6;
223
+ const FLY_GEAR = 2.1;
224
+ /** Cadence at which the motion streaks reach full strength. */
225
+ const STREAK_FULL_RPM = 95;
226
+ /**
227
+ * %FTP zone colours — LOCAL to this component. See the module header on why this is not
228
+ * `@rydr/game-sdk/difficulty`'s ramp.
229
+ */
230
+ const ZONES = [
231
+ { max: 0.55, color: "#6b7f9e", glow: "#9fb4d4" }, // recovery
232
+ { max: 0.75, color: "#38bdf8", glow: "#7dd3fc" }, // endurance
233
+ { max: 0.9, color: "#22c55e", glow: "#6ee7a2" }, // tempo
234
+ { max: 1.05, color: "#eab308", glow: "#fde047" }, // threshold
235
+ { max: 1.2, color: "#f97316", glow: "#fdba74" }, // vo2
236
+ { max: Infinity, color: "#ef4444", glow: "#fca5a5" }, // anaerobic
237
+ ];
238
+ const zoneFor = (ftpPct) => ZONES.find((z) => ftpPct < z.max);
239
+ const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
240
+ const lerp = (a, b, t) => a + (b - a) * t;
241
+ const rad = (deg) => (deg * Math.PI) / 180;
242
+ function svgNode(tag, attrs) {
243
+ const n = document.createElementNS(NS, tag);
244
+ if (attrs)
245
+ for (const k in attrs)
246
+ n.setAttribute(k, String(attrs[k]));
247
+ return n;
248
+ }
249
+ /** A point on the crank circle. 0° = pedal forward; the angle advances the way the crank turns. */
250
+ function pedalAt(angleDeg) {
251
+ return { x: BB.x + CRANK_R * Math.cos(rad(angleDeg)), y: BB.y + CRANK_R * Math.sin(rad(angleDeg)) };
252
+ }
253
+ /**
254
+ * Two-bone IK: given a root, a target, and two segment lengths, find the joint between them.
255
+ *
256
+ * `bend` picks which of the two mirror solutions to take — `"forward"` puts the joint on the +x side
257
+ * of the root→target line (a knee, which tracks ahead of the leg) and `"back"` on the −x side (an
258
+ * elbow, which drops behind the arm). Out-of-reach targets are clamped rather than returning NaN: a
259
+ * leg that can't reach straightens instead of vanishing, which is the failure a viewer forgives.
260
+ */
261
+ function solveJoint(root, target, l1, l2, bend) {
262
+ const dx = target.x - root.x;
263
+ const dy = target.y - root.y;
264
+ const raw = Math.hypot(dx, dy);
265
+ const d = clamp(raw, Math.abs(l1 - l2) + 0.001, l1 + l2 - 0.001);
266
+ const ux = raw ? dx / raw : 0;
267
+ const uy = raw ? dy / raw : 0;
268
+ const a = (l1 * l1 - l2 * l2 + d * d) / (2 * d);
269
+ const h = Math.sqrt(Math.max(0, l1 * l1 - a * a));
270
+ // The two perpendiculars to the root→target direction.
271
+ const px = -uy;
272
+ const py = ux;
273
+ const s = bend === "forward" ? (px >= 0 ? 1 : -1) : px >= 0 ? -1 : 1;
274
+ return { x: root.x + a * ux + s * h * px, y: root.y + a * uy + s * h * py };
275
+ }
276
+ /** `d` for a circular arc, swept from `a0` to `a1` degrees about a centre. */
277
+ function arcPath(cx, cy, r, a0, a1) {
278
+ const x0 = cx + r * Math.cos(rad(a0));
279
+ const y0 = cy + r * Math.sin(rad(a0));
280
+ const x1 = cx + r * Math.cos(rad(a1));
281
+ const y1 = cy + r * Math.sin(rad(a1));
282
+ const large = Math.abs(a1 - a0) > 180 ? 1 : 0;
283
+ const sweep = a1 > a0 ? 1 : 0;
284
+ return `M ${x0.toFixed(2)} ${y0.toFixed(2)} A ${r} ${r} 0 ${large} ${sweep} ${x1.toFixed(2)} ${y1.toFixed(2)}`;
285
+ }
286
+ /**
287
+ * How each design fills the one shared track.
288
+ *
289
+ * The track's geometry is common to all three — what differs is how the fill is drawn in it: one
290
+ * continuous bar, a stack of discrete rungs, or a bar with a bright leading edge. (An earlier pass
291
+ * gave `neon` a curved arc swept around the flywheel; an arc does not compose inside the bubble, and
292
+ * the bubble is the better answer to "make the gauge come out of the machine".)
293
+ */
294
+ const GAUGE_SPEC = {
295
+ solid: { shape: "bar", orient: "horizontal" },
296
+ blueprint: { shape: "bar", orient: "vertical" },
297
+ neon: { shape: "tube", orient: "horizontal" },
298
+ };
299
+ /**
300
+ * The default renderer: the flat SVG drawing, which is exactly what the component did inline before
301
+ * the seam existed. Nothing about the flat rig changed.
302
+ *
303
+ * It keeps its OWN dirty guards. Skipping a DOM write is the cheap win here, so the guards belong
304
+ * next to the writes rather than in the component — and a WebGL renderer's idea of "nothing to do" is
305
+ * a different question entirely (whether to draw a frame at all), which is its business, not ours.
306
+ *
307
+ * Seeded to `-Infinity`, NOT `NaN`: every guard is a `Math.abs(next - last) > eps` test, and any
308
+ * comparison against `NaN` is false, so a `NaN` seed silently suppresses the FIRST paint — and with
309
+ * it every write that never becomes dirty again.
310
+ */
311
+ export function createSvgRigRenderer({ design }) {
312
+ ensureStyles();
313
+ const n = buildSvg(design);
314
+ const last = { crank: -Infinity, cassette: -Infinity, fly: -Infinity, streak: -Infinity, pose: -Infinity };
315
+ const pt = (p) => `${p.x.toFixed(1)},${p.y.toFixed(1)}`;
316
+ return {
317
+ node: n.svg,
318
+ // An orthographic side view: the anchor is just its place in the viewBox.
319
+ anchor: () => ({
320
+ x: RIG_GEOMETRY.TRAINER_ANCHOR.x / VB_W,
321
+ y: RIG_GEOMETRY.TRAINER_ANCHOR.y / VB_H,
322
+ }),
323
+ setPose(pose) {
324
+ if (Math.abs(pose.crankAngle - last.crank) > 0.25) {
325
+ last.crank = pose.crankAngle;
326
+ n.crank.setAttribute("transform", `rotate(${pose.crankAngle.toFixed(1)} ${BB.x} ${BB.y})`);
327
+ }
328
+ if (Math.abs(pose.cassetteAngle - last.cassette) > 0.5) {
329
+ last.cassette = pose.cassetteAngle;
330
+ n.cassette.setAttribute("transform", `rotate(${pose.cassetteAngle.toFixed(1)} ${AXLE.x} ${AXLE.y})`);
331
+ }
332
+ if (Math.abs(pose.flyAngle - last.fly) > 0.5) {
333
+ last.fly = pose.flyAngle;
334
+ n.flySpokes.setAttribute("transform", `rotate(${pose.flyAngle.toFixed(1)} ${FLY.x} ${FLY.y})`);
335
+ }
336
+ if (Math.abs(pose.streak - last.streak) > 0.02) {
337
+ last.streak = pose.streak;
338
+ n.flyStreaks.setAttribute("opacity", pose.streak.toFixed(2));
339
+ }
340
+ // One scalar standing in for the whole body: if none of the joints moved enough to see, none of
341
+ // the eight writes below are worth making.
342
+ const moved = pose.hip.x + pose.hip.y + pose.shoulder.x + pose.shoulder.y + pose.kneeNear.x + pose.kneeNear.y;
343
+ if (Math.abs(moved - last.pose) > 0.15) {
344
+ last.pose = moved;
345
+ n.legNear.setAttribute("points", `${pt(pose.hip)} ${pt(pose.kneeNear)} ${pt(pose.pedalNear)}`);
346
+ n.legFar.setAttribute("points", `${pt(pose.hip)} ${pt(pose.kneeFar)} ${pt(pose.pedalFar)}`);
347
+ n.armNear.setAttribute("points", `${pt(pose.shoulder)} ${pt(pose.elbow)} ${pt(BAR)}`);
348
+ n.torso.setAttribute("x1", pose.hip.x.toFixed(1));
349
+ n.torso.setAttribute("y1", pose.hip.y.toFixed(1));
350
+ n.torso.setAttribute("x2", pose.shoulder.x.toFixed(1));
351
+ n.torso.setAttribute("y2", pose.shoulder.y.toFixed(1));
352
+ n.neck.setAttribute("x1", pose.shoulder.x.toFixed(1));
353
+ n.neck.setAttribute("y1", pose.shoulder.y.toFixed(1));
354
+ n.neck.setAttribute("x2", pose.head.x.toFixed(1));
355
+ n.neck.setAttribute("y2", pose.head.y.toFixed(1));
356
+ n.head.setAttribute("cx", pose.head.x.toFixed(1));
357
+ n.head.setAttribute("cy", pose.head.y.toFixed(1));
358
+ }
359
+ },
360
+ dispose() {
361
+ n.svg.remove();
362
+ },
363
+ };
364
+ }
365
+ /**
366
+ * The drawing space every renderer works in: a 200×200 box with y pointing DOWN and the floor at
367
+ * `GROUND`. A 3D renderer maps it once (x − 100, GROUND − y) and is done.
368
+ */
369
+ export const RIG_GEOMETRY = {
370
+ VB_W,
371
+ VB_H,
372
+ GROUND,
373
+ WHEEL_R,
374
+ AXLE,
375
+ FRONT,
376
+ BB,
377
+ CRANK_R,
378
+ CHAINRING_R,
379
+ CASSETTE_R,
380
+ SEAT_CLUSTER,
381
+ SADDLE,
382
+ HEADTUBE_TOP,
383
+ HEADTUBE_BOT,
384
+ BAR,
385
+ FLY,
386
+ /** The rider's rest hip and shoulder — a renderer building a skeleton needs the spine's length. */
387
+ HIP0,
388
+ SHOULDER0,
389
+ /**
390
+ * The point on the trainer the watts tooltip points at — its top face, where the machine is tall
391
+ * enough for a pointer to meet it. Both renderers answer {@link RigRenderer.anchor} from this.
392
+ */
393
+ TRAINER_ANCHOR: { x: 47, y: 157 },
394
+ };
395
+ export function mountRiderRig(host, opts = {}) {
396
+ ensureStyles();
397
+ const design = opts.design ?? "solid";
398
+ const { shape, orient } = GAUGE_SPEC[design];
399
+ const preset = SIZES[opts.size ?? "md"];
400
+ const showReadout = opts.readout !== false;
401
+ let ftp = Math.max(1, opts.ftp ?? 200);
402
+ let rawPower = 0;
403
+ let smoothPower = 0;
404
+ let targetCadence = opts.idleCadence ?? 0;
405
+ let smoothCadence = targetCadence;
406
+ let crankAngle = 20;
407
+ let cassetteAngle = 0;
408
+ let flyAngle = 0;
409
+ const reduceMotion = typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
410
+ const root = document.createElement("div");
411
+ root.className = `rrig rrig--${design}`;
412
+ root.style.width = `${preset.w}px`;
413
+ root.style.setProperty("--rrig-u", `${preset.u}px`);
414
+ const renderer = (opts.renderer ?? createSvgRigRenderer)({ design });
415
+ root.appendChild(renderer.node);
416
+ const anchor = renderer.anchor();
417
+ const n = buildTooltip(root, { shape, orient, showReadout, label: opts.label ?? "WATTS", anchor });
418
+ let tipOver = { ...opts.tip };
419
+ placeTip(n.tipRoot, orient, anchor, tipOver);
420
+ host.appendChild(root);
421
+ /** Dirty guards for the TOOLTIP. The renderer keeps its own for the drawing. */
422
+ const last = { frac: -Infinity, zone: "", readout: -Infinity };
423
+ /** Repaint everything the CURRENT state implies. Cheap: every write is guarded. */
424
+ function paint() {
425
+ const effort = smoothPower / ftp;
426
+ // ── the pose: solved HERE, once, for whatever is drawing ──────────────
427
+ // Nothing that moves is the bike: it is bolted to a fixed machine. Only the crank and the two
428
+ // things the chain turns rotate at all — see the module header.
429
+ //
430
+ // Out of the saddle past ~105% FTP. Below that the hip is planted: a rider at endurance pace who
431
+ // bobbed would read as struggling. The RIDER rises and sways; the bike never moves with them.
432
+ const standing = clamp((effort - 1.05) / 0.35, 0, 1);
433
+ const surge = Math.sin(rad(crankAngle * 2)) * standing;
434
+ const fold = clamp(effort, 0, 1.3);
435
+ const hip = { x: HIP0.x + surge * 1.2, y: HIP0.y - standing * 5 + surge * 1.2 };
436
+ const shoulder = {
437
+ x: SHOULDER0.x + fold * 5 + surge * 1,
438
+ y: SHOULDER0.y + fold * 7 - standing * 4,
439
+ };
440
+ const pedalNear = pedalAt(crankAngle);
441
+ const pedalFar = pedalAt(crankAngle + 180);
442
+ renderer.setPose({
443
+ hip,
444
+ shoulder,
445
+ // The head tucks toward the bars as the effort rises, riding on the shoulder.
446
+ head: {
447
+ x: shoulder.x + lerp(10, 14, clamp(fold, 0, 1)),
448
+ y: shoulder.y - lerp(12, 6, clamp(fold, 0, 1)),
449
+ },
450
+ elbow: solveJoint(shoulder, BAR, UPPER_ARM, FOREARM, "back"),
451
+ kneeNear: solveJoint(hip, pedalNear, THIGH, SHIN, "forward"),
452
+ kneeFar: solveJoint(hip, pedalFar, THIGH, SHIN, "forward"),
453
+ pedalNear,
454
+ pedalFar,
455
+ crankAngle,
456
+ cassetteAngle,
457
+ flyAngle,
458
+ // Streaks stand in for the speed the spokes can't show without strobing.
459
+ streak: clamp(smoothCadence / STREAK_FULL_RPM, 0, 1),
460
+ effort,
461
+ });
462
+ // ── the gauge ─────────────────────────────────────────────────────────
463
+ const frac = clamp(smoothPower / (ftp * GAUGE_MAX_FTP_PCT), 0, 1);
464
+ const zone = zoneFor(smoothPower / ftp);
465
+ if (zone.color !== last.zone) {
466
+ last.zone = zone.color;
467
+ n.tipRoot.style.setProperty("--rrig-zone", zone.color);
468
+ n.tipRoot.style.setProperty("--rrig-zone-glow", zone.glow);
469
+ }
470
+ if (Math.abs(frac - last.frac) > 0.002) {
471
+ last.frac = frac;
472
+ // One property, whichever way the bar runs: CSS owns the axis.
473
+ const pct = `${(frac * 100).toFixed(2)}%`;
474
+ if (orient === "vertical")
475
+ n.gaugeFill.style.height = pct;
476
+ else
477
+ n.gaugeFill.style.width = pct;
478
+ if (n.tip) {
479
+ // Hidden at the very start: a leading edge with nothing behind it reads as a stray mark.
480
+ if (orient === "vertical")
481
+ n.tip.style.bottom = pct;
482
+ else
483
+ n.tip.style.left = pct;
484
+ n.tip.style.opacity = frac > 0.02 ? "1" : "0";
485
+ }
486
+ }
487
+ // The readout carries the RAW number, not the smoothed one — see the module header.
488
+ if (n.readoutValue) {
489
+ const shown = Math.round(rawPower);
490
+ if (shown !== last.readout) {
491
+ last.readout = shown;
492
+ n.readoutValue.textContent = String(shown);
493
+ }
494
+ }
495
+ }
496
+ paint();
497
+ return {
498
+ root,
499
+ setTipPlacement(next) {
500
+ tipOver = { ...tipOver, ...next };
501
+ placeTip(n.tipRoot, orient, anchor, tipOver);
502
+ },
503
+ setPower(watts) {
504
+ rawPower = Number.isFinite(watts) ? Math.max(0, watts) : 0;
505
+ },
506
+ setCadence(rpm) {
507
+ targetCadence = Number.isFinite(rpm) ? Math.max(0, rpm) : 0;
508
+ },
509
+ setFtp(next) {
510
+ if (Number.isFinite(next) && next > 0) {
511
+ ftp = next;
512
+ last.frac = -Infinity; // force the gauge to re-lay against the new scale
513
+ last.zone = "";
514
+ paint();
515
+ }
516
+ },
517
+ tick(dt) {
518
+ const step = clamp(Number.isFinite(dt) ? dt : 0, 0, 0.1);
519
+ // Frame-rate-independent EMAs, same shape as HardwareStore's power smoothing.
520
+ smoothPower += (rawPower - smoothPower) * (1 - Math.exp(-step / POWER_TAU_S));
521
+ smoothCadence += (targetCadence - smoothCadence) * (1 - Math.exp(-step / CADENCE_TAU_S));
522
+ if (!reduceMotion) {
523
+ const revPerS = smoothCadence / 60;
524
+ crankAngle = (crankAngle + revPerS * 360 * step) % 360;
525
+ cassetteAngle = (cassetteAngle + revPerS * 360 * CASSETTE_GEAR * step) % 360;
526
+ flyAngle = (flyAngle + revPerS * 360 * FLY_GEAR * step) % 360;
527
+ }
528
+ paint();
529
+ },
530
+ dispose() {
531
+ renderer.dispose();
532
+ root.remove();
533
+ },
534
+ };
535
+ }
536
+ /* ───────────────────────────── the drawing ───────────────────────────── */
537
+ /** The flat drawing. Everything here is the rig itself; the tooltip is built separately. */
538
+ function buildSvg(design) {
539
+ const svg = svgNode("svg", { viewBox: `0 0 ${VB_W} ${VB_H}`, class: "rrig-svg", role: "img" });
540
+ svg.setAttribute("aria-label", "A rider pedalling on a direct-drive indoor trainer, with a live watts gauge");
541
+ svg.classList.add(`rrig-svg--${design}`);
542
+ // Ground line first — everything else sits on it.
543
+ svg.appendChild(svgNode("line", { class: "rrig-ground", x1: 8, y1: GROUND, x2: VB_W - 8, y2: GROUND }));
544
+ /* ── the trainer ──────────────────────────────────────────────────────── */
545
+ const trainer = svgNode("g", { class: "rrig-trainer" });
546
+ svg.appendChild(trainer);
547
+ // The housing: a wedge filling the space the rear wheel used to occupy, peaking at the axle it
548
+ // carries. Drawn (and filled) BEFORE the flywheel, so the flywheel reads as mounted IN the machine
549
+ // rather than crossing it — in line art one circle over another says "another wheel", not "behind",
550
+ // which is the trap an earlier pass fell into.
551
+ trainer.appendChild(svgNode("path", {
552
+ class: "rrig-trainer-body",
553
+ d: `M 34 ${GROUND} L 44 160 L 62 154 L ${AXLE.x} ${AXLE.y - 3} L 90 157 L 96 161 L 104 ${GROUND} Z`,
554
+ }));
555
+ trainer.appendChild(svgNode("rect", { class: "rrig-foot", x: 26, y: GROUND - 3, width: 18, height: 4, rx: 2 }));
556
+ trainer.appendChild(svgNode("rect", { class: "rrig-foot", x: 94, y: GROUND - 3, width: 18, height: 4, rx: 2 }));
557
+ const flyStreaks = svgNode("g", { class: "rrig-streaks", opacity: 0 });
558
+ for (let i = 0; i < 2; i++) {
559
+ flyStreaks.appendChild(svgNode("path", { class: "rrig-streak", d: arcPath(FLY.x, FLY.y, FLY.r - 4 - i * 4, 200 + i * 25, 320 + i * 25) }));
560
+ }
561
+ const flySpokes = svgNode("g", { class: "rrig-fly-spokes" });
562
+ for (let i = 0; i < 6; i++) {
563
+ const a = rad(i * 60);
564
+ flySpokes.appendChild(svgNode("line", {
565
+ class: "rrig-spoke",
566
+ x1: FLY.x,
567
+ y1: FLY.y,
568
+ x2: FLY.x + (FLY.r - 2) * Math.cos(a),
569
+ y2: FLY.y + (FLY.r - 2) * Math.sin(a),
570
+ }));
571
+ }
572
+ trainer.appendChild(svgNode("circle", { class: "rrig-fly", cx: FLY.x, cy: FLY.y, r: FLY.r }));
573
+ trainer.appendChild(flySpokes);
574
+ trainer.appendChild(flyStreaks);
575
+ trainer.appendChild(svgNode("circle", { class: "rrig-fly-hub", cx: FLY.x, cy: FLY.y, r: 3 }));
576
+ /* ── the bike ─────────────────────────────────────────────────────────── */
577
+ const bike = svgNode("g", { class: "rrig-bike" });
578
+ svg.appendChild(bike);
579
+ // The front wheel — the only one left, and it does NOT turn: parked on the floor with nothing
580
+ // driving it. Its spokes go straight into the bike group for exactly that reason; there is no node
581
+ // cached for them, because nothing will ever rotate them.
582
+ bike.appendChild(svgNode("circle", { class: "rrig-tyre", cx: FRONT.x, cy: FRONT.y, r: WHEEL_R }));
583
+ for (let i = 0; i < 8; i++) {
584
+ const a = rad(i * 45);
585
+ bike.appendChild(svgNode("line", {
586
+ class: "rrig-spoke",
587
+ x1: FRONT.x,
588
+ y1: FRONT.y,
589
+ x2: FRONT.x + (WHEEL_R - 3) * Math.cos(a),
590
+ y2: FRONT.y + (WHEEL_R - 3) * Math.sin(a),
591
+ }));
592
+ }
593
+ bike.appendChild(svgNode("circle", { class: "rrig-hub", cx: FRONT.x, cy: FRONT.y, r: 3 }));
594
+ // Frame: the double triangle, its rear ending at the TRAINER's axle instead of a rear hub.
595
+ bike.appendChild(svgNode("path", {
596
+ class: "rrig-frame",
597
+ d: `M ${AXLE.x} ${AXLE.y} L ${SEAT_CLUSTER.x} ${SEAT_CLUSTER.y}` + // seat stay
598
+ ` L ${HEADTUBE_TOP.x} ${HEADTUBE_TOP.y}` + // top tube
599
+ ` L ${HEADTUBE_BOT.x} ${HEADTUBE_BOT.y}` + // head tube
600
+ ` L ${BB.x} ${BB.y}` + // down tube
601
+ ` L ${AXLE.x} ${AXLE.y}` + // chain stay
602
+ ` M ${SEAT_CLUSTER.x} ${SEAT_CLUSTER.y} L ${BB.x} ${BB.y}` + // seat tube
603
+ ` M ${SEAT_CLUSTER.x} ${SEAT_CLUSTER.y} L ${SADDLE.x} ${SADDLE.y}`, // seatpost
604
+ }));
605
+ bike.appendChild(svgNode("line", { class: "rrig-frame", x1: HEADTUBE_BOT.x, y1: HEADTUBE_BOT.y, x2: FRONT.x, y2: FRONT.y }));
606
+ bike.appendChild(svgNode("line", { class: "rrig-frame", x1: HEADTUBE_TOP.x, y1: HEADTUBE_TOP.y, x2: BAR.x, y2: BAR.y }));
607
+ bike.appendChild(svgNode("path", { class: "rrig-saddle", d: `M ${SADDLE.x - 11} ${SADDLE.y + 1} L ${SADDLE.x + 8} ${SADDLE.y}` }));
608
+ bike.appendChild(svgNode("path", {
609
+ class: "rrig-bar",
610
+ d: `M ${BAR.x - 4} ${BAR.y - 5} Q ${BAR.x + 8} ${BAR.y - 5} ${BAR.x + 5} ${BAR.y + 8}`,
611
+ }));
612
+ // The chain, running from the chainring back to the TRAINER's cassette — the junction this whole
613
+ // drawing exists to show.
614
+ bike.appendChild(svgNode("circle", { class: "rrig-chainring", cx: BB.x, cy: BB.y, r: CHAINRING_R }));
615
+ bike.appendChild(svgNode("path", {
616
+ class: "rrig-chain",
617
+ d: `M ${AXLE.x} ${AXLE.y - CASSETTE_R} L ${BB.x} ${BB.y - CHAINRING_R}` +
618
+ ` M ${AXLE.x} ${AXLE.y + CASSETTE_R} L ${BB.x} ${BB.y + CHAINRING_R}`,
619
+ }));
620
+ // The cassette on the trainer's axle. Driven, so it turns.
621
+ const cassette = svgNode("g", { class: "rrig-cassette-spin" });
622
+ bike.appendChild(svgNode("circle", { class: "rrig-cassette", cx: AXLE.x, cy: AXLE.y, r: CASSETTE_R }));
623
+ for (let i = 0; i < 4; i++) {
624
+ const a = rad(i * 90);
625
+ cassette.appendChild(svgNode("line", {
626
+ class: "rrig-spoke",
627
+ x1: AXLE.x,
628
+ y1: AXLE.y,
629
+ x2: AXLE.x + (CASSETTE_R - 1.5) * Math.cos(a),
630
+ y2: AXLE.y + (CASSETTE_R - 1.5) * Math.sin(a),
631
+ }));
632
+ }
633
+ bike.appendChild(cassette);
634
+ bike.appendChild(svgNode("circle", { class: "rrig-hub", cx: AXLE.x, cy: AXLE.y, r: 2.5 }));
635
+ const crank = svgNode("g", { class: "rrig-crank" });
636
+ bike.appendChild(crank);
637
+ crank.appendChild(svgNode("line", { class: "rrig-crank-arm", x1: BB.x - CRANK_R, y1: BB.y, x2: BB.x + CRANK_R, y2: BB.y }));
638
+ crank.appendChild(svgNode("rect", { class: "rrig-pedal", x: BB.x + CRANK_R - 5, y: BB.y - 1.5, width: 10, height: 3, rx: 1.5 }));
639
+ crank.appendChild(svgNode("rect", { class: "rrig-pedal", x: BB.x - CRANK_R - 5, y: BB.y - 1.5, width: 10, height: 3, rx: 1.5 }));
640
+ /* ── the rider ────────────────────────────────────────────────────────── */
641
+ const rider = svgNode("g", { class: "rrig-rider" });
642
+ svg.appendChild(rider);
643
+ // Far side first, so the near limbs read as in front.
644
+ const legFar = svgNode("polyline", { class: "rrig-limb rrig-limb--far", points: "" });
645
+ rider.appendChild(legFar);
646
+ const torso = svgNode("line", { class: "rrig-torso", x1: 0, y1: 0, x2: 0, y2: 0 });
647
+ rider.appendChild(torso);
648
+ const armNear = svgNode("polyline", { class: "rrig-limb rrig-limb--arm", points: "" });
649
+ rider.appendChild(armNear);
650
+ const neck = svgNode("line", { class: "rrig-neck", x1: 0, y1: 0, x2: 0, y2: 0 });
651
+ rider.appendChild(neck);
652
+ const headEl = svgNode("circle", { class: "rrig-head", cx: 0, cy: 0, r: 8.5 });
653
+ rider.appendChild(headEl);
654
+ const legNear = svgNode("polyline", { class: "rrig-limb", points: "" });
655
+ rider.appendChild(legNear);
656
+ return { svg, crank, cassette, flySpokes, flyStreaks, legNear, legFar, armNear, torso, neck, head: headEl };
657
+ }
658
+ /**
659
+ * Position the tooltip so the POINTER'S TIP lands on `anchor`, rather than at a fixed percentage of
660
+ * the box. `arrow + half the arrow's width` is the tip's offset from the tooltip's own left edge, so
661
+ * subtracting it puts the tip — not the corner — on the machine.
662
+ *
663
+ * Clamped to the box: a renderer can report an anchor near the left edge, or an override can push the
664
+ * pointer offset past the anchor's own inset, and an unclamped subtraction hangs the whole tooltip
665
+ * outside the drawing. The pointer drifts off the anchor in that corner case, which is much less wrong
666
+ * than half the panel being cut off.
667
+ */
668
+ function placeTip(tipRoot, orient, anchor, over) {
669
+ // Three layers, narrowest first: an explicit override, then the layout's own default, then the
670
+ // renderer's anchor.
671
+ const dflt = TIP_ANCHOR[orient];
672
+ const arrow = over.arrow ?? dflt.arrow;
673
+ const x = over.x ?? dflt.x ?? anchor.x;
674
+ const y = over.y ?? dflt.y ?? anchor.y;
675
+ tipRoot.style.left = `max(0.4em, calc(${(x * 100).toFixed(2)}% - ${(arrow + 1.15).toFixed(2)}em))`;
676
+ tipRoot.style.bottom = `${((1 - y) * 100).toFixed(2)}%`;
677
+ tipRoot.style.setProperty("--rrig-arrow", `${arrow}em`);
678
+ }
679
+ /** The tooltip: HTML, positioned over whatever is drawing the rig. */
680
+ function buildTooltip(root, cfg) {
681
+ const tipRoot = document.createElement("div");
682
+ tipRoot.className = `rrig-tip rrig-tip--${cfg.orient}`;
683
+ placeTip(tipRoot, cfg.orient, cfg.anchor, {});
684
+ // The FTP tick is placed by CSS from this one value, so it and its label cannot drift apart.
685
+ tipRoot.style.setProperty("--rrig-ftp", `${(FTP_FRAC * 100).toFixed(3)}%`);
686
+ root.appendChild(tipRoot);
687
+ /**
688
+ * The glass box is a CHILD of the wrapper, and the pointer is its SIBLING — not its descendant.
689
+ *
690
+ * An element carrying `backdrop-filter` becomes a backdrop root: anything inside it can only
691
+ * sample its parent's own content, not the page behind. Nesting the pointer in the panel therefore
692
+ * neutered the pointer's own blur, and once it lost its borders there was nothing left of it but 7%
693
+ * white — it vanished. As a sibling of the panel it samples the real backdrop and shows up.
694
+ */
695
+ const panel = document.createElement("div");
696
+ panel.className = "rrig-tip-panel";
697
+ tipRoot.appendChild(panel);
698
+ // The PORTRAIT variant carries no figure at all — only the title and the bar.
699
+ //
700
+ // On that layout the bar is the reading: it is tall, continuous, and marked at FTP, which answers
701
+ // "how hard am I working" without a number. Printing the watts beside it competes with the thing it
702
+ // is annotating, and a rider glancing at a trainer screen reads the fill long before they read
703
+ // three digits. The landscape variants keep the figure, where there is a row for it.
704
+ let readoutValue;
705
+ if (cfg.showReadout) {
706
+ const head = document.createElement("div");
707
+ head.className = "rrig-tip-head";
708
+ if (cfg.orient === "horizontal") {
709
+ readoutValue = document.createElement("span");
710
+ readoutValue.className = "rrig-tip-num";
711
+ readoutValue.textContent = "0";
712
+ head.appendChild(readoutValue);
713
+ }
714
+ const unit = document.createElement("span");
715
+ unit.className = "rrig-tip-unit";
716
+ unit.textContent = cfg.label;
717
+ head.appendChild(unit);
718
+ panel.appendChild(head);
719
+ }
720
+ const track = document.createElement("div");
721
+ track.className = "rrig-tip-track";
722
+ panel.appendChild(track);
723
+ const gaugeFill = document.createElement("div");
724
+ gaugeFill.className = "rrig-tip-fill";
725
+ track.appendChild(gaugeFill);
726
+ let tip;
727
+ if (cfg.shape === "tube") {
728
+ tip = document.createElement("i");
729
+ tip.className = "rrig-tip-lead";
730
+ tip.style.opacity = "0";
731
+ track.appendChild(tip);
732
+ }
733
+ // The FTP reference. Positioned off `--rrig-ftp` with a -50% translate, so the tick and its label
734
+ // are centred on the same point by construction rather than by two hand-computed coordinates.
735
+ const ftp = document.createElement("div");
736
+ ftp.className = "rrig-tip-ftp";
737
+ ftp.innerHTML = `<i></i><b>FTP</b>`;
738
+ track.appendChild(ftp);
739
+ const arrow = document.createElement("div");
740
+ arrow.className = "rrig-tip-arrow";
741
+ tipRoot.appendChild(arrow);
742
+ return { tipRoot, gaugeFill, tip, readoutValue };
743
+ }
744
+ /* ───────────────────────────── styles ─────────────────────────────
745
+ * One stylesheet, three skins. Every design uses the SAME node structure and the same class names —
746
+ * what changes is the paint, which is why the geometry above has no idea which design it is drawing.
747
+ * `--rrig-zone` is set per frame from the %FTP zone and is the only colour that moves.
748
+ */
749
+ function ensureStyles() {
750
+ let s = document.getElementById(STYLE_ID);
751
+ if (!s) {
752
+ s = document.createElement("style");
753
+ s.id = STYLE_ID;
754
+ document.head.appendChild(s);
755
+ }
756
+ s.textContent = CSS;
757
+ }
758
+ const CSS = `
759
+ .rrig { display: block; max-width: 100%; position: relative;
760
+ --rrig-zone: #6b7f9e; --rrig-zone-glow: #9fb4d4; }
761
+ .rrig-svg { display: block; width: 100%; height: auto; overflow: visible; }
762
+ .rrig-svg * { vector-effect: non-scaling-stroke; }
763
+
764
+ /* ── the tooltip ──────────────────────────────────────────────────────────────────────────
765
+ Real glass: the backdrop is actually blurred, which is the whole reason this box is HTML and not
766
+ part of the drawing. The inset highlight on the top edge and the drop below it are what give it
767
+ thickness — a flat translucent panel reads as a grey rectangle.
768
+
769
+ Type is in px on purpose: it must NOT shrink with the drawing's size preset. A watts readout on a
770
+ trainer screen has one correct physical size.
771
+
772
+ NOTE: no backticks anywhere in this stylesheet — it is a template literal, and one in a comment
773
+ closes it. That has now cost two debugging rounds. */
774
+ /* The wrapper only positions. It must carry NO backdrop-filter of its own — see the note in build()
775
+ on why that would blind the pointer. */
776
+ .rrig-tip { position: absolute; z-index: 1; pointer-events: none;
777
+ font-size: var(--rrig-u, 10px);
778
+ font-family: system-ui, -apple-system, "Segoe UI", sans-serif; }
779
+ .rrig-tip { --rrig-edge: rgba(255,255,255,0.16); }
780
+ /* Depth. Three cues, and the middle one does most of the work:
781
+ · a heavy, soft drop shadow cast onto the artwork below;
782
+ · a brightness(0.62) in the backdrop filter, which DARKENS whatever the panel covers — smoked
783
+ glass. Blur alone says "translucent"; blur plus a drop in brightness says "a pane in front of
784
+ the scene", because the bike visibly dims where the panel crosses it;
785
+ · a hairline of near-black just outside the light border, which detaches the box from the
786
+ linework it sits on so the two frames never merge. */
787
+ .rrig-tip-panel { box-sizing: border-box;
788
+ padding: 0.9em 1.1em 1.9em;
789
+ border-radius: 1.3em;
790
+ background: rgba(255,255,255,0.05);
791
+ border: 1px solid var(--rrig-edge);
792
+ box-shadow: 0 0 0 1px rgba(0,0,0,0.45),
793
+ 0 1.6em 3.4em rgba(0,0,0,0.62), 0 0.5em 1.1em rgba(0,0,0,0.45),
794
+ inset 0 1px 0 rgba(255,255,255,0.34);
795
+ backdrop-filter: blur(14px) saturate(1.3) brightness(0.62);
796
+ -webkit-backdrop-filter: blur(14px) saturate(1.3) brightness(0.62);
797
+ display: flex; flex-direction: column; gap: 0.65em; }
798
+
799
+ /* The pointer: a REAL triangle, clipped, with no border of its own, sitting ON TOP of the bubble's
800
+ bottom edge and overlapping it by a pixel.
801
+
802
+ It was a rotated bordered square before, tucked behind the body. That is the common recipe and it
803
+ looked wrong here for two reasons: a rotated square's two bordered sides meet at a hard 90° corner
804
+ rather than a point, and the bubble's own bottom border ran straight across its base — so it read
805
+ as a small bordered square balanced on a corner rather than as a pointer. Clipping a triangle
806
+ removes the corner, dropping its border removes the second frame, and putting it in front lets its
807
+ fill cover the bubble's bottom border across its width, which is the seam. It keeps its own
808
+ backdrop-filter: without one it is a flat patch hanging off a blurred box. */
809
+ .rrig-tip-arrow { position: absolute; width: 2.3em; height: 1.15em;
810
+ left: var(--rrig-arrow); bottom: calc(-1.15em + 1px);
811
+ /* Filled with the panel's own EDGE colour: with no border of its own, the fill is
812
+ the only thing that draws it, and matching the frame makes it read as the frame
813
+ turning a corner rather than as a separate translucent wedge. */
814
+ background: var(--rrig-edge);
815
+ backdrop-filter: blur(14px) saturate(1.3) brightness(0.62);
816
+ -webkit-backdrop-filter: blur(14px) saturate(1.3) brightness(0.62);
817
+ clip-path: polygon(0 0, 100% 0, 50% 100%);
818
+ z-index: 1; }
819
+
820
+ .rrig-tip-head { display: flex; align-items: baseline; gap: 0.5em; line-height: 1; }
821
+ .rrig-tip-num { font-size: 2.3em; font-weight: 800; letter-spacing: -0.02em;
822
+ font-variant-numeric: tabular-nums; color: var(--rrig-zone);
823
+ transition: color 240ms ease; }
824
+ .rrig-tip-unit { font-size: 1.35em; font-weight: 800; letter-spacing: 0.13em; color: #a7b6cd; }
825
+ /* Portrait stacks, and inverts the hierarchy: WATTS is the title and the figure sits under it at a
826
+ modest size. On this variant the GAUGE is the reading — the exact number is a detail, and a 2.3em
827
+ figure made it shout over the bar it is meant to annotate. (column-reverse on a [number, unit] DOM
828
+ order puts the caption on top without reordering the markup.) Side by side the pair would also
829
+ set the bubble's width from the text rather than from the bar, and the point of the portrait box is
830
+ to be narrow. */
831
+ .rrig-tip--vertical .rrig-tip-panel > .rrig-tip-head { flex-direction: column-reverse; align-items: center; gap: 0.15em; }
832
+ .rrig-tip--vertical .rrig-tip-num { font-size: 1.55em; font-weight: 700; }
833
+ .rrig-tip--vertical .rrig-tip-unit { font-size: 1.7em; letter-spacing: 0.14em; }
834
+
835
+ /* The well is a translucent WHITE, not black.
836
+ Black was the instinct — a recessed groove — but the panel behind it is already dark glass, so a
837
+ black well simply vanished into it: the empty part of the gauge had no edge and the fill had nothing
838
+ to be read against. A light wash gives the track its own body, and the inset shadow along the top
839
+ plus the hairline rim are what still make it read as recessed rather than raised. */
840
+ .rrig-tip-track { position: relative; border-radius: 99px;
841
+ background: rgba(255,255,255,0.13);
842
+ box-shadow: inset 0 1px 2.5px rgba(0,0,0,0.55),
843
+ inset 0 0 0 1px rgba(255,255,255,0.13); }
844
+ .rrig-tip--horizontal .rrig-tip-track { width: 8.4em; height: 0.9em; }
845
+ /* Portrait: a deliberately BIG bar. With no figure beside it, it has to carry the reading on its own,
846
+ and the extra height is what makes a change of a few percent visible at a glance. It is nudged off
847
+ centre so the bar and its FTP label sit optically centred as a pair, rather than the bar alone. */
848
+ .rrig-tip--vertical .rrig-tip-track { width: 1.75em; height: 13em;
849
+ align-self: flex-start; margin-left: 0.7em; }
850
+
851
+ /* continuous fill */
852
+ .rrig-tip-fill { position: absolute; border-radius: 99px; background: var(--rrig-zone);
853
+ transition: width 90ms linear, height 90ms linear; }
854
+ .rrig-tip--horizontal .rrig-tip-fill { left: 0; top: 0; height: 100%; width: 0; }
855
+ .rrig-tip--vertical .rrig-tip-fill { left: 0; bottom: 0; width: 100%; height: 0; }
856
+
857
+ /* the tube's bright leading edge */
858
+ .rrig-tip-lead { position: absolute; background: #fff; border-radius: 0.2em;
859
+ box-shadow: 0 0 0.7em var(--rrig-zone-glow), 0 0 0.2em #fff;
860
+ transition: left 90ms linear, bottom 90ms linear, opacity 120ms ease; }
861
+ .rrig-tip--horizontal .rrig-tip-lead { top: -1px; width: 0.3em; height: calc(100% + 2px);
862
+ margin-left: -0.15em; }
863
+ .rrig-tip--vertical .rrig-tip-lead { left: -1px; height: 0.3em; width: calc(100% + 2px);
864
+ margin-bottom: -0.15em; }
865
+
866
+ /* The FTP reference. One custom property places BOTH the tick and its label, each centred on it by a
867
+ -50% translate — which is why they can no longer drift apart or land on anything else. The label
868
+ goes BELOW the bar in landscape (above it, it collided with the unit caption) and beside it in
869
+ portrait; the bubble's extra bottom padding is the room it needs. */
870
+ .rrig-tip-ftp { position: absolute; }
871
+ /* Dashed, so it reads as a reference line rather than as part of the fill it crosses. */
872
+ .rrig-tip-ftp i { position: absolute; }
873
+ .rrig-tip--horizontal .rrig-tip-ftp i { border-left: 2px dashed rgba(255,255,255,0.65); }
874
+ .rrig-tip--vertical .rrig-tip-ftp i { border-top: 2px dashed rgba(255,255,255,0.65); }
875
+ /* White, and NOT per-design: it labels the one fixed reference on the gauge, so it should read the
876
+ same on all three rather than tinting with the art direction like the unit caption does. */
877
+ .rrig-tip-ftp b { position: absolute; font-size: 1.2em; font-weight: 800; letter-spacing: 0.08em;
878
+ color: #ffffff; white-space: nowrap; line-height: 1; }
879
+ .rrig-tip--horizontal .rrig-tip-ftp { left: var(--rrig-ftp); top: 0; height: 100%; }
880
+ .rrig-tip--horizontal .rrig-tip-ftp i { left: -1px; top: -0.35em; width: 0; height: calc(100% + 0.7em); }
881
+ .rrig-tip--horizontal .rrig-tip-ftp b { top: calc(100% + 0.85em); left: 50%; transform: translateX(-50%); }
882
+ .rrig-tip--vertical .rrig-tip-ftp { bottom: var(--rrig-ftp); left: 0; width: 100%; }
883
+ .rrig-tip--vertical .rrig-tip-ftp i { top: -1px; left: -0.4em; height: 0; width: calc(100% + 0.8em); }
884
+ .rrig-tip--vertical .rrig-tip-ftp b { left: calc(100% + 1.1em); top: 50%; transform: translateY(-50%); }
885
+ /* Portrait needs no bottom gutter: its label sits beside the bar, not under it. */
886
+ .rrig-tip--vertical .rrig-tip-panel { padding: 1em 1.2em 1.3em; min-width: 10em; }
887
+
888
+ /* Without a backdrop filter the 6% wash is nearly invisible and the readout loses its ground, so the
889
+ panel falls back to an opaque dark fill. Checked on the arrow too — it inherits nothing. */
890
+ @supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
891
+ .rrig-tip-panel { background: rgba(13,18,28,0.92); }
892
+ .rrig-tip-arrow { background: rgba(21,28,42,0.95); }
893
+ }
894
+
895
+ /* ── shared skeleton defaults — the designs override the paint, never the shapes ─────────── */
896
+ .rrig-tyre, .rrig-fly, .rrig-cassette, .rrig-chainring { fill: none; }
897
+ .rrig-frame, .rrig-chain, .rrig-saddle, .rrig-bar, .rrig-crank-arm,
898
+ .rrig-limb, .rrig-torso, .rrig-neck, .rrig-spoke, .rrig-streak { fill: none; }
899
+ .rrig-limb, .rrig-torso, .rrig-neck { stroke-linecap: round; stroke-linejoin: round; }
900
+ .rrig-streak { stroke-linecap: round; }
901
+ .rrig-ground { stroke-linecap: round; }
902
+
903
+ /* ── SOLID: flat opaque silhouette. The default, and the most legible at distance. ───────── */
904
+ .rrig--solid .rrig-ground { stroke: #263247; stroke-width: 2; }
905
+ .rrig--solid .rrig-tyre { stroke: #39445c; stroke-width: 5; }
906
+ .rrig--solid .rrig-spoke { stroke: #232c3d; stroke-width: 1.2; }
907
+ .rrig--solid .rrig-hub { fill: #6d7f9c; }
908
+ .rrig--solid .rrig-streak { stroke: #93a6c9; stroke-width: 2.5; }
909
+ .rrig--solid .rrig-frame { stroke: #8ea3c4; stroke-width: 4; stroke-linejoin: round; }
910
+ .rrig--solid .rrig-chain { stroke: #55637e; stroke-width: 1.8; }
911
+ .rrig--solid .rrig-chainring { stroke: #6d7f9c; stroke-width: 2; }
912
+ .rrig--solid .rrig-cassette { stroke: #93a6c9; stroke-width: 3; }
913
+ .rrig--solid .rrig-saddle { stroke: #cdd9ec; stroke-width: 5; stroke-linecap: round; }
914
+ .rrig--solid .rrig-bar { stroke: #cdd9ec; stroke-width: 4; stroke-linecap: round; }
915
+ .rrig--solid .rrig-crank-arm { stroke: #b9c7de; stroke-width: 3; }
916
+ .rrig--solid .rrig-pedal { fill: #e6edf9; }
917
+ .rrig--solid .rrig-trainer-body { fill: #2b374e; stroke: #4a5a79; stroke-width: 2; }
918
+ .rrig--solid .rrig-foot { fill: #4a5a79; }
919
+ .rrig--solid .rrig-fly { stroke: #566b90; stroke-width: 4; fill: #1b2434; }
920
+ .rrig--solid .rrig-fly-hub { fill: #7c8fb3; }
921
+ .rrig--solid .rrig-limb { stroke: #f0b429; stroke-width: 8; }
922
+ .rrig--solid .rrig-limb--far { stroke: #a8791b; stroke-width: 7; }
923
+ .rrig--solid .rrig-limb--arm { stroke: #ff7a45; stroke-width: 7; }
924
+ .rrig--solid .rrig-torso { stroke: #ff7a45; stroke-width: 15; stroke-linecap: round; }
925
+ .rrig--solid .rrig-neck { stroke: #f0b429; stroke-width: 7; }
926
+ .rrig--solid .rrig-head { fill: #ffd9a8; stroke: #ff7a45; stroke-width: 3; }
927
+
928
+ /* ── BLUEPRINT: thin technical linework. An instrument, not a picture. ───────────────────── */
929
+ .rrig--blueprint .rrig-ground { stroke: #1d3a4d; stroke-width: 1; stroke-dasharray: 4 4; }
930
+ .rrig--blueprint .rrig-tyre { stroke: #3f7f9c; stroke-width: 1.4; }
931
+ .rrig--blueprint .rrig-spoke { stroke: #2b5f77; stroke-width: 0.7; }
932
+ .rrig--blueprint .rrig-hub { fill: none; stroke: #4fa3c7; stroke-width: 1.2; }
933
+ .rrig--blueprint .rrig-streak { stroke: #4fa3c7; stroke-width: 1; }
934
+ .rrig--blueprint .rrig-frame { stroke: #7fd4f0; stroke-width: 1.4; }
935
+ .rrig--blueprint .rrig-chain { stroke: #2b5f77; stroke-width: 0.8; stroke-dasharray: 2 2; }
936
+ .rrig--blueprint .rrig-chainring { stroke: #4fa3c7; stroke-width: 1; }
937
+ .rrig--blueprint .rrig-cassette { stroke: #7fd4f0; stroke-width: 1.2; }
938
+ .rrig--blueprint .rrig-saddle { stroke: #a9e6fa; stroke-width: 1.8; stroke-linecap: round; }
939
+ .rrig--blueprint .rrig-bar { stroke: #a9e6fa; stroke-width: 1.6; stroke-linecap: round; }
940
+ .rrig--blueprint .rrig-crank-arm { stroke: #a9e6fa; stroke-width: 1.4; }
941
+ .rrig--blueprint .rrig-pedal { fill: none; stroke: #a9e6fa; stroke-width: 1.2; }
942
+ .rrig--blueprint .rrig-trainer-body { fill: #0d2430; stroke: #3f7f9c; stroke-width: 1.2; }
943
+ .rrig--blueprint .rrig-foot { fill: none; stroke: #3f7f9c; stroke-width: 1; }
944
+ .rrig--blueprint .rrig-fly { stroke: #7fd4f0; stroke-width: 1.2; fill: none; }
945
+ .rrig--blueprint .rrig-fly-hub { fill: none; stroke: #4fa3c7; stroke-width: 1; }
946
+ /* The rider is the SUBJECT: in a palette this uniform they must out-weigh the machine, or the
947
+ drawing becomes one undifferentiated wireframe and you can no longer see a person in it. */
948
+ .rrig--blueprint .rrig-limb { stroke: #fef9c3; stroke-width: 3; }
949
+ .rrig--blueprint .rrig-limb--far { stroke: #8a7f4a; stroke-width: 2.2; }
950
+ .rrig--blueprint .rrig-limb--arm { stroke: #fff; stroke-width: 2.6; }
951
+ .rrig--blueprint .rrig-torso { stroke: #fff; stroke-width: 4; }
952
+ .rrig--blueprint .rrig-neck { stroke: #fef9c3; stroke-width: 2.4; }
953
+ .rrig--blueprint .rrig-head { fill: #0d2430; stroke: #fff; stroke-width: 2.4; }
954
+ .rrig--blueprint .rrig-tip { --rrig-edge: rgba(127,212,240,0.42); }
955
+
956
+ .rrig--blueprint .rrig-tip-unit { color: #6fbdda; }
957
+
958
+ /* ── NEON: glowing outline on dark. The arcade one. ──────────────────────────────────────── */
959
+ .rrig--neon .rrig-ground { stroke: #3b1d5e; stroke-width: 2; }
960
+ .rrig--neon .rrig-tyre { stroke: #c026d3; stroke-width: 2.5; filter: drop-shadow(0 0 4px #e879f9); }
961
+ .rrig--neon .rrig-spoke { stroke: #7e22ce; stroke-width: 1; }
962
+ .rrig--neon .rrig-hub { fill: #f0abfc; }
963
+ .rrig--neon .rrig-streak { stroke: #f0abfc; stroke-width: 2; }
964
+ .rrig--neon .rrig-frame { stroke: #22d3ee; stroke-width: 2.5; filter: drop-shadow(0 0 5px #67e8f9); }
965
+ .rrig--neon .rrig-chain { stroke: #6d28d9; stroke-width: 1.6; }
966
+ .rrig--neon .rrig-chainring { stroke: #22d3ee; stroke-width: 1.6; }
967
+ .rrig--neon .rrig-cassette { stroke: #e879f9; stroke-width: 2; filter: drop-shadow(0 0 4px #f0abfc); }
968
+ .rrig--neon .rrig-saddle { stroke: #a5f3fc; stroke-width: 3; stroke-linecap: round; }
969
+ .rrig--neon .rrig-bar { stroke: #a5f3fc; stroke-width: 2.5; stroke-linecap: round; }
970
+ .rrig--neon .rrig-crank-arm { stroke: #a5f3fc; stroke-width: 2; }
971
+ .rrig--neon .rrig-pedal { fill: #ecfeff; }
972
+ .rrig--neon .rrig-trainer-body { fill: #16082b; stroke: #7e22ce; stroke-width: 2; }
973
+ .rrig--neon .rrig-foot { fill: #7e22ce; }
974
+ .rrig--neon .rrig-fly { stroke: #c026d3; stroke-width: 2.5; fill: #16082b;
975
+ filter: drop-shadow(0 0 5px #e879f9); }
976
+ .rrig--neon .rrig-fly-hub { fill: #f0abfc; }
977
+ .rrig--neon .rrig-limb { stroke: #fde047; stroke-width: 3; filter: drop-shadow(0 0 4px #facc15); }
978
+ .rrig--neon .rrig-limb--far { stroke: #a16207; stroke-width: 2.5; filter: none; }
979
+ .rrig--neon .rrig-limb--arm { stroke: #fb7185; stroke-width: 2.5;
980
+ filter: drop-shadow(0 0 4px #fda4af); }
981
+ .rrig--neon .rrig-torso { stroke: #fb7185; stroke-width: 4.5; filter: drop-shadow(0 0 5px #fda4af); }
982
+ .rrig--neon .rrig-neck { stroke: #fde047; stroke-width: 2.5; }
983
+ .rrig--neon .rrig-head { fill: none; stroke: #fda4af; stroke-width: 2.5;
984
+ filter: drop-shadow(0 0 4px #fb7185); }
985
+ .rrig--neon .rrig-tip { --rrig-edge: rgba(216,180,254,0.44); }
986
+ .rrig--neon .rrig-tip-panel { box-shadow: 0 0 0 1px rgba(0,0,0,0.45),
987
+ 0 1.6em 3.4em rgba(0,0,0,0.62), 0 0.5em 1.1em rgba(0,0,0,0.45),
988
+ 0 0 2em rgba(192,38,211,0.26),
989
+ inset 0 1px 0 rgba(240,171,252,0.45); }
990
+
991
+ .rrig--neon .rrig-tip-num { text-shadow: 0 0 12px var(--rrig-zone-glow); }
992
+ .rrig--neon .rrig-tip-fill { box-shadow: 0 0 8px var(--rrig-zone-glow); }
993
+ .rrig--neon .rrig-tip-unit { color: #c4b5fd; }
994
+
995
+ @media (prefers-reduced-motion: reduce) {
996
+ .rrig-tip-fill, .rrig-tip-lead, .rrig-tip-num { transition: none; }
997
+ .rrig-streaks { opacity: 0 !important; }
998
+ }
999
+ `;
1000
+ //# sourceMappingURL=rider-rig.js.map