@rydr/game-sdk 8.13.0 → 8.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/protocol/identity.d.ts +39 -0
- package/dist/protocol/identity.d.ts.map +1 -1
- package/dist/protocol/version.d.ts +1 -1
- package/dist/protocol/version.js +1 -1
- package/dist/three/README.md +132 -4
- package/dist/three/character/riders.d.ts.map +1 -1
- package/dist/three/character/riders.js.map +1 -1
- package/dist/three/controller/index.d.ts +2 -0
- package/dist/three/controller/index.d.ts.map +1 -1
- package/dist/three/controller/index.js +2 -0
- package/dist/three/controller/index.js.map +1 -1
- package/dist/three/controller/joycon-designs.d.ts +15 -1
- package/dist/three/controller/joycon-designs.d.ts.map +1 -1
- package/dist/three/controller/joycon-designs.js +302 -60
- package/dist/three/controller/joycon-designs.js.map +1 -1
- package/dist/three/controller/joycon-object.d.ts +216 -0
- package/dist/three/controller/joycon-object.d.ts.map +1 -0
- package/dist/three/controller/joycon-object.js +578 -0
- package/dist/three/controller/joycon-object.js.map +1 -0
- package/dist/three/controller/joycon-spec.d.ts +116 -4
- package/dist/three/controller/joycon-spec.d.ts.map +1 -1
- package/dist/three/controller/joycon-spec.js +128 -22
- package/dist/three/controller/joycon-spec.js.map +1 -1
- package/dist/three/controller/press-pulse.d.ts +89 -0
- package/dist/three/controller/press-pulse.d.ts.map +1 -0
- package/dist/three/controller/press-pulse.js +309 -0
- package/dist/three/controller/press-pulse.js.map +1 -0
- package/dist/three/controller/three-pad-renderer.d.ts +11 -22
- package/dist/three/controller/three-pad-renderer.d.ts.map +1 -1
- package/dist/three/controller/three-pad-renderer.js +44 -258
- package/dist/three/controller/three-pad-renderer.js.map +1 -1
- package/dist/three/rider/three-rider-rig.d.ts +12 -0
- package/dist/three/rider/three-rider-rig.d.ts.map +1 -1
- package/dist/three/rider/three-rider-rig.js +102 -9
- package/dist/three/rider/three-rider-rig.js.map +1 -1
- package/dist/ui/README.md +133 -7
- package/dist/ui/choice-card.d.ts +8 -0
- package/dist/ui/choice-card.d.ts.map +1 -1
- package/dist/ui/choice-card.js +1 -1
- package/dist/ui/choice-card.js.map +1 -1
- package/dist/ui/controller/controller-pad.d.ts +105 -2
- package/dist/ui/controller/controller-pad.d.ts.map +1 -1
- package/dist/ui/controller/controller-pad.js +347 -16
- package/dist/ui/controller/controller-pad.js.map +1 -1
- package/dist/ui/controller/renderer.d.ts +13 -0
- package/dist/ui/controller/renderer.d.ts.map +1 -1
- package/dist/ui/controller/renderer.js.map +1 -1
- package/dist/ui/controller/styles.js +50 -1
- package/dist/ui/controller/styles.js.map +1 -1
- package/dist/ui/dialogue-card.d.ts +8 -0
- package/dist/ui/dialogue-card.d.ts.map +1 -1
- package/dist/ui/dialogue-card.js +1 -1
- package/dist/ui/dialogue-card.js.map +1 -1
- package/dist/ui/hud-placement.d.ts +81 -0
- package/dist/ui/hud-placement.d.ts.map +1 -0
- package/dist/ui/hud-placement.js +74 -0
- package/dist/ui/hud-placement.js.map +1 -0
- package/dist/ui/index.d.ts +1 -0
- package/dist/ui/index.d.ts.map +1 -1
- package/dist/ui/index.js +1 -0
- package/dist/ui/index.js.map +1 -1
- package/dist/ui/rider-rig.d.ts +108 -10
- package/dist/ui/rider-rig.d.ts.map +1 -1
- package/dist/ui/rider-rig.js +299 -48
- package/dist/ui/rider-rig.js.map +1 -1
- package/dist/ui/solo-labeled-diamond.d.ts +13 -0
- package/dist/ui/solo-labeled-diamond.d.ts.map +1 -1
- package/dist/ui/solo-labeled-diamond.js +1 -1
- package/dist/ui/solo-labeled-diamond.js.map +1 -1
- package/package.json +4 -2
package/dist/ui/rider-rig.js
CHANGED
|
@@ -60,7 +60,8 @@
|
|
|
60
60
|
* The scale runs `0 → 1.5 × ftp`, so the rider's own threshold sits at two thirds of the way up with
|
|
61
61
|
* headroom above it for a sprint, and it carries a labelled FTP reference mark. A 150 W rider and a
|
|
62
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}).
|
|
63
|
+
* raw-watt scale (the same rule as {@link createPowerRace}). The effort colour is a continuous ramp
|
|
64
|
+
* over %FTP — grey-blue, blue, violet, pink, red, with no band boundaries to jump at — and is
|
|
64
65
|
* are **local to this component**: they are deliberately NOT `@rydr/game-sdk/difficulty`'s ramp, which
|
|
65
66
|
* colours the rider's *chosen calibration level* and would be a different quantity wearing the same
|
|
66
67
|
* paint.
|
|
@@ -89,11 +90,16 @@
|
|
|
89
90
|
* Every gauge is a CONTINUOUS fill; what changes is the paint and the tooltip's orientation.
|
|
90
91
|
*
|
|
91
92
|
* · `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
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
93
|
+
* title and the bar under both. Most legible; start here. Its trainer's FLYWHEEL is the second
|
|
94
|
+
* reading — white, haloed in the gauge's own zone colour at a strength the effort sets, so the
|
|
95
|
+
* watts are legible from across a room without reading a number. The flywheel and not the front
|
|
96
|
+
* wheel: that disc is the one the legs actually drive, while the front wheel is parked on the floor
|
|
97
|
+
* driven by nothing.
|
|
98
|
+
* · `blueprint` — thin technical linework; a PORTRAIT tooltip whose bar runs vertically, with the
|
|
99
|
+
* caption above the figure and both stacked over a tall bar marked at FTP. On that variant the bar
|
|
100
|
+
* is still the reading — a rider glancing at a trainer screen reads a fill long before three
|
|
101
|
+
* digits — so the figure is set smaller than the landscape one: it answers "exactly how much",
|
|
102
|
+
* which the fill cannot, without taking the headline off the bar.
|
|
97
103
|
* · `neon` — glowing outline; a landscape tooltip whose fill carries a bright leading edge.
|
|
98
104
|
*
|
|
99
105
|
* ## SVG for the drawing, CSS for the tooltip
|
|
@@ -112,11 +118,43 @@
|
|
|
112
118
|
* is built once and a driver mutates cached nodes behind a dirty guard, exactly as `pad-svg.ts` does,
|
|
113
119
|
* so an idle rig costs no DOM writes at 60 Hz.
|
|
114
120
|
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
121
|
+
* ## In a game: a HUD piece
|
|
122
|
+
*
|
|
123
|
+
* The rig started as a panel for a page — an onboarding beat, a store page — and a game wants the same
|
|
124
|
+
* picture pinned into a corner of play while the rider rides. Two options cover that, and they are the
|
|
125
|
+
* only two a game needs:
|
|
126
|
+
*
|
|
127
|
+
* ```ts
|
|
128
|
+
* const rig = mountRiderRig(gameRoot, {
|
|
129
|
+
* ftp: session.identity.ftp,
|
|
130
|
+
* width: 300, // any size, not just the three presets
|
|
131
|
+
* place: { corner: "bottom-right", margin: 24 },// pinned, floating, click-through
|
|
132
|
+
* renderer: createThreeRiderRig, // optional: the 3D one
|
|
133
|
+
* });
|
|
134
|
+
* ```
|
|
135
|
+
*
|
|
136
|
+
* `place` is what turns it from a block in flow into a floating overlay; without it nothing about the
|
|
137
|
+
* component changes. Both are adjustable after mounting (`setWidth`, `setPlacement`) so a game can
|
|
138
|
+
* find the size and the corner by eye and paste the numbers back — `examples/ui/rig-hud.ts` is exactly
|
|
139
|
+
* that, a dev page over a fake game screen.
|
|
140
|
+
*
|
|
141
|
+
* **One 3D rig per screen.** `createThreeRiderRig` opens its own WebGL context beside the game's own
|
|
142
|
+
* scene. One HUD widget is a fair price for the picture; three is three contexts the game did not
|
|
143
|
+
* budget for. If the rig is decoration rather than the subject, the flat renderer is free — it is an
|
|
144
|
+
* `<svg>` — and it is the default for that reason.
|
|
145
|
+
*
|
|
146
|
+
* ── Sizing contract (same as the rest of the kit, with ONE exception) ──────────────────────────
|
|
147
|
+
* Tuned for a trainer screen read from a few feet away while pedalling. Don't override the `.rrig-*`
|
|
148
|
+
* dimensions or wrap it in `transform: scale()`.
|
|
149
|
+
*
|
|
150
|
+
* The exception is this component's own `width`, and it is narrow: the rig is a PICTURE, not a label,
|
|
151
|
+
* so it survives being re-sized in a way a keycap or a callout card does not — and a game pinning it
|
|
152
|
+
* into a corner genuinely cannot work from three fixed widths. `width` scales the tooltip's em base
|
|
153
|
+
* with the drawing rather than leaving the type behind, and it floors at 140px, below which the watts
|
|
154
|
+
* stop being readable. That is the whole exception: it licenses nothing elsewhere in the kit.
|
|
118
155
|
* ────────────────────────────────────────────────────────────────────────────────────────────────
|
|
119
156
|
*/
|
|
157
|
+
import { applyHudPlacement, anchorHudHost } from "./hud-placement.js";
|
|
120
158
|
const STYLE_ID = "rydr-rider-rig-styles";
|
|
121
159
|
const NS = "http://www.w3.org/2000/svg";
|
|
122
160
|
/**
|
|
@@ -132,6 +170,24 @@ const SIZES = {
|
|
|
132
170
|
md: { w: 330, u: 10 },
|
|
133
171
|
lg: { w: 440, u: 13 },
|
|
134
172
|
};
|
|
173
|
+
/** Narrowest the rig may be drawn. Below this the watts stop being readable and it is a logo, not a gauge. */
|
|
174
|
+
const MIN_WIDTH = 140;
|
|
175
|
+
/**
|
|
176
|
+
* The em base for a FREE width.
|
|
177
|
+
*
|
|
178
|
+
* The three presets are not on a straight line through the origin, but they are close (240/7.5 = 32,
|
|
179
|
+
* 330/10 = 33, 440/13 = 33.8), so one divisor reproduces all three within a fraction of a pixel and
|
|
180
|
+
* keeps a hand-picked width in the same family as the presets. Sizing the bubble off the width is the
|
|
181
|
+
* point: a tooltip pinned at 10px sits comfortably at `md` and covers half the machine at 180px.
|
|
182
|
+
*
|
|
183
|
+
* The FLOOR is the part that matters, and it is the kit's own rule rather than a tweak: type has a
|
|
184
|
+
* minimum physical size on a trainer screen read from a few feet away, and proportion does not get to
|
|
185
|
+
* override it. Left unclamped, a 170px HUD rig printed its watts at about 8px — present, legible to
|
|
186
|
+
* nobody, and the exact failure the sizing contract exists to prevent. So below the `sm` preset's base
|
|
187
|
+
* the bubble stops shrinking and simply takes up more of the picture, which is the right trade: a
|
|
188
|
+
* small rig whose number can still be read beats a smaller one whose number cannot.
|
|
189
|
+
*/
|
|
190
|
+
const emBase = (width) => Math.max(SIZES.sm.u, width / 33);
|
|
135
191
|
/* ───────────────────────────── geometry ─────────────────────────────
|
|
136
192
|
* One coordinate space, shared by all three designs, in a 200×200 viewBox.
|
|
137
193
|
*
|
|
@@ -152,8 +208,20 @@ const WHEEL_R = 26;
|
|
|
152
208
|
* flat rather than nose-up.
|
|
153
209
|
*/
|
|
154
210
|
const AXLE = { x: 78, y: 152 };
|
|
155
|
-
/**
|
|
156
|
-
|
|
211
|
+
/**
|
|
212
|
+
* Front hub. Its tyre is on the ground (`y + WHEEL_R === GROUND`), and it never turns.
|
|
213
|
+
*
|
|
214
|
+
* x is 163 and not 158, and the five units are load-bearing. At 158 the hub sat exactly `WHEEL_R` from
|
|
215
|
+
* the fork crown (`HEADTUBE_BOT`), which put the crown ON the tyre: the fork appeared to start inside
|
|
216
|
+
* the rubber and the tube read as spearing the wheel — worst in 3D, where a solid blade crosses a
|
|
217
|
+
* solid tyre with no line-art convention to excuse it. Moving the hub forward opens ~2.3 units of
|
|
218
|
+
* clearance, so the crown sits above the tyre the way a real fork's does and the blades run down
|
|
219
|
+
* beside the wheel to the hub.
|
|
220
|
+
*
|
|
221
|
+
* It lengthens the wheelbase to ~1.63 wheel diameters, which is still a road bike's number (the
|
|
222
|
+
* proportion note above allows 1.55; a lowrider starts nearer 2).
|
|
223
|
+
*/
|
|
224
|
+
const FRONT = { x: 163, y: 152 };
|
|
157
225
|
/** Bottom bracket — the crank's centre of rotation, and the anchor the whole rider hangs off. */
|
|
158
226
|
const BB = { x: 112, y: 157 };
|
|
159
227
|
const CRANK_R = 15;
|
|
@@ -224,18 +292,63 @@ const FLY_GEAR = 2.1;
|
|
|
224
292
|
/** Cadence at which the motion streaks reach full strength. */
|
|
225
293
|
const STREAK_FULL_RPM = 95;
|
|
226
294
|
/**
|
|
227
|
-
* %FTP
|
|
295
|
+
* %FTP effort colours — LOCAL to this component. See the module header on why this is not
|
|
228
296
|
* `@rydr/game-sdk/difficulty`'s ramp.
|
|
297
|
+
*
|
|
298
|
+
* A CONTINUOUS ramp, not a table of zones. It was six discrete bands (recovery, endurance, tempo,
|
|
299
|
+
* threshold, VO2, anaerobic) and every boundary was a jump: the bar changed colour in one frame while
|
|
300
|
+
* the watts behind it had barely moved, which reads as a glitch rather than as a rider getting closer
|
|
301
|
+
* to their limit. Effort is not quantised, so its colour should not be either — and now that the same
|
|
302
|
+
* colour also lights the flywheel, a jump was a lamp being switched, not a fire being stoked.
|
|
303
|
+
*
|
|
304
|
+
* The path is grey-blue → blue → violet → pink → red: cold and unsaturated when nothing is being
|
|
305
|
+
* asked, then rising in both saturation and heat. It stays out of green and amber deliberately —
|
|
306
|
+
* those are the two hues a HUD spends on "healthy" and "warning" elsewhere, and this is neither.
|
|
307
|
+
*
|
|
308
|
+
* Interpolation is straight RGB, which is only safe because consecutive stops are close in hue and
|
|
309
|
+
* all of them are saturated: a lerp from blue to pink through violet never passes near grey, so there
|
|
310
|
+
* is no muddy midpoint to correct for.
|
|
229
311
|
*/
|
|
230
|
-
const
|
|
231
|
-
{
|
|
232
|
-
{
|
|
233
|
-
{
|
|
234
|
-
{
|
|
235
|
-
{
|
|
236
|
-
{ max: Infinity, color: "#ef4444", glow: "#fca5a5" }, // anaerobic
|
|
312
|
+
const EFFORT_RAMP = [
|
|
313
|
+
{ at: 0, rgb: [107, 127, 158] }, // #6b7f9e — soft pedalling: cold, barely there
|
|
314
|
+
{ at: 0.45, rgb: [59, 130, 246] }, // #3b82f6 — blue: working
|
|
315
|
+
{ at: 0.75, rgb: [139, 92, 246] }, // #8b5cf6 — violet
|
|
316
|
+
{ at: 1, rgb: [236, 72, 153] }, // #ec4899 — pink: threshold, where the FTP mark sits
|
|
317
|
+
{ at: 1.3, rgb: [239, 68, 68] }, // #ef4444 — red: over the top, and it should look like it
|
|
237
318
|
];
|
|
238
|
-
const
|
|
319
|
+
const hex = (r, g, b) => `#${[r, g, b].map((v) => Math.round(clamp(v, 0, 255)).toString(16).padStart(2, "0")).join("")}`;
|
|
320
|
+
/**
|
|
321
|
+
* The effort's colour, and the lighter one that goes with it.
|
|
322
|
+
*
|
|
323
|
+
* The `glow` half is DERIVED — the base mixed 45% toward white — rather than authored per stop. A
|
|
324
|
+
* second hand-written table would be a second thing to keep in step for no gain: the light variant is
|
|
325
|
+
* only ever used as the hot core of a bloom or the outer edge of a shadow, and "the same colour, paler"
|
|
326
|
+
* is exactly what that wants.
|
|
327
|
+
*
|
|
328
|
+
* The input is quantised to 1/100 FTP before anything is computed. Without it the colour is a new
|
|
329
|
+
* string every frame, and every frame writes two custom properties on the root and invalidates style
|
|
330
|
+
* for the whole rig — for a difference no eye can see. 150 distinct colours across the scale is
|
|
331
|
+
* already far finer than the gradient it paints.
|
|
332
|
+
*/
|
|
333
|
+
function zoneFor(ftpPct) {
|
|
334
|
+
const t = Math.round(clamp(ftpPct, 0, 1.5) * 100) / 100;
|
|
335
|
+
let lo = EFFORT_RAMP[0];
|
|
336
|
+
let hi = EFFORT_RAMP[EFFORT_RAMP.length - 1];
|
|
337
|
+
for (let i = 0; i < EFFORT_RAMP.length - 1; i++) {
|
|
338
|
+
if (t >= EFFORT_RAMP[i].at && t <= EFFORT_RAMP[i + 1].at) {
|
|
339
|
+
lo = EFFORT_RAMP[i];
|
|
340
|
+
hi = EFFORT_RAMP[i + 1];
|
|
341
|
+
break;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
const span = hi.at - lo.at;
|
|
345
|
+
const k = span <= 0 ? 0 : clamp((t - lo.at) / span, 0, 1);
|
|
346
|
+
const c = [0, 1, 2].map((i) => lerp(lo.rgb[i], hi.rgb[i], k));
|
|
347
|
+
return {
|
|
348
|
+
color: hex(c[0], c[1], c[2]),
|
|
349
|
+
glow: hex(lerp(c[0], 255, 0.45), lerp(c[1], 255, 0.45), lerp(c[2], 255, 0.45)),
|
|
350
|
+
};
|
|
351
|
+
}
|
|
239
352
|
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
|
|
240
353
|
const lerp = (a, b, t) => a + (b - a) * t;
|
|
241
354
|
const rad = (deg) => (deg * Math.PI) / 180;
|
|
@@ -395,7 +508,10 @@ export const RIG_GEOMETRY = {
|
|
|
395
508
|
export function mountRiderRig(host, opts = {}) {
|
|
396
509
|
ensureStyles();
|
|
397
510
|
const design = opts.design ?? "solid";
|
|
398
|
-
const { shape
|
|
511
|
+
const { shape } = GAUGE_SPEC[design];
|
|
512
|
+
// The SHAPE of the fill stays the design's (a `neon` gauge keeps its leading edge whichever way it
|
|
513
|
+
// runs); only the direction is overridable.
|
|
514
|
+
const orient = opts.gauge == null ? GAUGE_SPEC[design].orient : opts.gauge === "portrait" ? "vertical" : "horizontal";
|
|
399
515
|
const preset = SIZES[opts.size ?? "md"];
|
|
400
516
|
const showReadout = opts.readout !== false;
|
|
401
517
|
let ftp = Math.max(1, opts.ftp ?? 200);
|
|
@@ -409,8 +525,25 @@ export function mountRiderRig(host, opts = {}) {
|
|
|
409
525
|
const reduceMotion = typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
410
526
|
const root = document.createElement("div");
|
|
411
527
|
root.className = `rrig rrig--${design}`;
|
|
412
|
-
|
|
413
|
-
|
|
528
|
+
/**
|
|
529
|
+
* Width and the em base that goes with it, in one place so the two cannot drift.
|
|
530
|
+
*
|
|
531
|
+
* A preset keeps its own hand-tuned em base; a free width derives one. Both write the same two
|
|
532
|
+
* properties, so `setWidth` is just this called again.
|
|
533
|
+
*/
|
|
534
|
+
function applyWidth(px) {
|
|
535
|
+
const w = px == null ? preset.w : Math.max(MIN_WIDTH, px);
|
|
536
|
+
const u = px == null ? preset.u : emBase(w);
|
|
537
|
+
root.style.width = `${w}px`;
|
|
538
|
+
root.style.setProperty("--rrig-u", `${u}px`);
|
|
539
|
+
}
|
|
540
|
+
applyWidth(opts.width);
|
|
541
|
+
let placement = opts.place ? { ...opts.place } : undefined;
|
|
542
|
+
if (placement) {
|
|
543
|
+
root.classList.add("rrig--placed");
|
|
544
|
+
applyHudPlacement(root, placement);
|
|
545
|
+
anchorHudHost(host, placement);
|
|
546
|
+
}
|
|
414
547
|
const renderer = (opts.renderer ?? createSvgRigRenderer)({ design });
|
|
415
548
|
root.appendChild(renderer.node);
|
|
416
549
|
const anchor = renderer.anchor();
|
|
@@ -419,10 +552,11 @@ export function mountRiderRig(host, opts = {}) {
|
|
|
419
552
|
placeTip(n.tipRoot, orient, anchor, tipOver);
|
|
420
553
|
host.appendChild(root);
|
|
421
554
|
/** Dirty guards for the TOOLTIP. The renderer keeps its own for the drawing. */
|
|
422
|
-
const last = { frac: -Infinity, zone: "", readout: -Infinity };
|
|
555
|
+
const last = { frac: -Infinity, zone: "", readout: -Infinity, effort: -Infinity };
|
|
423
556
|
/** Repaint everything the CURRENT state implies. Cheap: every write is guarded. */
|
|
424
557
|
function paint() {
|
|
425
558
|
const effort = smoothPower / ftp;
|
|
559
|
+
const zone = zoneFor(effort);
|
|
426
560
|
// ── the pose: solved HERE, once, for whatever is drawing ──────────────
|
|
427
561
|
// Nothing that moves is the bike: it is bolted to a fixed machine. Only the crank and the two
|
|
428
562
|
// things the chain turns rotate at all — see the module header.
|
|
@@ -458,14 +592,25 @@ export function mountRiderRig(host, opts = {}) {
|
|
|
458
592
|
// Streaks stand in for the speed the spokes can't show without strobing.
|
|
459
593
|
streak: clamp(smoothCadence / STREAK_FULL_RPM, 0, 1),
|
|
460
594
|
effort,
|
|
595
|
+
zone,
|
|
461
596
|
});
|
|
462
597
|
// ── the gauge ─────────────────────────────────────────────────────────
|
|
463
598
|
const frac = clamp(smoothPower / (ftp * GAUGE_MAX_FTP_PCT), 0, 1);
|
|
464
|
-
const zone = zoneFor(smoothPower / ftp);
|
|
465
599
|
if (zone.color !== last.zone) {
|
|
466
600
|
last.zone = zone.color;
|
|
467
|
-
|
|
468
|
-
|
|
601
|
+
// On the ROOT, not the tooltip: the tooltip inherits them from here exactly as it did when they
|
|
602
|
+
// were its own, and the drawing — which is the tooltip's sibling — can now read the same pair.
|
|
603
|
+
// That is what lets the solid wheel glow in the gauge's colour with no second colour table.
|
|
604
|
+
root.style.setProperty("--rrig-zone", zone.color);
|
|
605
|
+
root.style.setProperty("--rrig-zone-glow", zone.glow);
|
|
606
|
+
}
|
|
607
|
+
// Effort as a bare number, for a stylesheet to multiply into a length. Quantised: it feeds a glow
|
|
608
|
+
// whose size nobody can read to the percent, and an unguarded write here would be a style
|
|
609
|
+
// invalidation every frame for nothing.
|
|
610
|
+
const effortStep = Math.round(clamp(effort, 0, 1.5) * 20) / 20;
|
|
611
|
+
if (effortStep !== last.effort) {
|
|
612
|
+
last.effort = effortStep;
|
|
613
|
+
root.style.setProperty("--rrig-effort", effortStep.toFixed(2));
|
|
469
614
|
}
|
|
470
615
|
if (Math.abs(frac - last.frac) > 0.002) {
|
|
471
616
|
last.frac = frac;
|
|
@@ -500,6 +645,16 @@ export function mountRiderRig(host, opts = {}) {
|
|
|
500
645
|
tipOver = { ...tipOver, ...next };
|
|
501
646
|
placeTip(n.tipRoot, orient, anchor, tipOver);
|
|
502
647
|
},
|
|
648
|
+
setWidth(px) {
|
|
649
|
+
if (Number.isFinite(px))
|
|
650
|
+
applyWidth(px);
|
|
651
|
+
},
|
|
652
|
+
setPlacement(next) {
|
|
653
|
+
placement = { ...placement, ...next };
|
|
654
|
+
root.classList.add("rrig--placed"); // a rig mounted in flow can be pinned later
|
|
655
|
+
applyHudPlacement(root, placement);
|
|
656
|
+
anchorHudHost(host, placement);
|
|
657
|
+
},
|
|
503
658
|
setPower(watts) {
|
|
504
659
|
rawPower = Number.isFinite(watts) ? Math.max(0, watts) : 0;
|
|
505
660
|
},
|
|
@@ -535,8 +690,11 @@ export function mountRiderRig(host, opts = {}) {
|
|
|
535
690
|
}
|
|
536
691
|
/* ───────────────────────────── the drawing ───────────────────────────── */
|
|
537
692
|
/** The flat drawing. Everything here is the rig itself; the tooltip is built separately. */
|
|
693
|
+
/** Per-instance suffix for the gradient ids. Two rigs on one page must not share a `<defs>` entry. */
|
|
694
|
+
let uid = 0;
|
|
538
695
|
function buildSvg(design) {
|
|
539
696
|
const svg = svgNode("svg", { viewBox: `0 0 ${VB_W} ${VB_H}`, class: "rrig-svg", role: "img" });
|
|
697
|
+
const glowId = `rrig-flyglow-${++uid}`;
|
|
540
698
|
svg.setAttribute("aria-label", "A rider pedalling on a direct-drive indoor trainer, with a live watts gauge");
|
|
541
699
|
svg.classList.add(`rrig-svg--${design}`);
|
|
542
700
|
// Ground line first — everything else sits on it.
|
|
@@ -569,6 +727,38 @@ function buildSvg(design) {
|
|
|
569
727
|
y2: FLY.y + (FLY.r - 2) * Math.sin(a),
|
|
570
728
|
}));
|
|
571
729
|
}
|
|
730
|
+
/**
|
|
731
|
+
* The neon bloom BEHIND the flywheel.
|
|
732
|
+
*
|
|
733
|
+
* A `drop-shadow` was the first attempt and it is the wrong tool: it hugs the shape's own edge, so it
|
|
734
|
+
* reads as a rim light on the disc rather than as light coming OFF the machine. This is a real
|
|
735
|
+
* radial fade — bright at the disc, gone by two and a half radii out — which is what a neon glow
|
|
736
|
+
* actually looks like, and it lets the disc itself stay plain white and crisp in front of it.
|
|
737
|
+
*
|
|
738
|
+
* The stops take their colour from the SAME custom properties the gauge is painted from (the root
|
|
739
|
+
* carries them, see paint()), and the disc's opacity rides on `--rrig-effort`. So the bloom is the
|
|
740
|
+
* watts, in the drawing, and it cannot disagree with the bar.
|
|
741
|
+
*/
|
|
742
|
+
// The bloom peaks at the DISC'S EDGE — 38% out, which is where the flywheel's own rim falls in a
|
|
743
|
+
// circle 2.6 radii wide — and falls away from there. Peaking at the centre (the obvious reading of a
|
|
744
|
+
// radial gradient) puts the brightest part behind the disc, where it is hidden, and leaves the rim
|
|
745
|
+
// sitting on the dimmest ring of its own halo. Light comes OFF the wheel; it is brightest where it
|
|
746
|
+
// leaves it.
|
|
747
|
+
const glowGrad = svgNode("radialGradient", { id: glowId });
|
|
748
|
+
glowGrad.appendChild(svgNode("stop", { offset: "0%", class: "rrig-flyglow-mid" }));
|
|
749
|
+
glowGrad.appendChild(svgNode("stop", { offset: "38%", class: "rrig-flyglow-core" }));
|
|
750
|
+
glowGrad.appendChild(svgNode("stop", { offset: "62%", class: "rrig-flyglow-mid" }));
|
|
751
|
+
glowGrad.appendChild(svgNode("stop", { offset: "100%", class: "rrig-flyglow-edge" }));
|
|
752
|
+
const defs = svgNode("defs");
|
|
753
|
+
defs.appendChild(glowGrad);
|
|
754
|
+
svg.appendChild(defs);
|
|
755
|
+
trainer.appendChild(svgNode("circle", {
|
|
756
|
+
class: "rrig-fly-glow",
|
|
757
|
+
cx: FLY.x,
|
|
758
|
+
cy: FLY.y,
|
|
759
|
+
r: FLY.r * 2.6,
|
|
760
|
+
fill: `url(#${glowId})`,
|
|
761
|
+
}));
|
|
572
762
|
trainer.appendChild(svgNode("circle", { class: "rrig-fly", cx: FLY.x, cy: FLY.y, r: FLY.r }));
|
|
573
763
|
trainer.appendChild(flySpokes);
|
|
574
764
|
trainer.appendChild(flyStreaks);
|
|
@@ -695,22 +885,24 @@ function buildTooltip(root, cfg) {
|
|
|
695
885
|
const panel = document.createElement("div");
|
|
696
886
|
panel.className = "rrig-tip-panel";
|
|
697
887
|
tipRoot.appendChild(panel);
|
|
698
|
-
//
|
|
888
|
+
// BOTH layouts print the figure; what differs is where it sits and how loud it is.
|
|
889
|
+
//
|
|
890
|
+
// Landscape puts it beside the title, on its own row. Portrait stacks it ABOVE the bar — the caption
|
|
891
|
+
// on top, the number under it — and sets it smaller, because on that variant the bar is still the
|
|
892
|
+
// reading: it is tall, continuous and marked at FTP, and it answers "how hard am I working" before
|
|
893
|
+
// any digit is read. The figure is the exact value for when the fill is not enough, not the headline.
|
|
699
894
|
//
|
|
700
|
-
//
|
|
701
|
-
//
|
|
702
|
-
//
|
|
703
|
-
// three digits. The landscape variants keep the figure, where there is a row for it.
|
|
895
|
+
// The portrait variant shipped with no figure at all for exactly that reason, and it went too far:
|
|
896
|
+
// a rider who wants to know what they are pushing right now had nowhere to look, and the bar cannot
|
|
897
|
+
// answer that question to the watt. Restored deliberately, sized so it annotates rather than shouts.
|
|
704
898
|
let readoutValue;
|
|
705
899
|
if (cfg.showReadout) {
|
|
706
900
|
const head = document.createElement("div");
|
|
707
901
|
head.className = "rrig-tip-head";
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
head.appendChild(readoutValue);
|
|
713
|
-
}
|
|
902
|
+
readoutValue = document.createElement("span");
|
|
903
|
+
readoutValue.className = "rrig-tip-num";
|
|
904
|
+
readoutValue.textContent = "0";
|
|
905
|
+
head.appendChild(readoutValue);
|
|
714
906
|
const unit = document.createElement("span");
|
|
715
907
|
unit.className = "rrig-tip-unit";
|
|
716
908
|
unit.textContent = cfg.label;
|
|
@@ -720,15 +912,30 @@ function buildTooltip(root, cfg) {
|
|
|
720
912
|
const track = document.createElement("div");
|
|
721
913
|
track.className = "rrig-tip-track";
|
|
722
914
|
panel.appendChild(track);
|
|
915
|
+
/**
|
|
916
|
+
* A clipping well inside the track, holding everything that FILLS it.
|
|
917
|
+
*
|
|
918
|
+
* The fill is a full-width rectangle pinned to one end, and the track is a rounded well — so at the
|
|
919
|
+
* rounded end the fill's square corners poked outside the track's silhouette. Worst when the fill is
|
|
920
|
+
* short, which is exactly when it is most looked at.
|
|
921
|
+
*
|
|
922
|
+
* `overflow: hidden` on the TRACK itself would have been the one-liner, and it is wrong here: the
|
|
923
|
+
* FTP marker is a child of the track and overhangs it on purpose — the tick sticks out 0.4em each
|
|
924
|
+
* side and the label sits entirely outside it. Clipping the track would clip the label away. So the
|
|
925
|
+
* clip gets its own box and the marker stays out of it.
|
|
926
|
+
*/
|
|
927
|
+
const well = document.createElement("div");
|
|
928
|
+
well.className = "rrig-tip-well";
|
|
929
|
+
track.appendChild(well);
|
|
723
930
|
const gaugeFill = document.createElement("div");
|
|
724
931
|
gaugeFill.className = "rrig-tip-fill";
|
|
725
|
-
|
|
932
|
+
well.appendChild(gaugeFill);
|
|
726
933
|
let tip;
|
|
727
934
|
if (cfg.shape === "tube") {
|
|
728
935
|
tip = document.createElement("i");
|
|
729
936
|
tip.className = "rrig-tip-lead";
|
|
730
937
|
tip.style.opacity = "0";
|
|
731
|
-
|
|
938
|
+
well.appendChild(tip);
|
|
732
939
|
}
|
|
733
940
|
// The FTP reference. Positioned off `--rrig-ftp` with a -50% translate, so the tick and its label
|
|
734
941
|
// are centred on the same point by construction rather than by two hand-computed coordinates.
|
|
@@ -758,6 +965,12 @@ function ensureStyles() {
|
|
|
758
965
|
const CSS = `
|
|
759
966
|
.rrig { display: block; max-width: 100%; position: relative;
|
|
760
967
|
--rrig-zone: #6b7f9e; --rrig-zone-glow: #9fb4d4; }
|
|
968
|
+
/* Pinned to the game's screen. Click-through in full: it is a picture the game draws over itself, and
|
|
969
|
+
a HUD piece that ate a click on what is underneath is a bug in every game that mounts one. The
|
|
970
|
+
position, the offsets and the transform are written inline by applyHudPlacement — only what is true of
|
|
971
|
+
EVERY placement lives here. max-width is dropped with it: a pinned rig is sized by its own width,
|
|
972
|
+
and inheriting the host's width would shrink it in a narrow column. */
|
|
973
|
+
.rrig--placed { pointer-events: none; max-width: none; }
|
|
761
974
|
.rrig-svg { display: block; width: 100%; height: auto; overflow: visible; }
|
|
762
975
|
.rrig-svg * { vector-effect: non-scaling-stroke; }
|
|
763
976
|
|
|
@@ -828,9 +1041,15 @@ const CSS = `
|
|
|
828
1041
|
order puts the caption on top without reordering the markup.) Side by side the pair would also
|
|
829
1042
|
set the bubble's width from the text rather than from the bar, and the point of the portrait box is
|
|
830
1043
|
to be narrow. */
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
1044
|
+
/* Portrait stacks them in DOM order — the figure on top, its unit under it — which is how a reading is
|
|
1045
|
+
written down: the number first, the word that qualifies it second and smaller. (It ran the other way
|
|
1046
|
+
at first, caption over figure; that makes the box announce its own title before it says anything,
|
|
1047
|
+
and the title is the least interesting line in it.) */
|
|
1048
|
+
.rrig-tip--vertical .rrig-tip-panel > .rrig-tip-head { flex-direction: column; align-items: center; gap: 0.05em; }
|
|
1049
|
+
/* The figure leads and is the larger of the two, while staying clearly under the landscape figure's
|
|
1050
|
+
size — the BAR is still the reading on this variant, and the number annotates it. */
|
|
1051
|
+
.rrig-tip--vertical .rrig-tip-num { font-size: 2.8em; font-weight: 800; line-height: 1; }
|
|
1052
|
+
.rrig-tip--vertical .rrig-tip-unit { font-size: 1.75em; letter-spacing: 0.12em; }
|
|
834
1053
|
|
|
835
1054
|
/* The well is a translucent WHITE, not black.
|
|
836
1055
|
Black was the instinct — a recessed groove — but the panel behind it is already dark glass, so a
|
|
@@ -842,13 +1061,21 @@ const CSS = `
|
|
|
842
1061
|
box-shadow: inset 0 1px 2.5px rgba(0,0,0,0.55),
|
|
843
1062
|
inset 0 0 0 1px rgba(255,255,255,0.13); }
|
|
844
1063
|
.rrig-tip--horizontal .rrig-tip-track { width: 8.4em; height: 0.9em; }
|
|
845
|
-
/* Portrait: a deliberately BIG bar.
|
|
1064
|
+
/* Portrait: a deliberately BIG bar. It carries the reading, the figure above it only annotates,
|
|
846
1065
|
and the extra height is what makes a change of a few percent visible at a glance. It is nudged off
|
|
847
1066
|
centre so the bar and its FTP label sit optically centred as a pair, rather than the bar alone. */
|
|
848
1067
|
.rrig-tip--vertical .rrig-tip-track { width: 1.75em; height: 13em;
|
|
849
1068
|
align-self: flex-start; margin-left: 0.7em; }
|
|
850
1069
|
|
|
851
|
-
/*
|
|
1070
|
+
/* The well: clips the fill to the track's rounded shape. See the note in build() on why the clip is
|
|
1071
|
+
not on the track itself. */
|
|
1072
|
+
.rrig-tip-well { position: absolute; inset: 0; overflow: hidden; border-radius: inherit; }
|
|
1073
|
+
|
|
1074
|
+
/* continuous fill
|
|
1075
|
+
Its own radius is now decoration rather than structure — the well's curve is what shapes the ends.
|
|
1076
|
+
That also settles the near-empty case: a browser clamps a radius to half the smaller side, so at a
|
|
1077
|
+
height of a pixel this collapsed to a flat sliver. Clipped, the same sliver reads as a thin lens
|
|
1078
|
+
hugging the bottom of the well, which is the truth at 3 W and looks deliberate rather than broken. */
|
|
852
1079
|
.rrig-tip-fill { position: absolute; border-radius: 99px; background: var(--rrig-zone);
|
|
853
1080
|
transition: width 90ms linear, height 90ms linear; }
|
|
854
1081
|
.rrig-tip--horizontal .rrig-tip-fill { left: 0; top: 0; height: 100%; width: 0; }
|
|
@@ -881,7 +1108,11 @@ const CSS = `
|
|
|
881
1108
|
.rrig-tip--horizontal .rrig-tip-ftp b { top: calc(100% + 0.85em); left: 50%; transform: translateX(-50%); }
|
|
882
1109
|
.rrig-tip--vertical .rrig-tip-ftp { bottom: var(--rrig-ftp); left: 0; width: 100%; }
|
|
883
1110
|
.rrig-tip--vertical .rrig-tip-ftp i { top: -1px; left: -0.4em; height: 0; width: calc(100% + 0.8em); }
|
|
884
|
-
|
|
1111
|
+
/* Portrait prints it LARGER, and only portrait. It hangs off the side of the bar, where there is room;
|
|
1112
|
+
the landscape label sits UNDER the track inside the panel's bottom padding, and growing it there
|
|
1113
|
+
pushed it through the bubble's own edge. Same mark, two layouts, one of which has the space. */
|
|
1114
|
+
.rrig-tip--vertical .rrig-tip-ftp b { left: calc(100% + 1.1em); top: 50%; transform: translateY(-50%);
|
|
1115
|
+
font-size: 1.5em; }
|
|
885
1116
|
/* Portrait needs no bottom gutter: its label sits beside the bar, not under it. */
|
|
886
1117
|
.rrig-tip--vertical .rrig-tip-panel { padding: 1em 1.2em 1.3em; min-width: 10em; }
|
|
887
1118
|
|
|
@@ -894,6 +1125,16 @@ const CSS = `
|
|
|
894
1125
|
|
|
895
1126
|
/* ── shared skeleton defaults — the designs override the paint, never the shapes ─────────── */
|
|
896
1127
|
.rrig-tyre, .rrig-fly, .rrig-cassette, .rrig-chainring { fill: none; }
|
|
1128
|
+
|
|
1129
|
+
/* The flywheel's neon bloom. Off everywhere by default: only the design that makes the disc a reading
|
|
1130
|
+
turns it on, and the others keep their own art direction. The three stops are the fade — a hot core
|
|
1131
|
+
in the light zone colour, the saturated one through the middle, nothing at the edge. */
|
|
1132
|
+
.rrig-fly-glow { opacity: 0; transition: opacity 140ms linear; }
|
|
1133
|
+
.rrig-flyglow-core { stop-color: var(--rrig-zone-glow, #9fb4d4); stop-opacity: 0.9; }
|
|
1134
|
+
.rrig-flyglow-mid { stop-color: var(--rrig-zone, #6b7f9e); stop-opacity: 0.45; }
|
|
1135
|
+
.rrig-flyglow-edge { stop-color: var(--rrig-zone, #6b7f9e); stop-opacity: 0; }
|
|
1136
|
+
/* Never fully off — a dark machine at rest still reads as switched on — and never fully saturating. */
|
|
1137
|
+
.rrig--solid .rrig-fly-glow { opacity: calc(0.12 + var(--rrig-effort, 0) * 0.62); }
|
|
897
1138
|
.rrig-frame, .rrig-chain, .rrig-saddle, .rrig-bar, .rrig-crank-arm,
|
|
898
1139
|
.rrig-limb, .rrig-torso, .rrig-neck, .rrig-spoke, .rrig-streak { fill: none; }
|
|
899
1140
|
.rrig-limb, .rrig-torso, .rrig-neck { stroke-linecap: round; stroke-linejoin: round; }
|
|
@@ -916,8 +1157,18 @@ const CSS = `
|
|
|
916
1157
|
.rrig--solid .rrig-pedal { fill: #e6edf9; }
|
|
917
1158
|
.rrig--solid .rrig-trainer-body { fill: #2b374e; stroke: #4a5a79; stroke-width: 2; }
|
|
918
1159
|
.rrig--solid .rrig-foot { fill: #4a5a79; }
|
|
919
|
-
|
|
920
|
-
|
|
1160
|
+
/* The FLYWHEEL is the rig's effort reading, in the drawing itself.
|
|
1161
|
+
It is the right part to carry it and not the front wheel: this disc is the one the rider's legs
|
|
1162
|
+
actually drive, it is the machine's resistance made visible, and it is already spinning with the
|
|
1163
|
+
cadence — so lighting it says "this is what your watts are going into". The front wheel is parked on
|
|
1164
|
+
the floor doing nothing, and glowing it would have said the opposite.
|
|
1165
|
+
White rim and hub, haloed in the gauge's own zone colour, inherited from the root (see paint()) so
|
|
1166
|
+
there is no second colour table. The halo rides on --rrig-effort: barely there at rest, hot at
|
|
1167
|
+
threshold, burning in a sprint. Two shadows — a tight core that keeps the disc crisp, a wide bloom that
|
|
1168
|
+
carries the colour; one big shadow alone washes the spokes out. */
|
|
1169
|
+
.rrig--solid .rrig-fly { stroke: #ffffff; stroke-width: 4; fill: #1b2434; }
|
|
1170
|
+
.rrig--solid .rrig-fly-spokes .rrig-spoke { stroke: #ffffff; }
|
|
1171
|
+
.rrig--solid .rrig-fly-hub { fill: #ffffff; }
|
|
921
1172
|
.rrig--solid .rrig-limb { stroke: #f0b429; stroke-width: 8; }
|
|
922
1173
|
.rrig--solid .rrig-limb--far { stroke: #a8791b; stroke-width: 7; }
|
|
923
1174
|
.rrig--solid .rrig-limb--arm { stroke: #ff7a45; stroke-width: 7; }
|