@titan-design/react-ui 0.7.0 → 0.8.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 +109 -12
- package/dist/index.d.ts +109 -12
- package/dist/index.js +648 -231
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +648 -232
- package/dist/index.mjs.map +1 -1
- package/dist/{pages-8u_4WbL-.d.mts → pages-DCFBqp79.d.mts} +16 -7
- package/dist/{pages-DaWiBOGa.d.ts → pages-_fI3-x7Z.d.ts} +16 -7
- package/dist/pages.d.mts +1 -1
- package/dist/pages.d.ts +1 -1
- package/dist/pages.js +495 -92
- package/dist/pages.js.map +1 -1
- package/dist/pages.mjs +496 -93
- 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/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 +47 -0
- 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/TempoBar.stories.tsx +69 -0
- package/src/components/custom/Workout/TempoBar.test.tsx +119 -4
- package/src/components/custom/Workout/TempoBar.tsx +107 -22
- package/src/components/custom/Workout/VelocityStrip.stories.tsx +152 -9
- package/src/components/custom/Workout/VelocityStrip.test.tsx +64 -0
- package/src/components/custom/Workout/VelocityStrip.tsx +287 -40
- 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 +76 -18
- 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
|
@@ -84,15 +84,24 @@ export interface VelocityStripProps extends ViewProps {
|
|
|
84
84
|
* HEIGHT bar chart (rounded tops), whose chrome is prop-driven: with
|
|
85
85
|
* {@link showNumbers} or {@link showInfo} on it's the framed chart (raised surface,
|
|
86
86
|
* 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}.
|
|
87
|
+
* off it's a bare strip — the active-set "spotlight" of {@link ExerciseCard}. `hero`
|
|
88
|
+
* — the across-the-room, single-set wall treatment: tall bars, a per-bar m/s value
|
|
89
|
+
* label, a dashed running-best reference line, and dashed placeholders for the reps
|
|
90
|
+
* still to come (see {@link targetReps}). Reuses the zone scale and the live-rep pop.
|
|
88
91
|
*/
|
|
89
|
-
variant?: 'mini' | 'expanded'
|
|
90
|
-
/** `expanded` plot height in px (bars scale to this). Default 60. */
|
|
92
|
+
variant?: 'mini' | 'expanded' | 'hero'
|
|
93
|
+
/** `expanded` / `hero` plot height in px (bars scale to this). Default 60 (`expanded`) / 220 (`hero`). */
|
|
91
94
|
height?: number
|
|
92
95
|
/**
|
|
93
|
-
* `
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
+
* `hero` only: the set's planned rep count. Reps beyond {@link velocities} draw as
|
|
97
|
+
* dashed placeholder stubs so the wall reads "3 of 8 done" at a glance. Ignored
|
|
98
|
+
* (and no placeholders drawn) when absent or ≤ the performed-rep count.
|
|
99
|
+
*/
|
|
100
|
+
targetReps?: number
|
|
101
|
+
/**
|
|
102
|
+
* `expanded` / `hero` bar scaling. `peak` (default) scales to the set's own max
|
|
103
|
+
* (+15% headroom). `fixed` scales to a fixed velocity ceiling so bar heights read
|
|
104
|
+
* the same absolute velocity across sets (the spotlight).
|
|
96
105
|
*/
|
|
97
106
|
scale?: 'peak' | 'fixed'
|
|
98
107
|
/** `expanded` framed chart: per-bar m/s labels. Default true. */
|
|
@@ -372,6 +381,28 @@ const FIXED_MAX_VELOCITY = 1.15
|
|
|
372
381
|
/** Minimum bare-strip bar height (px) — a planned / variable / continue stub, or a near-zero rep. */
|
|
373
382
|
const BARE_STUB_HEIGHT = 3
|
|
374
383
|
|
|
384
|
+
// --- Hero variant ------------------------------------------------------------
|
|
385
|
+
// The across-the-room, single-set wall treatment. Absorbs the R2 "HeroVelocityBars"
|
|
386
|
+
// candidate into VelocityStrip: tall bars + per-bar value labels + a dashed
|
|
387
|
+
// running-best reference line + dashed placeholders for the reps still to come.
|
|
388
|
+
|
|
389
|
+
/** Default `hero` plot height (px) — tall enough to read a set's velocity shape across a room. */
|
|
390
|
+
const HERO_HEIGHT = 220
|
|
391
|
+
/** Vertical band reserved above the tallest bar for its value label (px). */
|
|
392
|
+
const HERO_LABEL_HEADROOM = 20
|
|
393
|
+
/** Gap between hero bars (px) — a group-notch-free even rhythm at wall scale. */
|
|
394
|
+
const HERO_BAR_GAP = 8
|
|
395
|
+
/** Cap on a single hero bar's width so a 2–3 rep set doesn't render slab-wide bars (px). */
|
|
396
|
+
const HERO_BAR_MAX_WIDTH = 52
|
|
397
|
+
/** Top-corner radius on hero bars (px). */
|
|
398
|
+
const HERO_BAR_RADIUS = 5
|
|
399
|
+
/** Minimum drawn height of a performed hero bar (px) — a near-zero rep still reads as a rep. */
|
|
400
|
+
const HERO_MIN_BAR_HEIGHT = 4
|
|
401
|
+
/** Dashed-stub border for a planned-but-unperformed hero rep (charcoal, reads on a dark wall). */
|
|
402
|
+
const HERO_PENDING_COLOR = primitiveColors.charcoal[100]
|
|
403
|
+
/** The dashed running-best reference line + its label (the lightest charcoal step). */
|
|
404
|
+
const HERO_REFERENCE_COLOR = primitiveColors.charcoal[0]
|
|
405
|
+
|
|
375
406
|
/** The bar-height scaling denominator for the given `scale` (guarded ≥ 0 by callers). */
|
|
376
407
|
function scaleDenominator(scale: 'peak' | 'fixed', maxVelocity: number): number {
|
|
377
408
|
return scale === 'fixed' ? FIXED_MAX_VELOCITY : maxVelocity * 1.15
|
|
@@ -407,6 +438,229 @@ const ANIMATION_DURATION = 400
|
|
|
407
438
|
const ANIMATION_EASING = Easing.bezier(0.22, 1, 0.36, 1)
|
|
408
439
|
const POP_EASING = Easing.bezier(0.34, 1.56, 0.64, 1)
|
|
409
440
|
|
|
441
|
+
/**
|
|
442
|
+
* The newest-rep entrance shared by the framed `expanded` chart and `hero`: a
|
|
443
|
+
* bounce when the rep is a new set peak, a pop otherwise. Honors reduced motion.
|
|
444
|
+
* Returns the scale `Animated.Value` for the live bar; inert while `active` is
|
|
445
|
+
* false or no live rep is present.
|
|
446
|
+
*/
|
|
447
|
+
function useLiveRepPop(
|
|
448
|
+
active: boolean,
|
|
449
|
+
liveRepIndex: number | undefined,
|
|
450
|
+
liveVelocity: number | undefined,
|
|
451
|
+
isNewPeak: boolean
|
|
452
|
+
): Animated.Value {
|
|
453
|
+
const prefersReducedMotion = usePrefersReducedMotion()
|
|
454
|
+
const [liveScale] = useState(() => new Animated.Value(1))
|
|
455
|
+
useEffect(() => {
|
|
456
|
+
if (!active || liveRepIndex == null || liveVelocity == null) return
|
|
457
|
+
if (prefersReducedMotion) {
|
|
458
|
+
liveScale.setValue(1)
|
|
459
|
+
return
|
|
460
|
+
}
|
|
461
|
+
if (isNewPeak) {
|
|
462
|
+
liveScale.setValue(1)
|
|
463
|
+
Animated.sequence([
|
|
464
|
+
Animated.timing(liveScale, {
|
|
465
|
+
toValue: 1.25,
|
|
466
|
+
duration: 150,
|
|
467
|
+
easing: Easing.out(Easing.quad),
|
|
468
|
+
useNativeDriver: true,
|
|
469
|
+
}),
|
|
470
|
+
Animated.spring(liveScale, {
|
|
471
|
+
toValue: 1,
|
|
472
|
+
friction: 3,
|
|
473
|
+
tension: 140,
|
|
474
|
+
useNativeDriver: true,
|
|
475
|
+
}),
|
|
476
|
+
]).start()
|
|
477
|
+
} else {
|
|
478
|
+
liveScale.setValue(0.8)
|
|
479
|
+
Animated.timing(liveScale, {
|
|
480
|
+
toValue: 1,
|
|
481
|
+
duration: 300,
|
|
482
|
+
easing: POP_EASING,
|
|
483
|
+
useNativeDriver: true,
|
|
484
|
+
}).start()
|
|
485
|
+
}
|
|
486
|
+
}, [active, liveRepIndex, liveVelocity, isNewPeak, prefersReducedMotion, liveScale])
|
|
487
|
+
return liveScale
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
interface HeroVelocityChartProps {
|
|
491
|
+
/** Performed per-rep mean concentric velocities (m/s). */
|
|
492
|
+
doneVelocities: number[]
|
|
493
|
+
/** Zone → color resolver, shared with the other variants (single-sources the scale). */
|
|
494
|
+
barColorFor: (velocity: number) => string
|
|
495
|
+
/** Bar-height scaling denominator (`peak` set-max or `fixed` ceiling). */
|
|
496
|
+
scaleDenom: number
|
|
497
|
+
/** Running-best velocity (the set peak) — drives the dashed reference line. */
|
|
498
|
+
referenceVelocity: number
|
|
499
|
+
liveRepIndex?: number
|
|
500
|
+
isNewPeak: boolean
|
|
501
|
+
targetReps?: number
|
|
502
|
+
height: number
|
|
503
|
+
className?: string
|
|
504
|
+
viewProps: ViewProps
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* The `hero` render: the across-the-room, single-set wall treatment. Tall bars +
|
|
509
|
+
* a per-bar value label + a dashed running-best reference line + dashed placeholders
|
|
510
|
+
* for the reps still to come. Reuses {@link barColorFor} (zone scale) and the
|
|
511
|
+
* {@link useLiveRepPop} entrance so it stays consistent with the framed chart.
|
|
512
|
+
*/
|
|
513
|
+
function HeroVelocityChart({
|
|
514
|
+
doneVelocities,
|
|
515
|
+
barColorFor,
|
|
516
|
+
scaleDenom,
|
|
517
|
+
referenceVelocity,
|
|
518
|
+
liveRepIndex,
|
|
519
|
+
isNewPeak,
|
|
520
|
+
targetReps,
|
|
521
|
+
height,
|
|
522
|
+
className,
|
|
523
|
+
viewProps,
|
|
524
|
+
}: HeroVelocityChartProps) {
|
|
525
|
+
const liveVelocity = liveRepIndex != null ? doneVelocities[liveRepIndex] : undefined
|
|
526
|
+
const liveScale = useLiveRepPop(true, liveRepIndex, liveVelocity, isNewPeak)
|
|
527
|
+
|
|
528
|
+
// Reserve a band above the tallest bar for its value label so a peak bar never clips.
|
|
529
|
+
const plotHeight = Math.max(0, height - HERO_LABEL_HEADROOM)
|
|
530
|
+
const barHeight = (velocity: number): number =>
|
|
531
|
+
scaleDenom > 0
|
|
532
|
+
? Math.max(HERO_MIN_BAR_HEIGHT, Math.min(1, velocity / scaleDenom) * plotHeight)
|
|
533
|
+
: HERO_MIN_BAR_HEIGHT
|
|
534
|
+
|
|
535
|
+
const pendingCount = Math.max(0, (targetReps ?? 0) - doneVelocities.length)
|
|
536
|
+
const referencePx =
|
|
537
|
+
referenceVelocity > 0 && scaleDenom > 0
|
|
538
|
+
? Math.min(plotHeight, (referenceVelocity / scaleDenom) * plotHeight)
|
|
539
|
+
: 0
|
|
540
|
+
|
|
541
|
+
const repCount = doneVelocities.length
|
|
542
|
+
const total = Math.max(repCount, targetReps ?? repCount)
|
|
543
|
+
const label =
|
|
544
|
+
referenceVelocity > 0
|
|
545
|
+
? `Velocity chart, ${repCount} of ${total} reps, best ${formatVelocity(referenceVelocity)} meters per second`
|
|
546
|
+
: `Velocity chart, ${repCount} of ${total} reps`
|
|
547
|
+
|
|
548
|
+
const { style: externalStyle, ...restProps } = viewProps
|
|
549
|
+
|
|
550
|
+
return (
|
|
551
|
+
<View
|
|
552
|
+
className={className}
|
|
553
|
+
style={[{ height }, externalStyle]}
|
|
554
|
+
accessibilityRole="image"
|
|
555
|
+
accessibilityLabel={label}
|
|
556
|
+
testID="velocity-strip-hero"
|
|
557
|
+
{...restProps}
|
|
558
|
+
>
|
|
559
|
+
<View
|
|
560
|
+
style={{
|
|
561
|
+
flex: 1,
|
|
562
|
+
flexDirection: 'row',
|
|
563
|
+
alignItems: 'flex-end',
|
|
564
|
+
gap: HERO_BAR_GAP,
|
|
565
|
+
position: 'relative',
|
|
566
|
+
borderBottomWidth: 2,
|
|
567
|
+
borderBottomColor: HERO_PENDING_COLOR,
|
|
568
|
+
}}
|
|
569
|
+
>
|
|
570
|
+
{/*
|
|
571
|
+
* Running-best reference line. Deliberately unlabeled: it sits at the tallest
|
|
572
|
+
* bar's top, and that bar already displays its velocity as its own value label,
|
|
573
|
+
* so a "best X.XX" tag would duplicate it — and would collide with that label
|
|
574
|
+
* whenever the peak bar is on the same side (declines peak-left, ends-on-best
|
|
575
|
+
* peak-right). The line alone reads as "how far has velocity fallen from best".
|
|
576
|
+
* The numeric best is in the container's accessibility label.
|
|
577
|
+
*/}
|
|
578
|
+
{referencePx > 0 && (
|
|
579
|
+
<View
|
|
580
|
+
accessibilityElementsHidden
|
|
581
|
+
style={{
|
|
582
|
+
position: 'absolute',
|
|
583
|
+
left: 0,
|
|
584
|
+
right: 0,
|
|
585
|
+
bottom: referencePx,
|
|
586
|
+
borderTopWidth: 1,
|
|
587
|
+
borderStyle: 'dashed',
|
|
588
|
+
borderColor: HERO_REFERENCE_COLOR,
|
|
589
|
+
pointerEvents: 'none',
|
|
590
|
+
}}
|
|
591
|
+
testID="velocity-hero-reference"
|
|
592
|
+
/>
|
|
593
|
+
)}
|
|
594
|
+
|
|
595
|
+
{doneVelocities.map((velocity, i) => {
|
|
596
|
+
const isLive = liveRepIndex === i
|
|
597
|
+
return (
|
|
598
|
+
<View
|
|
599
|
+
key={i}
|
|
600
|
+
accessibilityElementsHidden
|
|
601
|
+
style={{
|
|
602
|
+
flex: 1,
|
|
603
|
+
maxWidth: HERO_BAR_MAX_WIDTH,
|
|
604
|
+
height: '100%',
|
|
605
|
+
alignItems: 'center',
|
|
606
|
+
justifyContent: 'flex-end',
|
|
607
|
+
}}
|
|
608
|
+
>
|
|
609
|
+
<Text
|
|
610
|
+
className="text-text-primary"
|
|
611
|
+
style={{ fontSize: 12, fontWeight: '800', marginBottom: 4 }}
|
|
612
|
+
testID={`velocity-label-${i}`}
|
|
613
|
+
>
|
|
614
|
+
{formatVelocity(velocity)}
|
|
615
|
+
</Text>
|
|
616
|
+
<Animated.View
|
|
617
|
+
style={[
|
|
618
|
+
{
|
|
619
|
+
width: '100%',
|
|
620
|
+
height: barHeight(velocity),
|
|
621
|
+
borderTopLeftRadius: HERO_BAR_RADIUS,
|
|
622
|
+
borderTopRightRadius: HERO_BAR_RADIUS,
|
|
623
|
+
backgroundColor: barColorFor(velocity),
|
|
624
|
+
},
|
|
625
|
+
isLive ? { transform: [{ scale: liveScale }] } : null,
|
|
626
|
+
]}
|
|
627
|
+
testID={`velocity-bar-${i}`}
|
|
628
|
+
/>
|
|
629
|
+
</View>
|
|
630
|
+
)
|
|
631
|
+
})}
|
|
632
|
+
|
|
633
|
+
{Array.from({ length: pendingCount }, (_, i) => (
|
|
634
|
+
<View
|
|
635
|
+
key={`pending-${i}`}
|
|
636
|
+
accessibilityElementsHidden
|
|
637
|
+
style={{
|
|
638
|
+
flex: 1,
|
|
639
|
+
maxWidth: HERO_BAR_MAX_WIDTH,
|
|
640
|
+
height: '100%',
|
|
641
|
+
alignItems: 'center',
|
|
642
|
+
justifyContent: 'flex-end',
|
|
643
|
+
}}
|
|
644
|
+
>
|
|
645
|
+
<View
|
|
646
|
+
style={{
|
|
647
|
+
width: '100%',
|
|
648
|
+
height: HERO_MIN_BAR_HEIGHT * 3,
|
|
649
|
+
borderWidth: 1,
|
|
650
|
+
borderStyle: 'dashed',
|
|
651
|
+
borderColor: HERO_PENDING_COLOR,
|
|
652
|
+
borderTopLeftRadius: HERO_BAR_RADIUS,
|
|
653
|
+
borderTopRightRadius: HERO_BAR_RADIUS,
|
|
654
|
+
}}
|
|
655
|
+
testID="velocity-slot-todo"
|
|
656
|
+
/>
|
|
657
|
+
</View>
|
|
658
|
+
))}
|
|
659
|
+
</View>
|
|
660
|
+
</View>
|
|
661
|
+
)
|
|
662
|
+
}
|
|
663
|
+
|
|
410
664
|
export function VelocityStrip({
|
|
411
665
|
velocities,
|
|
412
666
|
set,
|
|
@@ -417,6 +671,7 @@ export function VelocityStrip({
|
|
|
417
671
|
onRepPress,
|
|
418
672
|
variant = 'expanded',
|
|
419
673
|
height = EXPANDED_HEIGHT,
|
|
674
|
+
targetReps,
|
|
420
675
|
scale = 'peak',
|
|
421
676
|
showNumbers = true,
|
|
422
677
|
showInfo = true,
|
|
@@ -456,47 +711,19 @@ export function VelocityStrip({
|
|
|
456
711
|
? (classifyBand(meanVelocity, zones)?.label ?? '')
|
|
457
712
|
: getVelocityZoneName(meanVelocity)
|
|
458
713
|
|
|
459
|
-
const prefersReducedMotion = usePrefersReducedMotion()
|
|
460
714
|
const [heightAnim] = useState(() => new Animated.Value(expanded ? height : 3))
|
|
461
715
|
const [labelOpacity] = useState(() => new Animated.Value(expanded ? 1 : 0))
|
|
462
716
|
const [infoOpacity] = useState(() => new Animated.Value(expanded ? 1 : 0))
|
|
463
|
-
const [liveScale] = useState(() => new Animated.Value(1))
|
|
464
717
|
|
|
465
718
|
// Newest-rep animation: pop for a normal rep, bounce when it sets a new peak.
|
|
466
719
|
const liveVelocity = liveRepIndex != null ? doneVelocities[liveRepIndex] : undefined
|
|
467
720
|
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])
|
|
721
|
+
const liveScale = useLiveRepPop(
|
|
722
|
+
variant === 'expanded' && framed,
|
|
723
|
+
liveRepIndex,
|
|
724
|
+
liveVelocity,
|
|
725
|
+
isNewPeak
|
|
726
|
+
)
|
|
500
727
|
|
|
501
728
|
useEffect(() => {
|
|
502
729
|
if (variant !== 'expanded' || !framed) return
|
|
@@ -529,6 +756,26 @@ export function VelocityStrip({
|
|
|
529
756
|
const repCount = doneVelocities.length
|
|
530
757
|
const miniLabel = set ? setAccessibilityLabel(set, repCount) : `Velocity strip, ${repCount} reps`
|
|
531
758
|
|
|
759
|
+
if (variant === 'hero') {
|
|
760
|
+
// Hero's plot is far taller than the expanded chart; apply its own default when
|
|
761
|
+
// the caller left `height` at the shared 60px default.
|
|
762
|
+
const heroHeight = height === EXPANDED_HEIGHT ? HERO_HEIGHT : height
|
|
763
|
+
return (
|
|
764
|
+
<HeroVelocityChart
|
|
765
|
+
doneVelocities={doneVelocities}
|
|
766
|
+
barColorFor={barColorFor}
|
|
767
|
+
scaleDenom={scaleDenom}
|
|
768
|
+
referenceVelocity={maxVelocity}
|
|
769
|
+
liveRepIndex={liveRepIndex}
|
|
770
|
+
isNewPeak={isNewPeak}
|
|
771
|
+
targetReps={targetReps}
|
|
772
|
+
height={heroHeight}
|
|
773
|
+
className={className}
|
|
774
|
+
viewProps={props}
|
|
775
|
+
/>
|
|
776
|
+
)
|
|
777
|
+
}
|
|
778
|
+
|
|
532
779
|
if (variant === 'mini') {
|
|
533
780
|
const { style: externalStyle, ...restProps } = props
|
|
534
781
|
// 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
|
+
})
|
|
@@ -19,9 +19,59 @@ const DEFAULT_MARKER_COLOR = primitiveColors.white
|
|
|
19
19
|
/** Tick mark + tick label colour. */
|
|
20
20
|
const TICK_COLOR = t['text-tertiary']
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
/** Density. `default` — the compact card/rail gauge. `wall` — the across-the-room dashboard scale. */
|
|
23
|
+
export type ZoneTrackSize = 'default' | 'wall'
|
|
24
|
+
|
|
25
|
+
interface ZoneTrackSizing {
|
|
26
|
+
trackHeight: number
|
|
27
|
+
needleOverhang: number
|
|
28
|
+
needleWidth: number
|
|
29
|
+
tickLineNormal: number
|
|
30
|
+
tickLineEmphasized: number
|
|
31
|
+
tickLineHeightPad: number
|
|
32
|
+
tickLineTopPad: number
|
|
33
|
+
labelRowHeight: number
|
|
34
|
+
labelMarginTop: number
|
|
35
|
+
tickFont: number
|
|
36
|
+
tickLetterSpacing: number
|
|
37
|
+
tickCellWidth: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Per-density magnitudes. `default` reproduces the original compact values exactly;
|
|
42
|
+
* `wall` scales the track, needle, tick lines and tick labels up together so the gauge
|
|
43
|
+
* reads across a room. `trackHeight` / `needleOverhang` remain overridable per call.
|
|
44
|
+
*/
|
|
45
|
+
const ZONE_SIZES: Record<ZoneTrackSize, ZoneTrackSizing> = {
|
|
46
|
+
default: {
|
|
47
|
+
trackHeight: 14,
|
|
48
|
+
needleOverhang: 6,
|
|
49
|
+
needleWidth: 4,
|
|
50
|
+
tickLineNormal: 1.5,
|
|
51
|
+
tickLineEmphasized: 2,
|
|
52
|
+
tickLineHeightPad: 4,
|
|
53
|
+
tickLineTopPad: 2,
|
|
54
|
+
labelRowHeight: 16,
|
|
55
|
+
labelMarginTop: 5,
|
|
56
|
+
tickFont: 9,
|
|
57
|
+
tickLetterSpacing: 0.5,
|
|
58
|
+
tickCellWidth: 28,
|
|
59
|
+
},
|
|
60
|
+
wall: {
|
|
61
|
+
trackHeight: 24,
|
|
62
|
+
needleOverhang: 9,
|
|
63
|
+
needleWidth: 7,
|
|
64
|
+
tickLineNormal: 2.5,
|
|
65
|
+
tickLineEmphasized: 3.5,
|
|
66
|
+
tickLineHeightPad: 6,
|
|
67
|
+
tickLineTopPad: 3,
|
|
68
|
+
labelRowHeight: 24,
|
|
69
|
+
labelMarginTop: 8,
|
|
70
|
+
tickFont: 14,
|
|
71
|
+
tickLetterSpacing: 0.5,
|
|
72
|
+
tickCellWidth: 48,
|
|
73
|
+
},
|
|
74
|
+
}
|
|
25
75
|
|
|
26
76
|
/** A single colour band of the zone gradient, spanning `[prev.upTo, upTo]` of the domain. */
|
|
27
77
|
export interface ZoneTrackZone {
|
|
@@ -82,9 +132,11 @@ export interface ZoneTrackProps extends ViewProps {
|
|
|
82
132
|
ticks?: ZoneTrackTick[]
|
|
83
133
|
/** Optional translucent range highlight drawn over the gradient. */
|
|
84
134
|
band?: ZoneTrackBand | null
|
|
85
|
-
/**
|
|
135
|
+
/** Density: `default` (compact) or `wall` (the across-the-room dashboard scale). Scales track, needle, ticks and labels together. */
|
|
136
|
+
size?: ZoneTrackSize
|
|
137
|
+
/** Track height in px. Overrides the `size` default (14 default / 24 wall). */
|
|
86
138
|
trackHeight?: number
|
|
87
|
-
/** How far a needle marker overhangs the track top + bottom, in px.
|
|
139
|
+
/** How far a needle marker overhangs the track top + bottom, in px. Overrides the `size` default (6 default / 9 wall). */
|
|
88
140
|
needleOverhang?: number
|
|
89
141
|
/** Muted colour of the track behind / beyond the fill. Default a charcoal step. */
|
|
90
142
|
trackColor?: string
|
|
@@ -117,14 +169,18 @@ export function ZoneTrack({
|
|
|
117
169
|
marker = null,
|
|
118
170
|
ticks,
|
|
119
171
|
band = null,
|
|
120
|
-
|
|
121
|
-
|
|
172
|
+
size = 'default',
|
|
173
|
+
trackHeight: trackHeightProp,
|
|
174
|
+
needleOverhang: needleOverhangProp,
|
|
122
175
|
trackColor = DEFAULT_TRACK_COLOR,
|
|
123
176
|
className,
|
|
124
177
|
style,
|
|
125
178
|
accessibilityLabel,
|
|
126
179
|
...props
|
|
127
180
|
}: ZoneTrackProps) {
|
|
181
|
+
const s = ZONE_SIZES[size]
|
|
182
|
+
const trackHeight = trackHeightProp ?? s.trackHeight
|
|
183
|
+
const needleOverhang = needleOverhangProp ?? s.needleOverhang
|
|
128
184
|
const isNeedle = marker?.type === 'needle'
|
|
129
185
|
const markerHeight = isNeedle ? trackHeight + needleOverhang * 2 : trackHeight
|
|
130
186
|
const markerFrac = marker != null ? fraction(marker.value, min, max) : 0
|
|
@@ -231,11 +287,11 @@ export function ZoneTrack({
|
|
|
231
287
|
position: 'absolute',
|
|
232
288
|
top: 0,
|
|
233
289
|
left: pct(markerFrac),
|
|
234
|
-
width:
|
|
290
|
+
width: s.needleWidth,
|
|
235
291
|
height: markerHeight,
|
|
236
|
-
borderRadius:
|
|
292
|
+
borderRadius: s.needleWidth / 2,
|
|
237
293
|
backgroundColor: marker.color ?? DEFAULT_MARKER_COLOR,
|
|
238
|
-
transform: [{ translateX: -
|
|
294
|
+
transform: [{ translateX: -s.needleWidth / 2 }],
|
|
239
295
|
}}
|
|
240
296
|
/>
|
|
241
297
|
)}
|
|
@@ -244,7 +300,7 @@ export function ZoneTrack({
|
|
|
244
300
|
{ticks?.map((tick, i) => {
|
|
245
301
|
const emphasized = tick.emphasized === true
|
|
246
302
|
const lineColor = tick.color ?? (emphasized ? t['brand-primary'] : TICK_COLOR)
|
|
247
|
-
const lineWidth = emphasized ?
|
|
303
|
+
const lineWidth = emphasized ? s.tickLineEmphasized : s.tickLineNormal
|
|
248
304
|
return (
|
|
249
305
|
<View
|
|
250
306
|
key={`line-${i}`}
|
|
@@ -252,10 +308,10 @@ export function ZoneTrack({
|
|
|
252
308
|
accessibilityElementsHidden
|
|
253
309
|
style={{
|
|
254
310
|
position: 'absolute',
|
|
255
|
-
top: (markerHeight - trackHeight) / 2 -
|
|
311
|
+
top: (markerHeight - trackHeight) / 2 - s.tickLineTopPad,
|
|
256
312
|
left: pct(fraction(tick.value, min, max)),
|
|
257
313
|
width: lineWidth,
|
|
258
|
-
height: trackHeight +
|
|
314
|
+
height: trackHeight + s.tickLineHeightPad,
|
|
259
315
|
borderRadius: lineWidth / 2,
|
|
260
316
|
backgroundColor: lineColor,
|
|
261
317
|
transform: [{ translateX: -lineWidth / 2 }],
|
|
@@ -267,7 +323,9 @@ export function ZoneTrack({
|
|
|
267
323
|
</View>
|
|
268
324
|
|
|
269
325
|
{ticks != null && ticks.length > 0 && (
|
|
270
|
-
<View
|
|
326
|
+
<View
|
|
327
|
+
style={{ position: 'relative', height: s.labelRowHeight, marginTop: s.labelMarginTop }}
|
|
328
|
+
>
|
|
271
329
|
{ticks.map((tick, i) => {
|
|
272
330
|
if (tick.label == null) return null
|
|
273
331
|
const emphasized = tick.emphasized === true
|
|
@@ -276,8 +334,8 @@ export function ZoneTrack({
|
|
|
276
334
|
<Text
|
|
277
335
|
testID="zone-track-tick-label"
|
|
278
336
|
style={{
|
|
279
|
-
fontSize:
|
|
280
|
-
letterSpacing:
|
|
337
|
+
fontSize: s.tickFont,
|
|
338
|
+
letterSpacing: s.tickLetterSpacing,
|
|
281
339
|
color: labelColor,
|
|
282
340
|
fontFamily: 'monospace',
|
|
283
341
|
fontWeight: emphasized ? '700' : '400',
|
|
@@ -293,8 +351,8 @@ export function ZoneTrack({
|
|
|
293
351
|
style={{
|
|
294
352
|
position: 'absolute',
|
|
295
353
|
left: pct(fraction(tick.value, min, max)),
|
|
296
|
-
transform: [{ translateX: -
|
|
297
|
-
width:
|
|
354
|
+
transform: [{ translateX: -s.tickCellWidth / 2 }],
|
|
355
|
+
width: s.tickCellWidth,
|
|
298
356
|
alignItems: 'center',
|
|
299
357
|
}}
|
|
300
358
|
>
|