@titan-design/react-ui 0.9.3 → 0.11.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/bodymap.js +78 -24
- package/dist/bodymap.js.map +1 -1
- package/dist/bodymap.mjs +78 -24
- package/dist/bodymap.mjs.map +1 -1
- package/dist/index.d.mts +429 -11
- package/dist/index.d.ts +429 -11
- package/dist/index.js +2386 -885
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2306 -836
- package/dist/index.mjs.map +1 -1
- package/dist/{pages-DyEx-lBf.d.ts → pages-B1hqnGIC.d.ts} +149 -15
- package/dist/{pages-D7hOD4Vj.d.mts → pages-CvsFqNtx.d.mts} +149 -15
- package/dist/pages.d.mts +1 -1
- package/dist/pages.d.ts +1 -1
- package/dist/pages.js +1900 -1609
- package/dist/pages.js.map +1 -1
- package/dist/pages.mjs +1901 -1610
- package/dist/pages.mjs.map +1 -1
- package/dist/theme/index.d.mts +129 -34
- package/dist/theme/index.d.ts +129 -34
- package/dist/theme/index.js +95 -19
- package/dist/theme/index.js.map +1 -1
- package/dist/theme/index.mjs +92 -20
- package/dist/theme/index.mjs.map +1 -1
- package/dist/theme/tokens-css.js +88 -24
- package/dist/theme/tokens-css.js.map +1 -1
- package/dist/theme/tokens-css.mjs +88 -24
- package/dist/theme/tokens-css.mjs.map +1 -1
- package/package.json +8 -3
- package/src/components/custom/Fatigue/DualGhostSpark.stories.tsx +258 -0
- package/src/components/custom/Fatigue/DualGhostSpark.test.tsx +138 -0
- package/src/components/custom/Fatigue/DualGhostSpark.tsx +208 -0
- package/src/components/custom/Fatigue/FatigueLights.stories.tsx +74 -0
- package/src/components/custom/Fatigue/FatigueLights.test.tsx +33 -0
- package/src/components/custom/Fatigue/FatigueLights.tsx +83 -0
- package/src/components/custom/Fatigue/GhostBand.test.tsx +235 -0
- package/src/components/custom/Fatigue/GhostBand.tsx +246 -0
- package/src/components/custom/Fatigue/GhostBloom.tsx +132 -0
- package/src/components/custom/Fatigue/GhostSpark.stories.tsx +175 -0
- package/src/components/custom/Fatigue/GhostSpark.test.tsx +45 -0
- package/src/components/custom/Fatigue/GhostSpark.tsx +126 -0
- package/src/components/custom/Fatigue/LiveFatigueCard.stories.tsx +126 -0
- package/src/components/custom/Fatigue/LiveFatigueCard.test.tsx +28 -0
- package/src/components/custom/Fatigue/LiveFatigueCard.tsx +86 -0
- package/src/components/custom/Fatigue/LiveFatiguePanel.stories.tsx +95 -0
- package/src/components/custom/Fatigue/LiveFatiguePanel.tsx +151 -0
- package/src/components/custom/Fatigue/README.md +93 -0
- package/src/components/custom/Fatigue/RomProgressionChart.stories.tsx +94 -0
- package/src/components/custom/Fatigue/RomProgressionChart.test.tsx +31 -0
- package/src/components/custom/Fatigue/RomProgressionChart.tsx +132 -0
- package/src/components/custom/Fatigue/SilverRedPalette.stories.tsx +226 -0
- package/src/components/custom/Fatigue/VelocityHero.stories.tsx +70 -0
- package/src/components/custom/Fatigue/VelocityHero.test.tsx +26 -0
- package/src/components/custom/Fatigue/VelocityHero.tsx +46 -0
- package/src/components/custom/Fatigue/VerdictHero.stories.tsx +59 -0
- package/src/components/custom/Fatigue/VerdictHero.test.tsx +49 -0
- package/src/components/custom/Fatigue/VerdictHero.tsx +79 -0
- package/src/components/custom/Fatigue/fatigue-mock.test.ts +59 -0
- package/src/components/custom/Fatigue/fatigue-mock.ts +268 -0
- package/src/components/custom/Fatigue/fatigue-model.ts +129 -0
- package/src/components/custom/Fatigue/fatigue-tokens.test.ts +70 -0
- package/src/components/custom/Fatigue/fatigue-tokens.ts +180 -0
- package/src/components/custom/Fatigue/index.ts +41 -0
- package/src/components/custom/Fatigue/tempo-pacing.test.ts +176 -0
- package/src/components/custom/Fatigue/tempo-pacing.ts +112 -0
- package/src/components/custom/Workout/BaseBadge.tsx +5 -5
- package/src/components/custom/Workout/DualVelocityStrip.test.tsx +685 -0
- package/src/components/custom/Workout/ExerciseCard.test.tsx +4 -4
- package/src/components/custom/Workout/ExerciseCard.tsx +3 -1
- package/src/components/custom/Workout/ExerciseDetailPage.tsx +7 -1
- package/src/components/custom/Workout/README.md +62 -16
- package/src/components/custom/Workout/RowInventory.stories.tsx +5 -2
- package/src/components/custom/Workout/S3FullRail.stories.tsx +13 -1
- package/src/components/custom/Workout/SegmentedBar.stories.tsx +3 -3
- package/src/components/custom/Workout/SessionHeader.tsx +2 -2
- package/src/components/custom/Workout/SessionRail.stories.tsx +4 -1
- package/src/components/custom/Workout/SessionRail.test.tsx +43 -0
- package/src/components/custom/Workout/SessionRail.tsx +55 -18
- package/src/components/custom/Workout/SetRow.test.tsx +6 -6
- package/src/components/custom/Workout/SetRow.tsx +9 -2
- package/src/components/custom/Workout/SupersetWrapper.stories.tsx +16 -25
- package/src/components/custom/Workout/SupersetWrapper.tsx +1 -1
- package/src/components/custom/Workout/TempoDisplay.tsx +3 -7
- package/src/components/custom/Workout/VelocityStrip.compact.stories.tsx +191 -0
- package/src/components/custom/Workout/VelocityStrip.dual.stories.tsx +186 -0
- package/src/components/custom/Workout/VelocityStrip.expanded.stories.tsx +296 -0
- package/src/components/custom/Workout/VelocityStrip.hero.stories.tsx +259 -0
- package/src/components/custom/Workout/VelocityStrip.stories.tsx +217 -250
- package/src/components/custom/Workout/VelocityStrip.test.tsx +196 -79
- package/src/components/custom/Workout/VelocityStrip.tsx +953 -609
- package/src/components/custom/Workout/VolumeLandmarkBar.stories.tsx +4 -1
- package/src/components/custom/Workout/WorkoutPill.tsx +2 -2
- package/src/components/custom/Workout/index.ts +3 -0
- package/src/components/custom/Workout/velocity-story-kit.tsx +438 -0
- package/src/components/custom/charts/SetBarChart.test.tsx +275 -0
- package/src/components/custom/charts/SetBarChart.tsx +651 -0
- package/src/components/custom/charts/live-rep-growth.ts +91 -0
- package/src/components/custom/index.ts +1 -0
- package/src/components/ui/surface/Surface.stories.tsx +195 -1
- package/src/components/ui/surface/Surface.test.tsx +148 -9
- package/src/components/ui/surface/Surface.tsx +37 -9
- package/src/components/ui/surface/SurfaceContext.ts +48 -8
- package/src/components/ui/surface/index.ts +1 -0
- package/src/components/ui/surface/surface.contract.test.ts +283 -0
- package/src/test/raw-color-patterns.test.ts +71 -0
- package/src/theme/ColorPalettes.stories.tsx +871 -0
- package/src/theme/ColorPrimitives.stories.tsx +300 -0
- package/src/theme/bar-paper.ts +59 -0
- package/src/theme/color-stories.coverage.test.ts +130 -0
- package/src/theme/color-story-kit.tsx +170 -0
- package/src/theme/config.ts +12 -0
- package/src/theme/elevation.ts +90 -67
- package/src/theme/global.css +35 -9
- package/src/theme/tokens/primitives.ts +40 -0
- package/src/theme/tokens/semantic.ts +63 -14
- package/tailwind.config.js +8 -0
- package/src/components/custom/Workout/VelocityStrip.modalities.stories.tsx +0 -254
- package/src/components/custom/Workout/VelocityStrip.responsive.stories.tsx +0 -145
- package/src/theme/ColorSystem.stories.tsx +0 -323
- package/src/theme/Colors.stories.tsx +0 -323
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
// Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
|
|
2
2
|
import { useEffect, useState } from 'react'
|
|
3
|
-
import {
|
|
4
|
-
View,
|
|
5
|
-
Text,
|
|
6
|
-
Pressable,
|
|
7
|
-
Animated,
|
|
8
|
-
Easing,
|
|
9
|
-
type ViewProps,
|
|
10
|
-
type ViewStyle,
|
|
11
|
-
type LayoutChangeEvent,
|
|
12
|
-
} from 'react-native'
|
|
3
|
+
import { View, Text, Pressable, Animated, type ViewProps, type ViewStyle } from 'react-native'
|
|
13
4
|
import { WORKOUT_TOKENS } from '../../../theme/workout-tokens'
|
|
14
|
-
import { primitiveColors,
|
|
5
|
+
import { primitiveColors, sequentialEffort } from '../../../theme/tokens/primitives'
|
|
6
|
+
import { getSemanticColors } from '../../../theme/tokens/semantic'
|
|
7
|
+
import { alpha } from '../../../utils/colors'
|
|
15
8
|
import { formatVelocity } from '../../../utils/workout-format'
|
|
16
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
SetBarChart,
|
|
11
|
+
type SetSlot,
|
|
12
|
+
type SetBarGeometry,
|
|
13
|
+
ChartSideRail,
|
|
14
|
+
SET_BAR_DEFAULT_HEIGHT,
|
|
15
|
+
} from '../charts/SetBarChart'
|
|
16
|
+
import { ANIMATION_EASING } from '../charts/live-rep-growth'
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
19
|
* Structural velocity-zone band accepted from an upstream analytics source
|
|
@@ -66,10 +66,63 @@ export interface VelocityStripProps extends ViewProps {
|
|
|
66
66
|
*/
|
|
67
67
|
zones?: readonly VelocityZoneBandProp[]
|
|
68
68
|
/**
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
69
|
+
* Bar-fill coloring mode. `loss` (DEFAULT) — color each performed bar by its
|
|
70
|
+
* velocity LOSS from the set's OWN best (see {@link getVelocityLossColor}), so a
|
|
71
|
+
* fatiguing set reads green→red by loss regardless of its absolute speed; ignores
|
|
72
|
+
* {@link zones} (it is a wholly separate scale). `zone` — color from the absolute
|
|
73
|
+
* velocity-zone scale ({@link zones} when provided, else the built-in
|
|
74
|
+
* ≥1.0/≥0.75/≥0.5 default). `todo`/`variable`/`continue` slots are unaffected either way.
|
|
75
|
+
*/
|
|
76
|
+
barColor?: 'zone' | 'loss'
|
|
77
|
+
/**
|
|
78
|
+
* `hero` only: draw the VL20 / VL30 velocity-loss decision bands behind the bars
|
|
79
|
+
* (see {@link VelocityLossBands}). Defaults to on when {@link barColor} is `loss`
|
|
80
|
+
* (the default), off for `zone` — the bands and the loss bar-fill are the two
|
|
81
|
+
* halves of the same loss-relative language.
|
|
82
|
+
*/
|
|
83
|
+
showLossBands?: boolean
|
|
84
|
+
/**
|
|
85
|
+
* `hero` only: `up` (default) grows bars UP from a bottom baseline; `down` mirrors
|
|
86
|
+
* the whole plot (bars grow DOWN from a top baseline, text upright). The diverging
|
|
87
|
+
* dual is just an `up` hero over a `down` hero sharing one axis — so any hero
|
|
88
|
+
* improvement reaches the dual for free.
|
|
89
|
+
*/
|
|
90
|
+
orientation?: 'up' | 'down'
|
|
91
|
+
/**
|
|
92
|
+
* `hero` only: OVERRIDE the height-scaling denominator with `scaleMax * headroom` instead of
|
|
93
|
+
* this strip's own `peak`/`fixed` scale. Affects BAR HEIGHT ONLY — velocity-LOSS coloring and
|
|
94
|
+
* the running-best reference line still measure off THIS strip's own velocities. The diverging
|
|
95
|
+
* dual passes the pair's shared max here so a stronger arm's bars read TALLER against one common
|
|
96
|
+
* scale while each arm still colors by its own loss.
|
|
97
|
+
*/
|
|
98
|
+
scaleMax?: number
|
|
99
|
+
/**
|
|
100
|
+
* `hero` only (internal): suppress this strip's own 2px baseline border. The diverging dual sets
|
|
101
|
+
* it on BOTH composed wings and draws ONE shared centre axis where they meet, so the axis is a
|
|
102
|
+
* single crisp line rather than two abutting baselines reading as a double-thick rule.
|
|
103
|
+
*/
|
|
104
|
+
hideBaseline?: boolean
|
|
105
|
+
/**
|
|
106
|
+
* `hero` only (internal): the EXACT rendered column structure, overriding this strip's own
|
|
107
|
+
* slot-building. The diverging dual builds ONE index-locked structure shared by both wings (same
|
|
108
|
+
* rep indices, same WIDE-gap positions, same column count; a column a side didn't log renders
|
|
109
|
+
* `empty`) and passes it here so bars line up column-for-column across the centre axis. Bar
|
|
110
|
+
* height / color / the running-best reference still read this strip's own `velocities`.
|
|
111
|
+
*/
|
|
112
|
+
columnSlots?: SetSlot[]
|
|
113
|
+
/**
|
|
114
|
+
* `hero` only: an optional stream / slot NAME (e.g. "Left Arm"), rendered as a rotated vertical
|
|
115
|
+
* edge label down the chart's left gutter — the same treatment the diverging dual gives each wing,
|
|
116
|
+
* so a single hero and a dual read consistently. Omitted → no gutter.
|
|
117
|
+
*/
|
|
118
|
+
label?: string
|
|
119
|
+
/**
|
|
120
|
+
* Live mode: index of the most-recently-completed rep. That bar GROWS UP FROM
|
|
121
|
+
* THE BASELINE to its full height as it enters, tracking the rep as it lands;
|
|
122
|
+
* if it is also the current set peak (a new best) the growth slightly
|
|
123
|
+
* overshoots then settles, reading as a small bounce. Only the latest bar
|
|
124
|
+
* animates. Honors `prefers-reduced-motion`. Framed `expanded` chart and
|
|
125
|
+
* `hero` only (interactive tap-to-expand use).
|
|
73
126
|
*/
|
|
74
127
|
liveRepIndex?: number
|
|
75
128
|
/**
|
|
@@ -81,16 +134,17 @@ export interface VelocityStripProps extends ViewProps {
|
|
|
81
134
|
onToggle?: () => void
|
|
82
135
|
onRepPress?: (index: number, velocity: number) => void
|
|
83
136
|
/**
|
|
84
|
-
* `
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
* off it's a bare strip — the
|
|
89
|
-
* — the across-the-room, single-set wall
|
|
90
|
-
* label, a dashed running-best reference line, and dashed
|
|
91
|
-
* still to come (see {@link targetReps}).
|
|
137
|
+
* `compact` — the flat resting strip: {@link SetBarChart} in FLAT mode (uniform short bars, no
|
|
138
|
+
* value labels), sharing hero's exact geometry (colors, paper, spacing, chunk-notch, slots, gutter).
|
|
139
|
+
* `expanded` — the velocity-HEIGHT bar chart (rounded tops), whose chrome is prop-driven: with
|
|
140
|
+
* {@link showNumbers} or {@link showInfo} on it's the framed chart (raised surface, padding, per-bar
|
|
141
|
+
* m/s labels, mean/loss info row, interactive collapse); with both off it's a bare strip — the
|
|
142
|
+
* active-set "spotlight" of {@link ExerciseCard}. `hero` — the across-the-room, single-set wall
|
|
143
|
+
* treatment: tall bars, a per-bar m/s value label, a dashed running-best reference line, and dashed
|
|
144
|
+
* placeholders for the reps still to come (see {@link targetReps}). All three share SetBarChart, so
|
|
145
|
+
* bars align across compact / expanded / hero.
|
|
92
146
|
*/
|
|
93
|
-
variant?: '
|
|
147
|
+
variant?: 'compact' | 'expanded' | 'hero'
|
|
94
148
|
/** `expanded` / `hero` plot height in px (bars scale to this). Default 60 (`expanded`) / 220 (`hero`). */
|
|
95
149
|
height?: number
|
|
96
150
|
/**
|
|
@@ -182,6 +236,44 @@ export function calculateMeanVelocity(velocities: number[]): number {
|
|
|
182
236
|
return sum / velocities.length
|
|
183
237
|
}
|
|
184
238
|
|
|
239
|
+
/**
|
|
240
|
+
* Velocity-loss band thresholds (loss %), the same VL10/VL20/VL30 coaching cues
|
|
241
|
+
* FatigueMeter's default thresholds use — kept in sync so a `barColor="loss"`
|
|
242
|
+
* strip and a fatigue hero's VL20/VL30 reference bands agree on where amber/red start.
|
|
243
|
+
*/
|
|
244
|
+
const VL_LOSS_THRESHOLDS: readonly [number, number, number] = [10, 20, 30]
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Map a per-rep velocity LOSS (%, from the set's own best — see {@link
|
|
248
|
+
* velocityLossForRep}) onto the same green→gold→orange→red scale as the absolute
|
|
249
|
+
* zone scale ({@link VEL_COLORS}), banded at the {@link VL_LOSS_THRESHOLDS}
|
|
250
|
+
* VL10/VL20/VL30 cues. Past VL20 reads orange ("past VL20 = amber" in coaching
|
|
251
|
+
* terms — this scale's amber/gold band is the yellow stop, orange is the VL20+
|
|
252
|
+
* band), past VL30 reads red, so a fatiguing set reads green→red by LOSS
|
|
253
|
+
* regardless of how slow its absolute velocity is.
|
|
254
|
+
*/
|
|
255
|
+
export function getVelocityLossColor(lossPct: number): string {
|
|
256
|
+
const [t1, t2, t3] = VL_LOSS_THRESHOLDS
|
|
257
|
+
if (lossPct < t1) return VEL_COLORS.green
|
|
258
|
+
if (lossPct < t2) return VEL_COLORS.yellow
|
|
259
|
+
if (lossPct < t3) return VEL_COLORS.orange
|
|
260
|
+
return VEL_COLORS.red
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* A single rep's velocity loss (%) relative to the set's own best rep, clamped to
|
|
265
|
+
* ≥ 0 (a best-so-far rep, or a set with no positive best, reports 0 loss — green).
|
|
266
|
+
* Rounded (matching {@link calculateVelocityLoss}'s convention) so a rep that is
|
|
267
|
+
* arithmetically the set's best — or lands exactly on a VL threshold — doesn't drift
|
|
268
|
+
* across a color-band boundary on floating-point noise (e.g. `1.0 − 0.9` ≈ `0.0999…998`).
|
|
269
|
+
* Feeds `barColor="loss"` bar coloring; distinct from {@link calculateVelocityLoss},
|
|
270
|
+
* which reports only the set's FINAL loss (last rep vs best) as a single summary number.
|
|
271
|
+
*/
|
|
272
|
+
function velocityLossForRep(velocity: number, best: number): number {
|
|
273
|
+
if (best <= 0) return 0
|
|
274
|
+
return Math.max(0, Math.round(((best - velocity) / best) * 100))
|
|
275
|
+
}
|
|
276
|
+
|
|
185
277
|
/** Classify a velocity into its band (slow → fast, min inclusive / max exclusive). */
|
|
186
278
|
function classifyBand(
|
|
187
279
|
velocity: number,
|
|
@@ -197,6 +289,18 @@ function bandColor(band: VelocityZoneBandProp): string {
|
|
|
197
289
|
return bandIdToEffortColor[band.id] ?? VEL_COLORS.green
|
|
198
290
|
}
|
|
199
291
|
|
|
292
|
+
/**
|
|
293
|
+
* The zone → bar-color resolver, single-sourced so every variant (and the dual
|
|
294
|
+
* diverging sibling) colors reps identically: profile-derived `zones` map through
|
|
295
|
+
* {@link bandColor}; the default (no-zones) path uses the built-in 4-color scale.
|
|
296
|
+
* Color is ALWAYS the velocity zone — never the voltra side.
|
|
297
|
+
*/
|
|
298
|
+
function makeBarColorFor(zones?: readonly VelocityZoneBandProp[]): (v: number) => string {
|
|
299
|
+
const hasZones = zones != null && zones.length > 0
|
|
300
|
+
return (v: number): string =>
|
|
301
|
+
hasZones ? bandColor(classifyBand(v, zones)!) : zoneHexMap[getVelocityZoneColor(v)]
|
|
302
|
+
}
|
|
303
|
+
|
|
200
304
|
function getLossStyle(loss: number): Record<string, string> | null {
|
|
201
305
|
if (loss > 25) return { color: VEL_COLORS.red }
|
|
202
306
|
if (loss > 20) return { color: VEL_COLORS.orange }
|
|
@@ -248,11 +352,6 @@ const REP_GAP = 2
|
|
|
248
352
|
*/
|
|
249
353
|
const WIDE_GAP = 8
|
|
250
354
|
|
|
251
|
-
/** Grey fill for planned-but-unperformed reps (charcoal placeholder, literal hex). */
|
|
252
|
-
const TODO_COLOR = primitiveColors.charcoal[300]
|
|
253
|
-
/** The thin cyan-800 outline on the open-ended "continue" slot — reads as "keep going". */
|
|
254
|
-
const CONTINUE_OUTLINE = primitiveRamps.cyan[800]
|
|
255
|
-
|
|
256
355
|
/** The done-velocity array a set contributes to the mean / loss / zone summary. */
|
|
257
356
|
function deriveDoneVelocities(set: VelocitySet): number[] {
|
|
258
357
|
switch (set.type) {
|
|
@@ -352,353 +451,696 @@ function setAccessibilityLabel(set: VelocitySet, repCount: number): string {
|
|
|
352
451
|
}
|
|
353
452
|
}
|
|
354
453
|
|
|
355
|
-
/**
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
454
|
+
/** Default framed `expanded` chart height (px). */
|
|
455
|
+
const EXPANDED_HEIGHT = 60
|
|
456
|
+
/** Default `compact` (flat resting strip) height (px) — a THIN radius-2 pill row, the resting glance. */
|
|
457
|
+
const COMPACT_HEIGHT = 8
|
|
458
|
+
|
|
459
|
+
/** The dashed running-best reference line color (the lightest charcoal step). */
|
|
460
|
+
const HERO_REFERENCE_COLOR = primitiveColors.charcoal[0]
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* A dashed running-best reference line spanning the plot width, absolutely positioned a
|
|
464
|
+
* pixel `offset` from the given `anchor` edge. Shared by the single `hero` chart (anchored
|
|
465
|
+
* to the baseline, `bottom`) and BOTH wings of the diverging dual chart (anchored to the
|
|
466
|
+
* centre axis, `top`) so the three sites render one line treatment (charcoal-0, 1px dashed).
|
|
467
|
+
* Presentation-only + non-interactive — the numeric best lives in each chart's container
|
|
468
|
+
* accessibility label, so the line itself is hidden from the a11y tree.
|
|
469
|
+
*
|
|
470
|
+
* REUSE FOLLOW-UP: `Sparkline` hand-rolls the same dashed-line overlay (plus opacity, an
|
|
471
|
+
* optional label, and a data-domain Y), and two lab specimens duplicate it again. A
|
|
472
|
+
* top-level `ReferenceLine` overlay primitive unifying all of them is a scoped follow-up
|
|
473
|
+
* (see the Workout README) — kept in-file here to keep the hero PR focused.
|
|
474
|
+
*/
|
|
475
|
+
function DashedReferenceLine({
|
|
476
|
+
anchor,
|
|
477
|
+
offset,
|
|
478
|
+
testID,
|
|
479
|
+
}: {
|
|
480
|
+
anchor: 'top' | 'bottom'
|
|
481
|
+
offset: number
|
|
482
|
+
testID: string
|
|
483
|
+
}) {
|
|
484
|
+
const edge: ViewStyle = anchor === 'top' ? { top: offset } : { bottom: offset }
|
|
485
|
+
return (
|
|
486
|
+
<View
|
|
487
|
+
accessibilityElementsHidden
|
|
488
|
+
style={{
|
|
489
|
+
position: 'absolute',
|
|
490
|
+
left: 0,
|
|
491
|
+
right: 0,
|
|
492
|
+
borderTopWidth: 1,
|
|
493
|
+
borderStyle: 'dashed',
|
|
494
|
+
borderColor: HERO_REFERENCE_COLOR,
|
|
495
|
+
pointerEvents: 'none',
|
|
496
|
+
...edge,
|
|
497
|
+
}}
|
|
498
|
+
testID={testID}
|
|
499
|
+
/>
|
|
500
|
+
)
|
|
367
501
|
}
|
|
368
502
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
503
|
+
const BAND_LABEL_FONT = 'monospace'
|
|
504
|
+
/**
|
|
505
|
+
* Below this plot height (px) the VL20/VL30 labels are DROPPED entirely (the dashed lines + washes
|
|
506
|
+
* stay) rather than shrunk into an overlapping smear. Tuned so a 120px dual (≈41px plot/wing) and the
|
|
507
|
+
* cramped mid-small range drop, while the accepted board scale (≈76px plot/wing) keeps its labels.
|
|
508
|
+
*/
|
|
509
|
+
const VL_LABEL_MIN_PLOT = 65
|
|
510
|
+
const VL_SEMANTIC = getSemanticColors('dark')
|
|
373
511
|
|
|
374
|
-
/** Default framed `expanded` chart height (px). */
|
|
375
|
-
const EXPANDED_HEIGHT = 60
|
|
376
512
|
/**
|
|
377
|
-
*
|
|
378
|
-
* (
|
|
379
|
-
*
|
|
513
|
+
* Velocity-LOSS decision bands for the `hero` variant: the VL20 / VL30 coaching
|
|
514
|
+
* cues (off the running best) drawn as absolutely-positioned bands + dashed
|
|
515
|
+
* threshold lines BEHIND the bars, on the hero's own peak scale so a bar dropping
|
|
516
|
+
* into the amber → red band reads as "these are your last effective reps". Shared
|
|
517
|
+
* by the single hero, the diverging dual wings, and {@link VelocityHero}; render
|
|
518
|
+
* it as the first child of the bottom-anchored plot container.
|
|
519
|
+
*
|
|
520
|
+
* `best` is the running-best velocity (the peak the loss is measured from);
|
|
521
|
+
* `scaleDenom` / `plotHeight` are the hero chart's bar-scaling geometry — a
|
|
522
|
+
* velocity `v` sits `(v / scaleDenom) * plotHeight` px up from the baseline,
|
|
523
|
+
* matching {@link HeroVelocityChart}'s `barHeight`.
|
|
380
524
|
*/
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
/**
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
const
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
const
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
525
|
+
export function VelocityLossBands({
|
|
526
|
+
best,
|
|
527
|
+
scaleDenom,
|
|
528
|
+
plotHeight,
|
|
529
|
+
flip = false,
|
|
530
|
+
}: {
|
|
531
|
+
best: number
|
|
532
|
+
scaleDenom: number
|
|
533
|
+
plotHeight: number
|
|
534
|
+
/** The parent plot is vertically mirrored (a `down` wing) — counter-flip the labels upright. */
|
|
535
|
+
flip?: boolean
|
|
536
|
+
}) {
|
|
537
|
+
if (best <= 0 || scaleDenom <= 0 || plotHeight <= 0) return null
|
|
538
|
+
const yOf = (v: number): number => (v / scaleDenom) * plotHeight
|
|
539
|
+
const vl20 = best * 0.8
|
|
540
|
+
const vl30 = best * 0.7
|
|
541
|
+
// The VL20 / VL30 lines sit ~0.09·plotHeight apart, so on a short chart their labels crowd. Scale
|
|
542
|
+
// the label font to the plot height; below VL_LABEL_MIN_PLOT DROP the labels entirely (keep the
|
|
543
|
+
// dashed lines + washes) rather than shrink them into an illegible smear.
|
|
544
|
+
const vlFont = Math.round(Math.max(7, Math.min(9, plotHeight * 0.05)))
|
|
545
|
+
const showLabels = plotHeight >= VL_LABEL_MIN_PLOT
|
|
546
|
+
const band = (loV: number, hiV: number, color: string) => (
|
|
547
|
+
<View
|
|
548
|
+
style={{
|
|
549
|
+
position: 'absolute',
|
|
550
|
+
left: 0,
|
|
551
|
+
right: 0,
|
|
552
|
+
bottom: yOf(loV),
|
|
553
|
+
height: Math.max(0, yOf(hiV) - yOf(loV)),
|
|
554
|
+
backgroundColor: color,
|
|
555
|
+
}}
|
|
556
|
+
/>
|
|
557
|
+
)
|
|
558
|
+
// The label sits ~90% along the threshold, with the dashed line breaking around it (a long segment
|
|
559
|
+
// before + a short stub after) — near the quiet right end where a declining set has room. Below the
|
|
560
|
+
// label threshold the line spans full width with no text.
|
|
561
|
+
const threshold = (v: number, color: string, label: string) => (
|
|
562
|
+
<View
|
|
563
|
+
style={{
|
|
564
|
+
position: 'absolute',
|
|
565
|
+
left: 0,
|
|
566
|
+
right: 0,
|
|
567
|
+
bottom: yOf(v),
|
|
568
|
+
// Collapse to zero height so the dashed border lands EXACTLY on `yOf(v)` — the
|
|
569
|
+
// band edge — instead of floating up half a text-row (the label then centres on
|
|
570
|
+
// the line, breaking it). Matches DashedReferenceLine's bare-border anchoring.
|
|
571
|
+
height: 0,
|
|
572
|
+
flexDirection: 'row',
|
|
573
|
+
alignItems: 'center',
|
|
574
|
+
}}
|
|
575
|
+
>
|
|
576
|
+
<View style={{ flex: 9, borderTopWidth: 1, borderStyle: 'dashed', borderColor: color }} />
|
|
577
|
+
{showLabels ? (
|
|
578
|
+
<>
|
|
579
|
+
<Text
|
|
580
|
+
style={{
|
|
581
|
+
marginHorizontal: 6,
|
|
582
|
+
fontSize: vlFont,
|
|
583
|
+
fontWeight: '800',
|
|
584
|
+
fontFamily: BAND_LABEL_FONT,
|
|
585
|
+
color,
|
|
586
|
+
...(flip ? { transform: [{ scaleY: -1 as number }] } : null),
|
|
587
|
+
}}
|
|
588
|
+
>
|
|
589
|
+
{label}
|
|
590
|
+
</Text>
|
|
591
|
+
<View style={{ flex: 1, borderTopWidth: 1, borderStyle: 'dashed', borderColor: color }} />
|
|
592
|
+
</>
|
|
593
|
+
) : null}
|
|
594
|
+
</View>
|
|
595
|
+
)
|
|
596
|
+
return (
|
|
597
|
+
<View
|
|
598
|
+
accessibilityElementsHidden
|
|
599
|
+
pointerEvents="none"
|
|
600
|
+
style={{ position: 'absolute', left: 0, right: 0, bottom: 0, top: 0 }}
|
|
601
|
+
testID="velocity-loss-bands"
|
|
602
|
+
>
|
|
603
|
+
{band(0, vl30, alpha(VL_SEMANTIC['status-error'], 0.09))}
|
|
604
|
+
{band(vl30, vl20, alpha(VL_SEMANTIC['status-warning'], 0.08))}
|
|
605
|
+
{threshold(vl20, alpha(VL_SEMANTIC['status-warning'], 0.75), 'VL 20%')}
|
|
606
|
+
{threshold(vl30, alpha(VL_SEMANTIC['status-error'], 0.75), 'VL 30%')}
|
|
607
|
+
</View>
|
|
608
|
+
)
|
|
609
|
+
}
|
|
408
610
|
|
|
409
611
|
/**
|
|
410
|
-
*
|
|
411
|
-
*
|
|
612
|
+
* The velocity hero's reference overlay, painted by {@link SetBarChart} via `renderReference` from
|
|
613
|
+
* the chart geometry: the VL20 / VL30 loss decision bands (when `showLossBands`) behind the bars,
|
|
614
|
+
* plus the dashed running-best line at the peak. Both measure off the chart's OWN `best` — the
|
|
615
|
+
* per-side loss language that survives the dual's shared height scale. Unlabeled by design (the peak
|
|
616
|
+
* bar already shows its value; the numeric best is in the chart's accessibility label).
|
|
412
617
|
*/
|
|
413
|
-
|
|
618
|
+
function velocityReferenceOverlay(g: SetBarGeometry, showLossBands: boolean) {
|
|
619
|
+
const referencePx = g.best > 0 && g.scaleDenom > 0 ? Math.min(g.plotHeight, g.yOf(g.best)) : 0
|
|
620
|
+
return (
|
|
621
|
+
<>
|
|
622
|
+
{showLossBands && (
|
|
623
|
+
<VelocityLossBands
|
|
624
|
+
best={g.best}
|
|
625
|
+
scaleDenom={g.scaleDenom}
|
|
626
|
+
plotHeight={g.plotHeight}
|
|
627
|
+
flip={g.flip}
|
|
628
|
+
/>
|
|
629
|
+
)}
|
|
630
|
+
{referencePx > 0 && (
|
|
631
|
+
<DashedReferenceLine
|
|
632
|
+
anchor="bottom"
|
|
633
|
+
offset={referencePx}
|
|
634
|
+
testID="velocity-hero-reference"
|
|
635
|
+
/>
|
|
636
|
+
)}
|
|
637
|
+
</>
|
|
638
|
+
)
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/** Framed expanded collapse-animation duration (ms). */
|
|
642
|
+
const ANIMATION_DURATION = 400
|
|
414
643
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
644
|
+
// --- Dual (bilateral) diverging chart ----------------------------------------
|
|
645
|
+
// The two-device (LEFT + RIGHT voltra) treatment. Instead of two stacked single
|
|
646
|
+
// heroes with independent baselines, ONE diverging chart shares a horizontal centre
|
|
647
|
+
// axis: LEFT reps grow UP, RIGHT reps grow DOWN, one mirrored pair per rep index. The
|
|
648
|
+
// asymmetry (left-dominant / right-lagging) reads pre-attentively as the silhouette.
|
|
649
|
+
// It reuses VelocityStrip's slot model ({@link buildSlots}/{@link VelocitySlot}), its
|
|
650
|
+
// zone color scale ({@link makeBarColorFor}), the hero geometry constants, and the
|
|
651
|
+
// live-rep entrance ({@link useLiveRepGrowth}) — side is POSITION only, never hue.
|
|
652
|
+
//
|
|
653
|
+
// LIVE-REP ENTRANCE — do NOT re-add a dual-only one. The dual used to hand-roll its wing bars and
|
|
654
|
+
// drive them from its own scale-pop hook, so the newest-rep animation existed TWICE and the two
|
|
655
|
+
// copies drifted. Composing single strips retired that hook: `liveRepIndex` is now an ordinary
|
|
656
|
+
// prop on each wing, so both wings animate through the same code path as a single hero and an
|
|
657
|
+
// entrance that fires on one wing only is not expressible. Whether the wings should POP (a
|
|
658
|
+
// whole-bar scale) rather than GROW from the axis is a live design question — but it belongs in
|
|
659
|
+
// SetBarChart's shared entrance, applied to the single hero and the wings together. Reintroducing
|
|
660
|
+
// it here would rebuild exactly the divergence this composition removed.
|
|
661
|
+
|
|
662
|
+
/** One voltra's velocity stream — the SAME shape VelocityStrip accepts (one of these). */
|
|
663
|
+
export interface DualVelocityStream {
|
|
664
|
+
/** Per-rep MEAN concentric velocity (m/s) for this side. */
|
|
665
|
+
velocities?: number[]
|
|
666
|
+
/** A structured set descriptor for this side (drives the typed slot vocabulary). */
|
|
667
|
+
set?: VelocitySet
|
|
668
|
+
/**
|
|
669
|
+
* The side's SLOT NAME (e.g. "Left Arm"), rendered as the vertical edge label. This is
|
|
670
|
+
* the slot's identity supplied by data — there is no hardcoded LEFT/RIGHT fallback. When
|
|
671
|
+
* absent or empty, that side renders no label.
|
|
672
|
+
*/
|
|
673
|
+
label?: string
|
|
418
674
|
}
|
|
419
675
|
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
676
|
+
export interface DualVelocityStripProps extends ViewProps {
|
|
677
|
+
/** The up-wing stream — drawn growing UP from the centre axis; its edge name comes from `left.label`. */
|
|
678
|
+
left: DualVelocityStream
|
|
679
|
+
/** The down-wing stream — drawn growing DOWN from the centre axis; its edge name comes from `right.label`. */
|
|
680
|
+
right: DualVelocityStream
|
|
681
|
+
/**
|
|
682
|
+
* Optional velocity-zone bands (shape-compatible with WA's `VelocityZones.bands`),
|
|
683
|
+
* shared with {@link VelocityStrip}. Colors the reps by zone on BOTH sides; side is
|
|
684
|
+
* never encoded by hue. When omitted the built-in default scale is used.
|
|
685
|
+
*/
|
|
686
|
+
zones?: readonly VelocityZoneBandProp[]
|
|
687
|
+
/**
|
|
688
|
+
* Bar-fill coloring mode, matching {@link VelocityStrip}. `loss` (DEFAULT) — each
|
|
689
|
+
* wing colors by its velocity LOSS from THAT arm's own best (per-wing, since arms
|
|
690
|
+
* fatigue independently). `zone` — the shared absolute zone scale. Side is never
|
|
691
|
+
* encoded by hue either way.
|
|
692
|
+
*/
|
|
693
|
+
barColor?: 'zone' | 'loss'
|
|
694
|
+
/**
|
|
695
|
+
* Planned rep count. Reps beyond a side's performed count draw as mirrored dashed
|
|
696
|
+
* todo stubs (same "3 of 8 done" read as the single hero), on both wings.
|
|
697
|
+
*/
|
|
698
|
+
targetReps?: number
|
|
699
|
+
/** Index of the most-recently-completed rep; that mirrored pair animates in (hero only). */
|
|
700
|
+
liveRepIndex?: number
|
|
701
|
+
/**
|
|
702
|
+
* `hero` — the across-the-room wall scale: tall wings, per-rep m/s value labels, and a dashed
|
|
703
|
+
* running-best reference line per side. `compact` — the flat resting form: dual-hero but flat +
|
|
704
|
+
* no labels (both wings are the `compact` VelocityStrip variant), same aligned structure + shared
|
|
705
|
+
* gutter + axis. `rail` — the compact rail-expanded lean renderer, no labels / reference lines.
|
|
706
|
+
*/
|
|
707
|
+
variant?: 'hero' | 'compact' | 'rail'
|
|
708
|
+
/**
|
|
709
|
+
* Bar-height scaling, shared across BOTH wings so the L/R asymmetry reads as bar
|
|
710
|
+
* length against one scale. `peak` (default) = the pair's max +headroom; `fixed` =
|
|
711
|
+
* a fixed velocity ceiling.
|
|
712
|
+
*/
|
|
713
|
+
scale?: 'peak' | 'fixed'
|
|
714
|
+
/** Total plot height (px), split evenly into the up (L) and down (R) wings. */
|
|
715
|
+
height?: number
|
|
716
|
+
className?: string
|
|
425
717
|
}
|
|
426
718
|
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
719
|
+
/** Default `hero` diverging height (px) — matches the single hero. */
|
|
720
|
+
const DUAL_HERO_HEIGHT = SET_BAR_DEFAULT_HEIGHT
|
|
721
|
+
/** Default `rail` diverging height (px) — compact enough to sit inside a rail slot. */
|
|
722
|
+
const DUAL_RAIL_HEIGHT = 96
|
|
723
|
+
/** Default `compact` diverging height (px) — two 5px rounded L/R sub-segments + the 1.5px fold gap. */
|
|
724
|
+
const DUAL_COMPACT_HEIGHT = 11.5
|
|
725
|
+
|
|
726
|
+
// --- Rail geometry (the compact dedicated path) ------------------------------
|
|
727
|
+
// The `hero` variant COMPOSES two single VelocityStrip heroes, so it inherits the
|
|
728
|
+
// hero geometry for free. The `rail` variant is far smaller than a hero (no value
|
|
729
|
+
// labels, reference lines, paper, or label headroom), so composing the hero would
|
|
730
|
+
// drag all of that in; it keeps a lean dedicated renderer at these compact metrics.
|
|
731
|
+
/** The vertical gap between the up/down wings on the axis-less rail (value-height dual-expanded). */
|
|
732
|
+
const DUAL_WING_GAP = 2
|
|
733
|
+
/** The centre gap (px) splitting the folded compact dual's L (top) and R (bottom) halves — a gap, no line. */
|
|
734
|
+
const COMPACT_FOLD_GAP = 1.5
|
|
735
|
+
/** Each folded-compact sub-segment's height (px) — a rounded pill per side, above/below the fold gap. */
|
|
736
|
+
const COMPACT_FOLD_HALF = 5
|
|
737
|
+
|
|
738
|
+
/** One side's performed per-rep velocities — flattened from a `set` descriptor, or the raw array. */
|
|
739
|
+
function streamDone(stream: DualVelocityStream): number[] {
|
|
740
|
+
return stream.set ? deriveDoneVelocities(stream.set) : (stream.velocities ?? [])
|
|
430
741
|
}
|
|
431
742
|
|
|
432
|
-
/**
|
|
433
|
-
function
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
743
|
+
/** A side's NATURAL slot list — its `set` slot vocabulary, or bare rep slots + a `targetReps` remainder. */
|
|
744
|
+
function streamNaturalSlots(stream: DualVelocityStream, targetReps?: number): SetSlot[] {
|
|
745
|
+
if (stream.set) {
|
|
746
|
+
return buildSlots(stream.set).map((s) => ({
|
|
747
|
+
kind: s.kind,
|
|
748
|
+
value: s.velocity,
|
|
749
|
+
leadingGap: s.leadingGap,
|
|
750
|
+
}))
|
|
751
|
+
}
|
|
752
|
+
const reps: SetSlot[] = (stream.velocities ?? []).map((v, i) => ({
|
|
753
|
+
kind: 'rep',
|
|
754
|
+
value: v,
|
|
755
|
+
leadingGap: i === 0 ? 0 : REP_GAP,
|
|
756
|
+
}))
|
|
757
|
+
const pad = Math.max(0, (targetReps ?? 0) - reps.length)
|
|
758
|
+
return [...reps, ...Array.from({ length: pad }, () => ({ kind: 'todo' as const }))]
|
|
444
759
|
}
|
|
445
760
|
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
761
|
+
/**
|
|
762
|
+
* The window-kind precedence when merging two sides' cells at a column: a set-type window (variable /
|
|
763
|
+
* continue) that either side has wins; else if either logged (or plans) a rep there it's a REP column;
|
|
764
|
+
* else it's a shared to-do. So a lagging side's not-yet-logged rep column stays a REP column (rendered
|
|
765
|
+
* `empty` for that side), index-locked to the other side's rep — never shifted or re-spaced.
|
|
766
|
+
*/
|
|
767
|
+
function mergeColumnKind(l: SetSlot | undefined, r: SetSlot | undefined): SetSlot['kind'] {
|
|
768
|
+
const kinds = [l?.kind, r?.kind]
|
|
769
|
+
if (kinds.includes('variable')) return 'variable'
|
|
770
|
+
if (kinds.includes('continue')) return 'continue'
|
|
771
|
+
if (kinds.includes('rep')) return 'rep'
|
|
772
|
+
return 'todo'
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/** One side's cell at a merged column: its logged rep (with value), else `empty` for a rep column, else the shared window kind. */
|
|
776
|
+
function sideColumnCell(
|
|
777
|
+
slot: SetSlot | undefined,
|
|
778
|
+
kind: SetSlot['kind'],
|
|
779
|
+
leadingGap: number
|
|
780
|
+
): SetSlot {
|
|
781
|
+
if (kind !== 'rep') return { kind, leadingGap }
|
|
782
|
+
return slot?.kind === 'rep'
|
|
783
|
+
? { kind: 'rep', value: slot.value, leadingGap }
|
|
784
|
+
: { kind: 'empty', leadingGap }
|
|
785
|
+
}
|
|
449
786
|
|
|
450
787
|
/**
|
|
451
|
-
*
|
|
452
|
-
*
|
|
453
|
-
*
|
|
454
|
-
*
|
|
788
|
+
* Build ONE index-locked column structure shared by both diverging wings from their natural slot
|
|
789
|
+
* lists: same column count, same rep indices, same WIDE-gap positions. Each column resolves to a
|
|
790
|
+
* shared kind ({@link mergeColumnKind}); per side it's that side's rep value, or `empty` when the
|
|
791
|
+
* side hasn't logged that rep (assumes symmetric bilateral reps — both sides step together).
|
|
455
792
|
*/
|
|
456
|
-
function
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
}
|
|
470
|
-
if (isNewPeak) {
|
|
471
|
-
liveScale.setValue(1)
|
|
472
|
-
Animated.sequence([
|
|
473
|
-
Animated.timing(liveScale, {
|
|
474
|
-
toValue: 1.25,
|
|
475
|
-
duration: 150,
|
|
476
|
-
easing: Easing.out(Easing.quad),
|
|
477
|
-
useNativeDriver: true,
|
|
478
|
-
}),
|
|
479
|
-
Animated.spring(liveScale, {
|
|
480
|
-
toValue: 1,
|
|
481
|
-
friction: 3,
|
|
482
|
-
tension: 140,
|
|
483
|
-
useNativeDriver: true,
|
|
484
|
-
}),
|
|
485
|
-
]).start()
|
|
486
|
-
} else {
|
|
487
|
-
liveScale.setValue(0.8)
|
|
488
|
-
Animated.timing(liveScale, {
|
|
489
|
-
toValue: 1,
|
|
490
|
-
duration: 300,
|
|
491
|
-
easing: POP_EASING,
|
|
492
|
-
useNativeDriver: true,
|
|
493
|
-
}).start()
|
|
494
|
-
}
|
|
495
|
-
}, [active, liveRepIndex, liveVelocity, isNewPeak, prefersReducedMotion, liveScale])
|
|
496
|
-
return liveScale
|
|
793
|
+
function alignDualSlots(left: SetSlot[], right: SetSlot[]): { left: SetSlot[]; right: SetSlot[] } {
|
|
794
|
+
const n = Math.max(left.length, right.length)
|
|
795
|
+
const L: SetSlot[] = []
|
|
796
|
+
const R: SetSlot[] = []
|
|
797
|
+
for (let i = 0; i < n; i++) {
|
|
798
|
+
const lt = left[i]
|
|
799
|
+
const rt = right[i]
|
|
800
|
+
const kind = mergeColumnKind(lt, rt)
|
|
801
|
+
const gap = Math.max(lt?.leadingGap ?? REP_GAP, rt?.leadingGap ?? REP_GAP)
|
|
802
|
+
L.push(sideColumnCell(lt, kind, gap))
|
|
803
|
+
R.push(sideColumnCell(rt, kind, gap))
|
|
804
|
+
}
|
|
805
|
+
return { left: L, right: R }
|
|
497
806
|
}
|
|
498
807
|
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
808
|
+
/** Shared inputs for both dual renderers — the resolved per-side done arrays + presentation props. */
|
|
809
|
+
interface DualChartProps {
|
|
810
|
+
leftDone: number[]
|
|
811
|
+
rightDone: number[]
|
|
812
|
+
/**
|
|
813
|
+
* The raw per-side streams. The `hero` renderer passes each side's `set` (or `velocities`)
|
|
814
|
+
* straight through to its composed VelocityStrip hero so a side's set-type WINDOWS (the range
|
|
815
|
+
* cyan variable window, the AMRAP/myo "continue", drop/myo chunk-notch gaps) render on the dual
|
|
816
|
+
* — not just the flattened reps. The lean `rail` renderer ignores them (it only takes velocities).
|
|
817
|
+
*/
|
|
818
|
+
leftStream?: DualVelocityStream
|
|
819
|
+
rightStream?: DualVelocityStream
|
|
820
|
+
leftLabel?: string
|
|
821
|
+
rightLabel?: string
|
|
822
|
+
zones?: readonly VelocityZoneBandProp[]
|
|
823
|
+
barColor: 'zone' | 'loss'
|
|
824
|
+
/** Shared across BOTH wings: `peak` (the pair's max +headroom) or `fixed` (a cross-set ceiling). */
|
|
825
|
+
scale: 'peak' | 'fixed'
|
|
510
826
|
targetReps?: number
|
|
511
827
|
height: number
|
|
512
828
|
className?: string
|
|
829
|
+
label: string
|
|
513
830
|
viewProps: ViewProps
|
|
514
831
|
}
|
|
515
832
|
|
|
516
833
|
/**
|
|
517
|
-
* The `hero`
|
|
518
|
-
* a
|
|
519
|
-
*
|
|
520
|
-
* {@link
|
|
834
|
+
* The `hero` diverging chart — COMPOSED from two single {@link VelocityStrip} heroes: an `up` hero
|
|
835
|
+
* over a `down` (vertically-mirrored) hero, sharing ONE height scale via {@link
|
|
836
|
+
* VelocityStripProps.scaleMax} and meeting at one shared centre axis. Each wing hides its own
|
|
837
|
+
* baseline ({@link VelocityStripProps.hideBaseline}) so the axis is a single crisp line. Because it
|
|
838
|
+
* is literally two heroes, every hero improvement — paper, loss bands, grow-from-bottom, the
|
|
839
|
+
* running-best reference, surface-relative placeholders, per-side loss coloring — reaches the dual
|
|
840
|
+
* for free. Side is POSITION only: both wings color reps by the SAME `barColor` scale; the shared
|
|
841
|
+
* `scaleMax` makes a stronger arm read TALLER while each wing still colors off its own best.
|
|
521
842
|
*/
|
|
522
|
-
function
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
843
|
+
function DualVelocityHero({
|
|
844
|
+
leftDone,
|
|
845
|
+
rightDone,
|
|
846
|
+
leftStream,
|
|
847
|
+
rightStream,
|
|
848
|
+
leftLabel,
|
|
849
|
+
rightLabel,
|
|
850
|
+
zones,
|
|
851
|
+
barColor,
|
|
852
|
+
scale,
|
|
853
|
+
targetReps,
|
|
527
854
|
liveRepIndex,
|
|
528
|
-
|
|
855
|
+
height,
|
|
856
|
+
className,
|
|
857
|
+
label,
|
|
858
|
+
viewProps,
|
|
859
|
+
}: DualChartProps & { liveRepIndex?: number }) {
|
|
860
|
+
// The two wings are separated by the SAME small gap as the expanded/rail dual (no centre-axis rule).
|
|
861
|
+
const plotHalf = (height - DUAL_WING_GAP) / 2
|
|
862
|
+
// ONE shared height scale across both wings so the L/R asymmetry reads as bar length, not two
|
|
863
|
+
// scales; each composed strip still colors by its OWN loss and draws its OWN best reference.
|
|
864
|
+
// `peak` shares the pair max via `scaleMax`; `fixed` shares the cross-set ceiling (both wings
|
|
865
|
+
// resolve the same fixed ceiling), so `scaleMax` is left off and `scale` carries it.
|
|
866
|
+
const sharedMax = Math.max(...leftDone, ...rightDone, 0)
|
|
867
|
+
// ONE index-locked column structure shared by both wings: same rep indices, same WIDE-gap
|
|
868
|
+
// positions, same column count. A column a side didn't log renders `empty` there — so a lagging
|
|
869
|
+
// side's next rep lands at the SAME column index as the other side, never shifted or re-spaced.
|
|
870
|
+
const aligned = alignDualSlots(
|
|
871
|
+
leftStream ? streamNaturalSlots(leftStream, targetReps) : [],
|
|
872
|
+
rightStream ? streamNaturalSlots(rightStream, targetReps) : []
|
|
873
|
+
)
|
|
874
|
+
const { style: externalStyle, ...restProps } = viewProps
|
|
875
|
+
|
|
876
|
+
// Each wing renders the SHARED aligned structure (`columnSlots`); its own `velocities` still drive
|
|
877
|
+
// bar height / per-side loss color / the running-best reference.
|
|
878
|
+
const wing = (orientation: 'up' | 'down', columns: SetSlot[], done: number[]) => (
|
|
879
|
+
<VelocityStrip
|
|
880
|
+
variant="hero"
|
|
881
|
+
orientation={orientation}
|
|
882
|
+
velocities={done}
|
|
883
|
+
columnSlots={columns}
|
|
884
|
+
scale={scale}
|
|
885
|
+
scaleMax={scale === 'fixed' ? undefined : sharedMax}
|
|
886
|
+
barColor={barColor}
|
|
887
|
+
zones={zones}
|
|
888
|
+
liveRepIndex={liveRepIndex}
|
|
889
|
+
height={plotHalf}
|
|
890
|
+
hideBaseline
|
|
891
|
+
/>
|
|
892
|
+
)
|
|
893
|
+
|
|
894
|
+
return (
|
|
895
|
+
<View
|
|
896
|
+
className={className}
|
|
897
|
+
style={[{ height, flexDirection: 'row' }, externalStyle]}
|
|
898
|
+
accessibilityRole="image"
|
|
899
|
+
accessibilityLabel={label}
|
|
900
|
+
testID="dual-velocity-strip"
|
|
901
|
+
{...restProps}
|
|
902
|
+
>
|
|
903
|
+
{/* The SHARED gutter/side-rail (34px, no hairline) — one section per wing. A single hero renders
|
|
904
|
+
the same rail with ONE section, so it's pixel-identical to this upper half. */}
|
|
905
|
+
<ChartSideRail
|
|
906
|
+
sectionExtent={plotHalf}
|
|
907
|
+
variant="hero"
|
|
908
|
+
sections={[
|
|
909
|
+
{ label: leftLabel, testID: 'dual-velocity-side-label-L' },
|
|
910
|
+
{ label: rightLabel, testID: 'dual-velocity-side-label-R' },
|
|
911
|
+
]}
|
|
912
|
+
/>
|
|
913
|
+
|
|
914
|
+
{/* The two composed heroes, separated by the shared wing gap (no centre-axis rule). Their own
|
|
915
|
+
labels are redundant with the dual's summary label, so the wings are a11y-hidden. */}
|
|
916
|
+
<View style={{ flex: 1, gap: DUAL_WING_GAP }}>
|
|
917
|
+
<View accessibilityElementsHidden testID="dual-velocity-wing-up">
|
|
918
|
+
{wing('up', aligned.left, leftDone)}
|
|
919
|
+
</View>
|
|
920
|
+
<View accessibilityElementsHidden testID="dual-velocity-wing-down">
|
|
921
|
+
{wing('down', aligned.right, rightDone)}
|
|
922
|
+
</View>
|
|
923
|
+
</View>
|
|
924
|
+
</View>
|
|
925
|
+
)
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/**
|
|
929
|
+
* The `rail` diverging chart — a lean, compact dedicated renderer. Composing the hero here would
|
|
930
|
+
* drag in its value labels, reference lines, paper, and label headroom, none of which belong at
|
|
931
|
+
* rail scale; instead it draws mirrored per-side bars at the compact rail metrics. Same shared
|
|
932
|
+
* height scale + per-side loss coloring + mirrored radius + shared axis as the hero, but no labels
|
|
933
|
+
* / reference lines / paper. (Set-type slot windows are a hero-composition concern; rail only ever
|
|
934
|
+
* takes plain velocities.)
|
|
935
|
+
*/
|
|
936
|
+
function DualVelocityRail({
|
|
937
|
+
leftDone,
|
|
938
|
+
rightDone,
|
|
939
|
+
leftStream,
|
|
940
|
+
rightStream,
|
|
941
|
+
zones,
|
|
942
|
+
barColor,
|
|
943
|
+
scale,
|
|
529
944
|
targetReps,
|
|
945
|
+
liveRepIndex,
|
|
530
946
|
height,
|
|
531
947
|
className,
|
|
948
|
+
label,
|
|
532
949
|
viewProps,
|
|
533
|
-
}:
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
//
|
|
538
|
-
//
|
|
539
|
-
const
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
const pendingCount = Math.max(0, (targetReps ?? 0) - doneVelocities.length)
|
|
550
|
-
const referencePx =
|
|
551
|
-
referenceVelocity > 0 && scaleDenom > 0
|
|
552
|
-
? Math.min(plotHeight, (referenceVelocity / scaleDenom) * plotHeight)
|
|
553
|
-
: 0
|
|
554
|
-
|
|
555
|
-
// When bars get too thin, keep only the peak + live-rep labels so the rest don't overlap.
|
|
556
|
-
const totalSlots = doneVelocities.length + pendingCount
|
|
557
|
-
const barWidth =
|
|
558
|
-
plotW > 0 && totalSlots > 0
|
|
559
|
-
? Math.min(HERO_BAR_MAX_WIDTH, (plotW - HERO_BAR_GAP * (totalSlots - 1)) / totalSlots)
|
|
560
|
-
: HERO_BAR_MAX_WIDTH
|
|
561
|
-
const labelsCrowded = plotW > 0 && barWidth < HERO_LABEL_MIN_BAR_WIDTH
|
|
562
|
-
// Step the inter-bar gap down with the bars so the gaps never dwarf the bars themselves.
|
|
563
|
-
const heroGap =
|
|
564
|
-
plotW === 0 ? HERO_BAR_GAP : barWidth < 16 ? 2 : barWidth < 28 ? 4 : HERO_BAR_GAP
|
|
565
|
-
const peakIndex = doneVelocities.reduce(
|
|
566
|
-
(best, v, i) => (v > doneVelocities[best] ? i : best),
|
|
567
|
-
0,
|
|
950
|
+
}: DualChartProps & { liveRepIndex?: number }) {
|
|
951
|
+
// The lean dual-EXPANDED. Composed from two bare `expanded` strips exactly as the hero composes
|
|
952
|
+
// two heroes, rather than drawing its own bars: composing is what makes the dual inherit the
|
|
953
|
+
// single's bar widths, gaps, chunk-notch, set-type slot windows, paper and live-rep growth. The
|
|
954
|
+
// bespoke renderer this replaces took only velocities, so the dual silently dropped every
|
|
955
|
+
// set-type window and let a lagging side render fewer bars than its partner.
|
|
956
|
+
const plotHalf = (height - DUAL_WING_GAP) / 2
|
|
957
|
+
// ONE shared height scale across both wings, so an L/R asymmetry reads as bar length rather than
|
|
958
|
+
// as two independent scales.
|
|
959
|
+
const sharedMax = Math.max(...leftDone, ...rightDone, 0)
|
|
960
|
+
// ONE index-locked column structure. A column a side did not log renders as an aligned empty, so
|
|
961
|
+
// the lagging side's next rep lands at the SAME column index as its partner's.
|
|
962
|
+
const aligned = alignDualSlots(
|
|
963
|
+
leftStream ? streamNaturalSlots(leftStream, targetReps) : [],
|
|
964
|
+
rightStream ? streamNaturalSlots(rightStream, targetReps) : []
|
|
568
965
|
)
|
|
569
|
-
const
|
|
570
|
-
!labelsCrowded || i === peakIndex || i === liveRepIndex
|
|
966
|
+
const { style: externalStyle, ...restProps } = viewProps
|
|
571
967
|
|
|
572
|
-
const
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
968
|
+
const wing = (orientation: 'up' | 'down', columns: SetSlot[], done: number[]) => (
|
|
969
|
+
<VelocityStrip
|
|
970
|
+
variant="expanded"
|
|
971
|
+
showNumbers={false}
|
|
972
|
+
showInfo={false}
|
|
973
|
+
orientation={orientation}
|
|
974
|
+
velocities={done}
|
|
975
|
+
columnSlots={columns}
|
|
976
|
+
scale={scale}
|
|
977
|
+
scaleMax={scale === 'fixed' ? undefined : sharedMax}
|
|
978
|
+
barColor={barColor}
|
|
979
|
+
zones={zones}
|
|
980
|
+
liveRepIndex={liveRepIndex}
|
|
981
|
+
height={plotHalf}
|
|
982
|
+
/>
|
|
983
|
+
)
|
|
984
|
+
|
|
985
|
+
return (
|
|
986
|
+
<View
|
|
987
|
+
className={className}
|
|
988
|
+
style={[{ height, flexDirection: 'column', gap: DUAL_WING_GAP }, externalStyle]}
|
|
989
|
+
accessibilityRole="image"
|
|
990
|
+
accessibilityLabel={label}
|
|
991
|
+
testID="dual-velocity-strip"
|
|
992
|
+
{...restProps}
|
|
993
|
+
>
|
|
994
|
+
{/* No gutter, side labels or centre axis at rail scale — the wings read as two rows via the
|
|
995
|
+
shared gap alone. Their own labels would duplicate the dual's summary label. */}
|
|
996
|
+
<View accessibilityElementsHidden testID="dual-velocity-wing-up">
|
|
997
|
+
{wing('up', aligned.left, leftDone)}
|
|
998
|
+
</View>
|
|
999
|
+
<View accessibilityElementsHidden testID="dual-velocity-wing-down">
|
|
1000
|
+
{wing('down', aligned.right, rightDone)}
|
|
1001
|
+
</View>
|
|
1002
|
+
</View>
|
|
1003
|
+
)
|
|
1004
|
+
}
|
|
578
1005
|
|
|
1006
|
+
/**
|
|
1007
|
+
* The `compact` diverging chart — the resting dual FOLDED into ONE 8px strip. Because the compact
|
|
1008
|
+
* bars are flat (colour-encoded, not height-encoded), the diverging pair need not stack into two
|
|
1009
|
+
* rows: each rep column splits at the centre into an L (top) and R (bottom) flat half (≈3.25px each,
|
|
1010
|
+
* a {@link COMPACT_FOLD_GAP}px gap between — a gap, not a line), so the pair occupies the SAME 8px as
|
|
1011
|
+
* the single compact — the per-rep analogue of the north-star stacked-halves. Index-locked (a lagging
|
|
1012
|
+
* side's un-logged reps render as faint empties); no gutter / labels / axis (no room at 8px). The
|
|
1013
|
+
* value-height diverging (where bar length must encode velocity) lives in `hero` / `rail`.
|
|
1014
|
+
*/
|
|
1015
|
+
function DualVelocityCompactStrip({
|
|
1016
|
+
leftDone,
|
|
1017
|
+
rightDone,
|
|
1018
|
+
leftStream,
|
|
1019
|
+
rightStream,
|
|
1020
|
+
zones,
|
|
1021
|
+
barColor,
|
|
1022
|
+
targetReps,
|
|
1023
|
+
height,
|
|
1024
|
+
className,
|
|
1025
|
+
label,
|
|
1026
|
+
viewProps,
|
|
1027
|
+
}: DualChartProps) {
|
|
1028
|
+
// COMPOSED, like the hero and rail: two `compact` strips at COMPACT_FOLD_HALF each, separated by
|
|
1029
|
+
// COMPACT_FOLD_GAP (5 + 1.5 + 5 = DUAL_COMPACT_HEIGHT exactly). Because a compact bar FILLS its
|
|
1030
|
+
// plot, each column reads as an L top pill over an R bottom pill — the folded design, but drawn
|
|
1031
|
+
// by the same SetBarChart the single uses. Composing rather than hand-rolling is what makes the
|
|
1032
|
+
// set-type vocabulary (to-do, the cyan variable/continue windows, drop chunk-notch) and the
|
|
1033
|
+
// index-locked empty arrive for free, and keeps them from drifting apart later.
|
|
1034
|
+
const half = height === DUAL_COMPACT_HEIGHT ? COMPACT_FOLD_HALF : (height - COMPACT_FOLD_GAP) / 2
|
|
1035
|
+
const aligned = alignDualSlots(
|
|
1036
|
+
leftStream ? streamNaturalSlots(leftStream, targetReps) : [],
|
|
1037
|
+
rightStream ? streamNaturalSlots(rightStream, targetReps) : []
|
|
1038
|
+
)
|
|
579
1039
|
const { style: externalStyle, ...restProps } = viewProps
|
|
580
1040
|
|
|
1041
|
+
const wing = (orientation: 'up' | 'down', columns: SetSlot[], done: number[]) => (
|
|
1042
|
+
<VelocityStrip
|
|
1043
|
+
variant="compact"
|
|
1044
|
+
orientation={orientation}
|
|
1045
|
+
velocities={done}
|
|
1046
|
+
columnSlots={columns}
|
|
1047
|
+
barColor={barColor}
|
|
1048
|
+
zones={zones}
|
|
1049
|
+
height={half}
|
|
1050
|
+
/>
|
|
1051
|
+
)
|
|
1052
|
+
|
|
581
1053
|
return (
|
|
582
1054
|
<View
|
|
583
1055
|
className={className}
|
|
584
|
-
style={[{ height }, externalStyle]}
|
|
1056
|
+
style={[{ height, flexDirection: 'column', gap: COMPACT_FOLD_GAP }, externalStyle]}
|
|
585
1057
|
accessibilityRole="image"
|
|
586
1058
|
accessibilityLabel={label}
|
|
587
|
-
testID="velocity-strip
|
|
1059
|
+
testID="dual-velocity-strip"
|
|
588
1060
|
{...restProps}
|
|
589
1061
|
>
|
|
590
|
-
<View
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
alignItems: 'flex-end',
|
|
596
|
-
gap: heroGap,
|
|
597
|
-
position: 'relative',
|
|
598
|
-
borderBottomWidth: 2,
|
|
599
|
-
borderBottomColor: HERO_PENDING_COLOR,
|
|
600
|
-
}}
|
|
601
|
-
>
|
|
602
|
-
{/*
|
|
603
|
-
* Running-best reference line. Deliberately unlabeled: it sits at the tallest
|
|
604
|
-
* bar's top, and that bar already displays its velocity as its own value label,
|
|
605
|
-
* so a "best X.XX" tag would duplicate it — and would collide with that label
|
|
606
|
-
* whenever the peak bar is on the same side (declines peak-left, ends-on-best
|
|
607
|
-
* peak-right). The line alone reads as "how far has velocity fallen from best".
|
|
608
|
-
* The numeric best is in the container's accessibility label.
|
|
609
|
-
*/}
|
|
610
|
-
{referencePx > 0 && (
|
|
611
|
-
<View
|
|
612
|
-
accessibilityElementsHidden
|
|
613
|
-
style={{
|
|
614
|
-
position: 'absolute',
|
|
615
|
-
left: 0,
|
|
616
|
-
right: 0,
|
|
617
|
-
bottom: referencePx,
|
|
618
|
-
borderTopWidth: 1,
|
|
619
|
-
borderStyle: 'dashed',
|
|
620
|
-
borderColor: HERO_REFERENCE_COLOR,
|
|
621
|
-
pointerEvents: 'none',
|
|
622
|
-
}}
|
|
623
|
-
testID="velocity-hero-reference"
|
|
624
|
-
/>
|
|
625
|
-
)}
|
|
626
|
-
|
|
627
|
-
{doneVelocities.map((velocity, i) => {
|
|
628
|
-
const isLive = liveRepIndex === i
|
|
629
|
-
return (
|
|
630
|
-
<View
|
|
631
|
-
key={i}
|
|
632
|
-
accessibilityElementsHidden
|
|
633
|
-
style={{
|
|
634
|
-
flex: 1,
|
|
635
|
-
maxWidth: HERO_BAR_MAX_WIDTH,
|
|
636
|
-
height: '100%',
|
|
637
|
-
alignItems: 'center',
|
|
638
|
-
justifyContent: 'flex-end',
|
|
639
|
-
}}
|
|
640
|
-
>
|
|
641
|
-
{showBarLabel(i) && (
|
|
642
|
-
<Text
|
|
643
|
-
className="text-text-primary"
|
|
644
|
-
style={{ fontSize: 12, fontWeight: '800', marginBottom: 4 }}
|
|
645
|
-
testID={`velocity-label-${i}`}
|
|
646
|
-
>
|
|
647
|
-
{formatVelocity(velocity)}
|
|
648
|
-
</Text>
|
|
649
|
-
)}
|
|
650
|
-
<Animated.View
|
|
651
|
-
style={[
|
|
652
|
-
{
|
|
653
|
-
width: '100%',
|
|
654
|
-
height: barHeight(velocity),
|
|
655
|
-
borderTopLeftRadius: HERO_BAR_RADIUS,
|
|
656
|
-
borderTopRightRadius: HERO_BAR_RADIUS,
|
|
657
|
-
backgroundColor: barColorFor(velocity),
|
|
658
|
-
},
|
|
659
|
-
isLive ? { transform: [{ scale: liveScale }] } : null,
|
|
660
|
-
]}
|
|
661
|
-
testID={`velocity-bar-${i}`}
|
|
662
|
-
/>
|
|
663
|
-
</View>
|
|
664
|
-
)
|
|
665
|
-
})}
|
|
666
|
-
|
|
667
|
-
{Array.from({ length: pendingCount }, (_, i) => (
|
|
668
|
-
<View
|
|
669
|
-
key={`pending-${i}`}
|
|
670
|
-
accessibilityElementsHidden
|
|
671
|
-
style={{
|
|
672
|
-
flex: 1,
|
|
673
|
-
maxWidth: HERO_BAR_MAX_WIDTH,
|
|
674
|
-
height: '100%',
|
|
675
|
-
alignItems: 'center',
|
|
676
|
-
justifyContent: 'flex-end',
|
|
677
|
-
}}
|
|
678
|
-
>
|
|
679
|
-
<View
|
|
680
|
-
style={{
|
|
681
|
-
width: '100%',
|
|
682
|
-
height: HERO_MIN_BAR_HEIGHT * 3,
|
|
683
|
-
borderWidth: 1,
|
|
684
|
-
borderStyle: 'dashed',
|
|
685
|
-
borderColor: HERO_PENDING_COLOR,
|
|
686
|
-
borderTopLeftRadius: HERO_BAR_RADIUS,
|
|
687
|
-
borderTopRightRadius: HERO_BAR_RADIUS,
|
|
688
|
-
}}
|
|
689
|
-
testID="velocity-slot-todo"
|
|
690
|
-
/>
|
|
691
|
-
</View>
|
|
692
|
-
))}
|
|
1062
|
+
<View accessibilityElementsHidden testID="dual-velocity-wing-up">
|
|
1063
|
+
{wing('up', aligned.left, leftDone)}
|
|
1064
|
+
</View>
|
|
1065
|
+
<View accessibilityElementsHidden testID="dual-velocity-wing-down">
|
|
1066
|
+
{wing('down', aligned.right, rightDone)}
|
|
693
1067
|
</View>
|
|
694
1068
|
</View>
|
|
695
1069
|
)
|
|
696
1070
|
}
|
|
697
1071
|
|
|
1072
|
+
/**
|
|
1073
|
+
* The dual-voltra (bilateral) DIVERGING per-rep velocity chart. The up-wing stream grows UP, the
|
|
1074
|
+
* down-wing stream grows DOWN from one shared centre axis. The `hero` variant COMPOSES two single
|
|
1075
|
+
* {@link VelocityStrip} heroes (see {@link DualVelocityHero}) so any hero improvement reaches the
|
|
1076
|
+
* dual for free; `rail` uses a lean value-height renderer ({@link DualVelocityRail}); `compact` folds
|
|
1077
|
+
* the pair into one 8px strip ({@link DualVelocityCompactStrip}). Single-voltra sets keep using
|
|
1078
|
+
* {@link VelocityStrip} (`variant="hero"`) — unchanged.
|
|
1079
|
+
*/
|
|
1080
|
+
export function DualVelocityStrip({
|
|
1081
|
+
left,
|
|
1082
|
+
right,
|
|
1083
|
+
zones,
|
|
1084
|
+
barColor = 'loss',
|
|
1085
|
+
targetReps,
|
|
1086
|
+
liveRepIndex,
|
|
1087
|
+
variant = 'hero',
|
|
1088
|
+
scale = 'peak',
|
|
1089
|
+
height,
|
|
1090
|
+
className,
|
|
1091
|
+
...props
|
|
1092
|
+
}: DualVelocityStripProps) {
|
|
1093
|
+
const resolvedHeight =
|
|
1094
|
+
height ??
|
|
1095
|
+
(variant === 'hero'
|
|
1096
|
+
? DUAL_HERO_HEIGHT
|
|
1097
|
+
: variant === 'compact'
|
|
1098
|
+
? DUAL_COMPACT_HEIGHT
|
|
1099
|
+
: DUAL_RAIL_HEIGHT)
|
|
1100
|
+
// The done arrays drive the shared max + per-side best + the summary counts; the raw streams
|
|
1101
|
+
// flow to the hero wings so a side's set-type WINDOWS render (the `rail` renderer uses `*Done`).
|
|
1102
|
+
const leftDone = streamDone(left)
|
|
1103
|
+
const rightDone = streamDone(right)
|
|
1104
|
+
|
|
1105
|
+
const label =
|
|
1106
|
+
`Dual velocity chart, left ${leftDone.length} of ${Math.max(leftDone.length, targetReps ?? leftDone.length)} reps, ` +
|
|
1107
|
+
`right ${rightDone.length} of ${Math.max(rightDone.length, targetReps ?? rightDone.length)} reps`
|
|
1108
|
+
|
|
1109
|
+
const shared: DualChartProps = {
|
|
1110
|
+
leftDone,
|
|
1111
|
+
rightDone,
|
|
1112
|
+
leftStream: left,
|
|
1113
|
+
rightStream: right,
|
|
1114
|
+
leftLabel: left.label,
|
|
1115
|
+
rightLabel: right.label,
|
|
1116
|
+
zones,
|
|
1117
|
+
barColor,
|
|
1118
|
+
scale,
|
|
1119
|
+
targetReps,
|
|
1120
|
+
height: resolvedHeight,
|
|
1121
|
+
className,
|
|
1122
|
+
label,
|
|
1123
|
+
viewProps: props,
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
// `liveRepIndex` reaches the rail too: its wings are composed strips, so the newest rep grows
|
|
1127
|
+
// from the midline on BOTH sides. Compact is flat, so a grow animation has nothing to animate.
|
|
1128
|
+
if (variant === 'rail') return <DualVelocityRail {...shared} liveRepIndex={liveRepIndex} />
|
|
1129
|
+
if (variant === 'compact') return <DualVelocityCompactStrip {...shared} />
|
|
1130
|
+
return <DualVelocityHero {...shared} liveRepIndex={liveRepIndex} />
|
|
1131
|
+
}
|
|
1132
|
+
|
|
698
1133
|
export function VelocityStrip({
|
|
699
1134
|
velocities,
|
|
700
1135
|
set,
|
|
701
1136
|
zones,
|
|
1137
|
+
barColor = 'loss',
|
|
1138
|
+
showLossBands,
|
|
1139
|
+
orientation = 'up',
|
|
1140
|
+
scaleMax,
|
|
1141
|
+
hideBaseline,
|
|
1142
|
+
columnSlots,
|
|
1143
|
+
label,
|
|
702
1144
|
liveRepIndex,
|
|
703
1145
|
expanded = true,
|
|
704
1146
|
onToggle,
|
|
@@ -712,17 +1154,13 @@ export function VelocityStrip({
|
|
|
712
1154
|
className,
|
|
713
1155
|
...props
|
|
714
1156
|
}: VelocityStripProps) {
|
|
1157
|
+
// Bands default on with the loss bar-fill (the two halves of the loss language),
|
|
1158
|
+
// off for the absolute zone scale; an explicit prop always wins.
|
|
1159
|
+
const lossBandsOn = showLossBands ?? barColor === 'loss'
|
|
715
1160
|
// A `set` descriptor derives its own done-velocity array; the legacy
|
|
716
1161
|
// `velocities` path stays the source of truth otherwise. Every summary calc
|
|
717
1162
|
// (mean / loss / zone) runs on this one array so the info row works either way.
|
|
718
1163
|
const doneVelocities = set ? deriveDoneVelocities(set) : (velocities ?? [])
|
|
719
|
-
const slots: VelocitySlot[] = set
|
|
720
|
-
? buildSlots(set)
|
|
721
|
-
: doneVelocities.map((v, i) => ({
|
|
722
|
-
kind: 'rep',
|
|
723
|
-
velocity: v,
|
|
724
|
-
leadingGap: i === 0 ? 0 : REP_GAP,
|
|
725
|
-
}))
|
|
726
1164
|
|
|
727
1165
|
const maxVelocity = Math.max(...doneVelocities, 0)
|
|
728
1166
|
const meanVelocity = calculateMeanVelocity(doneVelocities)
|
|
@@ -731,50 +1169,37 @@ export function VelocityStrip({
|
|
|
731
1169
|
// The framed chart (raised box, labels, info) vs the bare spotlight strip is the
|
|
732
1170
|
// only fork in the `expanded` variant — keyed by whether any chrome is requested.
|
|
733
1171
|
const framed = showNumbers || showInfo
|
|
734
|
-
const scaleDenom = scaleDenominator(scale, maxVelocity)
|
|
735
1172
|
|
|
736
1173
|
const hasZones = zones != null && zones.length > 0
|
|
1174
|
+
// Single-sourced zone resolver (diverging-hero) wrapped with the loss-relative mode: `barColor="loss"`
|
|
1175
|
+
// colors each rep by its velocity loss from the set's own best, else the shared zone scale. Every
|
|
1176
|
+
// variant hands this `colorFor` to SetBarChart, so all variants color identically.
|
|
1177
|
+
const zoneColorFor = makeBarColorFor(zones)
|
|
737
1178
|
const barColorFor = (v: number): string =>
|
|
738
|
-
|
|
739
|
-
const slotColor = (slot: VelocitySlot): string => {
|
|
740
|
-
if (slot.kind === 'rep') return barColorFor(slot.velocity ?? 0)
|
|
741
|
-
if (slot.kind === 'todo') return TODO_COLOR
|
|
742
|
-
return SET_STRIP_VARIABLE_COLOR
|
|
743
|
-
}
|
|
1179
|
+
barColor === 'loss' ? getVelocityLossColor(velocityLossForRep(v, maxVelocity)) : zoneColorFor(v)
|
|
744
1180
|
const meanZone = hasZones
|
|
745
1181
|
? (classifyBand(meanVelocity, zones)?.label ?? '')
|
|
746
1182
|
: getVelocityZoneName(meanVelocity)
|
|
747
1183
|
|
|
748
|
-
|
|
749
|
-
|
|
1184
|
+
// The framed collapse is now an IN-PLACE bar-height morph: `expandProgress` (0 collapsed → 1 open)
|
|
1185
|
+
// drives SetBarChart's bars flat↔value (no reflow), replacing the old collapse-to-3px height anim.
|
|
1186
|
+
// `infoOpacity` fades the info row; the per-bar labels fade with `expandProgress` in the overlay.
|
|
1187
|
+
const [expandProgress] = useState(() => new Animated.Value(expanded ? 1 : 0))
|
|
750
1188
|
const [infoOpacity] = useState(() => new Animated.Value(expanded ? 1 : 0))
|
|
751
1189
|
|
|
752
1190
|
// Newest-rep animation: pop for a normal rep, bounce when it sets a new peak.
|
|
753
1191
|
const liveVelocity = liveRepIndex != null ? doneVelocities[liveRepIndex] : undefined
|
|
754
1192
|
const isNewPeak = liveVelocity != null && maxVelocity > 0 && liveVelocity === maxVelocity
|
|
755
|
-
const liveScale = useLiveRepPop(
|
|
756
|
-
variant === 'expanded' && framed,
|
|
757
|
-
liveRepIndex,
|
|
758
|
-
liveVelocity,
|
|
759
|
-
isNewPeak
|
|
760
|
-
)
|
|
761
1193
|
|
|
762
1194
|
useEffect(() => {
|
|
763
1195
|
if (variant !== 'expanded' || !framed) return
|
|
764
|
-
|
|
765
1196
|
Animated.parallel([
|
|
766
|
-
Animated.timing(
|
|
767
|
-
toValue: expanded ?
|
|
1197
|
+
Animated.timing(expandProgress, {
|
|
1198
|
+
toValue: expanded ? 1 : 0,
|
|
768
1199
|
duration: ANIMATION_DURATION,
|
|
769
1200
|
easing: ANIMATION_EASING,
|
|
770
1201
|
useNativeDriver: false,
|
|
771
1202
|
}),
|
|
772
|
-
Animated.timing(labelOpacity, {
|
|
773
|
-
toValue: expanded ? 1 : 0,
|
|
774
|
-
duration: 300,
|
|
775
|
-
delay: expanded ? 150 : 0,
|
|
776
|
-
useNativeDriver: false,
|
|
777
|
-
}),
|
|
778
1203
|
Animated.timing(infoOpacity, {
|
|
779
1204
|
toValue: expanded ? 1 : 0,
|
|
780
1205
|
duration: 300,
|
|
@@ -782,7 +1207,7 @@ export function VelocityStrip({
|
|
|
782
1207
|
useNativeDriver: false,
|
|
783
1208
|
}),
|
|
784
1209
|
]).start()
|
|
785
|
-
}, [expanded, variant, framed,
|
|
1210
|
+
}, [expanded, variant, framed, expandProgress, infoOpacity])
|
|
786
1211
|
|
|
787
1212
|
// Nothing to draw: neither a legacy velocity array nor a set descriptor.
|
|
788
1213
|
if (set == null && velocities == null) return null
|
|
@@ -791,106 +1216,129 @@ export function VelocityStrip({
|
|
|
791
1216
|
const miniLabel = set ? setAccessibilityLabel(set, repCount) : `Velocity strip, ${repCount} reps`
|
|
792
1217
|
|
|
793
1218
|
if (variant === 'hero') {
|
|
794
|
-
// Hero's plot is far taller than the expanded chart; apply its own default when
|
|
795
|
-
//
|
|
796
|
-
|
|
1219
|
+
// Hero's plot is far taller than the expanded chart; apply its own default when the caller left
|
|
1220
|
+
// `height` at the shared 60px default. The diverging dual (which passes `columnSlots`) sets an
|
|
1221
|
+
// explicit per-wing height — exempt it, so a dual whose wing height happens to be 60px (a 120px
|
|
1222
|
+
// dual) isn't mistaken for "unset" and blown up to 220.
|
|
1223
|
+
const heroHeight =
|
|
1224
|
+
height === EXPANDED_HEIGHT && columnSlots == null ? SET_BAR_DEFAULT_HEIGHT : height
|
|
1225
|
+
// `columnSlots` (the diverging dual's shared index-locked structure) wins; else a `set` builds
|
|
1226
|
+
// its own typed slots, and the plain `velocities` path is bare rep slots + a `targetReps` remainder.
|
|
1227
|
+
const heroSlots: SetSlot[] =
|
|
1228
|
+
columnSlots ??
|
|
1229
|
+
(set
|
|
1230
|
+
? buildSlots(set).map((s) => ({
|
|
1231
|
+
kind: s.kind,
|
|
1232
|
+
value: s.velocity,
|
|
1233
|
+
leadingGap: s.leadingGap,
|
|
1234
|
+
}))
|
|
1235
|
+
: doneVelocities.map((v) => ({ kind: 'rep', value: v })))
|
|
1236
|
+
const total = Math.max(repCount, targetReps ?? repCount)
|
|
1237
|
+
const heroLabel =
|
|
1238
|
+
maxVelocity > 0
|
|
1239
|
+
? `Velocity chart, ${repCount} of ${total} reps, best ${formatVelocity(maxVelocity)} meters per second`
|
|
1240
|
+
: `Velocity chart, ${repCount} of ${total} reps`
|
|
797
1241
|
return (
|
|
798
|
-
<
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
1242
|
+
<SetBarChart
|
|
1243
|
+
slots={heroSlots}
|
|
1244
|
+
colorFor={barColorFor}
|
|
1245
|
+
height={heroHeight}
|
|
1246
|
+
scale={scale}
|
|
1247
|
+
scaleMax={scaleMax}
|
|
1248
|
+
orientation={orientation}
|
|
803
1249
|
liveRepIndex={liveRepIndex}
|
|
804
1250
|
isNewPeak={isNewPeak}
|
|
805
|
-
targetReps={targetReps}
|
|
806
|
-
|
|
1251
|
+
targetReps={set || columnSlots ? undefined : targetReps}
|
|
1252
|
+
label={label}
|
|
1253
|
+
showValueLabels
|
|
1254
|
+
formatValue={formatVelocity}
|
|
1255
|
+
renderReference={(g) => velocityReferenceOverlay(g, lossBandsOn)}
|
|
1256
|
+
hideBaseline
|
|
1257
|
+
testID="velocity-strip-hero"
|
|
1258
|
+
testIDPrefix="velocity"
|
|
1259
|
+
accessibilityLabel={heroLabel}
|
|
807
1260
|
className={className}
|
|
808
1261
|
viewProps={props}
|
|
809
1262
|
/>
|
|
810
1263
|
)
|
|
811
1264
|
}
|
|
812
1265
|
|
|
813
|
-
if (variant === '
|
|
814
|
-
|
|
815
|
-
//
|
|
816
|
-
//
|
|
817
|
-
//
|
|
1266
|
+
if (variant === 'compact') {
|
|
1267
|
+
// `compact` = the flat resting form: SetBarChart in FLAT mode (uniform short bars, no value
|
|
1268
|
+
// labels), sharing hero's geometry — same colors, paper, 0.08 spacing, proportional chunk-notch,
|
|
1269
|
+
// todo/variable/continue slots, and gutter — so a compact↔expanded toggle only changes bar HEIGHT.
|
|
1270
|
+
// `columnSlots` (the diverging dual-compact's shared index-locked structure) wins, like the hero.
|
|
1271
|
+
const compactSlots: SetSlot[] =
|
|
1272
|
+
columnSlots ??
|
|
1273
|
+
(set
|
|
1274
|
+
? buildSlots(set).map((s) => ({
|
|
1275
|
+
kind: s.kind,
|
|
1276
|
+
value: s.velocity,
|
|
1277
|
+
leadingGap: s.leadingGap,
|
|
1278
|
+
}))
|
|
1279
|
+
: doneVelocities.map((v) => ({ kind: 'rep', value: v })))
|
|
1280
|
+
const compactHeight = height === EXPANDED_HEIGHT ? COMPACT_HEIGHT : height
|
|
818
1281
|
return (
|
|
819
|
-
<
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
1282
|
+
<SetBarChart
|
|
1283
|
+
slots={compactSlots}
|
|
1284
|
+
colorFor={barColorFor}
|
|
1285
|
+
height={compactHeight}
|
|
1286
|
+
scale={scale}
|
|
1287
|
+
scaleMax={scaleMax}
|
|
1288
|
+
orientation={orientation}
|
|
1289
|
+
flat
|
|
1290
|
+
barRadius={2}
|
|
1291
|
+
cornerStyle="all"
|
|
1292
|
+
targetReps={set || columnSlots ? undefined : targetReps}
|
|
1293
|
+
label={label}
|
|
1294
|
+
hideBaseline
|
|
1295
|
+
testID="velocity-strip-compact"
|
|
1296
|
+
testIDPrefix="velocity"
|
|
826
1297
|
accessibilityLabel={miniLabel}
|
|
827
|
-
|
|
828
|
-
{
|
|
829
|
-
|
|
830
|
-
{slots.map((slot, i) => (
|
|
831
|
-
<View
|
|
832
|
-
key={i}
|
|
833
|
-
style={{
|
|
834
|
-
flex: 1,
|
|
835
|
-
backgroundColor: slotColor(slot),
|
|
836
|
-
borderRadius: 1,
|
|
837
|
-
minWidth: 4,
|
|
838
|
-
height: '100%' as unknown as number,
|
|
839
|
-
// The container's uniform 2px gap covers rep spacing; a wide slot adds
|
|
840
|
-
// only the EXTRA (WIDE_GAP − REP_GAP) so the notch totals WIDE_GAP.
|
|
841
|
-
marginLeft: Math.max(0, slot.leadingGap - REP_GAP),
|
|
842
|
-
...(slot.kind === 'continue'
|
|
843
|
-
? { borderWidth: 1, borderColor: CONTINUE_OUTLINE }
|
|
844
|
-
: {}),
|
|
845
|
-
}}
|
|
846
|
-
accessibilityElementsHidden
|
|
847
|
-
testID={slot.kind === 'rep' ? `velocity-bar-${i}` : `velocity-slot-${slot.kind}`}
|
|
848
|
-
/>
|
|
849
|
-
))}
|
|
850
|
-
</View>
|
|
1298
|
+
className={className}
|
|
1299
|
+
viewProps={props}
|
|
1300
|
+
/>
|
|
851
1301
|
)
|
|
852
1302
|
}
|
|
853
1303
|
|
|
854
|
-
// Bare `expanded` strip (both chrome flags off): the velocity-HEIGHT spotlight —
|
|
855
|
-
//
|
|
856
|
-
//
|
|
857
|
-
//
|
|
1304
|
+
// Bare `expanded` strip (both chrome flags off): the velocity-HEIGHT spotlight — now
|
|
1305
|
+
// FOLDED onto SetBarChart (value mode, no labels), so its bars share the SAME geometry
|
|
1306
|
+
// (widths / gaps / chunk-notch / slots / paper) as compact + hero. Only the height-mode
|
|
1307
|
+
// (value here, flat in compact) differs, so a compact↔spotlight toggle never reflows.
|
|
858
1308
|
if (!framed) {
|
|
859
|
-
|
|
1309
|
+
// `columnSlots` wins, exactly as it does for compact and hero. The diverging dual passes the
|
|
1310
|
+
// index-locked shared structure through here; ignoring it gave the expanded dual its own
|
|
1311
|
+
// per-side columns, so a lagging side rendered FEWER bars instead of an aligned empty cell.
|
|
1312
|
+
const spotlightSlots: SetSlot[] =
|
|
1313
|
+
columnSlots ??
|
|
1314
|
+
(set
|
|
1315
|
+
? buildSlots(set).map((s) => ({
|
|
1316
|
+
kind: s.kind,
|
|
1317
|
+
value: s.velocity,
|
|
1318
|
+
leadingGap: s.leadingGap,
|
|
1319
|
+
}))
|
|
1320
|
+
: doneVelocities.map((v) => ({ kind: 'rep', value: v })))
|
|
860
1321
|
return (
|
|
861
|
-
<
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
1322
|
+
<SetBarChart
|
|
1323
|
+
slots={spotlightSlots}
|
|
1324
|
+
colorFor={barColorFor}
|
|
1325
|
+
height={height}
|
|
1326
|
+
scale={scale}
|
|
1327
|
+
scaleMax={scaleMax}
|
|
1328
|
+
orientation={orientation}
|
|
1329
|
+
liveRepIndex={liveRepIndex}
|
|
1330
|
+
isNewPeak={isNewPeak}
|
|
1331
|
+
barRadius={2}
|
|
1332
|
+
cornerStyle="top"
|
|
1333
|
+
targetReps={set ? undefined : targetReps}
|
|
1334
|
+
label={label}
|
|
1335
|
+
hideBaseline
|
|
1336
|
+
testID="velocity-strip-spotlight"
|
|
1337
|
+
testIDPrefix="velocity"
|
|
868
1338
|
accessibilityLabel={miniLabel}
|
|
869
|
-
|
|
870
|
-
{
|
|
871
|
-
|
|
872
|
-
{slots.map((slot, i) => (
|
|
873
|
-
<View
|
|
874
|
-
key={i}
|
|
875
|
-
style={{
|
|
876
|
-
flex: 1,
|
|
877
|
-
minWidth: 4,
|
|
878
|
-
height: bareSlotHeight(slot, height, scaleDenom),
|
|
879
|
-
// Top-rounded to match the framed chart's bars (the "rounded tops from
|
|
880
|
-
// the numbers one"); square bottoms since bars sit on the baseline.
|
|
881
|
-
borderTopLeftRadius: 2,
|
|
882
|
-
borderTopRightRadius: 2,
|
|
883
|
-
backgroundColor: slotColor(slot),
|
|
884
|
-
marginLeft: Math.max(0, slot.leadingGap - REP_GAP),
|
|
885
|
-
...(slot.kind === 'continue'
|
|
886
|
-
? { borderWidth: 1, borderColor: CONTINUE_OUTLINE }
|
|
887
|
-
: {}),
|
|
888
|
-
}}
|
|
889
|
-
accessibilityElementsHidden
|
|
890
|
-
testID={slot.kind === 'rep' ? `velocity-bar-${i}` : `velocity-slot-${slot.kind}`}
|
|
891
|
-
/>
|
|
892
|
-
))}
|
|
893
|
-
</View>
|
|
1339
|
+
className={className}
|
|
1340
|
+
viewProps={props}
|
|
1341
|
+
/>
|
|
894
1342
|
)
|
|
895
1343
|
}
|
|
896
1344
|
|
|
@@ -901,204 +1349,100 @@ export function VelocityStrip({
|
|
|
901
1349
|
const hasInteractiveContainer = onToggle != null
|
|
902
1350
|
const hasInteractiveReps = onRepPress != null && expanded
|
|
903
1351
|
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
1352
|
+
const framedSlots: SetSlot[] = set
|
|
1353
|
+
? buildSlots(set).map((sl) => ({
|
|
1354
|
+
kind: sl.kind,
|
|
1355
|
+
value: sl.velocity,
|
|
1356
|
+
leadingGap: sl.leadingGap,
|
|
1357
|
+
}))
|
|
1358
|
+
: doneVelocities.map((v) => ({ kind: 'rep', value: v }))
|
|
1359
|
+
|
|
1360
|
+
// Per-bar overlay handed to SetBarChart so the framed chart keeps its m/s label (fading with the
|
|
1361
|
+
// expand) + its onRepPress hit-target WITHOUT re-rolling bars — one bar-rendering path remains.
|
|
1362
|
+
const needsBarOverlay = showNumbers || onRepPress != null
|
|
1363
|
+
const renderFramedBarOverlay = needsBarOverlay
|
|
1364
|
+
? (repIndex: number, value: number) => (
|
|
1365
|
+
<>
|
|
1366
|
+
{showNumbers && (
|
|
1367
|
+
<Animated.View
|
|
1368
|
+
style={{
|
|
1369
|
+
opacity: expandProgress,
|
|
1370
|
+
position: 'absolute',
|
|
1371
|
+
top: -13,
|
|
1372
|
+
left: 0,
|
|
1373
|
+
right: 0,
|
|
1374
|
+
alignItems: 'center',
|
|
1375
|
+
}}
|
|
1376
|
+
accessibilityElementsHidden
|
|
1377
|
+
pointerEvents="none"
|
|
1378
|
+
>
|
|
1379
|
+
<Text
|
|
1380
|
+
className="text-text-secondary"
|
|
1381
|
+
style={{ fontSize: 8, fontWeight: '600' }}
|
|
1382
|
+
testID={`velocity-label-${repIndex}`}
|
|
1383
|
+
>
|
|
1384
|
+
{formatVelocity(value)}
|
|
1385
|
+
</Text>
|
|
1386
|
+
</Animated.View>
|
|
1387
|
+
)}
|
|
1388
|
+
{onRepPress && expanded && (
|
|
1389
|
+
<Pressable
|
|
1390
|
+
style={{ position: 'absolute', top: 0, bottom: 0, left: 0, right: 0 }}
|
|
1391
|
+
onPress={() => onRepPress(repIndex, value)}
|
|
1392
|
+
accessibilityRole="button"
|
|
1393
|
+
accessibilityLabel={`Rep ${repIndex + 1}: ${formatVelocity(value)} meters per second, tap for details`}
|
|
1394
|
+
testID={`velocity-bar-pressable-${repIndex}`}
|
|
1395
|
+
/>
|
|
1396
|
+
)}
|
|
1397
|
+
</>
|
|
944
1398
|
)
|
|
945
|
-
|
|
1399
|
+
: undefined
|
|
946
1400
|
|
|
1401
|
+
// The framed chrome is a WRAPPER (raised box + info row + tap-to-collapse) around ONE SetBarChart
|
|
1402
|
+
// in value mode; the collapse is the in-place `expandProgress` bar-height morph, not a height strip.
|
|
947
1403
|
const stripContent = (
|
|
948
1404
|
<Animated.View
|
|
949
|
-
className={[className,
|
|
950
|
-
|
|
951
|
-
.join(' ')}
|
|
952
|
-
style={[
|
|
953
|
-
{
|
|
954
|
-
height: heightAnim,
|
|
955
|
-
width: '100%',
|
|
956
|
-
gap: 2,
|
|
957
|
-
borderRadius: expanded ? 6 : 2,
|
|
958
|
-
paddingTop: expanded ? 16 : 0,
|
|
959
|
-
paddingBottom: expanded ? 24 : 0,
|
|
960
|
-
// No horizontal inset — the framed chart's bars span full width, matching
|
|
961
|
-
// the bare spotlight and mini. Numbers/info sit in the vertical padding.
|
|
962
|
-
paddingHorizontal: 0,
|
|
963
|
-
paddingVertical: expanded ? undefined : 8,
|
|
964
|
-
overflow: expanded ? 'visible' : 'hidden',
|
|
965
|
-
},
|
|
966
|
-
]}
|
|
1405
|
+
className={[className, 'bg-surface-raised'].filter(Boolean).join(' ')}
|
|
1406
|
+
style={{ width: '100%', borderRadius: 6, paddingTop: 16, paddingBottom: showInfo ? 8 : 4 }}
|
|
967
1407
|
accessibilityRole={hasInteractiveContainer || hasInteractiveReps ? 'none' : 'button'}
|
|
968
1408
|
accessibilityLabel={hasInteractiveContainer || hasInteractiveReps ? undefined : stripLabel}
|
|
969
1409
|
testID="velocity-strip"
|
|
970
1410
|
{...props}
|
|
971
1411
|
>
|
|
972
|
-
<
|
|
973
|
-
{
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
const barStyle: ViewStyle = expanded
|
|
988
|
-
? {
|
|
989
|
-
height: `${barHeightPct}%`,
|
|
990
|
-
minHeight: 2,
|
|
991
|
-
borderTopLeftRadius: 2,
|
|
992
|
-
borderTopRightRadius: 2,
|
|
993
|
-
backgroundColor: barBackground,
|
|
994
|
-
}
|
|
995
|
-
: {
|
|
996
|
-
minHeight: '100%' as unknown as number,
|
|
997
|
-
borderTopLeftRadius: 2,
|
|
998
|
-
borderTopRightRadius: 2,
|
|
999
|
-
borderBottomLeftRadius: 0,
|
|
1000
|
-
borderBottomRightRadius: 0,
|
|
1001
|
-
backgroundColor: barBackground,
|
|
1002
|
-
}
|
|
1003
|
-
|
|
1004
|
-
const barInner = isLive ? (
|
|
1005
|
-
<Animated.View
|
|
1006
|
-
style={[barStyle, { transform: [{ scale: liveScale }] }]}
|
|
1007
|
-
testID={`velocity-bar-${i}`}
|
|
1008
|
-
accessibilityRole="image"
|
|
1009
|
-
accessibilityLabel={`Rep ${i + 1}: ${formatVelocity(v)} meters per second${liveLabelSuffix}`}
|
|
1010
|
-
/>
|
|
1011
|
-
) : (
|
|
1012
|
-
<View
|
|
1013
|
-
style={barStyle}
|
|
1014
|
-
testID={`velocity-bar-${i}`}
|
|
1015
|
-
accessibilityRole="image"
|
|
1016
|
-
accessibilityLabel={`Rep ${i + 1}: ${formatVelocity(v)} meters per second`}
|
|
1017
|
-
/>
|
|
1018
|
-
)
|
|
1019
|
-
|
|
1020
|
-
const bar = (
|
|
1021
|
-
<View
|
|
1022
|
-
key={i}
|
|
1023
|
-
style={{
|
|
1024
|
-
flex: 1,
|
|
1025
|
-
height: '100%',
|
|
1026
|
-
justifyContent: 'flex-end',
|
|
1027
|
-
position: 'relative',
|
|
1028
|
-
}}
|
|
1029
|
-
>
|
|
1030
|
-
{expanded && showNumbers && (
|
|
1031
|
-
<Animated.View
|
|
1032
|
-
style={{
|
|
1033
|
-
opacity: labelOpacity,
|
|
1034
|
-
alignItems: 'center',
|
|
1035
|
-
position: 'absolute',
|
|
1036
|
-
top: -13,
|
|
1037
|
-
left: 0,
|
|
1038
|
-
right: 0,
|
|
1039
|
-
}}
|
|
1040
|
-
accessibilityElementsHidden
|
|
1041
|
-
>
|
|
1042
|
-
<Text
|
|
1043
|
-
className="text-text-secondary"
|
|
1044
|
-
style={{ fontSize: 8, fontWeight: '600' }}
|
|
1045
|
-
testID={`velocity-label-${i}`}
|
|
1046
|
-
>
|
|
1047
|
-
{formatVelocity(v)}
|
|
1048
|
-
</Text>
|
|
1049
|
-
</Animated.View>
|
|
1050
|
-
)}
|
|
1051
|
-
{barInner}
|
|
1052
|
-
</View>
|
|
1053
|
-
)
|
|
1054
|
-
|
|
1055
|
-
if (onRepPress && expanded) {
|
|
1056
|
-
return (
|
|
1057
|
-
<Pressable
|
|
1058
|
-
key={i}
|
|
1059
|
-
style={{ flex: 1, height: '100%' }}
|
|
1060
|
-
onPress={() => onRepPress(i, v)}
|
|
1061
|
-
accessibilityRole="button"
|
|
1062
|
-
accessibilityLabel={`Rep ${i + 1}: ${formatVelocity(v)} meters per second, tap for details`}
|
|
1063
|
-
testID={`velocity-bar-pressable-${i}`}
|
|
1064
|
-
>
|
|
1065
|
-
{bar}
|
|
1066
|
-
</Pressable>
|
|
1067
|
-
)
|
|
1068
|
-
}
|
|
1069
|
-
|
|
1070
|
-
return bar
|
|
1071
|
-
})}
|
|
1072
|
-
</View>
|
|
1412
|
+
<SetBarChart
|
|
1413
|
+
slots={framedSlots}
|
|
1414
|
+
colorFor={barColorFor}
|
|
1415
|
+
height={height}
|
|
1416
|
+
scale={scale}
|
|
1417
|
+
scaleMax={scaleMax}
|
|
1418
|
+
expandProgress={expandProgress}
|
|
1419
|
+
renderBarOverlay={renderFramedBarOverlay}
|
|
1420
|
+
targetReps={set ? undefined : targetReps}
|
|
1421
|
+
barRadius={2}
|
|
1422
|
+
cornerStyle="top"
|
|
1423
|
+
hideBaseline
|
|
1424
|
+
testIDPrefix="velocity"
|
|
1425
|
+
/>
|
|
1073
1426
|
{expanded && showInfo && (
|
|
1074
1427
|
<Animated.View
|
|
1075
1428
|
style={{
|
|
1076
1429
|
flexDirection: 'row',
|
|
1077
1430
|
justifyContent: 'space-between',
|
|
1078
1431
|
opacity: infoOpacity,
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
left: 6,
|
|
1082
|
-
right: 6,
|
|
1432
|
+
marginTop: 6,
|
|
1433
|
+
paddingHorizontal: 6,
|
|
1083
1434
|
}}
|
|
1084
1435
|
testID="velocity-info-row"
|
|
1085
1436
|
>
|
|
1086
1437
|
<Text
|
|
1087
1438
|
className="text-text-secondary"
|
|
1088
|
-
style={{
|
|
1089
|
-
fontSize: 10,
|
|
1090
|
-
fontFamily: 'Inter, sans-serif',
|
|
1091
|
-
}}
|
|
1439
|
+
style={{ fontSize: 10, fontFamily: 'Inter, sans-serif' }}
|
|
1092
1440
|
>
|
|
1093
1441
|
{meanZone} {'·'} {formatVelocity(meanVelocity)} m/s
|
|
1094
1442
|
</Text>
|
|
1095
1443
|
<Text
|
|
1096
1444
|
className="text-text-secondary"
|
|
1097
|
-
style={{
|
|
1098
|
-
fontSize: 10,
|
|
1099
|
-
fontFamily: 'Inter, sans-serif',
|
|
1100
|
-
...getLossStyle(loss),
|
|
1101
|
-
}}
|
|
1445
|
+
style={{ fontSize: 10, fontFamily: 'Inter, sans-serif', ...getLossStyle(loss) }}
|
|
1102
1446
|
>
|
|
1103
1447
|
Loss: {loss}%
|
|
1104
1448
|
</Text>
|