@titan-design/react-ui 0.2.5 → 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/docs/WEB_SETUP.md +217 -0
- package/package.json +17 -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,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
|
+
}
|
|
@@ -64,7 +64,7 @@ export function Sidebar({
|
|
|
64
64
|
<View
|
|
65
65
|
style={{ width: currentWidth }}
|
|
66
66
|
className={cn(
|
|
67
|
-
'h-full flex-col bg-surface-base border-r border-border
|
|
67
|
+
'h-full flex-col bg-surface-base border-r border-border transition-all duration-200',
|
|
68
68
|
className
|
|
69
69
|
)}
|
|
70
70
|
{...props}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { Treemap, type TreemapDatum } from './Treemap'
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof Treemap> = {
|
|
5
|
+
title: 'Custom/Treemap',
|
|
6
|
+
component: Treemap,
|
|
7
|
+
tags: ['autodocs'],
|
|
8
|
+
argTypes: {
|
|
9
|
+
width: { control: { type: 'range', min: 200, max: 900, step: 20 } },
|
|
10
|
+
height: { control: { type: 'range', min: 120, max: 600, step: 20 } },
|
|
11
|
+
scale: { control: 'select', options: ['linear', 'sqrt', 'log'] },
|
|
12
|
+
maxTiles: { control: { type: 'range', min: 4, max: 80, step: 1 } },
|
|
13
|
+
},
|
|
14
|
+
}
|
|
15
|
+
export default meta
|
|
16
|
+
type Story = StoryObj<typeof Treemap>
|
|
17
|
+
|
|
18
|
+
const sample: TreemapDatum[] = [
|
|
19
|
+
{ id: 'router', value: 5035, color: '#F83030', label: 'router.ts' },
|
|
20
|
+
{ id: 'engine', value: 4378, color: '#F83030', label: 'engine.ts' },
|
|
21
|
+
{ id: 'linker', value: 1695, color: '#F4A736', label: 'linker.ts' },
|
|
22
|
+
{ id: 'validate', value: 1288, color: '#F4A736', label: 'validate.ts' },
|
|
23
|
+
{ id: 'hook', value: 1050, color: '#F4A736', label: 'hook.ts' },
|
|
24
|
+
{ id: 'update', value: 890, color: '#5B9BD5', label: 'update.ts' },
|
|
25
|
+
{ id: 'pool', value: 504, color: '#5B9BD5', label: 'pool.ts' },
|
|
26
|
+
{ id: 'log', value: 342, color: '#5B9BD5', label: 'log.ts' },
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
export const Default: Story = {
|
|
30
|
+
args: { data: sample, width: 560, height: 300, scale: 'sqrt' },
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A single outlier under a linear scale swallows the canvas... */
|
|
34
|
+
export const OutlierLinear: Story = {
|
|
35
|
+
args: {
|
|
36
|
+
data: [{ id: 'mega', value: 205422, color: '#F83030', label: 'mega.ts' }, ...sample],
|
|
37
|
+
width: 560,
|
|
38
|
+
height: 300,
|
|
39
|
+
scale: 'linear',
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** ...the same data with a sqrt scale keeps every tile legible. */
|
|
44
|
+
export const OutlierSqrt: Story = {
|
|
45
|
+
args: {
|
|
46
|
+
data: [{ id: 'mega', value: 205422, color: '#F83030', label: 'mega.ts' }, ...sample],
|
|
47
|
+
width: 560,
|
|
48
|
+
height: 300,
|
|
49
|
+
scale: 'sqrt',
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const Truncated: Story = {
|
|
54
|
+
args: {
|
|
55
|
+
data: Array.from({ length: 200 }, (_, i) => ({ id: `n${i}`, value: 200 - i })),
|
|
56
|
+
width: 560,
|
|
57
|
+
height: 300,
|
|
58
|
+
maxTiles: 40,
|
|
59
|
+
},
|
|
60
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
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 { Treemap, type TreemapDatum } from './Treemap'
|
|
5
|
+
|
|
6
|
+
const data: TreemapDatum[] = [
|
|
7
|
+
{ id: 'a', value: 100, color: '#5B9BD5', label: 'A' },
|
|
8
|
+
{ id: 'b', value: 40, color: '#14B8A6', label: 'B' },
|
|
9
|
+
{ id: 'c', value: 10, color: '#F4A736', label: 'C' },
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
describe('Treemap', () => {
|
|
13
|
+
it('renders one tile per positive datum', () => {
|
|
14
|
+
render(<Treemap data={data} width={300} height={200} />)
|
|
15
|
+
expect(screen.getByTestId('treemap-tile-a')).toBeInTheDocument()
|
|
16
|
+
expect(screen.getByTestId('treemap-tile-b')).toBeInTheDocument()
|
|
17
|
+
expect(screen.getByTestId('treemap-tile-c')).toBeInTheDocument()
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('drops non-positive values', () => {
|
|
21
|
+
render(<Treemap data={[...data, { id: 'zero', value: 0 }]} width={300} height={200} />)
|
|
22
|
+
expect(screen.queryByTestId('treemap-tile-zero')).not.toBeInTheDocument()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('folds overflow into a single "+M more" tile', () => {
|
|
26
|
+
const many = Array.from({ length: 10 }, (_, i) => ({ id: `n${i}`, value: 10 - i * 0.5 }))
|
|
27
|
+
render(<Treemap data={many} width={300} height={200} maxTiles={4} />)
|
|
28
|
+
expect(screen.getByTestId('treemap-tile-__more__')).toBeInTheDocument()
|
|
29
|
+
// 3 explicit + 1 fold = 4 tiles
|
|
30
|
+
expect(screen.getByLabelText('+7 more')).toBeInTheDocument()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('fires onPress with the tile id', () => {
|
|
34
|
+
const onPress = vi.fn()
|
|
35
|
+
render(<Treemap data={data} width={300} height={200} onPress={onPress} />)
|
|
36
|
+
fireEvent.click(screen.getByTestId('treemap-tile-a'))
|
|
37
|
+
expect(onPress).toHaveBeenCalledWith('a')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('does not fire onPress for the fold tile', () => {
|
|
41
|
+
const onPress = vi.fn()
|
|
42
|
+
const many = Array.from({ length: 6 }, (_, i) => ({ id: `n${i}`, value: 6 - i }))
|
|
43
|
+
render(<Treemap data={many} width={300} height={200} maxTiles={3} onPress={onPress} />)
|
|
44
|
+
fireEvent.click(screen.getByTestId('treemap-tile-__more__'))
|
|
45
|
+
expect(onPress).not.toHaveBeenCalled()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('assigns fallback palette colors when omitted', () => {
|
|
49
|
+
const { container } = render(
|
|
50
|
+
<Treemap data={[{ id: 'x', value: 1 }]} width={100} height={100} />,
|
|
51
|
+
)
|
|
52
|
+
expect(container.firstChild).toBeInTheDocument()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('renders nothing meaningful for all-zero data without crashing', () => {
|
|
56
|
+
render(<Treemap data={[{ id: 'z', value: 0 }]} width={100} height={100} />)
|
|
57
|
+
expect(screen.queryByTestId('treemap-tile-z')).not.toBeInTheDocument()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe('accessibility', () => {
|
|
61
|
+
it('has no accessibility violations', async () => {
|
|
62
|
+
const { container } = render(<Treemap data={data} width={300} height={200} />)
|
|
63
|
+
const results = await axe(container)
|
|
64
|
+
expect(results).toHaveNoViolations()
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
})
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { View, Text, Pressable, type ViewProps } from 'react-native'
|
|
3
|
+
import { cn } from '../../../utils/cn'
|
|
4
|
+
|
|
5
|
+
export interface TreemapDatum {
|
|
6
|
+
/** Stable identity — returned by onPress and used as the React key. */
|
|
7
|
+
id: string
|
|
8
|
+
/** Area weight (e.g. size, count, severity). Non-positive values are dropped. */
|
|
9
|
+
value: number
|
|
10
|
+
/** Tile fill color. Falls back to a built-in categorical palette by index. */
|
|
11
|
+
color?: string
|
|
12
|
+
/** Optional label rendered inside sufficiently large tiles. */
|
|
13
|
+
label?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type TreemapScale = 'linear' | 'sqrt' | 'log'
|
|
17
|
+
|
|
18
|
+
export interface TreemapProps extends Omit<ViewProps, 'children'> {
|
|
19
|
+
data: TreemapDatum[]
|
|
20
|
+
/** Layout box. Required — the treemap fills exactly this area. */
|
|
21
|
+
width: number
|
|
22
|
+
height: number
|
|
23
|
+
/**
|
|
24
|
+
* Area scale. Real-world weights are often heavy-tailed, where one outlier
|
|
25
|
+
* under a `linear` scale swallows the whole canvas. `sqrt` (default) damps
|
|
26
|
+
* that while preserving order; `log` compresses hardest.
|
|
27
|
+
*/
|
|
28
|
+
scale?: TreemapScale
|
|
29
|
+
/** Show at most N tiles; the remainder collapse into one "+M more" tile. */
|
|
30
|
+
maxTiles?: number
|
|
31
|
+
/** Fires with a tile's id on press (never for the "+M more" tile). */
|
|
32
|
+
onPress?: (id: string) => void
|
|
33
|
+
/** Draws a highlight border on the matching tile. */
|
|
34
|
+
selectedId?: string
|
|
35
|
+
className?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Titan categorical fallback palette (data-1..data-8). */
|
|
39
|
+
const PALETTE = [
|
|
40
|
+
'#5B9BD5', '#14B8A6', '#F4A736', '#F83030',
|
|
41
|
+
'#823CA0', '#D4A520', '#406D87', '#43C6B7',
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
interface Rect {
|
|
45
|
+
x: number
|
|
46
|
+
y: number
|
|
47
|
+
w: number
|
|
48
|
+
h: number
|
|
49
|
+
datum: TreemapDatum
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function scaleValue(v: number, scale: TreemapScale): number {
|
|
53
|
+
if (v <= 0) return 0
|
|
54
|
+
if (scale === 'sqrt') return Math.sqrt(v)
|
|
55
|
+
if (scale === 'log') return Math.log10(v + 1)
|
|
56
|
+
return v
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function worstRatio(row: { area: number }[], side: number): number {
|
|
60
|
+
const sum = row.reduce((s, r) => s + r.area, 0)
|
|
61
|
+
const thickness = sum / side
|
|
62
|
+
let worst = 0
|
|
63
|
+
for (const r of row) {
|
|
64
|
+
const len = r.area / thickness
|
|
65
|
+
const ratio = Math.max(thickness / len, len / thickness)
|
|
66
|
+
if (ratio > worst) worst = ratio
|
|
67
|
+
}
|
|
68
|
+
return worst
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Squarified treemap (Bruls, Huizing & van Wijk, 2000). */
|
|
72
|
+
function squarify(data: TreemapDatum[], w: number, h: number): Rect[] {
|
|
73
|
+
const total = data.reduce((s, d) => s + d.value, 0)
|
|
74
|
+
if (total <= 0 || w <= 0 || h <= 0) return []
|
|
75
|
+
const scaled = data.map((d) => ({ datum: d, area: (d.value / total) * w * h }))
|
|
76
|
+
const out: Rect[] = []
|
|
77
|
+
let rx = 0
|
|
78
|
+
let ry = 0
|
|
79
|
+
let rw = w
|
|
80
|
+
let rh = h
|
|
81
|
+
let i = 0
|
|
82
|
+
|
|
83
|
+
while (i < scaled.length) {
|
|
84
|
+
const vertical = rw >= rh
|
|
85
|
+
const side = vertical ? rh : rw
|
|
86
|
+
let row: typeof scaled = []
|
|
87
|
+
let bestWorst = Infinity
|
|
88
|
+
let j = i
|
|
89
|
+
while (j < scaled.length) {
|
|
90
|
+
const trial = [...row, scaled[j]]
|
|
91
|
+
const worst = worstRatio(trial, side)
|
|
92
|
+
if (row.length > 0 && worst > bestWorst) break
|
|
93
|
+
row = trial
|
|
94
|
+
bestWorst = worst
|
|
95
|
+
j++
|
|
96
|
+
}
|
|
97
|
+
const rowArea = row.reduce((s, r) => s + r.area, 0)
|
|
98
|
+
const thickness = rowArea / side
|
|
99
|
+
let along = vertical ? ry : rx
|
|
100
|
+
for (const r of row) {
|
|
101
|
+
const len = r.area / thickness
|
|
102
|
+
out.push(
|
|
103
|
+
vertical
|
|
104
|
+
? { x: rx, y: along, w: thickness, h: len, datum: r.datum }
|
|
105
|
+
: { x: along, y: ry, w: len, h: thickness, datum: r.datum },
|
|
106
|
+
)
|
|
107
|
+
along += len
|
|
108
|
+
}
|
|
109
|
+
if (vertical) {
|
|
110
|
+
rx += thickness
|
|
111
|
+
rw -= thickness
|
|
112
|
+
} else {
|
|
113
|
+
ry += thickness
|
|
114
|
+
rh -= thickness
|
|
115
|
+
}
|
|
116
|
+
i = j
|
|
117
|
+
}
|
|
118
|
+
return out
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Squarified treemap. SVG-free (absolutely-positioned tiles) so it renders
|
|
123
|
+
* identically on web (react-native-web) and native. Layout is computed from
|
|
124
|
+
* `value`; color and labels are caller-supplied.
|
|
125
|
+
*/
|
|
126
|
+
export function Treemap({
|
|
127
|
+
data,
|
|
128
|
+
width,
|
|
129
|
+
height,
|
|
130
|
+
scale = 'sqrt',
|
|
131
|
+
maxTiles = 60,
|
|
132
|
+
onPress,
|
|
133
|
+
selectedId,
|
|
134
|
+
className,
|
|
135
|
+
...props
|
|
136
|
+
}: TreemapProps) {
|
|
137
|
+
const clean = data
|
|
138
|
+
.filter((d) => d.value > 0)
|
|
139
|
+
.sort((a, b) => b.value - a.value)
|
|
140
|
+
.map((d, i) => ({ ...d, color: d.color ?? PALETTE[i % PALETTE.length] }))
|
|
141
|
+
|
|
142
|
+
let tiles = clean
|
|
143
|
+
if (clean.length > maxTiles) {
|
|
144
|
+
const head = clean.slice(0, maxTiles - 1)
|
|
145
|
+
const restValue = clean.slice(maxTiles - 1).reduce((s, d) => s + d.value, 0)
|
|
146
|
+
tiles = [
|
|
147
|
+
...head,
|
|
148
|
+
{ id: '__more__', value: restValue, color: '#3a3a3a', label: `+${clean.length - (maxTiles - 1)} more` },
|
|
149
|
+
]
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const weighted = tiles.map((d) => ({ ...d, value: scaleValue(d.value, scale) }))
|
|
153
|
+
const rects = squarify(weighted, width, height)
|
|
154
|
+
|
|
155
|
+
return (
|
|
156
|
+
<View className={cn('relative', className)} style={{ width, height }} {...props}>
|
|
157
|
+
{rects.map((r) => {
|
|
158
|
+
const selected = r.datum.id === selectedId
|
|
159
|
+
const labelFits = r.w > 54 && r.h > 22
|
|
160
|
+
const pressable = r.datum.id !== '__more__'
|
|
161
|
+
return (
|
|
162
|
+
<Pressable
|
|
163
|
+
key={r.datum.id}
|
|
164
|
+
testID={`treemap-tile-${r.datum.id}`}
|
|
165
|
+
accessibilityLabel={r.datum.label ?? r.datum.id}
|
|
166
|
+
onPress={pressable ? () => onPress?.(r.datum.id) : undefined}
|
|
167
|
+
style={{
|
|
168
|
+
position: 'absolute',
|
|
169
|
+
left: r.x,
|
|
170
|
+
top: r.y,
|
|
171
|
+
width: Math.max(0, r.w - 2),
|
|
172
|
+
height: Math.max(0, r.h - 2),
|
|
173
|
+
backgroundColor: r.datum.color,
|
|
174
|
+
borderRadius: 3,
|
|
175
|
+
opacity: selected ? 1 : 0.9,
|
|
176
|
+
borderWidth: selected ? 2 : 0,
|
|
177
|
+
borderColor: '#ffffff',
|
|
178
|
+
padding: 4,
|
|
179
|
+
overflow: 'hidden',
|
|
180
|
+
}}
|
|
181
|
+
>
|
|
182
|
+
{labelFits && (
|
|
183
|
+
<Text numberOfLines={2} className="text-[10px] font-semibold text-[#0b0b0b]">
|
|
184
|
+
{r.datum.label ?? r.datum.id}
|
|
185
|
+
</Text>
|
|
186
|
+
)}
|
|
187
|
+
</Pressable>
|
|
188
|
+
)
|
|
189
|
+
})}
|
|
190
|
+
</View>
|
|
191
|
+
)
|
|
192
|
+
}
|