@titan-design/react-ui 0.10.0 → 0.12.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 +173 -343
- package/dist/bodymap.js.map +1 -1
- package/dist/bodymap.mjs +173 -343
- package/dist/bodymap.mjs.map +1 -1
- package/dist/index.d.mts +416 -18
- package/dist/index.d.ts +416 -18
- package/dist/index.js +2512 -1307
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2480 -1299
- 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 +1644 -1575
- package/dist/pages.js.map +1 -1
- package/dist/pages.mjs +1645 -1576
- package/dist/pages.mjs.map +1 -1
- package/dist/theme/index.d.mts +409 -415
- package/dist/theme/index.d.ts +409 -415
- package/dist/theme/index.js +234 -408
- package/dist/theme/index.js.map +1 -1
- package/dist/theme/index.mjs +227 -399
- package/dist/theme/index.mjs.map +1 -1
- package/dist/theme/tokens-css.js +141 -140
- package/dist/theme/tokens-css.js.map +1 -1
- package/dist/theme/tokens-css.mjs +141 -140
- package/dist/theme/tokens-css.mjs.map +1 -1
- package/package.json +8 -3
- package/src/arch/Architecture.stories.tsx +9 -9
- package/src/components/custom/DateTime/DateTime.stories.tsx +1 -1
- 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/Sidebar/Sidebar.tsx +4 -4
- package/src/components/custom/Table/Table.tsx +2 -2
- package/src/components/custom/Workout/ActiveWorkoutPage.tsx +3 -3
- package/src/components/custom/Workout/BaseBadge.test.tsx +6 -1
- package/src/components/custom/Workout/BaseBadge.tsx +1 -1
- package/src/components/custom/Workout/BodyMap.tsx +2 -2
- package/src/components/custom/Workout/BodyMapDetailPanel.tsx +2 -2
- package/src/components/custom/Workout/DeviationBar.test.tsx +2 -1
- package/src/components/custom/Workout/DeviationBar.tsx +3 -2
- package/src/components/custom/Workout/DualVelocityStrip.test.tsx +685 -0
- package/src/components/custom/Workout/ExerciseCard.test.tsx +7 -7
- package/src/components/custom/Workout/ExerciseCard.tsx +5 -3
- package/src/components/custom/Workout/ExerciseDetailPage.tsx +11 -5
- package/src/components/custom/Workout/InputBar.tsx +2 -2
- package/src/components/custom/Workout/LiveAuraFrame.stories.tsx +1 -1
- package/src/components/custom/Workout/LiveAuraFrame.test.tsx +1 -1
- package/src/components/custom/Workout/LiveAuraFrame.tsx +3 -3
- package/src/components/custom/Workout/MesoCard.tsx +3 -1
- package/src/components/custom/Workout/MuscleGroupChip.tsx +4 -3
- package/src/components/custom/Workout/PrHistoryModal.tsx +2 -2
- package/src/components/custom/Workout/ProgramPlanningPage.tsx +1 -1
- package/src/components/custom/Workout/README.md +62 -16
- package/src/components/custom/Workout/ReadinessCheck.tsx +2 -2
- package/src/components/custom/Workout/RestTimer.tsx +2 -2
- package/src/components/custom/Workout/S3SetTypes.stories.tsx +3 -3
- package/src/components/custom/Workout/SegmentedBar.stories.tsx +3 -3
- package/src/components/custom/Workout/SessionHeader.stories.tsx +2 -2
- package/src/components/custom/Workout/SessionHeader.tsx +1 -1
- package/src/components/custom/Workout/SessionRail.stories.tsx +2 -2
- package/src/components/custom/Workout/SessionRail.test.tsx +43 -0
- package/src/components/custom/Workout/SessionRail.tsx +63 -18
- package/src/components/custom/Workout/SetBar.tsx +3 -3
- package/src/components/custom/Workout/SetRow.test.tsx +12 -9
- package/src/components/custom/Workout/SetRow.tsx +9 -2
- package/src/components/custom/Workout/StatusDot.tsx +5 -4
- package/src/components/custom/Workout/StrengthTrendChart.tsx +1 -1
- package/src/components/custom/Workout/TrainingStatusPage.tsx +2 -2
- 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 +199 -79
- package/src/components/custom/Workout/VelocityStrip.tsx +953 -609
- package/src/components/custom/Workout/VolumeLandmarkBar.tsx +3 -3
- package/src/components/custom/Workout/WeightBadge.test.tsx +6 -1
- package/src/components/custom/Workout/WeightBadge.tsx +2 -1
- package/src/components/custom/Workout/WorkoutCard.tsx +3 -1
- package/src/components/custom/Workout/WorkoutPill.tsx +5 -2
- package/src/components/custom/Workout/ZoneTrack.tsx +4 -4
- package/src/components/custom/Workout/index.ts +3 -0
- package/src/components/custom/Workout/setHeadingKit.tsx +14 -8
- 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/custom/stepper/Stepper.stories.tsx +1 -1
- package/src/components/custom/stepper/Stepper.tsx +3 -3
- package/src/components/shell/DeviceMenu.tsx +2 -2
- package/src/components/shell/SideNav.tsx +1 -1
- package/src/components/shell/TopBar.tsx +1 -1
- package/src/components/ui/autocomplete/Autocomplete.stories.tsx +1 -1
- package/src/components/ui/autocomplete/Autocomplete.tsx +1 -1
- package/src/components/ui/avatar/Avatar.tsx +2 -2
- package/src/components/ui/badge/Badge.tsx +2 -2
- package/src/components/ui/card/Card.tsx +3 -3
- package/src/components/ui/checkbox/Checkbox.tsx +1 -1
- package/src/components/ui/collapse/Collapse.tsx +1 -1
- package/src/components/ui/data-row/DataRow.stories.tsx +1 -1
- package/src/components/ui/drawer/Drawer.tsx +3 -3
- package/src/components/ui/form-field/FormField.tsx +1 -1
- package/src/components/ui/help-tip/HelpTip.tsx +1 -1
- package/src/components/ui/menu/Menu.tsx +2 -2
- package/src/components/ui/pill/Pill.tsx +2 -2
- package/src/components/ui/popover/Popover.stories.tsx +2 -2
- package/src/components/ui/popover/Popover.tsx +1 -1
- package/src/components/ui/progress/Progress.tsx +3 -3
- package/src/components/ui/radio/Radio.stories.tsx +1 -1
- package/src/components/ui/radio/Radio.tsx +4 -4
- package/src/components/ui/select/Select.tsx +3 -3
- package/src/components/ui/surface/Surface.stories.tsx +3 -3
- package/src/components/ui/surface/Surface.test.tsx +32 -19
- package/src/components/ui/surface/Surface.tsx +3 -3
- package/src/components/ui/surface/SurfaceContext.ts +19 -29
- package/src/components/ui/surface/surface.contract.test.ts +57 -44
- package/src/components/ui/switch/Switch.tsx +1 -1
- package/src/components/ui/tabs/Tabs.tsx +2 -2
- package/src/components/ui/toolbar-button/ToolbarButton.stories.tsx +5 -5
- package/src/components/ui/toolbar-button/ToolbarButton.test.tsx +1 -1
- package/src/components/ui/toolbar-button/ToolbarButton.tsx +36 -22
- package/src/stories/PresetShowcase.stories.tsx +21 -21
- package/src/test/raw-color-patterns.test.ts +71 -0
- package/src/theme/ColorPalettes.stories.tsx +874 -0
- package/src/theme/ColorPrimitives.stories.tsx +348 -0
- package/src/theme/Depth.stories.tsx +312 -0
- package/src/theme/DepthCalibration.stories.tsx +655 -0
- package/src/theme/ThemeProvider.tsx +1 -1
- package/src/theme/barrel.ts +7 -14
- package/src/theme/color-stories.coverage.test.ts +125 -0
- package/src/theme/color-story-kit.tsx +170 -0
- package/src/theme/color-utils.ts +197 -0
- package/src/theme/config.ts +0 -8
- package/src/theme/depth-calibration.test.tsx +97 -0
- package/src/theme/elevation.test.ts +62 -1
- package/src/theme/elevation.ts +34 -8
- package/src/theme/global.css +47 -53
- package/src/theme/grey-ramp.test.ts +166 -0
- package/src/theme/materials.test.ts +106 -0
- package/src/theme/materials.ts +211 -0
- package/src/theme/presets/audiobook.ts +4 -4
- package/src/theme/presets/types.ts +3 -3
- package/src/theme/semantic-contrast.test.ts +83 -0
- package/src/theme/tokens/primitives.ts +62 -115
- package/src/theme/tokens/semantic.ts +130 -82
- package/src/utils/colors.ts +1 -1
- package/tailwind.config.js +2 -5
- 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 -508
- package/src/theme/Colors.stories.tsx +0 -323
- package/src/theme/shadows.stories.tsx +0 -523
- package/src/theme/shadows.ts +0 -439
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { render, screen } from '@testing-library/react'
|
|
3
|
+
import { FatigueLights } from './FatigueLights'
|
|
4
|
+
import type { FatigueVerdict } from './fatigue-model'
|
|
5
|
+
|
|
6
|
+
const dims: FatigueVerdict['dimensions'] = { velocityLoss: 'alarm', rom: 'warn', tempo: 'ok' }
|
|
7
|
+
|
|
8
|
+
describe('FatigueLights', () => {
|
|
9
|
+
it('renders the three VEL/ROM/TEMPO labels', () => {
|
|
10
|
+
render(<FatigueLights dimensions={dims} />)
|
|
11
|
+
expect(screen.getByText('VEL')).toBeInTheDocument()
|
|
12
|
+
expect(screen.getByText('ROM')).toBeInTheDocument()
|
|
13
|
+
expect(screen.getByText('TEMPO')).toBeInTheDocument()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('composes three StatusDot primitives', () => {
|
|
17
|
+
render(<FatigueLights dimensions={dims} />)
|
|
18
|
+
expect(screen.getAllByTestId('status-dot')).toHaveLength(3)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('labels each dimension with its detail + status word', () => {
|
|
22
|
+
render(<FatigueLights dimensions={dims} />)
|
|
23
|
+
expect(screen.getByLabelText('Velocity loss, alarm')).toBeInTheDocument()
|
|
24
|
+
expect(screen.getByLabelText('ROM depth, watch')).toBeInTheDocument()
|
|
25
|
+
expect(screen.getByLabelText('Tempo, ok')).toBeInTheDocument()
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('renders neutral "warming up" lights when dimensions are null', () => {
|
|
29
|
+
render(<FatigueLights dimensions={null} />)
|
|
30
|
+
expect(screen.getByLabelText('Velocity loss, warming up')).toBeInTheDocument()
|
|
31
|
+
expect(screen.getAllByTestId('status-dot')).toHaveLength(3)
|
|
32
|
+
})
|
|
33
|
+
})
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
|
|
2
|
+
/**
|
|
3
|
+
* FatigueLights — the three "why" status dots (VEL · ROM · TEMPO) that stand behind
|
|
4
|
+
* the verdict word: the supporting reason the verdict reads the way it does. Each dot
|
|
5
|
+
* COMPOSES the shared {@link StatusDot} primitive (glow) wrapped in a {@link Tooltip}
|
|
6
|
+
* that reveals the dimension detail on hover.
|
|
7
|
+
*
|
|
8
|
+
* Warming up (`dimensions === null`) shows three neutral dots.
|
|
9
|
+
*/
|
|
10
|
+
import { View, Text } from 'react-native'
|
|
11
|
+
import { getSemanticColors } from '../../../theme/tokens/semantic'
|
|
12
|
+
import { StatusDot, type StatusDotVariant } from '../Workout/StatusDot'
|
|
13
|
+
import { Tooltip } from '../../ui/tooltip/Tooltip'
|
|
14
|
+
import { FONT_MONO } from './fatigue-tokens'
|
|
15
|
+
import type { DimensionTone, FatigueVerdict } from './fatigue-model'
|
|
16
|
+
|
|
17
|
+
const t = getSemanticColors('dark')
|
|
18
|
+
|
|
19
|
+
/** Tone → the StatusDot variant (ok=success, warn=warning, alarm=error). */
|
|
20
|
+
const TONE_VARIANT: Record<DimensionTone, StatusDotVariant> = {
|
|
21
|
+
ok: 'success',
|
|
22
|
+
warn: 'warning',
|
|
23
|
+
alarm: 'error',
|
|
24
|
+
}
|
|
25
|
+
const TONE_WORD: Record<DimensionTone, string> = { ok: 'ok', warn: 'watch', alarm: 'alarm' }
|
|
26
|
+
|
|
27
|
+
export interface FatigueLightsProps {
|
|
28
|
+
/** The three per-dimension tones. `null` = warming up (all neutral). */
|
|
29
|
+
dimensions: FatigueVerdict['dimensions'] | null
|
|
30
|
+
/** Distribute the three lights evenly across the full width (space-between). Default false (left-grouped). */
|
|
31
|
+
spread?: boolean
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function Light({
|
|
35
|
+
label,
|
|
36
|
+
tone,
|
|
37
|
+
detail,
|
|
38
|
+
}: {
|
|
39
|
+
label: string
|
|
40
|
+
tone: DimensionTone | null
|
|
41
|
+
detail: string
|
|
42
|
+
}) {
|
|
43
|
+
const variant: StatusDotVariant = tone ? TONE_VARIANT[tone] : 'neutral'
|
|
44
|
+
const word = tone ? TONE_WORD[tone] : 'warming up'
|
|
45
|
+
return (
|
|
46
|
+
<Tooltip label={`${detail} · ${word}`} placement="bottom">
|
|
47
|
+
<View
|
|
48
|
+
accessibilityLabel={`${detail}, ${word}`}
|
|
49
|
+
style={{ flexDirection: 'row', alignItems: 'center', gap: 5 }}
|
|
50
|
+
>
|
|
51
|
+
<StatusDot variant={variant} size="sm" glow />
|
|
52
|
+
<Text
|
|
53
|
+
style={{
|
|
54
|
+
fontSize: 9,
|
|
55
|
+
letterSpacing: 0.6,
|
|
56
|
+
fontFamily: FONT_MONO,
|
|
57
|
+
color: t['text-secondary'],
|
|
58
|
+
}}
|
|
59
|
+
>
|
|
60
|
+
{label}
|
|
61
|
+
</Text>
|
|
62
|
+
</View>
|
|
63
|
+
</Tooltip>
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function FatigueLights({ dimensions, spread = false }: FatigueLightsProps) {
|
|
68
|
+
return (
|
|
69
|
+
<View
|
|
70
|
+
testID="fatigue-lights"
|
|
71
|
+
style={{
|
|
72
|
+
flexDirection: 'row',
|
|
73
|
+
gap: spread ? 0 : 16,
|
|
74
|
+
alignItems: 'center',
|
|
75
|
+
justifyContent: spread ? 'space-between' : 'flex-start',
|
|
76
|
+
}}
|
|
77
|
+
>
|
|
78
|
+
<Light label="VEL" tone={dimensions?.velocityLoss ?? null} detail="Velocity loss" />
|
|
79
|
+
<Light label="ROM" tone={dimensions?.rom ?? null} detail="ROM depth" />
|
|
80
|
+
<Light label="TEMPO" tone={dimensions?.tempo ?? null} detail="Tempo" />
|
|
81
|
+
</View>
|
|
82
|
+
)
|
|
83
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { render } from '@testing-library/react'
|
|
3
|
+
import { GhostBand, BAND_H, type GhostBandProps } from './GhostBand'
|
|
4
|
+
import { PHASE_AXIS_COLOR, PHASE_AXIS_BASE_COLOR, PACING_TONE } from './fatigue-tokens'
|
|
5
|
+
import { getSemanticColors } from '../../../theme/tokens/semantic'
|
|
6
|
+
import type { PhaseSegment } from './fatigue-model'
|
|
7
|
+
|
|
8
|
+
const t = getSemanticColors('dark')
|
|
9
|
+
|
|
10
|
+
/** Phase runs with SEAMS between them — the shape the sample-derived model actually produces. */
|
|
11
|
+
const segments: PhaseSegment[] = [
|
|
12
|
+
{ phase: 'eccentric', startMs: 0, endMs: 1182 },
|
|
13
|
+
{ phase: 'idle', startMs: 1273, endMs: 1600 },
|
|
14
|
+
{ phase: 'concentric', startMs: 1691, endMs: 3545 },
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
const x = (ms: number) => 12 + (ms / 4000) * 300
|
|
18
|
+
|
|
19
|
+
const band = (segs: PhaseSegment[] = segments, props: Partial<GhostBandProps> = {}) =>
|
|
20
|
+
render(
|
|
21
|
+
<svg>
|
|
22
|
+
<GhostBand segments={segs} x={x} top={0} height={BAND_H} {...props} />
|
|
23
|
+
</svg>
|
|
24
|
+
).container
|
|
25
|
+
|
|
26
|
+
const geom = (r: Element) => ({
|
|
27
|
+
x: Number(r.getAttribute('x')),
|
|
28
|
+
width: Number(r.getAttribute('width')),
|
|
29
|
+
fill: r.getAttribute('fill'),
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
/** The strip floor — the one rect sitting directly in the clipped group. */
|
|
33
|
+
const floorOf = (c: HTMLElement) => geom(c.querySelector('g[clip-path] > rect')!)
|
|
34
|
+
/** Per-run BASE rects — these carry the run's true geometry (full width). */
|
|
35
|
+
const basesOf = (c: HTMLElement) =>
|
|
36
|
+
Array.from(c.querySelectorAll('[data-testid="ghost-band-base"]')).map(geom)
|
|
37
|
+
/** Per-run FILL rects — width is the share of the run earned against target. */
|
|
38
|
+
const fillsOf = (c: HTMLElement) =>
|
|
39
|
+
Array.from(c.querySelectorAll('[data-testid="ghost-band-fill"]')).map(geom)
|
|
40
|
+
const labelsOf = (c: HTMLElement) =>
|
|
41
|
+
Array.from(c.querySelectorAll('text')).map((n) => ({
|
|
42
|
+
text: n.textContent,
|
|
43
|
+
fill: n.getAttribute('fill'),
|
|
44
|
+
}))
|
|
45
|
+
|
|
46
|
+
/** A rep whose phases each run EXACTLY their prescribed duration. */
|
|
47
|
+
const onPace: PhaseSegment[] = [
|
|
48
|
+
{ phase: 'eccentric', startMs: 0, endMs: 2600 },
|
|
49
|
+
{ phase: 'hold', startMs: 2600, endMs: 3000 },
|
|
50
|
+
{ phase: 'concentric', startMs: 3000, endMs: 3950 },
|
|
51
|
+
]
|
|
52
|
+
const TEMPO: [number, number, number, number] = [2.6, 0.4, 0.95, 0.28]
|
|
53
|
+
|
|
54
|
+
describe('GhostBand', () => {
|
|
55
|
+
it('draws a contiguous strip — each run butts against the next with no gap', () => {
|
|
56
|
+
const runs = basesOf(band())
|
|
57
|
+
runs.forEach((r, i) => {
|
|
58
|
+
const next = runs[i + 1]
|
|
59
|
+
if (next) expect(r.x + r.width).toBeCloseTo(next.x, 5)
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('floors the whole rep with the idle tone so a pause reads as band, not a hole', () => {
|
|
64
|
+
const floor = floorOf(band())
|
|
65
|
+
expect(floor.fill).toBe(PHASE_AXIS_COLOR.idle)
|
|
66
|
+
expect(floor.x).toBeCloseTo(x(0), 5)
|
|
67
|
+
expect(floor.x + floor.width).toBeCloseTo(x(3545), 5)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('paints the pause run in the idle tone', () => {
|
|
71
|
+
expect(fillsOf(band()).map((r) => r.fill)).toEqual([
|
|
72
|
+
PHASE_AXIS_COLOR.eccentric,
|
|
73
|
+
PHASE_AXIS_COLOR.idle,
|
|
74
|
+
PHASE_AXIS_COLOR.concentric,
|
|
75
|
+
])
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('renders nothing when every run is zero-width', () => {
|
|
79
|
+
const c = band([{ phase: 'idle', startMs: 500, endMs: 500 }])
|
|
80
|
+
expect(c.querySelectorAll('rect')).toHaveLength(0)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('separates a hold from idle by VALUE, so a narrow hold still reads as a hold', () => {
|
|
84
|
+
const runs = fillsOf(
|
|
85
|
+
band([
|
|
86
|
+
{ phase: 'eccentric', startMs: 0, endMs: 1000 },
|
|
87
|
+
{ phase: 'hold', startMs: 1000, endMs: 1500 },
|
|
88
|
+
{ phase: 'concentric', startMs: 1500, endMs: 3000 },
|
|
89
|
+
])
|
|
90
|
+
)
|
|
91
|
+
expect(runs[1].fill).toBe(PHASE_AXIS_COLOR.hold)
|
|
92
|
+
expect(runs[1].fill).not.toBe(PHASE_AXIS_COLOR.idle)
|
|
93
|
+
// A filled hold sits BRIGHTER than idle, an unfilled one darker — never the same.
|
|
94
|
+
expect(PHASE_AXIS_COLOR.hold).not.toBe(PHASE_AXIS_BASE_COLOR.hold)
|
|
95
|
+
expect(PHASE_AXIS_BASE_COLOR.hold).not.toBe(PHASE_AXIS_COLOR.idle)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('labels ONLY the movement phases — hold and idle stay unnamed', () => {
|
|
99
|
+
// The pacing-tone contrast floor is measured against ECC/CON backgrounds only, so this
|
|
100
|
+
// exclusion is what keeps that narrower set honest. Widening it here needs the tones
|
|
101
|
+
// re-measured against whatever new background can sit behind a label.
|
|
102
|
+
const c = band(
|
|
103
|
+
[
|
|
104
|
+
{ phase: 'eccentric', startMs: 0, endMs: 1000 },
|
|
105
|
+
{ phase: 'hold', startMs: 1000, endMs: 1500 },
|
|
106
|
+
{ phase: 'concentric', startMs: 1500, endMs: 3000 },
|
|
107
|
+
{ phase: 'idle', startMs: 3000, endMs: 4000 },
|
|
108
|
+
],
|
|
109
|
+
{ showLabels: true }
|
|
110
|
+
)
|
|
111
|
+
expect(labelsOf(c).map((l) => l.text)).toEqual(['ECC', 'CON'])
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('DROPS a label that will not fit rather than clipping it', () => {
|
|
115
|
+
// A 300 ms concentric is ~22 px here — over the old flat 20 px floor, which rendered a
|
|
116
|
+
// word that ran past its own run and clipped.
|
|
117
|
+
const c = band(
|
|
118
|
+
[
|
|
119
|
+
{ phase: 'eccentric', startMs: 0, endMs: 1000 },
|
|
120
|
+
{ phase: 'concentric', startMs: 1000, endMs: 1300 },
|
|
121
|
+
],
|
|
122
|
+
{ showLabels: true }
|
|
123
|
+
)
|
|
124
|
+
expect(labelsOf(c).map((l) => l.text)).toEqual(['ECC'])
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('rounds the band once, as a clip — never per run', () => {
|
|
128
|
+
const c = band()
|
|
129
|
+
expect(c.querySelector('clipPath rect')?.getAttribute('rx')).toBe('2')
|
|
130
|
+
const runRects = Array.from(c.querySelectorAll('g[clip-path] > g > rect'))
|
|
131
|
+
expect(runRects.every((r) => r.getAttribute('rx') == null)).toBe(true)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
// --- pacing -----------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
it('fills every run completely when no tempo is prescribed', () => {
|
|
137
|
+
const bases = basesOf(band())
|
|
138
|
+
fillsOf(band()).forEach((f, i) => expect(f.width).toBeCloseTo(bases[i].width, 5))
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('leaves the geometry alone when pacing turns on — only the FILL changes', () => {
|
|
142
|
+
const flat = basesOf(band(onPace))
|
|
143
|
+
const paced = basesOf(band(onPace, { targetTempoSeconds: TEMPO }))
|
|
144
|
+
expect(paced).toEqual(flat)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('fills a run that hit its target to the brim', () => {
|
|
148
|
+
const c = band(onPace, { targetTempoSeconds: TEMPO })
|
|
149
|
+
const bases = basesOf(c)
|
|
150
|
+
fillsOf(c).forEach((f, i) => expect(f.width).toBeCloseTo(bases[i].width, 5))
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('leaves a FAST phase partly unfilled — the base shows through', () => {
|
|
154
|
+
// A 1.3 s eccentric against a 2.6 s target is exactly half paced.
|
|
155
|
+
const fast: PhaseSegment[] = [{ phase: 'eccentric', startMs: 0, endMs: 1300 }]
|
|
156
|
+
const c = band(fast, { targetTempoSeconds: TEMPO })
|
|
157
|
+
expect(fillsOf(c)[0].width).toBeCloseTo(basesOf(c)[0].width * 0.5, 5)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('CAPS a slow phase at its own run width rather than overflowing', () => {
|
|
161
|
+
const slow: PhaseSegment[] = [{ phase: 'eccentric', startMs: 0, endMs: 9000 }]
|
|
162
|
+
const c = band(slow, { targetTempoSeconds: TEMPO })
|
|
163
|
+
expect(fillsOf(c)[0].width).toBeCloseTo(basesOf(c)[0].width, 5)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('paints the muted base UNDER the fill so an unfilled remainder is still band', () => {
|
|
167
|
+
const fast: PhaseSegment[] = [{ phase: 'eccentric', startMs: 0, endMs: 1300 }]
|
|
168
|
+
const c = band(fast, { targetTempoSeconds: TEMPO })
|
|
169
|
+
expect(basesOf(c)[0].fill).toBe(PHASE_AXIS_BASE_COLOR.eccentric)
|
|
170
|
+
expect(fillsOf(c)[0].fill).toBe(PHASE_AXIS_COLOR.eccentric)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('tones each label by its OWN pacing, independently', () => {
|
|
174
|
+
const mixed: PhaseSegment[] = [
|
|
175
|
+
{ phase: 'eccentric', startMs: 0, endMs: 1000 }, // 1.0s vs 2.6s → ahead
|
|
176
|
+
{ phase: 'concentric', startMs: 1000, endMs: 4000 }, // 3.0s vs 0.95s → over
|
|
177
|
+
]
|
|
178
|
+
const c = band(mixed, { targetTempoSeconds: TEMPO, showLabels: true })
|
|
179
|
+
expect(labelsOf(c)).toEqual([
|
|
180
|
+
{ text: 'ECC', fill: PACING_TONE.ahead },
|
|
181
|
+
{ text: 'CON', fill: PACING_TONE.over },
|
|
182
|
+
])
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('tones a label ON PACE when the phase hits its target', () => {
|
|
186
|
+
const c = band(onPace, { targetTempoSeconds: TEMPO, showLabels: true })
|
|
187
|
+
expect(labelsOf(c).map((l) => l.fill)).toEqual([PACING_TONE.onPace, PACING_TONE.onPace])
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('keeps labels plain when nothing is prescribed to pace against', () => {
|
|
191
|
+
const c = band(onPace, { showLabels: true })
|
|
192
|
+
expect(labelsOf(c).every((l) => l.fill === t['text-primary'])).toBe(true)
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
// --- prescribed (nothing performed yet) --------------------------------------
|
|
196
|
+
|
|
197
|
+
it('paints a PRESCRIBED rep entirely unfilled — nothing has been performed', () => {
|
|
198
|
+
const c = band(onPace, { targetTempoSeconds: TEMPO, prescribed: true })
|
|
199
|
+
fillsOf(c).forEach((f) => expect(f.width).toBe(0))
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('still draws the prescribed runs at full width, so the shape reads', () => {
|
|
203
|
+
const plain = basesOf(band(onPace, { targetTempoSeconds: TEMPO }))
|
|
204
|
+
const pre = basesOf(band(onPace, { targetTempoSeconds: TEMPO, prescribed: true }))
|
|
205
|
+
expect(pre).toEqual(plain)
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('keeps prescribed labels PLAIN — an unstarted rep is neither ahead nor behind', () => {
|
|
209
|
+
const c = band(onPace, { targetTempoSeconds: TEMPO, prescribed: true, showLabels: true })
|
|
210
|
+
expect(labelsOf(c).every((l) => l.fill === t['text-primary'])).toBe(true)
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
// --- the well ---------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
it('recesses every run, so an unearned remainder reads as empty channel', () => {
|
|
216
|
+
const c = band(onPace, { targetTempoSeconds: TEMPO })
|
|
217
|
+
expect(c.querySelectorAll('[data-testid="ghost-band-well"]')).toHaveLength(onPace.length)
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('sits the well UNDER the fill — a filled run hides its own recess', () => {
|
|
221
|
+
const c = band(onPace, { targetTempoSeconds: TEMPO })
|
|
222
|
+
const order = Array.from(c.querySelectorAll('g[clip-path] > g > rect')).map((r) =>
|
|
223
|
+
r.getAttribute('data-testid')
|
|
224
|
+
)
|
|
225
|
+
expect(order.slice(0, 3)).toEqual(['ghost-band-base', 'ghost-band-well', 'ghost-band-fill'])
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
it('spans the well across the whole run, not just the unfilled part', () => {
|
|
229
|
+
// Recessing only the exposed remainder would step in tone at the fill's edge.
|
|
230
|
+
const fast: PhaseSegment[] = [{ phase: 'eccentric', startMs: 0, endMs: 1300 }]
|
|
231
|
+
const c = band(fast, { targetTempoSeconds: TEMPO })
|
|
232
|
+
const well = geom(c.querySelector('[data-testid="ghost-band-well"]')!)
|
|
233
|
+
expect(well.width).toBeCloseTo(basesOf(c)[0].width, 5)
|
|
234
|
+
})
|
|
235
|
+
})
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
// Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
|
|
2
|
+
/**
|
|
3
|
+
* GhostBand — the wide phase-coloured AXIS BAND that carries movement phase for the
|
|
4
|
+
* ghost family (eccentric magenta / concentric cyan / hold and idle grey), with the
|
|
5
|
+
* ECC / HOLD / CON labels rendered INSIDE it. Extracted from GhostSpark so the single
|
|
6
|
+
* sparkline and a future top/bottom dual compose the SAME band instead of re-rolling it.
|
|
7
|
+
*
|
|
8
|
+
* It is a pure SVG group: the caller owns the x-scale (`x`) and the band's vertical
|
|
9
|
+
* placement (`top`), so the same band serves a bottom-pinned single or a centred dual.
|
|
10
|
+
*
|
|
11
|
+
* ## Pacing
|
|
12
|
+
*
|
|
13
|
+
* Given a target tempo, each run paints a MUTED base with a brighter fill growing
|
|
14
|
+
* left-to-right across `elapsed / target` of its own width (capped at full), and its label
|
|
15
|
+
* takes the pacing tone — ahead / on pace / over. The band's GEOMETRY is untouched by this:
|
|
16
|
+
* runs keep their actual-time positions and widths so the internal boundaries still land
|
|
17
|
+
* under the sparkline's phase transitions. Pacing is a fill INSIDE the existing bars, never
|
|
18
|
+
* a re-layout of them.
|
|
19
|
+
*
|
|
20
|
+
* The two encodings read together: a slow phase is a WIDE run filled to the brim with an
|
|
21
|
+
* error-toned label; a fast phase is a NARROW run only partly filled, warning-toned.
|
|
22
|
+
*
|
|
23
|
+
* Every run is recessed into a WELL, so the part not yet earned reads as empty channel
|
|
24
|
+
* rather than as a darker shade of the phase. The well needs no flag: where nothing is
|
|
25
|
+
* prescribed the fill covers the whole run and the recess is hidden behind it.
|
|
26
|
+
*/
|
|
27
|
+
import { useId } from 'react'
|
|
28
|
+
import { getSemanticColors } from '../../../theme/tokens/semantic'
|
|
29
|
+
import { primitiveColors } from '../../../theme/tokens/primitives'
|
|
30
|
+
import { FONT_UI, PHASE_AXIS_COLOR, PHASE_AXIS_BASE_COLOR } from './fatigue-tokens'
|
|
31
|
+
import { phaseFillFraction, pacingTone, phaseTargetsMs, type TempoTuple } from './tempo-pacing'
|
|
32
|
+
import type { PhaseSegment } from './fatigue-model'
|
|
33
|
+
|
|
34
|
+
const t = getSemanticColors('dark')
|
|
35
|
+
|
|
36
|
+
/** Band height in px. */
|
|
37
|
+
export const BAND_H = 16
|
|
38
|
+
/** Gap between the band's near edge and a bloom's baseline. */
|
|
39
|
+
export const BAND_GAP = 4
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Phase → the word rendered inside its run.
|
|
43
|
+
*
|
|
44
|
+
* Only the two MOVEMENT phases are named. `idle` never was — dead time has no name — and
|
|
45
|
+
* `hold` is deliberately unlabelled too: holds are the narrowest runs on the band, so the
|
|
46
|
+
* word was dropping out at exactly the sizes it mattered, and the runs it did fit were
|
|
47
|
+
* spending their whole width on it. A hold is legible without the word, from its tone,
|
|
48
|
+
* its position between the movement phases, and the fact that it fills.
|
|
49
|
+
*/
|
|
50
|
+
const PHASE_LABEL: Partial<Record<PhaseSegment['phase'], string>> = {
|
|
51
|
+
eccentric: 'ECC',
|
|
52
|
+
concentric: 'CON',
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Label glyph advance at fontSize 8 + letterSpacing 1, plus a little side padding. */
|
|
56
|
+
const LABEL_CH_PX = 7
|
|
57
|
+
const LABEL_PAD_PX = 6
|
|
58
|
+
|
|
59
|
+
/** The well shades toward black so it recesses every phase tone identically. */
|
|
60
|
+
const WELL_SHADE = primitiveColors.black
|
|
61
|
+
/** The faint light line on the well's floor — the far lip catching light. */
|
|
62
|
+
const WELL_FLOOR_LIGHT = primitiveColors.white
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Does `label` fit inside a run `segW` px wide? Sized per WORD, not a flat floor — a flat
|
|
66
|
+
* 20 px clipped 'HOLD' to 'HOL' on exactly the short top-hold runs the label exists for.
|
|
67
|
+
*/
|
|
68
|
+
function labelFits(label: string, segW: number): boolean {
|
|
69
|
+
return segW >= label.length * LABEL_CH_PX + LABEL_PAD_PX
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface GhostBandProps {
|
|
73
|
+
/** The current rep's phase runs, in stream order. */
|
|
74
|
+
segments: PhaseSegment[]
|
|
75
|
+
/** Time (ms) → px mapper, owned by the caller. */
|
|
76
|
+
x: (ms: number) => number
|
|
77
|
+
/** The band's top edge, px. */
|
|
78
|
+
top: number
|
|
79
|
+
/** Band height, px. Default {@link BAND_H}. */
|
|
80
|
+
height?: number
|
|
81
|
+
/** Reveal the ECC / HOLD / CON labels inside the band. */
|
|
82
|
+
showLabels?: boolean
|
|
83
|
+
/**
|
|
84
|
+
* Label colour when there is no pacing tone to apply. Default the primary text token.
|
|
85
|
+
* Ignored for a run that paces — that label takes {@link pacingTone}.
|
|
86
|
+
*/
|
|
87
|
+
labelColor?: string
|
|
88
|
+
/**
|
|
89
|
+
* Prescribed tempo `[ecc, pauseBottom, con, pauseTop]` in seconds. Supplying it turns on
|
|
90
|
+
* PACING: runs paint muted-base + fill, and labels take the ahead/on-pace/over tone.
|
|
91
|
+
*
|
|
92
|
+
* Omit it and the band paints flat at full tone with plain labels — the pre-pacing look,
|
|
93
|
+
* which is also the honest one when nothing was prescribed to pace against.
|
|
94
|
+
*/
|
|
95
|
+
targetTempoSeconds?: TempoTuple | null
|
|
96
|
+
/**
|
|
97
|
+
* These runs are PRESCRIBED, not performed — the set has not started. Every run paints
|
|
98
|
+
* unfilled at its base tone with a plain label, because nothing has been paced yet.
|
|
99
|
+
*
|
|
100
|
+
* Used by the empty state, where the band shows the SHAPE of the rep that was asked for
|
|
101
|
+
* (from the target tempo) rather than an axis with nothing on it.
|
|
102
|
+
*/
|
|
103
|
+
prescribed?: boolean
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The phase-coloured axis band — ONE contiguous strip whose internal boundaries land
|
|
108
|
+
* exactly on the sparkline's phase transitions.
|
|
109
|
+
*
|
|
110
|
+
* Contiguity is structural, not incidental: the band is a single rounded silhouette
|
|
111
|
+
* (clipped), floored with the idle tone across its whole time extent, and each phase run
|
|
112
|
+
* paints SQUARE inside that clip, extended to the next run's start. No per-segment inset
|
|
113
|
+
* and no per-segment rounding — those read as gaps between sections.
|
|
114
|
+
*/
|
|
115
|
+
export function GhostBand({
|
|
116
|
+
segments,
|
|
117
|
+
x,
|
|
118
|
+
top,
|
|
119
|
+
height = BAND_H,
|
|
120
|
+
showLabels = false,
|
|
121
|
+
labelColor = t['text-primary'],
|
|
122
|
+
targetTempoSeconds = null,
|
|
123
|
+
prescribed = false,
|
|
124
|
+
}: GhostBandProps) {
|
|
125
|
+
const rawId = useId()
|
|
126
|
+
const safeId = rawId.replace(/[^a-zA-Z0-9]/g, '')
|
|
127
|
+
const clipId = `ghost-band-${safeId}`
|
|
128
|
+
const wellId = `ghost-band-well-${safeId}`
|
|
129
|
+
|
|
130
|
+
const drawn = segments.filter((seg) => x(seg.endMs) - x(seg.startMs) > 0)
|
|
131
|
+
if (drawn.length === 0) return null
|
|
132
|
+
|
|
133
|
+
const bandLeft = x(drawn[0].startMs)
|
|
134
|
+
const bandRight = x(drawn[drawn.length - 1].endMs)
|
|
135
|
+
const bandW = bandRight - bandLeft
|
|
136
|
+
if (bandW <= 0) return null
|
|
137
|
+
|
|
138
|
+
const pacing = targetTempoSeconds != null && !prescribed
|
|
139
|
+
// Each run's elapsed time IS its own duration — the in-flight run's `endMs` is already
|
|
140
|
+
// "now" — so pacing needs no clock of its own.
|
|
141
|
+
const targetsMs = phaseTargetsMs(
|
|
142
|
+
drawn.map((seg) => seg.phase),
|
|
143
|
+
targetTempoSeconds
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
// Butt each run against the NEXT run's start (not its own end) so rounding between the
|
|
147
|
+
// two can never open a seam; the last run runs to the band edge.
|
|
148
|
+
const runs = drawn.map((seg, i) => {
|
|
149
|
+
const left = x(seg.startMs)
|
|
150
|
+
const right = i === drawn.length - 1 ? bandRight : x(drawn[i + 1].startMs)
|
|
151
|
+
const width = Math.max(0, right - left)
|
|
152
|
+
const targetMs = targetsMs[i]
|
|
153
|
+
const elapsedMs = seg.endMs - seg.startMs
|
|
154
|
+
return {
|
|
155
|
+
phase: seg.phase,
|
|
156
|
+
left,
|
|
157
|
+
width,
|
|
158
|
+
// Prescribed → nothing performed, so nothing filled. No target at all → the run reads
|
|
159
|
+
// complete rather than perpetually empty.
|
|
160
|
+
fillWidth: prescribed ? 0 : pacing ? width * phaseFillFraction(elapsedMs, targetMs) : width,
|
|
161
|
+
labelTone: pacing ? pacingTone(elapsedMs, targetMs) : labelColor,
|
|
162
|
+
}
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
return (
|
|
166
|
+
<g>
|
|
167
|
+
<defs>
|
|
168
|
+
<clipPath id={clipId}>
|
|
169
|
+
<rect x={bandLeft} y={top} width={bandW} height={height} rx={2} />
|
|
170
|
+
</clipPath>
|
|
171
|
+
{/* The WELL — dark under the top lip, falling off fast, with a faint light line on
|
|
172
|
+
the floor. SVG has no `inset` box-shadow, so a recess is a gradient. */}
|
|
173
|
+
<linearGradient id={wellId} x1="0" y1="0" x2="0" y2="1">
|
|
174
|
+
<stop offset="0%" stopColor={WELL_SHADE} stopOpacity={0.6} />
|
|
175
|
+
<stop offset="42%" stopColor={WELL_SHADE} stopOpacity={0.12} />
|
|
176
|
+
<stop offset="88%" stopColor={WELL_SHADE} stopOpacity={0.02} />
|
|
177
|
+
<stop offset="100%" stopColor={WELL_FLOOR_LIGHT} stopOpacity={0.07} />
|
|
178
|
+
</linearGradient>
|
|
179
|
+
</defs>
|
|
180
|
+
<g clipPath={`url(#${clipId})`}>
|
|
181
|
+
{/* the strip floor — the idle tone spans the rep so a pause is band, not a hole. */}
|
|
182
|
+
<rect
|
|
183
|
+
x={bandLeft}
|
|
184
|
+
y={top}
|
|
185
|
+
width={bandW}
|
|
186
|
+
height={height}
|
|
187
|
+
fill={PHASE_AXIS_COLOR.idle}
|
|
188
|
+
data-testid="ghost-band-floor"
|
|
189
|
+
/>
|
|
190
|
+
{/* Each run: the muted base at FULL run width, then the pacing fill over the part
|
|
191
|
+
of it that has been earned. Both square inside the one clip — the fill is a
|
|
192
|
+
fill, never an inset, so no boundary can open a seam. */}
|
|
193
|
+
{runs.map((run, i) => (
|
|
194
|
+
<g key={i}>
|
|
195
|
+
<rect
|
|
196
|
+
x={run.left}
|
|
197
|
+
y={top}
|
|
198
|
+
width={run.width}
|
|
199
|
+
height={height}
|
|
200
|
+
fill={PHASE_AXIS_BASE_COLOR[run.phase]}
|
|
201
|
+
data-testid="ghost-band-base"
|
|
202
|
+
/>
|
|
203
|
+
<rect
|
|
204
|
+
x={run.left}
|
|
205
|
+
y={top}
|
|
206
|
+
width={run.width}
|
|
207
|
+
height={height}
|
|
208
|
+
fill={`url(#${wellId})`}
|
|
209
|
+
data-testid="ghost-band-well"
|
|
210
|
+
/>
|
|
211
|
+
<rect
|
|
212
|
+
x={run.left}
|
|
213
|
+
y={top}
|
|
214
|
+
width={run.fillWidth}
|
|
215
|
+
height={height}
|
|
216
|
+
fill={PHASE_AXIS_COLOR[run.phase]}
|
|
217
|
+
data-testid="ghost-band-fill"
|
|
218
|
+
/>
|
|
219
|
+
</g>
|
|
220
|
+
))}
|
|
221
|
+
{/* Labels last so a fill sweeping past never paints over the word. */}
|
|
222
|
+
{showLabels &&
|
|
223
|
+
runs.map((run, i) => {
|
|
224
|
+
const label = PHASE_LABEL[run.phase]
|
|
225
|
+
if (!label || !labelFits(label, run.width)) return null
|
|
226
|
+
return (
|
|
227
|
+
<text
|
|
228
|
+
key={i}
|
|
229
|
+
x={run.left + run.width / 2}
|
|
230
|
+
y={top + height / 2}
|
|
231
|
+
textAnchor="middle"
|
|
232
|
+
dominantBaseline="central"
|
|
233
|
+
fill={run.labelTone}
|
|
234
|
+
fontSize={8}
|
|
235
|
+
fontWeight={800}
|
|
236
|
+
letterSpacing={1}
|
|
237
|
+
fontFamily={FONT_UI}
|
|
238
|
+
>
|
|
239
|
+
{label}
|
|
240
|
+
</text>
|
|
241
|
+
)
|
|
242
|
+
})}
|
|
243
|
+
</g>
|
|
244
|
+
</g>
|
|
245
|
+
)
|
|
246
|
+
}
|