@rydr/game-sdk 8.10.0 → 8.12.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/README.md +26 -2
- package/dist/client/PlatformClient.d.ts.map +1 -1
- package/dist/client/PlatformClient.js +14 -2
- package/dist/client/PlatformClient.js.map +1 -1
- package/dist/client/Room.d.ts +9 -4
- package/dist/client/Room.d.ts.map +1 -1
- package/dist/client/Room.js +8 -5
- package/dist/client/Room.js.map +1 -1
- package/dist/host/PlatformHost.d.ts +4 -2
- package/dist/host/PlatformHost.d.ts.map +1 -1
- package/dist/host/PlatformHost.js +5 -3
- package/dist/host/PlatformHost.js.map +1 -1
- package/dist/protocol/identity.d.ts +63 -0
- package/dist/protocol/identity.d.ts.map +1 -1
- package/dist/protocol/identity.js +40 -1
- package/dist/protocol/identity.js.map +1 -1
- package/dist/protocol/messages.d.ts +11 -4
- package/dist/protocol/messages.d.ts.map +1 -1
- package/dist/protocol/room.d.ts +26 -0
- package/dist/protocol/room.d.ts.map +1 -1
- package/dist/protocol/version.d.ts +2 -2
- package/dist/protocol/version.d.ts.map +1 -1
- package/dist/protocol/version.js +23 -2
- package/dist/protocol/version.js.map +1 -1
- package/dist/three/README.md +111 -0
- package/dist/three/character/index.d.ts +14 -0
- package/dist/three/character/index.d.ts.map +1 -0
- package/dist/three/character/index.js +14 -0
- package/dist/three/character/index.js.map +1 -0
- package/dist/three/character/kit.d.ts +74 -0
- package/dist/three/character/kit.d.ts.map +1 -0
- package/dist/three/character/kit.js +254 -0
- package/dist/three/character/kit.js.map +1 -0
- package/dist/three/character/riders.d.ts +20 -0
- package/dist/three/character/riders.d.ts.map +1 -0
- package/dist/three/character/riders.js +383 -0
- package/dist/three/character/riders.js.map +1 -0
- package/dist/three/character/skeleton.d.ts +105 -0
- package/dist/three/character/skeleton.d.ts.map +1 -0
- package/dist/three/character/skeleton.js +57 -0
- package/dist/three/character/skeleton.js.map +1 -0
- package/dist/three/controller/glyph-texture.d.ts +29 -0
- package/dist/three/controller/glyph-texture.d.ts.map +1 -0
- package/dist/three/controller/glyph-texture.js +68 -0
- package/dist/three/controller/glyph-texture.js.map +1 -0
- package/dist/three/controller/index.d.ts +12 -0
- package/dist/three/controller/index.d.ts.map +1 -0
- package/dist/three/controller/index.js +12 -0
- package/dist/three/controller/index.js.map +1 -0
- package/dist/three/controller/joycon-designs.d.ts +49 -0
- package/dist/three/controller/joycon-designs.d.ts.map +1 -0
- package/dist/three/controller/joycon-designs.js +742 -0
- package/dist/three/controller/joycon-designs.js.map +1 -0
- package/dist/three/controller/joycon-shoulder-mesh.d.ts +41 -0
- package/dist/three/controller/joycon-shoulder-mesh.d.ts.map +1 -0
- package/dist/three/controller/joycon-shoulder-mesh.js +527 -0
- package/dist/three/controller/joycon-shoulder-mesh.js.map +1 -0
- package/dist/three/controller/joycon-spec.d.ts +233 -0
- package/dist/three/controller/joycon-spec.d.ts.map +1 -0
- package/dist/three/controller/joycon-spec.js +285 -0
- package/dist/three/controller/joycon-spec.js.map +1 -0
- package/dist/three/controller/three-pad-renderer.d.ts +81 -0
- package/dist/three/controller/three-pad-renderer.d.ts.map +1 -0
- package/dist/three/controller/three-pad-renderer.js +433 -0
- package/dist/three/controller/three-pad-renderer.js.map +1 -0
- package/dist/three/index.d.ts +2 -0
- package/dist/three/index.d.ts.map +1 -1
- package/dist/three/index.js +2 -0
- package/dist/three/index.js.map +1 -1
- package/dist/three/rider/cyclist-pose.d.ts +62 -0
- package/dist/three/rider/cyclist-pose.d.ts.map +1 -0
- package/dist/three/rider/cyclist-pose.js +173 -0
- package/dist/three/rider/cyclist-pose.js.map +1 -0
- package/dist/three/rider/index.d.ts +8 -0
- package/dist/three/rider/index.d.ts.map +1 -0
- package/dist/three/rider/index.js +8 -0
- package/dist/three/rider/index.js.map +1 -0
- package/dist/three/rider/three-rider-rig.d.ts +80 -0
- package/dist/three/rider/three-rider-rig.d.ts.map +1 -0
- package/dist/three/rider/three-rider-rig.js +418 -0
- package/dist/three/rider/three-rider-rig.js.map +1 -0
- package/dist/ui/README.md +153 -1
- package/dist/ui/controller/combo-demo.d.ts +9 -0
- package/dist/ui/controller/combo-demo.d.ts.map +1 -1
- package/dist/ui/controller/combo-demo.js +1 -0
- package/dist/ui/controller/combo-demo.js.map +1 -1
- package/dist/ui/controller/control-spotlight.d.ts +10 -1
- package/dist/ui/controller/control-spotlight.d.ts.map +1 -1
- package/dist/ui/controller/control-spotlight.js +42 -42
- package/dist/ui/controller/control-spotlight.js.map +1 -1
- package/dist/ui/controller/control-trainer.d.ts +14 -0
- package/dist/ui/controller/control-trainer.d.ts.map +1 -1
- package/dist/ui/controller/control-trainer.js +6 -0
- package/dist/ui/controller/control-trainer.js.map +1 -1
- package/dist/ui/controller/controller-map.d.ts +9 -0
- package/dist/ui/controller/controller-map.d.ts.map +1 -1
- package/dist/ui/controller/controller-map.js +8 -6
- package/dist/ui/controller/controller-map.js.map +1 -1
- package/dist/ui/controller/controller-pad.d.ts +35 -2
- package/dist/ui/controller/controller-pad.d.ts.map +1 -1
- package/dist/ui/controller/controller-pad.js +47 -7
- package/dist/ui/controller/controller-pad.js.map +1 -1
- package/dist/ui/controller/index.d.ts +1 -0
- package/dist/ui/controller/index.d.ts.map +1 -1
- package/dist/ui/controller/index.js +1 -0
- package/dist/ui/controller/index.js.map +1 -1
- package/dist/ui/controller/renderer.d.ts +118 -0
- package/dist/ui/controller/renderer.d.ts.map +1 -0
- package/dist/ui/controller/renderer.js +93 -0
- package/dist/ui/controller/renderer.js.map +1 -0
- package/dist/ui/controller/styles.js +30 -0
- package/dist/ui/controller/styles.js.map +1 -1
- package/dist/ui/index.d.ts +6 -0
- package/dist/ui/index.d.ts.map +1 -1
- package/dist/ui/index.js +6 -0
- package/dist/ui/index.js.map +1 -1
- package/dist/ui/rider-rig.d.ts +315 -0
- package/dist/ui/rider-rig.d.ts.map +1 -0
- package/dist/ui/rider-rig.js +1000 -0
- package/dist/ui/rider-rig.js.map +1 -0
- package/dist/ui/showcase/index.d.ts +11 -0
- package/dist/ui/showcase/index.d.ts.map +1 -1
- package/dist/ui/showcase/index.js +109 -1
- package/dist/ui/showcase/index.js.map +1 -1
- 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
|