@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
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
// Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
|
|
2
|
+
/**
|
|
3
|
+
* SetBarChart — the generic per-rep value-height bar chart both the velocity hero and the
|
|
4
|
+
* ROM progression compose. It OWNS the chart geometry (value→height scaling, the shared
|
|
5
|
+
* `scaleMax` override, the plot-width-driven adaptive bar width / gap stepping / value-label
|
|
6
|
+
* thinning) and the shared bar language (paper treatment, grow-from-bottom live rep, up/down
|
|
7
|
+
* orientation, the set-type slot vocabulary). Everything domain-specific — bar COLOR, the
|
|
8
|
+
* reference overlay (velocity VL bands / ROM working+short lines) — is passed in, so an
|
|
9
|
+
* improvement to the chart (spacing, paper, live-grow) lands once for every consumer.
|
|
10
|
+
*
|
|
11
|
+
* VALUE-HEIGHT ONLY: the flat 3px `mini` strip stays its own thin strip in VelocityStrip.
|
|
12
|
+
*/
|
|
13
|
+
import { useState, type ReactNode } from 'react'
|
|
14
|
+
import {
|
|
15
|
+
View,
|
|
16
|
+
Text,
|
|
17
|
+
Animated,
|
|
18
|
+
type ViewProps,
|
|
19
|
+
type ViewStyle,
|
|
20
|
+
type LayoutChangeEvent,
|
|
21
|
+
} from 'react-native'
|
|
22
|
+
import { primitiveRamps } from '../../../theme/tokens/primitives'
|
|
23
|
+
import { barPaper } from '../../../theme/bar-paper'
|
|
24
|
+
import { useOnSurfaceColor, useSurface, surfaceBackground } from '../../ui/surface/SurfaceContext'
|
|
25
|
+
import { useLiveRepGrowth } from './live-rep-growth'
|
|
26
|
+
|
|
27
|
+
/** Linear-blend two #RRGGBB hexes (`t`=0 → a, 1 → b) — the surface-relative solid to-do tone. */
|
|
28
|
+
function mixHex(a: string, b: string, t: number): string {
|
|
29
|
+
const parse = (h: string): [number, number, number] => {
|
|
30
|
+
const s = h.replace('#', '')
|
|
31
|
+
return [parseInt(s.slice(0, 2), 16), parseInt(s.slice(2, 4), 16), parseInt(s.slice(4, 6), 16)]
|
|
32
|
+
}
|
|
33
|
+
const [ar, ag, ab] = parse(a)
|
|
34
|
+
const [br, bg, bb] = parse(b)
|
|
35
|
+
const ch = (x: number, y: number): string =>
|
|
36
|
+
Math.round(x + (y - x) * t)
|
|
37
|
+
.toString(16)
|
|
38
|
+
.padStart(2, '0')
|
|
39
|
+
return `#${ch(ar, br)}${ch(ag, bg)}${ch(ab, bb)}`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The cyan variable-window fill — the range set's `floor..max` window (shared with SetStrip). */
|
|
43
|
+
export const VARIABLE_FILL = primitiveRamps.cyan[900]
|
|
44
|
+
/** The thin cyan-800 outline on the open-ended "continue" slot — reads as "keep going". */
|
|
45
|
+
export const CONTINUE_OUTLINE = primitiveRamps.cyan[800]
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* One drawn cell of the chart. `rep` carries a `value` and draws a value-height bar;
|
|
49
|
+
* `todo` / `variable` / `continue` are the set-type window stubs (planned remainder, the
|
|
50
|
+
* range floor..max window, the AMRAP/myo open tail); `empty` is a rep COLUMN a side didn't
|
|
51
|
+
* log — a faint constant-contrast placeholder that keeps the two diverging wings index-locked
|
|
52
|
+
* (a lagging side's missing rep sits at the SAME column index as the other side's rep).
|
|
53
|
+
* `leadingGap` adds spacing BEFORE the cell beyond the uniform inter-bar gap — the WIDE
|
|
54
|
+
* chunk-notch that splits drop sub-loads / myo clusters / cluster intra-rests. Absent
|
|
55
|
+
* `leadingGap` = the ordinary rep gap.
|
|
56
|
+
*/
|
|
57
|
+
export interface SetSlot {
|
|
58
|
+
kind: 'rep' | 'todo' | 'variable' | 'continue' | 'empty'
|
|
59
|
+
value?: number
|
|
60
|
+
leadingGap?: number
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The geometry SetBarChart hands a {@link SetBarChartProps.renderReference} overlay painter. */
|
|
64
|
+
export interface SetBarGeometry {
|
|
65
|
+
/** The height-scaling denominator (a value `v` sits `yOf(v)` px up from the baseline). */
|
|
66
|
+
scaleDenom: number
|
|
67
|
+
/** The drawable plot height (px) — `height` minus the value-label headroom when labels show. */
|
|
68
|
+
plotHeight: number
|
|
69
|
+
/** px UP from the baseline for a value. */
|
|
70
|
+
yOf: (value: number) => number
|
|
71
|
+
/** The tallest performed value — the running-best reps are measured against. */
|
|
72
|
+
best: number
|
|
73
|
+
/** The plot is vertically mirrored (a `down` wing) — counter-flip any text in the overlay. */
|
|
74
|
+
flip: boolean
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface SetBarChartProps {
|
|
78
|
+
/** Ordered cells — performed reps + any set-type window stubs. */
|
|
79
|
+
slots: SetSlot[]
|
|
80
|
+
/** value → bar fill color. Reps only; window stubs use the fixed set-type tones. */
|
|
81
|
+
colorFor: (value: number) => string
|
|
82
|
+
/** Plot height (px). Bars scale into this (minus the value-label headroom when labels show). */
|
|
83
|
+
height: number
|
|
84
|
+
/**
|
|
85
|
+
* Height scaling. `peak` (default) scales to the performed max (+headroom); `fixed` scales
|
|
86
|
+
* to a fixed velocity ceiling so heights read the same absolute value across sets. Overridden
|
|
87
|
+
* by {@link scaleMax}.
|
|
88
|
+
*/
|
|
89
|
+
scale?: 'peak' | 'fixed'
|
|
90
|
+
/**
|
|
91
|
+
* OVERRIDE the height denominator with `scaleMax * headroom` (BAR HEIGHT ONLY; color + the
|
|
92
|
+
* reference `best` still read this chart's own values). The diverging dual passes the pair's
|
|
93
|
+
* shared max so a stronger side reads TALLER against one common scale.
|
|
94
|
+
*/
|
|
95
|
+
scaleMax?: number
|
|
96
|
+
/** `up` (default) grows bars UP from a bottom baseline; `down` mirrors the plot (grows DOWN, text upright). */
|
|
97
|
+
orientation?: 'up' | 'down'
|
|
98
|
+
/** Index (into the rep slots) of the newest rep — that bar grows up from the baseline as it lands. */
|
|
99
|
+
liveRepIndex?: number
|
|
100
|
+
/** The live rep set a new peak — its entrance overshoots then settles (a small bounce). */
|
|
101
|
+
isNewPeak?: boolean
|
|
102
|
+
/**
|
|
103
|
+
* Append dashed todo placeholders so the chart reads "3 of N done". Ignored when the `slots`
|
|
104
|
+
* already run to (or past) this count — a caller that supplies its own todo slots omits this.
|
|
105
|
+
*/
|
|
106
|
+
targetReps?: number
|
|
107
|
+
/**
|
|
108
|
+
* Inter-bar gap as a fraction of bar width (gap ≈ gapRatio·barWidth, floored at 2px). Default
|
|
109
|
+
* {@link GAP_RATIO}. Lower = denser (toward the near-touching expanded density); narrow screens
|
|
110
|
+
* floor to 2px regardless. Exposed mainly for a spacing-density comparison.
|
|
111
|
+
*/
|
|
112
|
+
gapRatio?: number
|
|
113
|
+
/** Corner radius on the bars (px). Default 5. */
|
|
114
|
+
barRadius?: number
|
|
115
|
+
/**
|
|
116
|
+
* Which corners round. `top` (default) — the bolder top-rounded WALL HERO look. `all` — the thin
|
|
117
|
+
* radius-2 flat-bar language (the SegmentedBar pill), for rep-level compact + expanded in the
|
|
118
|
+
* rail / card / table so a compact↔expanded morph keeps a constant all-corners radius.
|
|
119
|
+
*/
|
|
120
|
+
cornerStyle?: 'top' | 'all'
|
|
121
|
+
/**
|
|
122
|
+
* FLAT height mode (the `compact` resting form). Every rep bar renders at ONE uniform short height
|
|
123
|
+
* ({@link FLAT_BAR_FRACTION} of the plot) instead of value-height — EVERYTHING else identical
|
|
124
|
+
* (colors, paper, the proportional spacing + chunk-notch, todo/variable/continue slots, gutter).
|
|
125
|
+
* Value labels are suppressed. Same x-positions/widths/gaps as value mode, so a compact↔expanded
|
|
126
|
+
* toggle only changes bar HEIGHTS in place (no horizontal reflow).
|
|
127
|
+
*/
|
|
128
|
+
flat?: boolean
|
|
129
|
+
/**
|
|
130
|
+
* Draw a per-rep value label. Default false. When on, every label pins to one aligned row at the
|
|
131
|
+
* plot TOP (not offset per bar height) in the muted on-surface-secondary tone. Ignored in {@link flat} mode.
|
|
132
|
+
*/
|
|
133
|
+
showValueLabels?: boolean
|
|
134
|
+
/** Format a rep value for its label. Default `String`. */
|
|
135
|
+
formatValue?: (value: number) => string
|
|
136
|
+
/**
|
|
137
|
+
* To-do placeholder treatment. `solid` (default) — a solid surface-relative section (the plane
|
|
138
|
+
* blended toward the on-surface neutral). `dashed` — a dashed outline stub. Exists so ROM can keep
|
|
139
|
+
* its dashed to-do while the hero (and the dual that composes it) take the solid section.
|
|
140
|
+
*/
|
|
141
|
+
todoVariant?: 'solid' | 'dashed'
|
|
142
|
+
/**
|
|
143
|
+
* Pad the rendered columns to at least this many with placeholder to-do cells. The diverging dual
|
|
144
|
+
* passes the union column count of both wings so bars line up top↔bottom across the centre axis.
|
|
145
|
+
*/
|
|
146
|
+
minColumns?: number
|
|
147
|
+
/** Paint a reference overlay (VL bands / working-standard lines) given the chart geometry. */
|
|
148
|
+
renderReference?: (geometry: SetBarGeometry) => ReactNode
|
|
149
|
+
/**
|
|
150
|
+
* Per-rep OVERLAY, rendered inside each rep column above the bar (for the framed `expanded`
|
|
151
|
+
* chrome to inject its per-bar m/s label + `onRepPress` pressable without duplicating bar
|
|
152
|
+
* rendering). When provided, SetBarChart suppresses its OWN value label (the overlay owns it) and
|
|
153
|
+
* the rep columns become interactive (not a11y-hidden).
|
|
154
|
+
*/
|
|
155
|
+
renderBarOverlay?: (repIndex: number, value: number) => ReactNode
|
|
156
|
+
/**
|
|
157
|
+
* IN-PLACE expand animation: an `Animated.Value` 0→1 that morphs every rep bar between its FLAT
|
|
158
|
+
* (compact) height and its VALUE height, and fades the value labels in. The x-geometry stays fixed
|
|
159
|
+
* (widths / gaps / positions), so a compact↔expanded toggle animates only heights — no reflow.
|
|
160
|
+
* Overrides `flat` + the live-rep grow while animating.
|
|
161
|
+
*/
|
|
162
|
+
expandProgress?: Animated.Value
|
|
163
|
+
/** Suppress the plot's own 2px baseline border (the dual draws ONE shared centre axis instead). */
|
|
164
|
+
hideBaseline?: boolean
|
|
165
|
+
/** Container testID. */
|
|
166
|
+
testID?: string
|
|
167
|
+
/** Prefix for the per-cell testIDs: `${prefix}-bar-N`, `${prefix}-label-N`, `${prefix}-slot-KIND`. Default `setbar`. */
|
|
168
|
+
testIDPrefix?: string
|
|
169
|
+
/** When set, the container reads as an `image` with this label; omit for a plain (unlabeled) plot. */
|
|
170
|
+
accessibilityLabel?: string
|
|
171
|
+
/**
|
|
172
|
+
* Optional stream / slot NAME (e.g. "Left Arm") rendered as a rotated vertical edge label down a
|
|
173
|
+
* left gutter — the same treatment the diverging dual gives each wing. Font size scales to `height`
|
|
174
|
+
* so it fits without truncation. Omitted → no gutter.
|
|
175
|
+
*/
|
|
176
|
+
label?: string
|
|
177
|
+
className?: string
|
|
178
|
+
/** Rest View props (style is merged onto the container). */
|
|
179
|
+
viewProps?: ViewProps
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The `scale="fixed"` velocity ceiling (m/s) — a bar height reads the same absolute value across sets. */
|
|
183
|
+
const FIXED_MAX_VALUE = 1.15
|
|
184
|
+
/** Headroom above the peak bar: just enough to seat its value label without a big empty band on top. */
|
|
185
|
+
export const PEAK_HEADROOM = 1.03
|
|
186
|
+
|
|
187
|
+
/** The height-scaling denominator for `scale` at the given performed max (guarded ≥ 0 by callers). */
|
|
188
|
+
export function scaleDenominator(scale: 'peak' | 'fixed', maxValue: number): number {
|
|
189
|
+
return scale === 'fixed' ? FIXED_MAX_VALUE : maxValue * PEAK_HEADROOM
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The per-bar value-label font size (px), scaled to chart `height` (floored at 8) so the numbers stay
|
|
194
|
+
* proportionate to the bars at small heights instead of reading oversized. Hits the full 12px at the
|
|
195
|
+
* standard single-hero (220px) and dual-wing (~110px) scales; shrinks toward the floor on tiny charts.
|
|
196
|
+
*/
|
|
197
|
+
export function valueLabelFontSize(height: number): number {
|
|
198
|
+
return Math.round(Math.max(8, Math.min(12, height * 0.11)))
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// --- Hero geometry -----------------------------------------------------------
|
|
202
|
+
/** Default plot height (px) — tall enough to read a set's shape across a room. */
|
|
203
|
+
export const SET_BAR_DEFAULT_HEIGHT = 220
|
|
204
|
+
/** Value-label row height (px) — the only headroom reserved above the bars when labels show. */
|
|
205
|
+
const LABEL_ROW_HEIGHT = 16
|
|
206
|
+
/** Gap between the value-label row and the peak bar top (px). */
|
|
207
|
+
const LABEL_GAP = 3
|
|
208
|
+
/** Inter-bar gap as a fraction of bar width — the locked dense default (near expanded density). */
|
|
209
|
+
export const GAP_RATIO = 0.08
|
|
210
|
+
/** Floor on the proportional inter-bar gap (px) — narrow plots tighten to ~expanded density. */
|
|
211
|
+
const MIN_BAR_GAP = 2
|
|
212
|
+
/**
|
|
213
|
+
* The EXTRA margin at a chunk boundary (drop sub-load / myo cluster / cluster intra-rest), as a
|
|
214
|
+
* fraction of bar width — proportional so it scales with the dense spacing and reads as a clear
|
|
215
|
+
* cluster break (total boundary gap ≈ this + the normal gapRatio ≈ ⅓ of a bar). Floored so small
|
|
216
|
+
* charts still separate clusters.
|
|
217
|
+
*/
|
|
218
|
+
const CHUNK_NOTCH_RATIO = 0.25
|
|
219
|
+
/** Floor on the chunk-boundary extra margin (px). */
|
|
220
|
+
const CHUNK_NOTCH_MIN_PX = 10
|
|
221
|
+
/** Cap on a single bar's width so a 2–3 rep set doesn't render slab-wide bars (px). */
|
|
222
|
+
export const BAR_MAX_WIDTH = 120
|
|
223
|
+
/** Below this per-bar width the value labels collide, so all but the peak + live rep are dropped. */
|
|
224
|
+
const LABEL_MIN_BAR_WIDTH = 30
|
|
225
|
+
/** Default top-corner radius on bars (px). */
|
|
226
|
+
const DEFAULT_BAR_RADIUS = 5
|
|
227
|
+
/** Minimum drawn height of a performed bar (px) — a near-zero rep still reads as a rep. */
|
|
228
|
+
const MIN_BAR_HEIGHT = 4
|
|
229
|
+
/** In `flat` (compact) mode every rep bar is this fraction of the plot height — a uniform short bar. */
|
|
230
|
+
const FLAT_BAR_FRACTION = 0.5
|
|
231
|
+
/** The stub height for a window cell (todo / variable / continue / empty) — a short, clear placeholder. */
|
|
232
|
+
const STUB_HEIGHT = 9
|
|
233
|
+
/** The ordinary rep gap the per-slot `leadingGap` is measured against (extra margin = leadingGap − this). */
|
|
234
|
+
const REP_GAP = 2
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* The shared adaptive bar layout — solve `N·bw + (N−1)·(gapRatio·bw) = plotWidth` for the per-bar
|
|
238
|
+
* width (capped at {@link BAR_MAX_WIDTH}) and the proportional inter-bar gap (floored at MIN_BAR_GAP).
|
|
239
|
+
* SetBarChart uses this for its own bars; the diverging compact/rail renderers call it too, so their
|
|
240
|
+
* columns land at the SAME x-positions and widths as the single chart — one shared geometry.
|
|
241
|
+
*/
|
|
242
|
+
export function computeBarLayout(
|
|
243
|
+
plotWidth: number,
|
|
244
|
+
count: number,
|
|
245
|
+
gapRatio: number = GAP_RATIO
|
|
246
|
+
): { barWidth: number; gap: number } {
|
|
247
|
+
const rawBarWidth =
|
|
248
|
+
plotWidth > 0 && count > 0 ? plotWidth / (count + (count - 1) * gapRatio) : BAR_MAX_WIDTH
|
|
249
|
+
const barWidth = Math.min(BAR_MAX_WIDTH, rawBarWidth)
|
|
250
|
+
const gap = plotWidth === 0 ? MIN_BAR_GAP : Math.max(MIN_BAR_GAP, gapRatio * barWidth)
|
|
251
|
+
return { barWidth, gap }
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Is this cell a performed rep (carries a value)? */
|
|
255
|
+
function isRep(slot: SetSlot): boolean {
|
|
256
|
+
return slot.kind === 'rep'
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The performed-rep index for each cell (`-1` for a window stub) — reps are contiguous from 0 in
|
|
261
|
+
* every set type, so this maps `liveRepIndex` / peak / label-thinning (rep-indexed) onto cells.
|
|
262
|
+
*/
|
|
263
|
+
function repIndexByCell(cells: SetSlot[]): number[] {
|
|
264
|
+
let count = 0
|
|
265
|
+
return cells.map((slot) => (isRep(slot) ? count++ : -1))
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function SetBarChart({
|
|
269
|
+
slots,
|
|
270
|
+
colorFor,
|
|
271
|
+
height,
|
|
272
|
+
scale = 'peak',
|
|
273
|
+
scaleMax,
|
|
274
|
+
orientation = 'up',
|
|
275
|
+
liveRepIndex,
|
|
276
|
+
isNewPeak = false,
|
|
277
|
+
targetReps,
|
|
278
|
+
gapRatio = GAP_RATIO,
|
|
279
|
+
barRadius = DEFAULT_BAR_RADIUS,
|
|
280
|
+
cornerStyle = 'top',
|
|
281
|
+
flat = false,
|
|
282
|
+
showValueLabels: showValueLabelsProp = false,
|
|
283
|
+
formatValue = String,
|
|
284
|
+
todoVariant = 'solid',
|
|
285
|
+
minColumns,
|
|
286
|
+
renderReference,
|
|
287
|
+
renderBarOverlay,
|
|
288
|
+
expandProgress,
|
|
289
|
+
hideBaseline = false,
|
|
290
|
+
testID,
|
|
291
|
+
testIDPrefix = 'setbar',
|
|
292
|
+
accessibilityLabel,
|
|
293
|
+
label,
|
|
294
|
+
className,
|
|
295
|
+
viewProps = {},
|
|
296
|
+
}: SetBarChartProps) {
|
|
297
|
+
// Append todo placeholders up to `targetReps` (a caller that builds its own todo slots omits it),
|
|
298
|
+
// then pad to `minColumns` so a diverging wing aligns column-for-column with its sibling.
|
|
299
|
+
const targetPadded = Math.max(slots.length, targetReps ?? 0, minColumns ?? 0)
|
|
300
|
+
const cells: SetSlot[] =
|
|
301
|
+
targetPadded > slots.length
|
|
302
|
+
? [
|
|
303
|
+
...slots,
|
|
304
|
+
...Array.from({ length: targetPadded - slots.length }, () => ({ kind: 'todo' as const })),
|
|
305
|
+
]
|
|
306
|
+
: slots
|
|
307
|
+
|
|
308
|
+
const repValues = cells.filter(isRep).map((s) => s.value ?? 0)
|
|
309
|
+
const best = repValues.length > 0 ? Math.max(...repValues) : 0
|
|
310
|
+
|
|
311
|
+
// The whole plot mirrors for `down`; text nodes counter-flip so they read upright.
|
|
312
|
+
const flip = orientation === 'down'
|
|
313
|
+
const flipStyle = flip ? ({ transform: [{ scaleY: -1 as number }] } as const) : null
|
|
314
|
+
|
|
315
|
+
// Planned/to-do reps + the baseline draw in a SURFACE-relative neutral (on-surface tertiary)
|
|
316
|
+
// so they stay legible on every plane instead of a fixed charcoal.
|
|
317
|
+
const placeholderColor = useOnSurfaceColor('tertiary')
|
|
318
|
+
// The expanded strip's solid to-do tone: the surface plane blended toward the neutral (the same
|
|
319
|
+
// relative model), so a `todoVariant="solid"` section holds ~constant contrast on every plane.
|
|
320
|
+
const surface = useSurface()
|
|
321
|
+
const surfaceBg = surfaceBackground(surface.level, surface.mode)
|
|
322
|
+
const solidTodoColor = mixHex(surfaceBg, placeholderColor, 0.55)
|
|
323
|
+
// An `empty` cell (a rep the diverging side didn't log) is fainter than a planned to-do.
|
|
324
|
+
const emptyColor = mixHex(surfaceBg, placeholderColor, 0.28)
|
|
325
|
+
// `all` rounds every corner (the thin flat-bar pill); `top` keeps the bolder hero top-round.
|
|
326
|
+
const barCorners: ViewStyle =
|
|
327
|
+
cornerStyle === 'all'
|
|
328
|
+
? { borderRadius: barRadius }
|
|
329
|
+
: { borderTopLeftRadius: barRadius, borderTopRightRadius: barRadius }
|
|
330
|
+
|
|
331
|
+
// `scaleMax` (the diverging dual's shared pair-max) overrides the height denominator so both
|
|
332
|
+
// sides share ONE height scale; color + the reference still read this chart's own values.
|
|
333
|
+
const scaleDenom =
|
|
334
|
+
scaleMax != null && scaleMax > 0 ? scaleMax * PEAK_HEADROOM : scaleDenominator(scale, best)
|
|
335
|
+
|
|
336
|
+
// `flat` (compact) mode suppresses value labels and renders every rep at one uniform short height.
|
|
337
|
+
const showValueLabels = showValueLabelsProp && !flat
|
|
338
|
+
// Reserve ONLY the value-label row (+ its gap to the peak bar) as headroom — not a fat fixed
|
|
339
|
+
// band — so the bars fill the plot and, at small heights, aren't over-stubbed.
|
|
340
|
+
const plotHeight = Math.max(0, height - (showValueLabels ? LABEL_ROW_HEIGHT + LABEL_GAP : 0))
|
|
341
|
+
const yOf = (value: number): number => (scaleDenom > 0 ? (value / scaleDenom) * plotHeight : 0)
|
|
342
|
+
// The `expandProgress` morph starts from a PARTIAL flat height (bars visibly grow to their value);
|
|
343
|
+
// a STATIC compact strip instead FILLS its plot — an 8px plot → an 8px bar, the SegmentedBar model —
|
|
344
|
+
// so a resting strip is sized by its container, not left half-empty.
|
|
345
|
+
const flatBarHeight = Math.max(MIN_BAR_HEIGHT, FLAT_BAR_FRACTION * plotHeight)
|
|
346
|
+
const compactBarHeight = Math.max(MIN_BAR_HEIGHT, plotHeight)
|
|
347
|
+
const valueBarHeight = (value: number): number =>
|
|
348
|
+
scaleDenom > 0
|
|
349
|
+
? Math.max(MIN_BAR_HEIGHT, Math.min(1, value / scaleDenom) * plotHeight)
|
|
350
|
+
: MIN_BAR_HEIGHT
|
|
351
|
+
const barHeight = (value: number): number => (flat ? compactBarHeight : valueBarHeight(value))
|
|
352
|
+
|
|
353
|
+
// Live-rep grow-from-bottom (rep index → cell index; reps are contiguous from 0 in every set type).
|
|
354
|
+
const liveValue =
|
|
355
|
+
liveRepIndex != null && liveRepIndex < repValues.length ? repValues[liveRepIndex] : undefined
|
|
356
|
+
const liveGrowth = useLiveRepGrowth(true, liveRepIndex, liveValue, isNewPeak)
|
|
357
|
+
|
|
358
|
+
// Measure the plot so per-bar value labels can thin on a narrow chart (they collide once bars
|
|
359
|
+
// get thin). Width 0 (unmeasured) → show all, so test/server renders are unchanged.
|
|
360
|
+
const [plotW, setPlotW] = useState(0)
|
|
361
|
+
const onPlotLayout = (e: LayoutChangeEvent) => setPlotW(e.nativeEvent.layout.width)
|
|
362
|
+
|
|
363
|
+
// Inter-bar gap is PROPORTIONAL to bar width (gap ≈ gapRatio·barWidth, floored at MIN_BAR_GAP).
|
|
364
|
+
// Solve N·bw + (N−1)·(gapRatio·bw) = plotW → bw = plotW / (N + (N−1)·gapRatio); the bars tighten to
|
|
365
|
+
// ~expanded density on narrow plots (the floor) and the ratio sets how dense they read at wall scale.
|
|
366
|
+
const totalCells = cells.length
|
|
367
|
+
const { barWidth, gap } = computeBarLayout(plotW, totalCells, gapRatio)
|
|
368
|
+
const labelsCrowded = plotW > 0 && barWidth < LABEL_MIN_BAR_WIDTH
|
|
369
|
+
|
|
370
|
+
// Peak rep index (over rep values) — its label always survives label-thinning.
|
|
371
|
+
const peakRepIndex = repValues.reduce((b, v, i) => (v > repValues[b] ? i : b), 0)
|
|
372
|
+
const showBarLabel = (repIndex: number): boolean =>
|
|
373
|
+
!labelsCrowded || repIndex === peakRepIndex || repIndex === liveRepIndex
|
|
374
|
+
|
|
375
|
+
const geometry: SetBarGeometry = { scaleDenom, plotHeight, yOf, best, flip }
|
|
376
|
+
|
|
377
|
+
const { style: externalStyle, ...restProps } = viewProps
|
|
378
|
+
const a11y =
|
|
379
|
+
accessibilityLabel != null ? { accessibilityRole: 'image' as const, accessibilityLabel } : {}
|
|
380
|
+
|
|
381
|
+
// Which performed rep each cell is (window stubs → -1) — for live-grow / peak / label thinning.
|
|
382
|
+
const repIndices = repIndexByCell(cells)
|
|
383
|
+
|
|
384
|
+
return (
|
|
385
|
+
<View
|
|
386
|
+
className={className}
|
|
387
|
+
style={[{ height, flexDirection: 'row' }, externalStyle]}
|
|
388
|
+
testID={testID}
|
|
389
|
+
{...a11y}
|
|
390
|
+
{...restProps}
|
|
391
|
+
>
|
|
392
|
+
{label ? (
|
|
393
|
+
<ChartSideRail
|
|
394
|
+
sections={[{ label, testID: `${testIDPrefix}-side-label` }]}
|
|
395
|
+
sectionExtent={height}
|
|
396
|
+
/>
|
|
397
|
+
) : null}
|
|
398
|
+
<View
|
|
399
|
+
onLayout={onPlotLayout}
|
|
400
|
+
style={{
|
|
401
|
+
flex: 1,
|
|
402
|
+
flexDirection: 'row',
|
|
403
|
+
alignItems: 'flex-end',
|
|
404
|
+
gap,
|
|
405
|
+
position: 'relative',
|
|
406
|
+
borderBottomWidth: hideBaseline ? 0 : 2,
|
|
407
|
+
borderBottomColor: placeholderColor,
|
|
408
|
+
...flipStyle,
|
|
409
|
+
}}
|
|
410
|
+
>
|
|
411
|
+
{renderReference?.(geometry)}
|
|
412
|
+
|
|
413
|
+
{cells.map((slot, i) => {
|
|
414
|
+
// A chunk boundary (leadingGap > the ordinary rep gap) gets extra margin PROPORTIONAL to
|
|
415
|
+
// bar width (floored) so the cluster break scales with the dense spacing and stays clear;
|
|
416
|
+
// ordinary cells add nothing beyond the container's flex gap.
|
|
417
|
+
const isChunkBoundary = (slot.leadingGap ?? REP_GAP) > REP_GAP
|
|
418
|
+
const extraGap = isChunkBoundary
|
|
419
|
+
? Math.max(CHUNK_NOTCH_MIN_PX, CHUNK_NOTCH_RATIO * barWidth)
|
|
420
|
+
: 0
|
|
421
|
+
const column: ViewStyle = {
|
|
422
|
+
flex: 1,
|
|
423
|
+
maxWidth: BAR_MAX_WIDTH,
|
|
424
|
+
height: '100%',
|
|
425
|
+
alignItems: 'center',
|
|
426
|
+
justifyContent: 'flex-end',
|
|
427
|
+
marginLeft: extraGap,
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (!isRep(slot)) {
|
|
431
|
+
return (
|
|
432
|
+
<View key={i} accessibilityElementsHidden style={column}>
|
|
433
|
+
{renderStub(
|
|
434
|
+
slot.kind,
|
|
435
|
+
barCorners,
|
|
436
|
+
// Flat (compact) stubs fill the plot like the compact bars; else the fixed short stub.
|
|
437
|
+
flat ? compactBarHeight : STUB_HEIGHT,
|
|
438
|
+
placeholderColor,
|
|
439
|
+
testIDPrefix,
|
|
440
|
+
todoVariant,
|
|
441
|
+
solidTodoColor,
|
|
442
|
+
emptyColor
|
|
443
|
+
)}
|
|
444
|
+
</View>
|
|
445
|
+
)
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const repIndex = repIndices[i]
|
|
449
|
+
const value = slot.value ?? 0
|
|
450
|
+
const isLive = liveRepIndex === repIndex
|
|
451
|
+
const color = colorFor(value)
|
|
452
|
+
// Bar height: `expandProgress` morphs flat↔value in place; else the live-rep grow; else static.
|
|
453
|
+
const barHeightStyle = expandProgress
|
|
454
|
+
? expandProgress.interpolate({
|
|
455
|
+
inputRange: [0, 1],
|
|
456
|
+
outputRange: [flatBarHeight, valueBarHeight(value)],
|
|
457
|
+
})
|
|
458
|
+
: isLive
|
|
459
|
+
? liveGrowth.interpolate({ inputRange: [0, 1], outputRange: [0, barHeight(value)] })
|
|
460
|
+
: barHeight(value)
|
|
461
|
+
// The wrapper's `renderBarOverlay` owns labels + press when present (so SetBarChart doesn't
|
|
462
|
+
// duplicate them); its own label shows otherwise, fading in with `expandProgress`.
|
|
463
|
+
const ownLabel = showValueLabels && !renderBarOverlay && showBarLabel(repIndex)
|
|
464
|
+
const labelBottom = (expandProgress ? valueBarHeight(best) : barHeight(best)) + LABEL_GAP
|
|
465
|
+
return (
|
|
466
|
+
<View key={i} accessibilityElementsHidden={renderBarOverlay == null} style={column}>
|
|
467
|
+
{ownLabel && (
|
|
468
|
+
// All labels pin to one aligned row JUST ABOVE THE PEAK BAR (not the container top),
|
|
469
|
+
// in the muted on-surface-secondary tone — so the row hugs the bars, no floating void.
|
|
470
|
+
<Animated.View
|
|
471
|
+
style={{
|
|
472
|
+
position: 'absolute',
|
|
473
|
+
bottom: labelBottom,
|
|
474
|
+
left: 0,
|
|
475
|
+
right: 0,
|
|
476
|
+
alignItems: 'center',
|
|
477
|
+
...(expandProgress ? { opacity: expandProgress } : null),
|
|
478
|
+
}}
|
|
479
|
+
pointerEvents="none"
|
|
480
|
+
>
|
|
481
|
+
<Text
|
|
482
|
+
className="text-text-secondary"
|
|
483
|
+
style={[{ fontSize: valueLabelFontSize(height), fontWeight: '700' }, flipStyle]}
|
|
484
|
+
testID={`${testIDPrefix}-label-${repIndex}`}
|
|
485
|
+
>
|
|
486
|
+
{formatValue(value)}
|
|
487
|
+
</Text>
|
|
488
|
+
</Animated.View>
|
|
489
|
+
)}
|
|
490
|
+
<Animated.View
|
|
491
|
+
style={[
|
|
492
|
+
{ width: '100%', height: barHeightStyle, backgroundColor: color },
|
|
493
|
+
barCorners,
|
|
494
|
+
barPaper(color, flip),
|
|
495
|
+
]}
|
|
496
|
+
testID={`${testIDPrefix}-bar-${repIndex}`}
|
|
497
|
+
/>
|
|
498
|
+
{renderBarOverlay?.(repIndex, value)}
|
|
499
|
+
</View>
|
|
500
|
+
)
|
|
501
|
+
})}
|
|
502
|
+
</View>
|
|
503
|
+
</View>
|
|
504
|
+
)
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** A window cell (planned / variable / continue / empty) — a fixed-height stub in its set-type tone. */
|
|
508
|
+
function renderStub(
|
|
509
|
+
kind: SetSlot['kind'],
|
|
510
|
+
barCorners: ViewStyle,
|
|
511
|
+
stubHeight: number,
|
|
512
|
+
placeholderColor: string,
|
|
513
|
+
testIDPrefix: string,
|
|
514
|
+
todoVariant: 'dashed' | 'solid',
|
|
515
|
+
solidTodoColor: string,
|
|
516
|
+
emptyColor: string
|
|
517
|
+
): ReactNode {
|
|
518
|
+
const base: ViewStyle = {
|
|
519
|
+
width: '100%',
|
|
520
|
+
height: stubHeight,
|
|
521
|
+
...barCorners,
|
|
522
|
+
}
|
|
523
|
+
if (kind === 'variable') {
|
|
524
|
+
return (
|
|
525
|
+
<View
|
|
526
|
+
style={{ ...base, backgroundColor: VARIABLE_FILL }}
|
|
527
|
+
testID={`${testIDPrefix}-slot-variable`}
|
|
528
|
+
/>
|
|
529
|
+
)
|
|
530
|
+
}
|
|
531
|
+
if (kind === 'continue') {
|
|
532
|
+
return (
|
|
533
|
+
<View
|
|
534
|
+
style={{ ...base, borderWidth: 1, borderColor: CONTINUE_OUTLINE }}
|
|
535
|
+
testID={`${testIDPrefix}-slot-continue`}
|
|
536
|
+
/>
|
|
537
|
+
)
|
|
538
|
+
}
|
|
539
|
+
if (kind === 'empty') {
|
|
540
|
+
// A rep column the diverging side did NOT log — a faint constant-contrast section, quieter than
|
|
541
|
+
// a planned to-do (it's a hole in this side's data, index-locked to the other side's rep).
|
|
542
|
+
return (
|
|
543
|
+
<View
|
|
544
|
+
style={{ ...base, backgroundColor: emptyColor }}
|
|
545
|
+
testID={`${testIDPrefix}-slot-empty`}
|
|
546
|
+
/>
|
|
547
|
+
)
|
|
548
|
+
}
|
|
549
|
+
// todo — a solid surface-relative section (expanded language) or a dashed outline (hero language).
|
|
550
|
+
const todoStyle: ViewStyle =
|
|
551
|
+
todoVariant === 'solid'
|
|
552
|
+
? { ...base, backgroundColor: solidTodoColor }
|
|
553
|
+
: { ...base, borderWidth: 1, borderStyle: 'dashed', borderColor: placeholderColor }
|
|
554
|
+
return <View style={todoStyle} testID={`${testIDPrefix}-slot-todo`} />
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** Side/stream-name label font size (px), scaled to chart height so a long name fits un-truncated. */
|
|
558
|
+
export function sideLabelFontSize(height: number): number {
|
|
559
|
+
return Math.round(Math.max(7, Math.min(13, height * 0.06)))
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Below this vertical extent (px) the side label collapses to initials rather than shrink further.
|
|
564
|
+
* A 120px dual (60px per wing) sits below this, so its wings degrade to "L" / "R"; the compact `rail`
|
|
565
|
+
* is exempted at its call site (it keeps its own tuned full-name treatment).
|
|
566
|
+
*/
|
|
567
|
+
export const SIDE_LABEL_INITIALS_BELOW = 70
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* The side-label text for the available vertical `extent`: the full name when there's room, else it
|
|
571
|
+
* degrades to INITIALS — the first letter of each word ("Left Arm" → "L A", "Left" → "L", "Right" →
|
|
572
|
+
* "R") — so a slot name stays legible at tiny chart heights instead of shrinking into a smear.
|
|
573
|
+
*/
|
|
574
|
+
export function sideLabelText(label: string, extent: number): string {
|
|
575
|
+
if (extent >= SIDE_LABEL_INITIALS_BELOW) return label
|
|
576
|
+
return label
|
|
577
|
+
.trim()
|
|
578
|
+
.split(/\s+/)
|
|
579
|
+
.map((word) => word.charAt(0))
|
|
580
|
+
.join(' ')
|
|
581
|
+
.toUpperCase()
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/** One section of the shared {@link ChartSideRail} — a wing's rotated stream/slot-name label. */
|
|
585
|
+
export interface SideRailSection {
|
|
586
|
+
label?: string
|
|
587
|
+
testID: string
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* The ONE shared gutter/side-rail lockup used by BOTH the single hero and the diverging dual, so a
|
|
592
|
+
* single chart is pixel-identical to the dual's upper half — going dual only adds a lower section.
|
|
593
|
+
* A fixed-width gutter (34px hero / 18px rail) with the `border-border` right hairline (the vertical
|
|
594
|
+
* axis) and one rotated, height-responsive, initials-collapsing label per section (each `sectionExtent`
|
|
595
|
+
* tall). The single passes ONE section; the dual passes two (up + down wings). Rendered null when no
|
|
596
|
+
* section carries a label. The rotate lives on a wrapper sized to `sectionExtent` so `numberOfLines`
|
|
597
|
+
* resolves against the tall vertical extent — a long name only ellipsizes when it exceeds the section.
|
|
598
|
+
*/
|
|
599
|
+
export function ChartSideRail({
|
|
600
|
+
sections,
|
|
601
|
+
sectionExtent,
|
|
602
|
+
variant = 'hero',
|
|
603
|
+
}: {
|
|
604
|
+
sections: SideRailSection[]
|
|
605
|
+
sectionExtent: number
|
|
606
|
+
variant?: 'hero' | 'rail'
|
|
607
|
+
}) {
|
|
608
|
+
if (!sections.some((s) => s.label)) return null
|
|
609
|
+
const isRail = variant === 'rail'
|
|
610
|
+
const labelLength = Math.max(0, sectionExtent - (isRail ? 0 : 6))
|
|
611
|
+
// Font scales to the SECTION (wing) height so a slot name fits without truncating; the compact rail
|
|
612
|
+
// keeps its own tuned 7px full-name treatment (hero + single collapse to initials at small heights).
|
|
613
|
+
const fontSize = isRail ? 7 : sideLabelFontSize(sectionExtent)
|
|
614
|
+
const textStyle = {
|
|
615
|
+
maxWidth: '100%' as const,
|
|
616
|
+
textAlign: 'center' as const,
|
|
617
|
+
fontSize,
|
|
618
|
+
fontWeight: '700' as const,
|
|
619
|
+
letterSpacing: isRail || fontSize < 10 ? 0 : 2,
|
|
620
|
+
textTransform: 'uppercase' as const,
|
|
621
|
+
}
|
|
622
|
+
return (
|
|
623
|
+
<View style={{ width: isRail ? 18 : 34 }} accessibilityElementsHidden>
|
|
624
|
+
{sections.map((s) => (
|
|
625
|
+
<View
|
|
626
|
+
key={s.testID}
|
|
627
|
+
style={{ height: sectionExtent, alignItems: 'center', justifyContent: 'center' }}
|
|
628
|
+
>
|
|
629
|
+
{s.label ? (
|
|
630
|
+
<View
|
|
631
|
+
style={{
|
|
632
|
+
width: labelLength,
|
|
633
|
+
alignItems: 'center',
|
|
634
|
+
transform: [{ rotate: '-90deg' }],
|
|
635
|
+
}}
|
|
636
|
+
>
|
|
637
|
+
<Text
|
|
638
|
+
className="text-text-tertiary"
|
|
639
|
+
style={textStyle}
|
|
640
|
+
numberOfLines={1}
|
|
641
|
+
testID={s.testID}
|
|
642
|
+
>
|
|
643
|
+
{isRail ? s.label : sideLabelText(s.label, sectionExtent)}
|
|
644
|
+
</Text>
|
|
645
|
+
</View>
|
|
646
|
+
) : null}
|
|
647
|
+
</View>
|
|
648
|
+
))}
|
|
649
|
+
</View>
|
|
650
|
+
)
|
|
651
|
+
}
|