@titan-design/react-ui 0.2.6 → 0.2.7
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/chunk-3VLOBS6O.mjs +846 -0
- package/dist/chunk-3VLOBS6O.mjs.map +1 -0
- package/dist/{chunk-UXVRPOME.mjs → chunk-5SSKGS6E.mjs} +3 -783
- package/dist/chunk-5SSKGS6E.mjs.map +1 -0
- package/dist/index.d.mts +1005 -4
- package/dist/index.d.ts +1005 -4
- package/dist/index.js +6043 -213
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +5901 -177
- package/dist/index.mjs.map +1 -1
- package/dist/theme/index.d.mts +57 -1
- package/dist/theme/index.d.ts +57 -1
- package/dist/theme/index.js +63 -0
- package/dist/theme/index.js.map +1 -1
- package/dist/theme/index.mjs +2 -1
- package/dist/theme/tokens-css.d.mts +18 -0
- package/dist/theme/tokens-css.d.ts +18 -0
- package/dist/theme/tokens-css.js +289 -0
- package/dist/theme/tokens-css.js.map +1 -0
- package/dist/theme/tokens-css.mjs +24 -0
- package/dist/theme/tokens-css.mjs.map +1 -0
- package/package.json +16 -5
- package/src/components/custom/DateTime/DateTime.stories.tsx +1 -1
- package/src/components/custom/Gauge/Gauge.stories.tsx +45 -0
- package/src/components/custom/Gauge/Gauge.test.tsx +71 -0
- package/src/components/custom/Gauge/Gauge.tsx +167 -0
- package/src/components/custom/Gauge/index.ts +2 -0
- package/src/components/custom/Scatter/Scatter.stories.tsx +85 -0
- package/src/components/custom/Scatter/Scatter.test.tsx +96 -0
- package/src/components/custom/Scatter/Scatter.tsx +260 -0
- package/src/components/custom/Scatter/index.ts +2 -0
- package/src/components/custom/Sidebar/Sidebar.tsx +1 -1
- package/src/components/custom/Treemap/Treemap.stories.tsx +60 -0
- package/src/components/custom/Treemap/Treemap.test.tsx +67 -0
- package/src/components/custom/Treemap/Treemap.tsx +192 -0
- package/src/components/custom/Treemap/index.ts +2 -0
- package/src/components/custom/Workout/BaseBadge.stories.tsx +75 -0
- package/src/components/custom/Workout/BaseBadge.test.tsx +102 -0
- package/src/components/custom/Workout/BaseBadge.tsx +113 -0
- package/src/components/custom/Workout/BodyMap.stories.tsx +121 -0
- package/src/components/custom/Workout/BodyMap.test.tsx +124 -0
- package/src/components/custom/Workout/BodyMap.tsx +348 -0
- package/src/components/custom/Workout/BodyMapDetailPanel.stories.tsx +171 -0
- package/src/components/custom/Workout/BodyMapDetailPanel.test.tsx +225 -0
- package/src/components/custom/Workout/BodyMapDetailPanel.tsx +478 -0
- package/src/components/custom/Workout/CapacityBandChart.stories.tsx +125 -0
- package/src/components/custom/Workout/CapacityBandChart.test.tsx +193 -0
- package/src/components/custom/Workout/CapacityBandChart.tsx +493 -0
- package/src/components/custom/Workout/DeviationBar.stories.tsx +58 -0
- package/src/components/custom/Workout/DeviationBar.test.tsx +86 -0
- package/src/components/custom/Workout/DeviationBar.tsx +78 -0
- package/src/components/custom/Workout/ExerciseCard.stories.tsx +186 -0
- package/src/components/custom/Workout/ExerciseCard.test.tsx +319 -0
- package/src/components/custom/Workout/ExerciseCard.tsx +429 -0
- package/src/components/custom/Workout/InputBar.stories.tsx +75 -0
- package/src/components/custom/Workout/InputBar.test.tsx +116 -0
- package/src/components/custom/Workout/InputBar.tsx +163 -0
- package/src/components/custom/Workout/IntensityBar.stories.tsx +60 -0
- package/src/components/custom/Workout/IntensityBar.test.tsx +113 -0
- package/src/components/custom/Workout/IntensityBar.tsx +166 -0
- package/src/components/custom/Workout/MesoCard.stories.tsx +152 -0
- package/src/components/custom/Workout/MesoCard.test.tsx +170 -0
- package/src/components/custom/Workout/MesoCard.tsx +235 -0
- package/src/components/custom/Workout/MesoProgressBar.stories.tsx +85 -0
- package/src/components/custom/Workout/MesoProgressBar.test.tsx +141 -0
- package/src/components/custom/Workout/MesoProgressBar.tsx +161 -0
- package/src/components/custom/Workout/MesoStatusCard.stories.tsx +126 -0
- package/src/components/custom/Workout/MesoStatusCard.test.tsx +209 -0
- package/src/components/custom/Workout/MesoStatusCard.tsx +456 -0
- package/src/components/custom/Workout/MuscleGroupChip.stories.tsx +118 -0
- package/src/components/custom/Workout/MuscleGroupChip.test.tsx +91 -0
- package/src/components/custom/Workout/MuscleGroupChip.tsx +92 -0
- package/src/components/custom/Workout/PlaceholderStrip.stories.tsx +94 -0
- package/src/components/custom/Workout/PlaceholderStrip.test.tsx +83 -0
- package/src/components/custom/Workout/PlaceholderStrip.tsx +91 -0
- package/src/components/custom/Workout/PrBadge.stories.tsx +105 -0
- package/src/components/custom/Workout/PrBadge.test.tsx +111 -0
- package/src/components/custom/Workout/PrBadge.tsx +103 -0
- package/src/components/custom/Workout/PrHistoryModal.stories.tsx +116 -0
- package/src/components/custom/Workout/PrHistoryModal.test.tsx +161 -0
- package/src/components/custom/Workout/PrHistoryModal.tsx +244 -0
- package/src/components/custom/Workout/ReadinessCheck.stories.tsx +126 -0
- package/src/components/custom/Workout/ReadinessCheck.test.tsx +181 -0
- package/src/components/custom/Workout/ReadinessCheck.tsx +266 -0
- package/src/components/custom/Workout/RestTimer.tsx +8 -6
- package/src/components/custom/Workout/SetRow.stories.tsx +165 -0
- package/src/components/custom/Workout/SetRow.test.tsx +222 -0
- package/src/components/custom/Workout/SetRow.tsx +253 -0
- package/src/components/custom/Workout/Sparkline.stories.tsx +125 -0
- package/src/components/custom/Workout/Sparkline.test.tsx +113 -0
- package/src/components/custom/Workout/Sparkline.tsx +188 -0
- package/src/components/custom/Workout/StatusDot.stories.tsx +150 -0
- package/src/components/custom/Workout/StatusDot.test.tsx +165 -0
- package/src/components/custom/Workout/StatusDot.tsx +159 -0
- package/src/components/custom/Workout/StrengthTrendChart.stories.tsx +130 -0
- package/src/components/custom/Workout/StrengthTrendChart.test.tsx +217 -0
- package/src/components/custom/Workout/StrengthTrendChart.tsx +628 -0
- package/src/components/custom/Workout/SupersetWrapper.tsx +1 -2
- package/src/components/custom/Workout/TempoDisplay.stories.tsx +66 -0
- package/src/components/custom/Workout/TempoDisplay.test.tsx +90 -0
- package/src/components/custom/Workout/TempoDisplay.tsx +207 -0
- package/src/components/custom/Workout/VelocityStrip.stories.tsx +125 -0
- package/src/components/custom/Workout/VelocityStrip.test.tsx +234 -0
- package/src/components/custom/Workout/VelocityStrip.tsx +287 -0
- package/src/components/custom/Workout/WeekRow.stories.tsx +121 -0
- package/src/components/custom/Workout/WeekRow.test.tsx +117 -0
- package/src/components/custom/Workout/WeekRow.tsx +140 -0
- package/src/components/custom/Workout/WeightBadge.stories.tsx +87 -0
- package/src/components/custom/Workout/WeightBadge.test.tsx +164 -0
- package/src/components/custom/Workout/WeightBadge.tsx +113 -0
- package/src/components/custom/Workout/WorkoutCard.stories.tsx +135 -0
- package/src/components/custom/Workout/WorkoutCard.test.tsx +172 -0
- package/src/components/custom/Workout/WorkoutCard.tsx +226 -0
- package/src/components/custom/Workout/WorkoutPill.stories.tsx +63 -0
- package/src/components/custom/Workout/WorkoutPill.test.tsx +153 -0
- package/src/components/custom/Workout/WorkoutPill.tsx +177 -0
- package/src/components/custom/Workout/index.ts +113 -0
- package/src/components/custom/Workout/muscleTaxonomy.ts +252 -0
- package/src/components/custom/index.ts +3 -0
- package/src/components/custom/stepper/Stepper.stories.tsx +1 -1
- package/src/components/custom/stepper/Stepper.tsx +2 -2
- package/src/components/ui/autocomplete/Autocomplete.stories.tsx +1 -1
- package/src/components/ui/autocomplete/Autocomplete.tsx +1 -1
- package/src/components/ui/card/Card.tsx +1 -1
- 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 +1 -1
- package/src/components/ui/popover/Popover.stories.tsx +2 -2
- package/src/components/ui/popover/Popover.tsx +1 -1
- package/src/components/ui/radio/Radio.stories.tsx +1 -1
- package/src/components/ui/section/Section.stories.tsx +4 -4
- package/src/components/ui/select/Select.tsx +1 -1
- package/src/components/ui/stack/Stack.stories.tsx +4 -4
- package/src/components/ui/tabs/Tabs.tsx +2 -2
- package/src/components/ui/toolbar-button/ToolbarButton.stories.tsx +1 -1
- package/src/components/ui/toolbar-button/ToolbarButton.tsx +1 -1
- package/src/examples/react-hook-form/ControlledFields.tsx +171 -0
- package/src/examples/react-hook-form/ReactHookForm.stories.tsx +51 -0
- package/src/examples/react-hook-form/ReactHookForm.test.tsx +61 -0
- package/src/examples/react-hook-form/RhfContactForm.tsx +99 -0
- package/src/stories/PresetShowcase.stories.tsx +15 -15
- package/src/theme/index.ts +2 -1
- package/src/theme/manifest/css-property-manifest.example.json +79 -0
- package/src/theme/manifest/css-property-manifest.schema.json +93 -0
- package/src/theme/manifest/css-property-manifest.test.ts +81 -0
- package/src/theme/manifest/css-property-manifest.types.ts +49 -0
- package/src/theme/manifest/css-property-manifest.validate.ts +84 -0
- package/src/theme/manifest/index.ts +11 -0
- package/src/theme/tokens-css.test.ts +28 -0
- package/src/theme/tokens-css.ts +34 -0
- package/src/theme/workout-tokens.ts +61 -0
- package/dist/chunk-UXVRPOME.mjs.map +0 -1
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { render, screen } from '@testing-library/react'
|
|
3
|
+
import { axe } from 'jest-axe'
|
|
4
|
+
import { Gauge } from './Gauge'
|
|
5
|
+
|
|
6
|
+
describe('Gauge', () => {
|
|
7
|
+
it('renders the arc as a fixed set of segments', () => {
|
|
8
|
+
render(<Gauge value={50} />)
|
|
9
|
+
expect(screen.getAllByTestId('gauge-segment')).toHaveLength(40)
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('renders the value, unit, and label', () => {
|
|
13
|
+
render(<Gauge value={82} unit="%" label="Health" />)
|
|
14
|
+
expect(screen.getByTestId('gauge-value')).toHaveTextContent('82')
|
|
15
|
+
expect(screen.getByTestId('gauge-label')).toHaveTextContent('Health')
|
|
16
|
+
expect(screen.getByText('%')).toBeInTheDocument()
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('colors the value by the active status band', () => {
|
|
20
|
+
const { rerender } = render(<Gauge value={30} />)
|
|
21
|
+
expect(screen.getByTestId('gauge-value')).toHaveStyle({ color: '#D14343' })
|
|
22
|
+
rerender(<Gauge value={70} />)
|
|
23
|
+
expect(screen.getByTestId('gauge-value')).toHaveStyle({ color: '#FFB020' })
|
|
24
|
+
rerender(<Gauge value={90} />)
|
|
25
|
+
expect(screen.getByTestId('gauge-value')).toHaveStyle({ color: '#14B8A6' })
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('honors a single-color override over thresholds', () => {
|
|
29
|
+
render(<Gauge value={10} color="#5B9BD5" />)
|
|
30
|
+
expect(screen.getByTestId('gauge-value')).toHaveStyle({ color: '#5B9BD5' })
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('clamps an out-of-range value without crashing', () => {
|
|
34
|
+
render(<Gauge value={150} unit="%" />)
|
|
35
|
+
// Displayed value is verbatim; only the fill is clamped.
|
|
36
|
+
expect(screen.getByTestId('gauge-value')).toHaveTextContent('150')
|
|
37
|
+
expect(screen.getAllByTestId('gauge-segment')).toHaveLength(40)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('supports an arbitrary min/max domain', () => {
|
|
41
|
+
render(<Gauge value={5} min={0} max={10} label="Score" />)
|
|
42
|
+
expect(screen.getByTestId('gauge-value')).toHaveTextContent('5')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('respects custom thresholds', () => {
|
|
46
|
+
render(
|
|
47
|
+
<Gauge
|
|
48
|
+
value={45}
|
|
49
|
+
thresholds={[
|
|
50
|
+
{ value: 0, color: '#14B8A6' },
|
|
51
|
+
{ value: 50, color: '#D14343' },
|
|
52
|
+
]}
|
|
53
|
+
/>,
|
|
54
|
+
)
|
|
55
|
+
expect(screen.getByTestId('gauge-value')).toHaveStyle({ color: '#14B8A6' })
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
describe('accessibility', () => {
|
|
59
|
+
it('exposes an image role with a descriptive label', () => {
|
|
60
|
+
render(<Gauge value={82} unit="%" label="Health" />)
|
|
61
|
+
const gauge = screen.getByTestId('gauge')
|
|
62
|
+
expect(gauge).toHaveAttribute('role', 'img')
|
|
63
|
+
expect(gauge.getAttribute('aria-label')).toContain('Health: 82% of 100')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('has no accessibility violations', async () => {
|
|
67
|
+
const { container } = render(<Gauge value={82} unit="%" label="Health" />)
|
|
68
|
+
expect(await axe(container)).toHaveNoViolations()
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
})
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { View, Text, type ViewProps } from 'react-native'
|
|
2
|
+
import { cn } from '../../../utils/cn'
|
|
3
|
+
|
|
4
|
+
export interface GaugeThreshold {
|
|
5
|
+
/** Band start, in the gauge's value units. */
|
|
6
|
+
value: number
|
|
7
|
+
/** Color applied to filled segments at or above this band. */
|
|
8
|
+
color: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface GaugeProps extends Omit<ViewProps, 'children'> {
|
|
12
|
+
/** Current value. Clamped to [min, max]. */
|
|
13
|
+
value: number
|
|
14
|
+
min?: number
|
|
15
|
+
max?: number
|
|
16
|
+
/** Diameter in px. */
|
|
17
|
+
size?: number
|
|
18
|
+
/** Caption under the big number. */
|
|
19
|
+
label?: string
|
|
20
|
+
/** Unit suffix beside the big number (e.g. "%"). */
|
|
21
|
+
unit?: string
|
|
22
|
+
/**
|
|
23
|
+
* Band coloring, ascending by `value`. A filled segment takes the color of
|
|
24
|
+
* the highest band whose `value` it reaches. Defaults to Titan status tokens
|
|
25
|
+
* (red < 60, amber < 80, green ≥ 80 on a 0–100 scale).
|
|
26
|
+
*/
|
|
27
|
+
thresholds?: GaugeThreshold[]
|
|
28
|
+
/** Single-color override for filled segments (ignores `thresholds`). */
|
|
29
|
+
color?: string
|
|
30
|
+
className?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const STATUS_SUCCESS = '#14B8A6'
|
|
34
|
+
const STATUS_WARNING = '#FFB020'
|
|
35
|
+
const STATUS_ERROR = '#D14343'
|
|
36
|
+
const TRACK = 'rgba(255,255,255,0.08)'
|
|
37
|
+
|
|
38
|
+
/** Titan status-token bands for a 0–100 score. */
|
|
39
|
+
const DEFAULT_THRESHOLDS: GaugeThreshold[] = [
|
|
40
|
+
{ value: 0, color: STATUS_ERROR },
|
|
41
|
+
{ value: 60, color: STATUS_WARNING },
|
|
42
|
+
{ value: 80, color: STATUS_SUCCESS },
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
const ARC_START = 135
|
|
46
|
+
const ARC_SWEEP = 270
|
|
47
|
+
const SEGMENTS = 40
|
|
48
|
+
|
|
49
|
+
function clamp01(n: number): number {
|
|
50
|
+
if (Number.isNaN(n)) return 0
|
|
51
|
+
return Math.max(0, Math.min(1, n))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function formatValue(v: number): string {
|
|
55
|
+
return Number.isInteger(v) ? `${v}` : v.toFixed(1)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Color of the highest band whose start value the fraction reaches. */
|
|
59
|
+
function bandColor(fraction: number, bands: GaugeThreshold[], min: number, max: number): string {
|
|
60
|
+
const value = min + fraction * (max - min)
|
|
61
|
+
const sorted = [...bands].sort((a, b) => a.value - b.value)
|
|
62
|
+
let color = sorted[0]?.color ?? STATUS_SUCCESS
|
|
63
|
+
for (const band of sorted) {
|
|
64
|
+
if (value >= band.value) color = band.color
|
|
65
|
+
}
|
|
66
|
+
return color
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface Tick {
|
|
70
|
+
key: number
|
|
71
|
+
left: number
|
|
72
|
+
top: number
|
|
73
|
+
rotate: string
|
|
74
|
+
color: string
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Position + color each radial segment of the arc. */
|
|
78
|
+
function buildTicks(fill: number, size: number, activeColor: (f: number) => string): Tick[] {
|
|
79
|
+
const center = size / 2
|
|
80
|
+
const radius = center - size * 0.08
|
|
81
|
+
return Array.from({ length: SEGMENTS }, (_, i) => {
|
|
82
|
+
const fraction = (i + 0.5) / SEGMENTS
|
|
83
|
+
const deg = ARC_START + fraction * ARC_SWEEP
|
|
84
|
+
const rad = (deg * Math.PI) / 180
|
|
85
|
+
return {
|
|
86
|
+
key: i,
|
|
87
|
+
left: center + radius * Math.cos(rad),
|
|
88
|
+
top: center + radius * Math.sin(rad),
|
|
89
|
+
rotate: `${deg - 90}deg`,
|
|
90
|
+
color: fraction <= fill ? activeColor(fraction) : TRACK,
|
|
91
|
+
}
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* SVG-free radial gauge (absolutely-positioned segment Views), matching the
|
|
97
|
+
* codebase's chart convention so it renders identically on web and native. The
|
|
98
|
+
* 270° arc fills with band colors as the value rises; the center reuses the
|
|
99
|
+
* Metric visual language (large bold value + unit + caption). Ideal for a 0–100
|
|
100
|
+
* health / quality score.
|
|
101
|
+
*/
|
|
102
|
+
export function Gauge({
|
|
103
|
+
value,
|
|
104
|
+
min = 0,
|
|
105
|
+
max = 100,
|
|
106
|
+
size = 160,
|
|
107
|
+
label,
|
|
108
|
+
unit,
|
|
109
|
+
thresholds = DEFAULT_THRESHOLDS,
|
|
110
|
+
color,
|
|
111
|
+
className,
|
|
112
|
+
...props
|
|
113
|
+
}: GaugeProps) {
|
|
114
|
+
const span = max - min || 1
|
|
115
|
+
const fill = clamp01((value - min) / span)
|
|
116
|
+
const activeColor = (f: number) => color ?? bandColor(f, thresholds, min, max)
|
|
117
|
+
const ticks = buildTicks(fill, size, activeColor)
|
|
118
|
+
const displayColor = color ?? bandColor(fill, thresholds, min, max)
|
|
119
|
+
const tickLength = size * 0.11
|
|
120
|
+
const tickThickness = Math.max(2, (size * 0.9) / SEGMENTS)
|
|
121
|
+
|
|
122
|
+
const ariaLabel = `${label ? `${label}: ` : ''}${formatValue(value)}${unit ?? ''} of ${formatValue(max)}`
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<View
|
|
126
|
+
className={cn('relative', className)}
|
|
127
|
+
style={{ width: size, height: size }}
|
|
128
|
+
accessibilityRole="image"
|
|
129
|
+
accessibilityLabel={ariaLabel}
|
|
130
|
+
testID="gauge"
|
|
131
|
+
{...props}
|
|
132
|
+
>
|
|
133
|
+
{ticks.map((t) => (
|
|
134
|
+
<View
|
|
135
|
+
key={t.key}
|
|
136
|
+
accessibilityElementsHidden
|
|
137
|
+
testID="gauge-segment"
|
|
138
|
+
style={{
|
|
139
|
+
position: 'absolute',
|
|
140
|
+
left: t.left - tickThickness / 2,
|
|
141
|
+
top: t.top - tickLength / 2,
|
|
142
|
+
width: tickThickness,
|
|
143
|
+
height: tickLength,
|
|
144
|
+
borderRadius: tickThickness / 2,
|
|
145
|
+
backgroundColor: t.color,
|
|
146
|
+
transform: [{ rotate: t.rotate }],
|
|
147
|
+
}}
|
|
148
|
+
/>
|
|
149
|
+
))}
|
|
150
|
+
|
|
151
|
+
{/* Center readout — Metric visual language. */}
|
|
152
|
+
<View style={{ position: 'absolute', top: 0, left: 0, width: size, height: size }} className="items-center justify-center">
|
|
153
|
+
<View className="flex-row items-baseline gap-0.5">
|
|
154
|
+
<Text testID="gauge-value" className="text-3xl font-bold" style={{ color: displayColor }}>
|
|
155
|
+
{formatValue(value)}
|
|
156
|
+
</Text>
|
|
157
|
+
{unit && <Text className="text-sm text-text-tertiary">{unit}</Text>}
|
|
158
|
+
</View>
|
|
159
|
+
{label && (
|
|
160
|
+
<Text testID="gauge-label" className="text-xs text-text-secondary mt-1">
|
|
161
|
+
{label}
|
|
162
|
+
</Text>
|
|
163
|
+
)}
|
|
164
|
+
</View>
|
|
165
|
+
</View>
|
|
166
|
+
)
|
|
167
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { Scatter, type ScatterDatum } from './Scatter'
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof Scatter> = {
|
|
5
|
+
title: 'Custom/Scatter',
|
|
6
|
+
component: Scatter,
|
|
7
|
+
tags: ['autodocs'],
|
|
8
|
+
argTypes: {
|
|
9
|
+
width: { control: { type: 'range', min: 240, max: 800, step: 20 } },
|
|
10
|
+
height: { control: { type: 'range', min: 180, max: 600, step: 20 } },
|
|
11
|
+
diagonal: { control: 'boolean' },
|
|
12
|
+
},
|
|
13
|
+
}
|
|
14
|
+
export default meta
|
|
15
|
+
type Story = StoryObj<typeof Scatter>
|
|
16
|
+
|
|
17
|
+
/** Modules on the instability × abstractness "main sequence". */
|
|
18
|
+
const mainSequence: ScatterDatum[] = [
|
|
19
|
+
{ id: 'core', x: 0.08, y: 0.85, r: 10, color: '#14B8A6', label: 'core' },
|
|
20
|
+
{ id: 'domain', x: 0.22, y: 0.7, r: 8, color: '#14B8A6', label: 'domain' },
|
|
21
|
+
{ id: 'services', x: 0.45, y: 0.4, r: 12, color: '#5B9BD5', label: 'services' },
|
|
22
|
+
{ id: 'api', x: 0.6, y: 0.35, r: 7, color: '#5B9BD5', label: 'api' },
|
|
23
|
+
{ id: 'cli', x: 0.9, y: 0.08, r: 9, color: '#F4A736', label: 'cli' },
|
|
24
|
+
{ id: 'utils', x: 0.85, y: 0.75, r: 6, color: '#F83030', label: 'utils (pain?)' },
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
export const Default: Story = {
|
|
28
|
+
args: {
|
|
29
|
+
data: mainSequence,
|
|
30
|
+
width: 480,
|
|
31
|
+
height: 340,
|
|
32
|
+
diagonal: true,
|
|
33
|
+
axis: { xLabel: 'Instability (I)', yLabel: 'Abstractness (A)', xMin: 0, xMax: 1, yMin: 0, yMax: 1 },
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const NoDiagonal: Story = {
|
|
38
|
+
args: {
|
|
39
|
+
data: mainSequence,
|
|
40
|
+
width: 480,
|
|
41
|
+
height: 340,
|
|
42
|
+
axis: { xLabel: 'Instability (I)', yLabel: 'Abstractness (A)', xMin: 0, xMax: 1, yMin: 0, yMax: 1 },
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const Selected: Story = {
|
|
47
|
+
args: {
|
|
48
|
+
data: mainSequence,
|
|
49
|
+
width: 480,
|
|
50
|
+
height: 340,
|
|
51
|
+
diagonal: true,
|
|
52
|
+
selectedId: 'utils',
|
|
53
|
+
axis: { xLabel: 'Instability (I)', yLabel: 'Abstractness (A)', xMin: 0, xMax: 1, yMin: 0, yMax: 1 },
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Auto-domain from a single point — the frame still renders cleanly. */
|
|
58
|
+
export const SinglePoint: Story = {
|
|
59
|
+
args: {
|
|
60
|
+
data: [{ id: 'only', x: 0.5, y: 0.5, r: 10, label: 'only module' }],
|
|
61
|
+
width: 400,
|
|
62
|
+
height: 300,
|
|
63
|
+
axis: { xLabel: 'I', yLabel: 'A' },
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** An outlier far outside the main cluster, kept legible by an explicit domain. */
|
|
68
|
+
export const Outlier: Story = {
|
|
69
|
+
args: {
|
|
70
|
+
data: [...mainSequence, { id: 'legacy', x: 0.98, y: 0.98, r: 16, color: '#F83030', label: 'legacy.ts' }],
|
|
71
|
+
width: 480,
|
|
72
|
+
height: 340,
|
|
73
|
+
diagonal: true,
|
|
74
|
+
axis: { xLabel: 'Instability (I)', yLabel: 'Abstractness (A)', xMin: 0, xMax: 1, yMin: 0, yMax: 1 },
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const Empty: Story = {
|
|
79
|
+
args: {
|
|
80
|
+
data: [],
|
|
81
|
+
width: 400,
|
|
82
|
+
height: 300,
|
|
83
|
+
axis: { xLabel: 'Instability (I)', yLabel: 'Abstractness (A)', xMin: 0, xMax: 1, yMin: 0, yMax: 1 },
|
|
84
|
+
},
|
|
85
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { render, screen, fireEvent } from '@testing-library/react'
|
|
3
|
+
import { axe } from 'jest-axe'
|
|
4
|
+
import { Scatter, type ScatterDatum } from './Scatter'
|
|
5
|
+
|
|
6
|
+
const data: ScatterDatum[] = [
|
|
7
|
+
{ id: 'a', x: 0.1, y: 0.9, label: 'core' },
|
|
8
|
+
{ id: 'b', x: 0.5, y: 0.5, label: 'mid' },
|
|
9
|
+
{ id: 'c', x: 0.9, y: 0.1, label: 'leaf' },
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
const base = { data, width: 320, height: 240 }
|
|
13
|
+
|
|
14
|
+
describe('Scatter', () => {
|
|
15
|
+
it('renders one point per datum', () => {
|
|
16
|
+
render(<Scatter {...base} />)
|
|
17
|
+
expect(screen.getByTestId('scatter-point-a')).toBeInTheDocument()
|
|
18
|
+
expect(screen.getByTestId('scatter-point-b')).toBeInTheDocument()
|
|
19
|
+
expect(screen.getByTestId('scatter-point-c')).toBeInTheDocument()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('renders gridlines on both axes', () => {
|
|
23
|
+
render(<Scatter {...base} />)
|
|
24
|
+
expect(screen.getAllByTestId('scatter-gridline-x').length).toBeGreaterThanOrEqual(2)
|
|
25
|
+
expect(screen.getAllByTestId('scatter-gridline-y').length).toBeGreaterThanOrEqual(2)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('fires onPress with the point id', () => {
|
|
29
|
+
const onPress = vi.fn()
|
|
30
|
+
render(<Scatter {...base} onPress={onPress} />)
|
|
31
|
+
fireEvent.click(screen.getByTestId('scatter-point-b'))
|
|
32
|
+
expect(onPress).toHaveBeenCalledWith('b')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('draws the main-sequence diagonal only when requested', () => {
|
|
36
|
+
const { rerender } = render(<Scatter {...base} />)
|
|
37
|
+
expect(screen.queryByTestId('scatter-diagonal')).not.toBeInTheDocument()
|
|
38
|
+
rerender(<Scatter {...base} diagonal />)
|
|
39
|
+
expect(screen.getByTestId('scatter-diagonal')).toBeInTheDocument()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('renders axis labels when provided', () => {
|
|
43
|
+
render(<Scatter {...base} axis={{ xLabel: 'Instability', yLabel: 'Abstractness' }} />)
|
|
44
|
+
expect(screen.getByTestId('scatter-x-label')).toHaveTextContent('Instability')
|
|
45
|
+
expect(screen.getByTestId('scatter-y-label')).toHaveTextContent('Abstractness')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('labels a point by its label, falling back to id', () => {
|
|
49
|
+
render(<Scatter data={[{ id: 'solo', x: 0.2, y: 0.4 }]} width={200} height={200} />)
|
|
50
|
+
expect(screen.getByLabelText('solo')).toBeInTheDocument()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('renders a single point without crashing', () => {
|
|
54
|
+
render(<Scatter data={[{ id: 'one', x: 5, y: 5 }]} width={200} height={200} />)
|
|
55
|
+
expect(screen.getByTestId('scatter-point-one')).toBeInTheDocument()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('renders an empty plot frame with no points', () => {
|
|
59
|
+
render(<Scatter data={[]} width={200} height={200} />)
|
|
60
|
+
expect(screen.getByTestId('scatter-canvas')).toBeInTheDocument()
|
|
61
|
+
expect(screen.queryByTestId('scatter-points')?.childNodes.length ?? 0).toBe(0)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('places an outlier inside the plot when domains are overridden', () => {
|
|
65
|
+
render(
|
|
66
|
+
<Scatter
|
|
67
|
+
data={[{ id: 'big', x: 999, y: 999 }]}
|
|
68
|
+
width={200}
|
|
69
|
+
height={200}
|
|
70
|
+
axis={{ xMin: 0, xMax: 1000, yMin: 0, yMax: 1000 }}
|
|
71
|
+
/>,
|
|
72
|
+
)
|
|
73
|
+
expect(screen.getByTestId('scatter-point-big')).toBeInTheDocument()
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
describe('accessibility', () => {
|
|
77
|
+
it('labels the canvas as an image', () => {
|
|
78
|
+
render(<Scatter {...base} axis={{ xLabel: 'I', yLabel: 'A' }} />)
|
|
79
|
+
const canvas = screen.getByTestId('scatter-canvas')
|
|
80
|
+
expect(canvas).toHaveAttribute('role', 'img')
|
|
81
|
+
expect(canvas.getAttribute('aria-label')).toContain('3 points')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('has no accessibility violations', async () => {
|
|
85
|
+
const { container } = render(
|
|
86
|
+
<Scatter {...base} diagonal axis={{ xLabel: 'I', yLabel: 'A' }} selectedId="b" />,
|
|
87
|
+
)
|
|
88
|
+
expect(await axe(container)).toHaveNoViolations()
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('has no accessibility violations when empty', async () => {
|
|
92
|
+
const { container } = render(<Scatter data={[]} width={200} height={200} />)
|
|
93
|
+
expect(await axe(container)).toHaveNoViolations()
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
})
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { View, Text, Pressable, type ViewProps } from 'react-native'
|
|
2
|
+
import { cn } from '../../../utils/cn'
|
|
3
|
+
|
|
4
|
+
export interface ScatterDatum {
|
|
5
|
+
/** Stable identity — returned by onPress and used as the React key. */
|
|
6
|
+
id: string
|
|
7
|
+
/** Horizontal position in data space. */
|
|
8
|
+
x: number
|
|
9
|
+
/** Vertical position in data space. */
|
|
10
|
+
y: number
|
|
11
|
+
/** Bubble radius in px. Falls back to a fixed default when omitted. */
|
|
12
|
+
r?: number
|
|
13
|
+
/** Bubble fill color. Falls back to a built-in categorical palette by index. */
|
|
14
|
+
color?: string
|
|
15
|
+
/** Optional label shown for large or selected points. */
|
|
16
|
+
label?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ScatterAxis {
|
|
20
|
+
xLabel?: string
|
|
21
|
+
yLabel?: string
|
|
22
|
+
/** Domain overrides. Any omitted bound is derived from the data. */
|
|
23
|
+
xMin?: number
|
|
24
|
+
xMax?: number
|
|
25
|
+
yMin?: number
|
|
26
|
+
yMax?: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ScatterProps extends Omit<ViewProps, 'children'> {
|
|
30
|
+
data: ScatterDatum[]
|
|
31
|
+
/** Layout box. Required — the plot fills exactly this area. */
|
|
32
|
+
width: number
|
|
33
|
+
height: number
|
|
34
|
+
/** Axis labels and optional domain overrides. */
|
|
35
|
+
axis?: ScatterAxis
|
|
36
|
+
/**
|
|
37
|
+
* Draw the y = 1 − x "main sequence" reference line (NDepend idiom, where
|
|
38
|
+
* distance from the diagonal measures the zone of pain / zone of uselessness).
|
|
39
|
+
*/
|
|
40
|
+
diagonal?: boolean
|
|
41
|
+
/** Fires with a point's id on press. */
|
|
42
|
+
onPress?: (id: string) => void
|
|
43
|
+
/** Draws a highlight ring on the matching point. */
|
|
44
|
+
selectedId?: string
|
|
45
|
+
className?: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Titan categorical fallback palette (data-1..data-8). */
|
|
49
|
+
const PALETTE = [
|
|
50
|
+
'#5B9BD5', '#14B8A6', '#F4A736', '#F83030',
|
|
51
|
+
'#823CA0', '#D4A520', '#406D87', '#43C6B7',
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
const GRID_LINE = 'rgba(255,255,255,0.07)'
|
|
55
|
+
const AXIS_LINE = 'rgba(255,255,255,0.18)'
|
|
56
|
+
const DIAGONAL_LINE = 'rgba(255,255,255,0.28)'
|
|
57
|
+
const DEFAULT_R = 6
|
|
58
|
+
|
|
59
|
+
const PLOT_LEFT = 40
|
|
60
|
+
const PLOT_RIGHT = 12
|
|
61
|
+
const PLOT_TOP = 12
|
|
62
|
+
const PLOT_BOTTOM = 34
|
|
63
|
+
const TICK_COUNT = 5
|
|
64
|
+
|
|
65
|
+
interface Domain {
|
|
66
|
+
min: number
|
|
67
|
+
max: number
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Derive a padded [min, max] from values, honoring explicit overrides. */
|
|
71
|
+
function domainOf(values: number[], min?: number, max?: number): Domain {
|
|
72
|
+
const lo = min ?? (values.length ? Math.min(...values) : 0)
|
|
73
|
+
const hi = max ?? (values.length ? Math.max(...values) : 1)
|
|
74
|
+
if (hi > lo) return { min: lo, max: hi }
|
|
75
|
+
const pad = Math.abs(hi) * 0.1 || 0.5
|
|
76
|
+
return { min: lo - pad, max: hi + pad }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Evenly spaced tick values across a domain. */
|
|
80
|
+
function ticksOf(d: Domain, count: number): number[] {
|
|
81
|
+
return Array.from({ length: count }, (_, i) => d.min + ((d.max - d.min) * i) / (count - 1))
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function formatTick(v: number): string {
|
|
85
|
+
if (Number.isInteger(v)) return `${v}`
|
|
86
|
+
return v.toFixed(Math.abs(v) < 1 ? 2 : 1)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A single straight segment drawn as one rotated View (SVG-free). */
|
|
90
|
+
function LineSegment({
|
|
91
|
+
x1, y1, x2, y2, color, testID,
|
|
92
|
+
}: { x1: number; y1: number; x2: number; y2: number; color: string; testID: string }) {
|
|
93
|
+
const dx = x2 - x1
|
|
94
|
+
const dy = y2 - y1
|
|
95
|
+
const length = Math.sqrt(dx * dx + dy * dy)
|
|
96
|
+
const angle = Math.atan2(dy, dx) * (180 / Math.PI)
|
|
97
|
+
return (
|
|
98
|
+
<View
|
|
99
|
+
testID={testID}
|
|
100
|
+
accessibilityElementsHidden
|
|
101
|
+
style={{
|
|
102
|
+
position: 'absolute',
|
|
103
|
+
left: x1,
|
|
104
|
+
top: y1,
|
|
105
|
+
width: length,
|
|
106
|
+
height: 0,
|
|
107
|
+
borderTopWidth: 1,
|
|
108
|
+
borderStyle: 'dashed',
|
|
109
|
+
borderTopColor: color,
|
|
110
|
+
transformOrigin: '0 0',
|
|
111
|
+
transform: [{ rotate: `${angle}deg` }],
|
|
112
|
+
}}
|
|
113
|
+
/>
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* SVG-free scatter / bubble plot (absolutely-positioned Views), matching the
|
|
119
|
+
* codebase's chart convention so it renders identically on web and native. Used
|
|
120
|
+
* for "main-sequence" plots (instability × abstractness) via the `diagonal`
|
|
121
|
+
* reference line, or any two metrics. Positions and bubble sizes are computed
|
|
122
|
+
* from the data; color and labels are caller-supplied.
|
|
123
|
+
*/
|
|
124
|
+
export function Scatter({
|
|
125
|
+
data,
|
|
126
|
+
width,
|
|
127
|
+
height,
|
|
128
|
+
axis = {},
|
|
129
|
+
diagonal = false,
|
|
130
|
+
onPress,
|
|
131
|
+
selectedId,
|
|
132
|
+
className,
|
|
133
|
+
...props
|
|
134
|
+
}: ScatterProps) {
|
|
135
|
+
const innerW = Math.max(1, width - PLOT_LEFT - PLOT_RIGHT)
|
|
136
|
+
const innerH = Math.max(1, height - PLOT_TOP - PLOT_BOTTOM)
|
|
137
|
+
const xd = domainOf(data.map((d) => d.x), axis.xMin, axis.xMax)
|
|
138
|
+
const yd = domainOf(data.map((d) => d.y), axis.yMin, axis.yMax)
|
|
139
|
+
|
|
140
|
+
const toX = (x: number) => PLOT_LEFT + ((x - xd.min) / (xd.max - xd.min)) * innerW
|
|
141
|
+
const toY = (y: number) => PLOT_TOP + (1 - (y - yd.min) / (yd.max - yd.min)) * innerH
|
|
142
|
+
|
|
143
|
+
const points = data.map((d, i) => ({
|
|
144
|
+
datum: d,
|
|
145
|
+
cx: toX(d.x),
|
|
146
|
+
cy: toY(d.y),
|
|
147
|
+
radius: d.r ?? DEFAULT_R,
|
|
148
|
+
color: d.color ?? PALETTE[i % PALETTE.length],
|
|
149
|
+
}))
|
|
150
|
+
|
|
151
|
+
const ariaLabel = `Scatter plot of ${axis.xLabel ?? 'x'} versus ${axis.yLabel ?? 'y'}, ${data.length} point${data.length === 1 ? '' : 's'}`
|
|
152
|
+
|
|
153
|
+
return (
|
|
154
|
+
<View className={cn('relative', className)} style={{ width, height }} {...props}>
|
|
155
|
+
{/* Non-interactive labelled canvas: grid, axes, ticks, diagonal. */}
|
|
156
|
+
<View
|
|
157
|
+
accessibilityRole="image"
|
|
158
|
+
accessibilityLabel={ariaLabel}
|
|
159
|
+
testID="scatter-canvas"
|
|
160
|
+
style={{ position: 'absolute', top: 0, left: 0, width, height, overflow: 'hidden' }}
|
|
161
|
+
>
|
|
162
|
+
{ticksOf(yd, TICK_COUNT).map((t, i) => {
|
|
163
|
+
const y = toY(t)
|
|
164
|
+
return (
|
|
165
|
+
<View
|
|
166
|
+
key={`y-${i}`}
|
|
167
|
+
accessibilityElementsHidden
|
|
168
|
+
testID="scatter-gridline-y"
|
|
169
|
+
style={{ position: 'absolute', left: PLOT_LEFT, width: innerW, top: y, height: 1, backgroundColor: GRID_LINE }}
|
|
170
|
+
>
|
|
171
|
+
<Text className="text-[9px] text-text-tertiary" style={{ position: 'absolute', left: -PLOT_LEFT, top: -6, width: PLOT_LEFT - 6, textAlign: 'right' }}>
|
|
172
|
+
{formatTick(t)}
|
|
173
|
+
</Text>
|
|
174
|
+
</View>
|
|
175
|
+
)
|
|
176
|
+
})}
|
|
177
|
+
{ticksOf(xd, TICK_COUNT).map((t, i) => {
|
|
178
|
+
const x = toX(t)
|
|
179
|
+
return (
|
|
180
|
+
<View
|
|
181
|
+
key={`x-${i}`}
|
|
182
|
+
accessibilityElementsHidden
|
|
183
|
+
testID="scatter-gridline-x"
|
|
184
|
+
style={{ position: 'absolute', top: PLOT_TOP, height: innerH, left: x, width: 1, backgroundColor: GRID_LINE }}
|
|
185
|
+
>
|
|
186
|
+
<Text className="text-[9px] text-text-tertiary" style={{ position: 'absolute', top: innerH + 4, left: -16, width: 32, textAlign: 'center' }}>
|
|
187
|
+
{formatTick(t)}
|
|
188
|
+
</Text>
|
|
189
|
+
</View>
|
|
190
|
+
)
|
|
191
|
+
})}
|
|
192
|
+
|
|
193
|
+
{/* Axis frame (left + bottom). */}
|
|
194
|
+
<View accessibilityElementsHidden style={{ position: 'absolute', left: PLOT_LEFT, top: PLOT_TOP, width: 1, height: innerH, backgroundColor: AXIS_LINE }} />
|
|
195
|
+
<View accessibilityElementsHidden style={{ position: 'absolute', left: PLOT_LEFT, top: PLOT_TOP + innerH, width: innerW, height: 1, backgroundColor: AXIS_LINE }} />
|
|
196
|
+
|
|
197
|
+
{diagonal && (
|
|
198
|
+
<LineSegment
|
|
199
|
+
testID="scatter-diagonal"
|
|
200
|
+
color={DIAGONAL_LINE}
|
|
201
|
+
x1={toX(xd.min)}
|
|
202
|
+
y1={toY(1 - xd.min)}
|
|
203
|
+
x2={toX(xd.max)}
|
|
204
|
+
y2={toY(1 - xd.max)}
|
|
205
|
+
/>
|
|
206
|
+
)}
|
|
207
|
+
|
|
208
|
+
{axis.xLabel && (
|
|
209
|
+
<Text testID="scatter-x-label" className="text-[10px] font-semibold text-text-secondary" style={{ position: 'absolute', left: PLOT_LEFT, width: innerW, bottom: 2, textAlign: 'center' }}>
|
|
210
|
+
{axis.xLabel}
|
|
211
|
+
</Text>
|
|
212
|
+
)}
|
|
213
|
+
{axis.yLabel && (
|
|
214
|
+
<Text testID="scatter-y-label" className="text-[10px] font-semibold text-text-secondary" style={{ position: 'absolute', left: -innerH / 2 + 6, top: PLOT_TOP + innerH / 2, width: innerH, textAlign: 'center', transformOrigin: '50% 50%', transform: [{ rotate: '-90deg' }] }}>
|
|
215
|
+
{axis.yLabel}
|
|
216
|
+
</Text>
|
|
217
|
+
)}
|
|
218
|
+
</View>
|
|
219
|
+
|
|
220
|
+
{/* Interactive point overlay (kept out of the image-role canvas). */}
|
|
221
|
+
<View style={{ position: 'absolute', top: 0, left: 0, width, height }} testID="scatter-points">
|
|
222
|
+
{points.map((p) => {
|
|
223
|
+
const selected = p.datum.id === selectedId
|
|
224
|
+
const showLabel = p.datum.label && (selected || p.radius >= 10)
|
|
225
|
+
return (
|
|
226
|
+
<Pressable
|
|
227
|
+
key={p.datum.id}
|
|
228
|
+
testID={`scatter-point-${p.datum.id}`}
|
|
229
|
+
accessibilityRole="button"
|
|
230
|
+
accessibilityLabel={p.datum.label ?? p.datum.id}
|
|
231
|
+
onPress={() => onPress?.(p.datum.id)}
|
|
232
|
+
style={{
|
|
233
|
+
position: 'absolute',
|
|
234
|
+
left: p.cx - p.radius,
|
|
235
|
+
top: p.cy - p.radius,
|
|
236
|
+
width: p.radius * 2,
|
|
237
|
+
height: p.radius * 2,
|
|
238
|
+
borderRadius: p.radius,
|
|
239
|
+
backgroundColor: p.color,
|
|
240
|
+
opacity: selected ? 1 : 0.82,
|
|
241
|
+
borderWidth: selected ? 2 : 0,
|
|
242
|
+
borderColor: '#ffffff',
|
|
243
|
+
}}
|
|
244
|
+
>
|
|
245
|
+
{showLabel && (
|
|
246
|
+
<Text
|
|
247
|
+
numberOfLines={1}
|
|
248
|
+
className="text-[9px] font-semibold text-text-primary"
|
|
249
|
+
style={{ position: 'absolute', left: p.radius * 2 + 3, top: p.radius - 6, width: 80 }}
|
|
250
|
+
>
|
|
251
|
+
{p.datum.label}
|
|
252
|
+
</Text>
|
|
253
|
+
)}
|
|
254
|
+
</Pressable>
|
|
255
|
+
)
|
|
256
|
+
})}
|
|
257
|
+
</View>
|
|
258
|
+
</View>
|
|
259
|
+
)
|
|
260
|
+
}
|