@titan-design/react-ui 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +112 -55
- package/dist/index.d.ts +112 -55
- package/dist/index.js +795 -501
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +795 -498
- package/dist/index.mjs.map +1 -1
- package/dist/{pages-8u_4WbL-.d.mts → pages-D7hOD4Vj.d.mts} +17 -8
- package/dist/{pages-DaWiBOGa.d.ts → pages-DyEx-lBf.d.ts} +17 -8
- package/dist/pages.d.mts +1 -1
- package/dist/pages.d.ts +1 -1
- package/dist/pages.js +691 -250
- package/dist/pages.js.map +1 -1
- package/dist/pages.mjs +692 -251
- package/dist/pages.mjs.map +1 -1
- package/package.json +1 -1
- package/src/components/custom/CircularTimer/CircularTimer.stories.tsx +90 -0
- package/src/components/custom/CircularTimer/CircularTimer.test.tsx +62 -0
- package/src/components/custom/CircularTimer/CircularTimer.tsx +112 -0
- package/src/components/custom/CircularTimer/index.ts +1 -0
- package/src/components/custom/Workout/ExerciseIndicator.tsx +1 -1
- package/src/components/custom/Workout/FatigueMeter.stories.tsx +2 -2
- package/src/components/custom/Workout/FatigueMeter.test.tsx +12 -0
- package/src/components/custom/Workout/FatigueMeter.tsx +7 -3
- package/src/components/custom/Workout/README.md +70 -3
- package/src/components/custom/Workout/RestTimer.stories.tsx +132 -2
- package/src/components/custom/Workout/RestTimer.test.tsx +73 -0
- package/src/components/custom/Workout/RestTimer.tsx +141 -50
- package/src/components/custom/Workout/SetsRepsLoad.test.tsx +6 -0
- package/src/components/custom/Workout/SetsRepsLoad.tsx +15 -6
- package/src/components/custom/Workout/TempoDisplay.stories.tsx +240 -65
- package/src/components/custom/Workout/TempoDisplay.test.tsx +61 -16
- package/src/components/custom/Workout/TempoDisplay.tsx +173 -60
- package/src/components/custom/Workout/VelocityStrip.stories.tsx +152 -9
- package/src/components/custom/Workout/VelocityStrip.test.tsx +67 -3
- package/src/components/custom/Workout/VelocityStrip.tsx +323 -42
- package/src/components/custom/Workout/ZoneTrack.stories.tsx +2 -3
- package/src/components/custom/Workout/ZoneTrack.test.tsx +34 -0
- package/src/components/custom/Workout/ZoneTrack.tsx +91 -19
- package/src/components/custom/Workout/index.ts +1 -9
- package/src/components/custom/index.ts +1 -0
- package/src/components/ui/alert/Alert.stories.tsx +85 -2
- package/src/components/ui/alert/Alert.test.tsx +52 -0
- package/src/components/ui/alert/Alert.tsx +54 -20
- package/src/components/ui/progress/Progress.tsx +47 -22
- package/src/lab/north-star/LivePage.tsx +101 -0
- package/src/lab/north-star/LiveView.tsx +409 -0
- package/src/lab/north-star/LiveWallDashboard.stories.tsx +129 -0
- package/src/lab/north-star/RestView.tsx +140 -0
- package/src/lab/north-star/fixtures.ts +224 -0
- package/src/components/custom/Workout/TempoBar.stories.tsx +0 -95
- package/src/components/custom/Workout/TempoBar.test.tsx +0 -103
- package/src/components/custom/Workout/TempoBar.tsx +0 -215
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
Easing,
|
|
9
9
|
type ViewProps,
|
|
10
10
|
type ViewStyle,
|
|
11
|
+
type LayoutChangeEvent,
|
|
11
12
|
} from 'react-native'
|
|
12
13
|
import { WORKOUT_TOKENS } from '../../../theme/workout-tokens'
|
|
13
14
|
import { primitiveColors, primitiveRamps, sequentialEffort } from '../../../theme/tokens/primitives'
|
|
@@ -19,7 +20,7 @@ import { SET_STRIP_VARIABLE_COLOR } from './SetStrip'
|
|
|
19
20
|
* (e.g. workout-analytics' `VelocityZones.bands`).
|
|
20
21
|
*
|
|
21
22
|
* Deliberately a plain structural shape — titan never imports the analytics
|
|
22
|
-
* package (same policy
|
|
23
|
+
* package (the same presentational-only policy the Workout family follows). Any object with
|
|
23
24
|
* this shape can be passed, so `zones={waVelocityZones.bands}` works directly.
|
|
24
25
|
* Bands are ordered slow → fast, contiguous, and cover `[0, ∞)` with the top
|
|
25
26
|
* band's `max === null`. Bands carry NO color — color is a UI concern resolved
|
|
@@ -84,15 +85,24 @@ export interface VelocityStripProps extends ViewProps {
|
|
|
84
85
|
* HEIGHT bar chart (rounded tops), whose chrome is prop-driven: with
|
|
85
86
|
* {@link showNumbers} or {@link showInfo} on it's the framed chart (raised surface,
|
|
86
87
|
* padding, per-bar m/s labels, mean/loss info row, interactive collapse); with both
|
|
87
|
-
* off it's a bare strip — the active-set "spotlight" of {@link ExerciseCard}.
|
|
88
|
+
* off it's a bare strip — the active-set "spotlight" of {@link ExerciseCard}. `hero`
|
|
89
|
+
* — the across-the-room, single-set wall treatment: tall bars, a per-bar m/s value
|
|
90
|
+
* label, a dashed running-best reference line, and dashed placeholders for the reps
|
|
91
|
+
* still to come (see {@link targetReps}). Reuses the zone scale and the live-rep pop.
|
|
88
92
|
*/
|
|
89
|
-
variant?: 'mini' | 'expanded'
|
|
90
|
-
/** `expanded` plot height in px (bars scale to this). Default 60. */
|
|
93
|
+
variant?: 'mini' | 'expanded' | 'hero'
|
|
94
|
+
/** `expanded` / `hero` plot height in px (bars scale to this). Default 60 (`expanded`) / 220 (`hero`). */
|
|
91
95
|
height?: number
|
|
92
96
|
/**
|
|
93
|
-
* `
|
|
94
|
-
*
|
|
95
|
-
*
|
|
97
|
+
* `hero` only: the set's planned rep count. Reps beyond {@link velocities} draw as
|
|
98
|
+
* dashed placeholder stubs so the wall reads "3 of 8 done" at a glance. Ignored
|
|
99
|
+
* (and no placeholders drawn) when absent or ≤ the performed-rep count.
|
|
100
|
+
*/
|
|
101
|
+
targetReps?: number
|
|
102
|
+
/**
|
|
103
|
+
* `expanded` / `hero` bar scaling. `peak` (default) scales to the set's own max
|
|
104
|
+
* (+15% headroom). `fixed` scales to a fixed velocity ceiling so bar heights read
|
|
105
|
+
* the same absolute velocity across sets (the spotlight).
|
|
96
106
|
*/
|
|
97
107
|
scale?: 'peak' | 'fixed'
|
|
98
108
|
/** `expanded` framed chart: per-bar m/s labels. Default true. */
|
|
@@ -372,9 +382,39 @@ const FIXED_MAX_VELOCITY = 1.15
|
|
|
372
382
|
/** Minimum bare-strip bar height (px) — a planned / variable / continue stub, or a near-zero rep. */
|
|
373
383
|
const BARE_STUB_HEIGHT = 3
|
|
374
384
|
|
|
385
|
+
// --- Hero variant ------------------------------------------------------------
|
|
386
|
+
// The across-the-room, single-set wall treatment. Absorbs the R2 "HeroVelocityBars"
|
|
387
|
+
// candidate into VelocityStrip: tall bars + per-bar value labels + a dashed
|
|
388
|
+
// running-best reference line + dashed placeholders for the reps still to come.
|
|
389
|
+
|
|
390
|
+
/** Default `hero` plot height (px) — tall enough to read a set's velocity shape across a room. */
|
|
391
|
+
const HERO_HEIGHT = 220
|
|
392
|
+
/** Vertical band reserved above the tallest bar for its value label (px). */
|
|
393
|
+
const HERO_LABEL_HEADROOM = 20
|
|
394
|
+
/** Gap between hero bars (px) — a group-notch-free even rhythm at wall scale. */
|
|
395
|
+
const HERO_BAR_GAP = 8
|
|
396
|
+
/** Cap on a single hero bar's width so a 2–3 rep set doesn't render slab-wide bars (px). */
|
|
397
|
+
const HERO_BAR_MAX_WIDTH = 120
|
|
398
|
+
/** Below this per-bar width the value labels collide, so all but the peak + live rep are dropped. */
|
|
399
|
+
const HERO_LABEL_MIN_BAR_WIDTH = 30
|
|
400
|
+
/** Top-corner radius on hero bars (px). */
|
|
401
|
+
const HERO_BAR_RADIUS = 5
|
|
402
|
+
/** Minimum drawn height of a performed hero bar (px) — a near-zero rep still reads as a rep. */
|
|
403
|
+
const HERO_MIN_BAR_HEIGHT = 4
|
|
404
|
+
/** Dashed-stub border for a planned-but-unperformed hero rep (charcoal, reads on a dark wall). */
|
|
405
|
+
const HERO_PENDING_COLOR = primitiveColors.charcoal[100]
|
|
406
|
+
/** The dashed running-best reference line + its label (the lightest charcoal step). */
|
|
407
|
+
const HERO_REFERENCE_COLOR = primitiveColors.charcoal[0]
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Headroom above the peak bar: just enough to seat its value label without a big empty
|
|
411
|
+
* band at the top (was 1.15 — too airy for the wall hero).
|
|
412
|
+
*/
|
|
413
|
+
const PEAK_HEADROOM = 1.06
|
|
414
|
+
|
|
375
415
|
/** The bar-height scaling denominator for the given `scale` (guarded ≥ 0 by callers). */
|
|
376
416
|
function scaleDenominator(scale: 'peak' | 'fixed', maxVelocity: number): number {
|
|
377
|
-
return scale === 'fixed' ? FIXED_MAX_VELOCITY : maxVelocity *
|
|
417
|
+
return scale === 'fixed' ? FIXED_MAX_VELOCITY : maxVelocity * PEAK_HEADROOM
|
|
378
418
|
}
|
|
379
419
|
|
|
380
420
|
/** Bare-strip bar height (px): velocity-scaled for a performed rep, a short stub otherwise. */
|
|
@@ -407,6 +447,254 @@ const ANIMATION_DURATION = 400
|
|
|
407
447
|
const ANIMATION_EASING = Easing.bezier(0.22, 1, 0.36, 1)
|
|
408
448
|
const POP_EASING = Easing.bezier(0.34, 1.56, 0.64, 1)
|
|
409
449
|
|
|
450
|
+
/**
|
|
451
|
+
* The newest-rep entrance shared by the framed `expanded` chart and `hero`: a
|
|
452
|
+
* bounce when the rep is a new set peak, a pop otherwise. Honors reduced motion.
|
|
453
|
+
* Returns the scale `Animated.Value` for the live bar; inert while `active` is
|
|
454
|
+
* false or no live rep is present.
|
|
455
|
+
*/
|
|
456
|
+
function useLiveRepPop(
|
|
457
|
+
active: boolean,
|
|
458
|
+
liveRepIndex: number | undefined,
|
|
459
|
+
liveVelocity: number | undefined,
|
|
460
|
+
isNewPeak: boolean
|
|
461
|
+
): Animated.Value {
|
|
462
|
+
const prefersReducedMotion = usePrefersReducedMotion()
|
|
463
|
+
const [liveScale] = useState(() => new Animated.Value(1))
|
|
464
|
+
useEffect(() => {
|
|
465
|
+
if (!active || liveRepIndex == null || liveVelocity == null) return
|
|
466
|
+
if (prefersReducedMotion) {
|
|
467
|
+
liveScale.setValue(1)
|
|
468
|
+
return
|
|
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
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
interface HeroVelocityChartProps {
|
|
500
|
+
/** Performed per-rep mean concentric velocities (m/s). */
|
|
501
|
+
doneVelocities: number[]
|
|
502
|
+
/** Zone → color resolver, shared with the other variants (single-sources the scale). */
|
|
503
|
+
barColorFor: (velocity: number) => string
|
|
504
|
+
/** Bar-height scaling denominator (`peak` set-max or `fixed` ceiling). */
|
|
505
|
+
scaleDenom: number
|
|
506
|
+
/** Running-best velocity (the set peak) — drives the dashed reference line. */
|
|
507
|
+
referenceVelocity: number
|
|
508
|
+
liveRepIndex?: number
|
|
509
|
+
isNewPeak: boolean
|
|
510
|
+
targetReps?: number
|
|
511
|
+
height: number
|
|
512
|
+
className?: string
|
|
513
|
+
viewProps: ViewProps
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* The `hero` render: the across-the-room, single-set wall treatment. Tall bars +
|
|
518
|
+
* a per-bar value label + a dashed running-best reference line + dashed placeholders
|
|
519
|
+
* for the reps still to come. Reuses {@link barColorFor} (zone scale) and the
|
|
520
|
+
* {@link useLiveRepPop} entrance so it stays consistent with the framed chart.
|
|
521
|
+
*/
|
|
522
|
+
function HeroVelocityChart({
|
|
523
|
+
doneVelocities,
|
|
524
|
+
barColorFor,
|
|
525
|
+
scaleDenom,
|
|
526
|
+
referenceVelocity,
|
|
527
|
+
liveRepIndex,
|
|
528
|
+
isNewPeak,
|
|
529
|
+
targetReps,
|
|
530
|
+
height,
|
|
531
|
+
className,
|
|
532
|
+
viewProps,
|
|
533
|
+
}: HeroVelocityChartProps) {
|
|
534
|
+
const liveVelocity = liveRepIndex != null ? doneVelocities[liveRepIndex] : undefined
|
|
535
|
+
const liveScale = useLiveRepPop(true, liveRepIndex, liveVelocity, isNewPeak)
|
|
536
|
+
|
|
537
|
+
// Measure the plot so per-bar value labels can thin on a narrow chart (they collide once
|
|
538
|
+
// bars get thin). Width 0 (unmeasured) → show all, so test/server renders are unchanged.
|
|
539
|
+
const [plotW, setPlotW] = useState(0)
|
|
540
|
+
const onPlotLayout = (e: LayoutChangeEvent) => setPlotW(e.nativeEvent.layout.width)
|
|
541
|
+
|
|
542
|
+
// Reserve a band above the tallest bar for its value label so a peak bar never clips.
|
|
543
|
+
const plotHeight = Math.max(0, height - HERO_LABEL_HEADROOM)
|
|
544
|
+
const barHeight = (velocity: number): number =>
|
|
545
|
+
scaleDenom > 0
|
|
546
|
+
? Math.max(HERO_MIN_BAR_HEIGHT, Math.min(1, velocity / scaleDenom) * plotHeight)
|
|
547
|
+
: HERO_MIN_BAR_HEIGHT
|
|
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,
|
|
568
|
+
)
|
|
569
|
+
const showBarLabel = (i: number): boolean =>
|
|
570
|
+
!labelsCrowded || i === peakIndex || i === liveRepIndex
|
|
571
|
+
|
|
572
|
+
const repCount = doneVelocities.length
|
|
573
|
+
const total = Math.max(repCount, targetReps ?? repCount)
|
|
574
|
+
const label =
|
|
575
|
+
referenceVelocity > 0
|
|
576
|
+
? `Velocity chart, ${repCount} of ${total} reps, best ${formatVelocity(referenceVelocity)} meters per second`
|
|
577
|
+
: `Velocity chart, ${repCount} of ${total} reps`
|
|
578
|
+
|
|
579
|
+
const { style: externalStyle, ...restProps } = viewProps
|
|
580
|
+
|
|
581
|
+
return (
|
|
582
|
+
<View
|
|
583
|
+
className={className}
|
|
584
|
+
style={[{ height }, externalStyle]}
|
|
585
|
+
accessibilityRole="image"
|
|
586
|
+
accessibilityLabel={label}
|
|
587
|
+
testID="velocity-strip-hero"
|
|
588
|
+
{...restProps}
|
|
589
|
+
>
|
|
590
|
+
<View
|
|
591
|
+
onLayout={onPlotLayout}
|
|
592
|
+
style={{
|
|
593
|
+
flex: 1,
|
|
594
|
+
flexDirection: 'row',
|
|
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
|
+
))}
|
|
693
|
+
</View>
|
|
694
|
+
</View>
|
|
695
|
+
)
|
|
696
|
+
}
|
|
697
|
+
|
|
410
698
|
export function VelocityStrip({
|
|
411
699
|
velocities,
|
|
412
700
|
set,
|
|
@@ -417,6 +705,7 @@ export function VelocityStrip({
|
|
|
417
705
|
onRepPress,
|
|
418
706
|
variant = 'expanded',
|
|
419
707
|
height = EXPANDED_HEIGHT,
|
|
708
|
+
targetReps,
|
|
420
709
|
scale = 'peak',
|
|
421
710
|
showNumbers = true,
|
|
422
711
|
showInfo = true,
|
|
@@ -456,47 +745,19 @@ export function VelocityStrip({
|
|
|
456
745
|
? (classifyBand(meanVelocity, zones)?.label ?? '')
|
|
457
746
|
: getVelocityZoneName(meanVelocity)
|
|
458
747
|
|
|
459
|
-
const prefersReducedMotion = usePrefersReducedMotion()
|
|
460
748
|
const [heightAnim] = useState(() => new Animated.Value(expanded ? height : 3))
|
|
461
749
|
const [labelOpacity] = useState(() => new Animated.Value(expanded ? 1 : 0))
|
|
462
750
|
const [infoOpacity] = useState(() => new Animated.Value(expanded ? 1 : 0))
|
|
463
|
-
const [liveScale] = useState(() => new Animated.Value(1))
|
|
464
751
|
|
|
465
752
|
// Newest-rep animation: pop for a normal rep, bounce when it sets a new peak.
|
|
466
753
|
const liveVelocity = liveRepIndex != null ? doneVelocities[liveRepIndex] : undefined
|
|
467
754
|
const isNewPeak = liveVelocity != null && maxVelocity > 0 && liveVelocity === maxVelocity
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
if (isNewPeak) {
|
|
475
|
-
liveScale.setValue(1)
|
|
476
|
-
Animated.sequence([
|
|
477
|
-
Animated.timing(liveScale, {
|
|
478
|
-
toValue: 1.25,
|
|
479
|
-
duration: 150,
|
|
480
|
-
easing: Easing.out(Easing.quad),
|
|
481
|
-
useNativeDriver: true,
|
|
482
|
-
}),
|
|
483
|
-
Animated.spring(liveScale, {
|
|
484
|
-
toValue: 1,
|
|
485
|
-
friction: 3,
|
|
486
|
-
tension: 140,
|
|
487
|
-
useNativeDriver: true,
|
|
488
|
-
}),
|
|
489
|
-
]).start()
|
|
490
|
-
} else {
|
|
491
|
-
liveScale.setValue(0.8)
|
|
492
|
-
Animated.timing(liveScale, {
|
|
493
|
-
toValue: 1,
|
|
494
|
-
duration: 300,
|
|
495
|
-
easing: POP_EASING,
|
|
496
|
-
useNativeDriver: true,
|
|
497
|
-
}).start()
|
|
498
|
-
}
|
|
499
|
-
}, [variant, framed, liveRepIndex, liveVelocity, isNewPeak, prefersReducedMotion, liveScale])
|
|
755
|
+
const liveScale = useLiveRepPop(
|
|
756
|
+
variant === 'expanded' && framed,
|
|
757
|
+
liveRepIndex,
|
|
758
|
+
liveVelocity,
|
|
759
|
+
isNewPeak
|
|
760
|
+
)
|
|
500
761
|
|
|
501
762
|
useEffect(() => {
|
|
502
763
|
if (variant !== 'expanded' || !framed) return
|
|
@@ -529,6 +790,26 @@ export function VelocityStrip({
|
|
|
529
790
|
const repCount = doneVelocities.length
|
|
530
791
|
const miniLabel = set ? setAccessibilityLabel(set, repCount) : `Velocity strip, ${repCount} reps`
|
|
531
792
|
|
|
793
|
+
if (variant === 'hero') {
|
|
794
|
+
// Hero's plot is far taller than the expanded chart; apply its own default when
|
|
795
|
+
// the caller left `height` at the shared 60px default.
|
|
796
|
+
const heroHeight = height === EXPANDED_HEIGHT ? HERO_HEIGHT : height
|
|
797
|
+
return (
|
|
798
|
+
<HeroVelocityChart
|
|
799
|
+
doneVelocities={doneVelocities}
|
|
800
|
+
barColorFor={barColorFor}
|
|
801
|
+
scaleDenom={scaleDenom}
|
|
802
|
+
referenceVelocity={maxVelocity}
|
|
803
|
+
liveRepIndex={liveRepIndex}
|
|
804
|
+
isNewPeak={isNewPeak}
|
|
805
|
+
targetReps={targetReps}
|
|
806
|
+
height={heroHeight}
|
|
807
|
+
className={className}
|
|
808
|
+
viewProps={props}
|
|
809
|
+
/>
|
|
810
|
+
)
|
|
811
|
+
}
|
|
812
|
+
|
|
532
813
|
if (variant === 'mini') {
|
|
533
814
|
const { style: externalStyle, ...restProps } = props
|
|
534
815
|
// The container keeps the uniform REP_GAP (so a no-`set` strip is byte-identical
|
|
@@ -102,15 +102,14 @@ export const BareTrack: Story = {
|
|
|
102
102
|
args: { zones: FATIGUE_ZONES, max: 40 },
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
/**
|
|
105
|
+
/** The across-the-room wall scale — `size="wall"` grows track, needle, tick lines and labels together. */
|
|
106
106
|
export const WallDensity: Story = {
|
|
107
107
|
args: {
|
|
108
108
|
zones: FATIGUE_ZONES,
|
|
109
109
|
max: 40,
|
|
110
110
|
marker: { type: 'needle', value: 15 },
|
|
111
111
|
ticks: FATIGUE_TICKS,
|
|
112
|
-
|
|
113
|
-
needleOverhang: 9,
|
|
112
|
+
size: 'wall',
|
|
114
113
|
},
|
|
115
114
|
}
|
|
116
115
|
|
|
@@ -198,3 +198,37 @@ describe('ZoneTrack', () => {
|
|
|
198
198
|
})
|
|
199
199
|
})
|
|
200
200
|
})
|
|
201
|
+
|
|
202
|
+
describe('ZoneTrack size="wall"', () => {
|
|
203
|
+
const TICKS = [
|
|
204
|
+
{ value: 0, label: 'fresh' },
|
|
205
|
+
{ value: 40, label: 'stop' },
|
|
206
|
+
]
|
|
207
|
+
|
|
208
|
+
it('scales the needle and tick labels up together', () => {
|
|
209
|
+
render(
|
|
210
|
+
<ZoneTrack
|
|
211
|
+
zones={ZONES}
|
|
212
|
+
max={40}
|
|
213
|
+
size="wall"
|
|
214
|
+
marker={{ type: 'needle', value: 20 }}
|
|
215
|
+
ticks={TICKS}
|
|
216
|
+
/>
|
|
217
|
+
)
|
|
218
|
+
expect(screen.getByTestId('zone-track-needle')).toHaveStyle({ width: '7px' })
|
|
219
|
+
expect(screen.getAllByTestId('zone-track-tick-label')[0]).toHaveStyle({ fontSize: 14 })
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it('defaults to the compact scale (needle 4 / label 9) when size is omitted', () => {
|
|
223
|
+
render(
|
|
224
|
+
<ZoneTrack zones={ZONES} max={40} marker={{ type: 'needle', value: 20 }} ticks={TICKS} />
|
|
225
|
+
)
|
|
226
|
+
expect(screen.getByTestId('zone-track-needle')).toHaveStyle({ width: '4px' })
|
|
227
|
+
expect(screen.getAllByTestId('zone-track-tick-label')[0]).toHaveStyle({ fontSize: 9 })
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it('still lets an explicit trackHeight override the size default', () => {
|
|
231
|
+
render(<ZoneTrack zones={ZONES} max={40} size="wall" trackHeight={30} />)
|
|
232
|
+
expect(screen.getByTestId('zone-track-track')).toHaveStyle({ height: '30px' })
|
|
233
|
+
})
|
|
234
|
+
})
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
|
|
2
|
-
import {
|
|
2
|
+
import { useState } from 'react'
|
|
3
|
+
import { View, Text, type ViewProps, type DimensionValue, type LayoutChangeEvent } from 'react-native'
|
|
3
4
|
import { getSemanticColors } from '../../../theme/tokens/semantic'
|
|
4
5
|
import { primitiveColors } from '../../../theme/tokens/primitives'
|
|
5
6
|
import { alpha } from '../../../utils/colors'
|
|
@@ -19,9 +20,59 @@ const DEFAULT_MARKER_COLOR = primitiveColors.white
|
|
|
19
20
|
/** Tick mark + tick label colour. */
|
|
20
21
|
const TICK_COLOR = t['text-tertiary']
|
|
21
22
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
/** Density. `default` — the compact card/rail gauge. `wall` — the across-the-room dashboard scale. */
|
|
24
|
+
export type ZoneTrackSize = 'default' | 'wall'
|
|
25
|
+
|
|
26
|
+
interface ZoneTrackSizing {
|
|
27
|
+
trackHeight: number
|
|
28
|
+
needleOverhang: number
|
|
29
|
+
needleWidth: number
|
|
30
|
+
tickLineNormal: number
|
|
31
|
+
tickLineEmphasized: number
|
|
32
|
+
tickLineHeightPad: number
|
|
33
|
+
tickLineTopPad: number
|
|
34
|
+
labelRowHeight: number
|
|
35
|
+
labelMarginTop: number
|
|
36
|
+
tickFont: number
|
|
37
|
+
tickLetterSpacing: number
|
|
38
|
+
tickCellWidth: number
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Per-density magnitudes. `default` reproduces the original compact values exactly;
|
|
43
|
+
* `wall` scales the track, needle, tick lines and tick labels up together so the gauge
|
|
44
|
+
* reads across a room. `trackHeight` / `needleOverhang` remain overridable per call.
|
|
45
|
+
*/
|
|
46
|
+
const ZONE_SIZES: Record<ZoneTrackSize, ZoneTrackSizing> = {
|
|
47
|
+
default: {
|
|
48
|
+
trackHeight: 14,
|
|
49
|
+
needleOverhang: 6,
|
|
50
|
+
needleWidth: 4,
|
|
51
|
+
tickLineNormal: 1.5,
|
|
52
|
+
tickLineEmphasized: 2,
|
|
53
|
+
tickLineHeightPad: 4,
|
|
54
|
+
tickLineTopPad: 2,
|
|
55
|
+
labelRowHeight: 16,
|
|
56
|
+
labelMarginTop: 5,
|
|
57
|
+
tickFont: 9,
|
|
58
|
+
tickLetterSpacing: 0.5,
|
|
59
|
+
tickCellWidth: 28,
|
|
60
|
+
},
|
|
61
|
+
wall: {
|
|
62
|
+
trackHeight: 24,
|
|
63
|
+
needleOverhang: 9,
|
|
64
|
+
needleWidth: 7,
|
|
65
|
+
tickLineNormal: 2.5,
|
|
66
|
+
tickLineEmphasized: 3.5,
|
|
67
|
+
tickLineHeightPad: 6,
|
|
68
|
+
tickLineTopPad: 3,
|
|
69
|
+
labelRowHeight: 24,
|
|
70
|
+
labelMarginTop: 8,
|
|
71
|
+
tickFont: 14,
|
|
72
|
+
tickLetterSpacing: 0.5,
|
|
73
|
+
tickCellWidth: 48,
|
|
74
|
+
},
|
|
75
|
+
}
|
|
25
76
|
|
|
26
77
|
/** A single colour band of the zone gradient, spanning `[prev.upTo, upTo]` of the domain. */
|
|
27
78
|
export interface ZoneTrackZone {
|
|
@@ -82,9 +133,11 @@ export interface ZoneTrackProps extends ViewProps {
|
|
|
82
133
|
ticks?: ZoneTrackTick[]
|
|
83
134
|
/** Optional translucent range highlight drawn over the gradient. */
|
|
84
135
|
band?: ZoneTrackBand | null
|
|
85
|
-
/**
|
|
136
|
+
/** Density: `default` (compact) or `wall` (the across-the-room dashboard scale). Scales track, needle, ticks and labels together. */
|
|
137
|
+
size?: ZoneTrackSize
|
|
138
|
+
/** Track height in px. Overrides the `size` default (14 default / 24 wall). */
|
|
86
139
|
trackHeight?: number
|
|
87
|
-
/** How far a needle marker overhangs the track top + bottom, in px.
|
|
140
|
+
/** How far a needle marker overhangs the track top + bottom, in px. Overrides the `size` default (6 default / 9 wall). */
|
|
88
141
|
needleOverhang?: number
|
|
89
142
|
/** Muted colour of the track behind / beyond the fill. Default a charcoal step. */
|
|
90
143
|
trackColor?: string
|
|
@@ -117,14 +170,29 @@ export function ZoneTrack({
|
|
|
117
170
|
marker = null,
|
|
118
171
|
ticks,
|
|
119
172
|
band = null,
|
|
120
|
-
|
|
121
|
-
|
|
173
|
+
size = 'default',
|
|
174
|
+
trackHeight: trackHeightProp,
|
|
175
|
+
needleOverhang: needleOverhangProp,
|
|
122
176
|
trackColor = DEFAULT_TRACK_COLOR,
|
|
123
177
|
className,
|
|
124
178
|
style,
|
|
125
179
|
accessibilityLabel,
|
|
126
180
|
...props
|
|
127
181
|
}: ZoneTrackProps) {
|
|
182
|
+
const s = ZONE_SIZES[size]
|
|
183
|
+
const trackHeight = trackHeightProp ?? s.trackHeight
|
|
184
|
+
const needleOverhang = needleOverhangProp ?? s.needleOverhang
|
|
185
|
+
|
|
186
|
+
// Thin the tick labels on a narrow track so they never overlap (each occupies a fixed
|
|
187
|
+
// `tickCellWidth` cell). Width 0 (unmeasured) → show all, so test/server renders match.
|
|
188
|
+
const [trackW, setTrackW] = useState(0)
|
|
189
|
+
const onTrackLayout = (e: LayoutChangeEvent) => setTrackW(e.nativeEvent.layout.width)
|
|
190
|
+
const tickCount = ticks?.length ?? 0
|
|
191
|
+
const tickSpacing = trackW > 0 && tickCount > 0 ? trackW / tickCount : Infinity
|
|
192
|
+
const tickStep = tickSpacing < s.tickCellWidth ? Math.ceil(s.tickCellWidth / tickSpacing) : 1
|
|
193
|
+
// Keep the first, last, evenly-stepped, and any emphasized ticks; drop the rest's labels.
|
|
194
|
+
const showTickLabel = (i: number, emphasized: boolean): boolean =>
|
|
195
|
+
tickStep <= 1 || i === 0 || i === tickCount - 1 || i % tickStep === 0 || emphasized
|
|
128
196
|
const isNeedle = marker?.type === 'needle'
|
|
129
197
|
const markerHeight = isNeedle ? trackHeight + needleOverhang * 2 : trackHeight
|
|
130
198
|
const markerFrac = marker != null ? fraction(marker.value, min, max) : 0
|
|
@@ -144,6 +212,7 @@ export function ZoneTrack({
|
|
|
144
212
|
accessibilityValue={marker != null ? { min, max, now: marker.value } : { min, max, now: min }}
|
|
145
213
|
accessibilityLabel={accessibilityLabel ?? `Zone track: ${marker?.value ?? min}`}
|
|
146
214
|
testID="zone-track"
|
|
215
|
+
onLayout={onTrackLayout}
|
|
147
216
|
{...props}
|
|
148
217
|
>
|
|
149
218
|
<View style={{ position: 'relative', height: markerHeight }}>
|
|
@@ -231,11 +300,11 @@ export function ZoneTrack({
|
|
|
231
300
|
position: 'absolute',
|
|
232
301
|
top: 0,
|
|
233
302
|
left: pct(markerFrac),
|
|
234
|
-
width:
|
|
303
|
+
width: s.needleWidth,
|
|
235
304
|
height: markerHeight,
|
|
236
|
-
borderRadius:
|
|
305
|
+
borderRadius: s.needleWidth / 2,
|
|
237
306
|
backgroundColor: marker.color ?? DEFAULT_MARKER_COLOR,
|
|
238
|
-
transform: [{ translateX: -
|
|
307
|
+
transform: [{ translateX: -s.needleWidth / 2 }],
|
|
239
308
|
}}
|
|
240
309
|
/>
|
|
241
310
|
)}
|
|
@@ -244,7 +313,7 @@ export function ZoneTrack({
|
|
|
244
313
|
{ticks?.map((tick, i) => {
|
|
245
314
|
const emphasized = tick.emphasized === true
|
|
246
315
|
const lineColor = tick.color ?? (emphasized ? t['brand-primary'] : TICK_COLOR)
|
|
247
|
-
const lineWidth = emphasized ?
|
|
316
|
+
const lineWidth = emphasized ? s.tickLineEmphasized : s.tickLineNormal
|
|
248
317
|
return (
|
|
249
318
|
<View
|
|
250
319
|
key={`line-${i}`}
|
|
@@ -252,10 +321,10 @@ export function ZoneTrack({
|
|
|
252
321
|
accessibilityElementsHidden
|
|
253
322
|
style={{
|
|
254
323
|
position: 'absolute',
|
|
255
|
-
top: (markerHeight - trackHeight) / 2 -
|
|
324
|
+
top: (markerHeight - trackHeight) / 2 - s.tickLineTopPad,
|
|
256
325
|
left: pct(fraction(tick.value, min, max)),
|
|
257
326
|
width: lineWidth,
|
|
258
|
-
height: trackHeight +
|
|
327
|
+
height: trackHeight + s.tickLineHeightPad,
|
|
259
328
|
borderRadius: lineWidth / 2,
|
|
260
329
|
backgroundColor: lineColor,
|
|
261
330
|
transform: [{ translateX: -lineWidth / 2 }],
|
|
@@ -267,17 +336,20 @@ export function ZoneTrack({
|
|
|
267
336
|
</View>
|
|
268
337
|
|
|
269
338
|
{ticks != null && ticks.length > 0 && (
|
|
270
|
-
<View
|
|
339
|
+
<View
|
|
340
|
+
style={{ position: 'relative', height: s.labelRowHeight, marginTop: s.labelMarginTop }}
|
|
341
|
+
>
|
|
271
342
|
{ticks.map((tick, i) => {
|
|
272
343
|
if (tick.label == null) return null
|
|
273
344
|
const emphasized = tick.emphasized === true
|
|
345
|
+
if (!showTickLabel(i, emphasized)) return null
|
|
274
346
|
const labelColor = tick.color ?? (emphasized ? t['brand-primary'] : TICK_COLOR)
|
|
275
347
|
const labelNode = (
|
|
276
348
|
<Text
|
|
277
349
|
testID="zone-track-tick-label"
|
|
278
350
|
style={{
|
|
279
|
-
fontSize:
|
|
280
|
-
letterSpacing:
|
|
351
|
+
fontSize: s.tickFont,
|
|
352
|
+
letterSpacing: s.tickLetterSpacing,
|
|
281
353
|
color: labelColor,
|
|
282
354
|
fontFamily: 'monospace',
|
|
283
355
|
fontWeight: emphasized ? '700' : '400',
|
|
@@ -293,8 +365,8 @@ export function ZoneTrack({
|
|
|
293
365
|
style={{
|
|
294
366
|
position: 'absolute',
|
|
295
367
|
left: pct(fraction(tick.value, min, max)),
|
|
296
|
-
transform: [{ translateX: -
|
|
297
|
-
width:
|
|
368
|
+
transform: [{ translateX: -s.tickCellWidth / 2 }],
|
|
369
|
+
width: s.tickCellWidth,
|
|
298
370
|
alignItems: 'center',
|
|
299
371
|
}}
|
|
300
372
|
>
|