jfs-components 0.0.79 → 0.0.85
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/CHANGELOG.md +29 -0
- package/lib/commonjs/components/AppBar/AppBar.js +70 -6
- package/lib/commonjs/components/AreaLineChart/AreaLineChart.js +866 -0
- package/lib/commonjs/components/AreaLineChart/chartMath.js +252 -0
- package/lib/commonjs/components/Attached/Attached.js +76 -7
- package/lib/commonjs/components/BubbleChart/BubbleChart.js +191 -0
- package/lib/commonjs/components/BubbleChart/bubblePacking.js +378 -0
- package/lib/commonjs/components/Checkbox/Checkbox.js +18 -2
- package/lib/commonjs/components/ClusterBubble/ClusterBubble.js +272 -0
- package/lib/commonjs/components/Drawer/Drawer.js +6 -1
- package/lib/commonjs/components/DropdownInput/DropdownInput.js +30 -6
- package/lib/commonjs/components/ExpandableCheckbox/ExpandableCheckbox.js +17 -11
- package/lib/commonjs/components/FormField/FormField.js +1 -14
- package/lib/commonjs/components/FullscreenModal/FullscreenModal.js +5 -1
- package/lib/commonjs/components/ListItem/ListItem.js +6 -11
- package/lib/commonjs/components/MessageField/MessageField.js +1 -13
- package/lib/commonjs/components/MetricLegendItem/MetricLegendItem.js +7 -1
- package/lib/commonjs/components/PaymentFeedback/PaymentFeedback.js +12 -9
- package/lib/commonjs/components/PlanComparisonCard/PlanComparisonCard.js +69 -160
- package/lib/commonjs/components/Spinner/Spinner.js +217 -0
- package/lib/commonjs/components/TextInput/TextInput.js +33 -18
- package/lib/commonjs/components/index.js +34 -0
- package/lib/commonjs/design-tokens/Coin Variables-variables-full.json +1 -1
- package/lib/commonjs/icons/components/IconArrowdown.js +19 -0
- package/lib/commonjs/icons/components/IconArrowup.js +19 -0
- package/lib/commonjs/icons/components/IconChevrondowncircle.js +19 -0
- package/lib/commonjs/icons/components/IconChevronleftcircle.js +19 -0
- package/lib/commonjs/icons/components/IconChevronrightcircle.js +19 -0
- package/lib/commonjs/icons/components/IconChevronupcircle.js +19 -0
- package/lib/commonjs/icons/components/IconOsnavback.js +19 -0
- package/lib/commonjs/icons/components/IconOsnavcenter.js +19 -0
- package/lib/commonjs/icons/components/IconOsnavhome.js +19 -0
- package/lib/commonjs/icons/components/IconOsnavtask.js +19 -0
- package/lib/commonjs/icons/components/IconSignin.js +19 -0
- package/lib/commonjs/icons/components/IconSignout.js +19 -0
- package/lib/commonjs/icons/components/index.js +132 -0
- package/lib/commonjs/icons/registry.js +2 -2
- package/lib/module/components/AppBar/AppBar.js +70 -6
- package/lib/module/components/AreaLineChart/AreaLineChart.js +859 -0
- package/lib/module/components/AreaLineChart/chartMath.js +242 -0
- package/lib/module/components/Attached/Attached.js +76 -7
- package/lib/module/components/BubbleChart/BubbleChart.js +185 -0
- package/lib/module/components/BubbleChart/bubblePacking.js +370 -0
- package/lib/module/components/Checkbox/Checkbox.js +18 -2
- package/lib/module/components/ClusterBubble/ClusterBubble.js +267 -0
- package/lib/module/components/Drawer/Drawer.js +6 -1
- package/lib/module/components/DropdownInput/DropdownInput.js +30 -6
- package/lib/module/components/ExpandableCheckbox/ExpandableCheckbox.js +17 -11
- package/lib/module/components/FormField/FormField.js +3 -16
- package/lib/module/components/FullscreenModal/FullscreenModal.js +5 -1
- package/lib/module/components/ListItem/ListItem.js +6 -11
- package/lib/module/components/MessageField/MessageField.js +3 -15
- package/lib/module/components/MetricLegendItem/MetricLegendItem.js +7 -1
- package/lib/module/components/PaymentFeedback/PaymentFeedback.js +13 -9
- package/lib/module/components/PlanComparisonCard/PlanComparisonCard.js +72 -160
- package/lib/module/components/Spinner/Spinner.js +212 -0
- package/lib/module/components/TextInput/TextInput.js +34 -19
- package/lib/module/components/index.js +4 -0
- package/lib/module/design-tokens/Coin Variables-variables-full.json +1 -1
- package/lib/module/icons/components/IconArrowdown.js +12 -0
- package/lib/module/icons/components/IconArrowup.js +12 -0
- package/lib/module/icons/components/IconChevrondowncircle.js +12 -0
- package/lib/module/icons/components/IconChevronleftcircle.js +12 -0
- package/lib/module/icons/components/IconChevronrightcircle.js +12 -0
- package/lib/module/icons/components/IconChevronupcircle.js +12 -0
- package/lib/module/icons/components/IconOsnavback.js +12 -0
- package/lib/module/icons/components/IconOsnavcenter.js +12 -0
- package/lib/module/icons/components/IconOsnavhome.js +12 -0
- package/lib/module/icons/components/IconOsnavtask.js +12 -0
- package/lib/module/icons/components/IconSignin.js +12 -0
- package/lib/module/icons/components/IconSignout.js +12 -0
- package/lib/module/icons/components/index.js +12 -0
- package/lib/module/icons/registry.js +2 -2
- package/lib/typescript/src/components/AppBar/AppBar.d.ts +12 -1
- package/lib/typescript/src/components/AreaLineChart/AreaLineChart.d.ts +212 -0
- package/lib/typescript/src/components/AreaLineChart/chartMath.d.ts +90 -0
- package/lib/typescript/src/components/Attached/Attached.d.ts +19 -16
- package/lib/typescript/src/components/BubbleChart/BubbleChart.d.ts +81 -0
- package/lib/typescript/src/components/BubbleChart/bubblePacking.d.ts +83 -0
- package/lib/typescript/src/components/ClusterBubble/ClusterBubble.d.ts +76 -0
- package/lib/typescript/src/components/DropdownInput/DropdownInput.d.ts +3 -2
- package/lib/typescript/src/components/ListItem/ListItem.d.ts +3 -3
- package/lib/typescript/src/components/MetricLegendItem/MetricLegendItem.d.ts +7 -1
- package/lib/typescript/src/components/PaymentFeedback/PaymentFeedback.d.ts +5 -1
- package/lib/typescript/src/components/PlanComparisonCard/PlanComparisonCard.d.ts +10 -8
- package/lib/typescript/src/components/Spinner/Spinner.d.ts +45 -0
- package/lib/typescript/src/components/index.d.ts +4 -0
- package/lib/typescript/src/icons/components/IconArrowdown.d.ts +3 -0
- package/lib/typescript/src/icons/components/IconArrowup.d.ts +3 -0
- package/lib/typescript/src/icons/components/IconChevrondowncircle.d.ts +3 -0
- package/lib/typescript/src/icons/components/IconChevronleftcircle.d.ts +3 -0
- package/lib/typescript/src/icons/components/IconChevronrightcircle.d.ts +3 -0
- package/lib/typescript/src/icons/components/IconChevronupcircle.d.ts +3 -0
- package/lib/typescript/src/icons/components/IconOsnavback.d.ts +3 -0
- package/lib/typescript/src/icons/components/IconOsnavcenter.d.ts +3 -0
- package/lib/typescript/src/icons/components/IconOsnavhome.d.ts +3 -0
- package/lib/typescript/src/icons/components/IconOsnavtask.d.ts +3 -0
- package/lib/typescript/src/icons/components/IconSignin.d.ts +3 -0
- package/lib/typescript/src/icons/components/IconSignout.d.ts +3 -0
- package/lib/typescript/src/icons/components/index.d.ts +12 -0
- package/lib/typescript/src/icons/registry.d.ts +1 -1
- package/package.json +3 -2
- package/src/components/AppBar/AppBar.tsx +92 -12
- package/src/components/AreaLineChart/AreaLineChart.tsx +1161 -0
- package/src/components/AreaLineChart/chartMath.ts +265 -0
- package/src/components/Attached/Attached.tsx +94 -7
- package/src/components/BubbleChart/BubbleChart.tsx +319 -0
- package/src/components/BubbleChart/bubblePacking.ts +397 -0
- package/src/components/Checkbox/Checkbox.tsx +14 -2
- package/src/components/ClusterBubble/ClusterBubble.tsx +359 -0
- package/src/components/Drawer/Drawer.tsx +4 -0
- package/src/components/DropdownInput/DropdownInput.tsx +54 -20
- package/src/components/ExpandableCheckbox/ExpandableCheckbox.tsx +13 -9
- package/src/components/FormField/FormField.tsx +3 -19
- package/src/components/FullscreenModal/FullscreenModal.tsx +3 -0
- package/src/components/ListItem/ListItem.tsx +14 -16
- package/src/components/MessageField/MessageField.tsx +3 -18
- package/src/components/MetricLegendItem/MetricLegendItem.tsx +20 -6
- package/src/components/PaymentFeedback/PaymentFeedback.tsx +15 -8
- package/src/components/PlanComparisonCard/PlanComparisonCard.tsx +82 -192
- package/src/components/Spinner/Spinner.tsx +273 -0
- package/src/components/TextInput/TextInput.tsx +37 -19
- package/src/components/index.ts +4 -0
- package/src/design-tokens/Coin Variables-variables-full.json +1 -1
- package/src/icons/components/IconArrowdown.tsx +11 -0
- package/src/icons/components/IconArrowup.tsx +11 -0
- package/src/icons/components/IconChevrondowncircle.tsx +11 -0
- package/src/icons/components/IconChevronleftcircle.tsx +11 -0
- package/src/icons/components/IconChevronrightcircle.tsx +11 -0
- package/src/icons/components/IconChevronupcircle.tsx +11 -0
- package/src/icons/components/IconOsnavback.tsx +11 -0
- package/src/icons/components/IconOsnavcenter.tsx +11 -0
- package/src/icons/components/IconOsnavhome.tsx +11 -0
- package/src/icons/components/IconOsnavtask.tsx +11 -0
- package/src/icons/components/IconSignin.tsx +11 -0
- package/src/icons/components/IconSignout.tsx +11 -0
- package/src/icons/components/index.ts +12 -0
- package/src/icons/registry.ts +49 -1
|
@@ -0,0 +1,1161 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
createContext,
|
|
3
|
+
useCallback,
|
|
4
|
+
useContext,
|
|
5
|
+
useEffect,
|
|
6
|
+
useMemo,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
} from 'react'
|
|
10
|
+
import {
|
|
11
|
+
LayoutChangeEvent,
|
|
12
|
+
PanResponder,
|
|
13
|
+
Platform,
|
|
14
|
+
Pressable,
|
|
15
|
+
StyleSheet,
|
|
16
|
+
Text,
|
|
17
|
+
View,
|
|
18
|
+
type GestureResponderEvent,
|
|
19
|
+
type StyleProp,
|
|
20
|
+
type TextStyle,
|
|
21
|
+
type ViewStyle,
|
|
22
|
+
} from 'react-native'
|
|
23
|
+
import Svg, { Circle, Line, Path } from 'react-native-svg'
|
|
24
|
+
import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
|
|
25
|
+
import { useTokens } from '../../design-tokens/JFSThemeProvider'
|
|
26
|
+
import { EMPTY_MODES } from '../../utils/react-utils'
|
|
27
|
+
import MetricLegendItem from '../MetricLegendItem/MetricLegendItem'
|
|
28
|
+
import {
|
|
29
|
+
buildAreaPath,
|
|
30
|
+
buildLineSegments,
|
|
31
|
+
createLinearScale,
|
|
32
|
+
extent,
|
|
33
|
+
nearestIndex,
|
|
34
|
+
niceTicks,
|
|
35
|
+
resolvePoints,
|
|
36
|
+
type Curve,
|
|
37
|
+
type LinearScale,
|
|
38
|
+
type PixelPoint,
|
|
39
|
+
type ResolvedPoint,
|
|
40
|
+
} from './chartMath'
|
|
41
|
+
|
|
42
|
+
// --- Public types ---------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
/** A single data point. Bare numbers are also accepted in `data`. */
|
|
45
|
+
export type ChartPoint = {
|
|
46
|
+
/** Position on the x domain. Defaults to the array index. */
|
|
47
|
+
x?: number | string
|
|
48
|
+
/** Value on the y domain. */
|
|
49
|
+
y: number
|
|
50
|
+
/**
|
|
51
|
+
* Marks this point as projected / low-confidence. The line segment
|
|
52
|
+
* ending at this point is rendered dashed.
|
|
53
|
+
*/
|
|
54
|
+
projected?: boolean
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** One line+area series. Pass one for a single chart, several to overlap. */
|
|
58
|
+
export type ChartSeries = {
|
|
59
|
+
/** Stable React key. */
|
|
60
|
+
key?: React.Key
|
|
61
|
+
/** The data, either bare y-values or `ChartPoint`s. */
|
|
62
|
+
data: Array<number | ChartPoint>
|
|
63
|
+
/** Legend / tooltip label. */
|
|
64
|
+
label?: string
|
|
65
|
+
/**
|
|
66
|
+
* `Appearance / DataViz` mode used to resolve the series color from the
|
|
67
|
+
* `dataViz/bg` token (e.g. `Primary`, `Secondary`). Defaults cycle.
|
|
68
|
+
*/
|
|
69
|
+
appearance?: string
|
|
70
|
+
/** Hard-override the line + dot color (bypasses token resolution). */
|
|
71
|
+
color?: string
|
|
72
|
+
/** Hard-override the area fill color (bypasses token resolution). */
|
|
73
|
+
areaColor?: string
|
|
74
|
+
/** Whether to render the filled area. Defaults to `true`. */
|
|
75
|
+
showArea?: boolean
|
|
76
|
+
/** Whether to render the line stroke. Defaults to `true`. */
|
|
77
|
+
showLine?: boolean
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export type ChartInset = {
|
|
81
|
+
top?: number
|
|
82
|
+
bottom?: number
|
|
83
|
+
left?: number
|
|
84
|
+
right?: number
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export type GoalPinConfig = {
|
|
88
|
+
/** Pill content. */
|
|
89
|
+
value: React.ReactNode
|
|
90
|
+
/** Data index the pin anchors to. Defaults to the last point. */
|
|
91
|
+
atIndex?: number
|
|
92
|
+
/** Which series the pin anchors to. Defaults to `0`. */
|
|
93
|
+
seriesIndex?: number
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export type AreaLineChartProps = {
|
|
97
|
+
/**
|
|
98
|
+
* The series to plot. A single entry renders the "Area Line Chart"
|
|
99
|
+
* preset; multiple entries render the overlapping multi-series preset
|
|
100
|
+
* with an automatic legend.
|
|
101
|
+
*/
|
|
102
|
+
series: ChartSeries[]
|
|
103
|
+
/** Labels rendered along the x axis (left-to-right). */
|
|
104
|
+
xLabels?: Array<string | number>
|
|
105
|
+
/** Force the lower bound of the y domain. */
|
|
106
|
+
yMin?: number
|
|
107
|
+
/** Force the upper bound of the y domain. */
|
|
108
|
+
yMax?: number
|
|
109
|
+
/** Approximate number of y-axis ticks / grid lines. Defaults to `4`. */
|
|
110
|
+
numberOfTicks?: number
|
|
111
|
+
/** Interpolation between points. Defaults to `linear` (straight segments). */
|
|
112
|
+
curve?: Curve
|
|
113
|
+
/** Plot drawing height in px (excludes the x-axis row). Defaults to `218`. */
|
|
114
|
+
height?: number
|
|
115
|
+
/** Fake margin inside the SVG so strokes/dots/pins are not clipped. */
|
|
116
|
+
contentInset?: ChartInset
|
|
117
|
+
/** Toggle the background grid. Defaults to `true`. */
|
|
118
|
+
showGrid?: boolean
|
|
119
|
+
/** Toggle the x-axis labels. Defaults to `true`. */
|
|
120
|
+
showXAxis?: boolean
|
|
121
|
+
/** Toggle the y-axis labels. Defaults to `true`. */
|
|
122
|
+
showYAxis?: boolean
|
|
123
|
+
/** Toggle the legend (multi-series only). Defaults to `true`. */
|
|
124
|
+
showLegend?: boolean
|
|
125
|
+
/** Render a dot on every data point. Defaults to `false`. */
|
|
126
|
+
showDots?: boolean
|
|
127
|
+
/** Format an x label. Receives the raw label and index. */
|
|
128
|
+
formatX?: (label: string | number, index: number) => React.ReactNode
|
|
129
|
+
/** Format a y-axis tick label. */
|
|
130
|
+
formatY?: (value: number) => React.ReactNode
|
|
131
|
+
/** Format a value shown in the tooltip. Defaults to `formatY`. */
|
|
132
|
+
formatValue?: (value: number, series: ChartSeries) => React.ReactNode
|
|
133
|
+
/** Render a goal pin anchored to a data point. */
|
|
134
|
+
goalPin?: GoalPinConfig
|
|
135
|
+
/** Controlled active index (the highlighted point). */
|
|
136
|
+
activeIndex?: number | null
|
|
137
|
+
/** Initial active index for the uncontrolled case. */
|
|
138
|
+
defaultActiveIndex?: number | null
|
|
139
|
+
/** Notified whenever the active index changes. */
|
|
140
|
+
onActiveIndexChange?: (index: number | null) => void
|
|
141
|
+
/** Enable hover/press-drag interaction + tooltip. Defaults to `true`. */
|
|
142
|
+
interactive?: boolean
|
|
143
|
+
/** Design token modes for theming (e.g. `{ 'Color Mode': 'Light' }`). */
|
|
144
|
+
modes?: Record<string, any>
|
|
145
|
+
/** Container style override. */
|
|
146
|
+
style?: StyleProp<ViewStyle>
|
|
147
|
+
/** Extra SVG-decorator children rendered on top of the default layers. */
|
|
148
|
+
children?: React.ReactNode
|
|
149
|
+
/** Accessibility label for the whole chart. */
|
|
150
|
+
accessibilityLabel?: string
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// --- Internal resolved types ----------------------------------------------
|
|
154
|
+
|
|
155
|
+
type ResolvedSeries = {
|
|
156
|
+
key: React.Key
|
|
157
|
+
label?: string
|
|
158
|
+
appearance: string
|
|
159
|
+
lineColor: string
|
|
160
|
+
areaColor: string
|
|
161
|
+
showArea: boolean
|
|
162
|
+
showLine: boolean
|
|
163
|
+
points: ResolvedPoint[]
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
type ChartContextValue = {
|
|
167
|
+
width: number
|
|
168
|
+
height: number
|
|
169
|
+
inset: Required<ChartInset>
|
|
170
|
+
xScale: LinearScale
|
|
171
|
+
yScale: LinearScale
|
|
172
|
+
yTicks: number[]
|
|
173
|
+
/** Pixel x position for each canonical data index. */
|
|
174
|
+
indexXs: number[]
|
|
175
|
+
/** Number of canonical data points. */
|
|
176
|
+
count: number
|
|
177
|
+
series: ResolvedSeries[]
|
|
178
|
+
curve: Curve
|
|
179
|
+
activeIndex: number | null
|
|
180
|
+
setActiveIndex: (index: number | null) => void
|
|
181
|
+
xLabels?: Array<string | number>
|
|
182
|
+
formatX: (label: string | number, index: number) => React.ReactNode
|
|
183
|
+
formatY: (value: number) => React.ReactNode
|
|
184
|
+
showDots: boolean
|
|
185
|
+
modes: Record<string, any>
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const ChartContext = createContext<ChartContextValue | null>(null)
|
|
189
|
+
|
|
190
|
+
/** Access the surrounding chart geometry from a decorator/sub-component. */
|
|
191
|
+
export function useChart(): ChartContextValue {
|
|
192
|
+
const ctx = useContext(ChartContext)
|
|
193
|
+
if (!ctx) {
|
|
194
|
+
throw new Error('AreaLineChart sub-components must be used within <AreaLineChart>')
|
|
195
|
+
}
|
|
196
|
+
return ctx
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// --- Helpers ---------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
const DEFAULT_APPEARANCE_CYCLE = [
|
|
202
|
+
'Primary',
|
|
203
|
+
'Secondary',
|
|
204
|
+
'Tertiary',
|
|
205
|
+
'Quaternary',
|
|
206
|
+
'Quinary',
|
|
207
|
+
'Senary',
|
|
208
|
+
] as const
|
|
209
|
+
|
|
210
|
+
const DEFAULT_INSET: Required<ChartInset> = { top: 16, bottom: 8, left: 8, right: 8 }
|
|
211
|
+
|
|
212
|
+
const toNumber = (value: unknown, fallback: number): number => {
|
|
213
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value
|
|
214
|
+
if (typeof value === 'string') {
|
|
215
|
+
const parsed = Number(value)
|
|
216
|
+
if (Number.isFinite(parsed)) return parsed
|
|
217
|
+
}
|
|
218
|
+
return fallback
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const toFontWeight = (value: unknown, fallback: TextStyle['fontWeight']): TextStyle['fontWeight'] => {
|
|
222
|
+
if (typeof value === 'number') return String(value) as TextStyle['fontWeight']
|
|
223
|
+
if (typeof value === 'string') return value as TextStyle['fontWeight']
|
|
224
|
+
return fallback
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const appearanceFor = (index: number) =>
|
|
228
|
+
DEFAULT_APPEARANCE_CYCLE[index % DEFAULT_APPEARANCE_CYCLE.length]
|
|
229
|
+
|
|
230
|
+
/** Resolve a series' strong (line/dot) color via the `dataViz/bg` token. */
|
|
231
|
+
const resolveLineColor = (
|
|
232
|
+
color: string | undefined,
|
|
233
|
+
appearance: string,
|
|
234
|
+
modes: Record<string, any>
|
|
235
|
+
): string => {
|
|
236
|
+
if (color) return color
|
|
237
|
+
return (
|
|
238
|
+
(getVariableByName('dataViz/bg', {
|
|
239
|
+
...modes,
|
|
240
|
+
'Appearance / DataViz': appearance,
|
|
241
|
+
'Emphasis / DataViz': 'High',
|
|
242
|
+
}) as string | null) ?? '#5d00b5'
|
|
243
|
+
)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Resolve a series' light area-fill color via the `dataViz/bg` token. */
|
|
247
|
+
const resolveAreaColor = (
|
|
248
|
+
color: string | undefined,
|
|
249
|
+
lineColor: string,
|
|
250
|
+
appearance: string,
|
|
251
|
+
modes: Record<string, any>
|
|
252
|
+
): string => {
|
|
253
|
+
if (color) return color
|
|
254
|
+
return (
|
|
255
|
+
(getVariableByName('dataViz/bg', {
|
|
256
|
+
...modes,
|
|
257
|
+
'Appearance / DataViz': appearance,
|
|
258
|
+
'Emphasis / DataViz': 'Low',
|
|
259
|
+
}) as string | null) ?? lineColor
|
|
260
|
+
)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const defaultFormatY = (value: number): React.ReactNode => String(value)
|
|
264
|
+
const defaultFormatX = (label: string | number): React.ReactNode => String(label)
|
|
265
|
+
|
|
266
|
+
// --- Main component --------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* `AreaLineChart` is a lightweight, token-driven area/line chart built
|
|
270
|
+
* entirely on `react-native-svg`. A single `series` renders one filled
|
|
271
|
+
* area with a line on top (plus an optional goal pin); multiple `series`
|
|
272
|
+
* overlap with an automatic legend. It supports smooth/linear curves,
|
|
273
|
+
* dashed "projected" segments, a configurable grid and axes, and a
|
|
274
|
+
* cross-platform crosshair tooltip (hover on web, press-drag on native).
|
|
275
|
+
*
|
|
276
|
+
* The reusable building blocks (`AreaLineChart.Grid`, `.XAxis`, `.YAxis`,
|
|
277
|
+
* `.GoalPin`) read the shared chart geometry through `useChart()`, so you
|
|
278
|
+
* can also compose them manually or add your own SVG decorators as
|
|
279
|
+
* children.
|
|
280
|
+
*
|
|
281
|
+
* @component
|
|
282
|
+
*/
|
|
283
|
+
function AreaLineChart({
|
|
284
|
+
series,
|
|
285
|
+
xLabels,
|
|
286
|
+
yMin,
|
|
287
|
+
yMax,
|
|
288
|
+
numberOfTicks = 4,
|
|
289
|
+
curve = 'linear',
|
|
290
|
+
height = 218,
|
|
291
|
+
contentInset,
|
|
292
|
+
showGrid = true,
|
|
293
|
+
showXAxis = true,
|
|
294
|
+
showYAxis = true,
|
|
295
|
+
showLegend = true,
|
|
296
|
+
showDots = false,
|
|
297
|
+
formatX = defaultFormatX,
|
|
298
|
+
formatY = defaultFormatY,
|
|
299
|
+
formatValue,
|
|
300
|
+
goalPin,
|
|
301
|
+
activeIndex: activeIndexProp,
|
|
302
|
+
defaultActiveIndex = null,
|
|
303
|
+
onActiveIndexChange,
|
|
304
|
+
interactive = true,
|
|
305
|
+
modes: propModes = EMPTY_MODES,
|
|
306
|
+
style,
|
|
307
|
+
children,
|
|
308
|
+
accessibilityLabel,
|
|
309
|
+
}: AreaLineChartProps) {
|
|
310
|
+
const { modes: globalModes } = useTokens()
|
|
311
|
+
const modes = useMemo(() => ({ ...globalModes, ...propModes }), [globalModes, propModes])
|
|
312
|
+
|
|
313
|
+
const inset = useMemo<Required<ChartInset>>(
|
|
314
|
+
() => ({ ...DEFAULT_INSET, ...(contentInset || {}) }),
|
|
315
|
+
[contentInset]
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
// Plot width is measured; height is fixed by the prop.
|
|
319
|
+
const [plotWidth, setPlotWidth] = useState(0)
|
|
320
|
+
|
|
321
|
+
const handlePlotLayout = useCallback((e: LayoutChangeEvent) => {
|
|
322
|
+
const w = e.nativeEvent.layout.width
|
|
323
|
+
setPlotWidth((prev) => (Math.abs(prev - w) > 0.5 ? w : prev))
|
|
324
|
+
}, [])
|
|
325
|
+
|
|
326
|
+
// Active index (controlled or uncontrolled).
|
|
327
|
+
const isControlled = activeIndexProp !== undefined
|
|
328
|
+
const [uncontrolledActive, setUncontrolledActive] = useState<number | null>(defaultActiveIndex)
|
|
329
|
+
const activeIndex = isControlled ? activeIndexProp! : uncontrolledActive
|
|
330
|
+
|
|
331
|
+
const setActiveIndex = useCallback(
|
|
332
|
+
(index: number | null) => {
|
|
333
|
+
if (!isControlled) setUncontrolledActive(index)
|
|
334
|
+
onActiveIndexChange?.(index)
|
|
335
|
+
},
|
|
336
|
+
[isControlled, onActiveIndexChange]
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
// Resolve every series (points + colors).
|
|
340
|
+
const resolvedSeries = useMemo<ResolvedSeries[]>(() => {
|
|
341
|
+
return series.map((s, index) => {
|
|
342
|
+
const appearance = s.appearance ?? appearanceFor(index)
|
|
343
|
+
const lineColor = resolveLineColor(s.color, appearance, modes)
|
|
344
|
+
const areaColor = resolveAreaColor(s.areaColor, lineColor, appearance, modes)
|
|
345
|
+
return {
|
|
346
|
+
key: s.key ?? `series-${index}`,
|
|
347
|
+
label: s.label,
|
|
348
|
+
appearance,
|
|
349
|
+
lineColor,
|
|
350
|
+
areaColor,
|
|
351
|
+
showArea: s.showArea !== false,
|
|
352
|
+
showLine: s.showLine !== false,
|
|
353
|
+
points: resolvePoints(s.data),
|
|
354
|
+
}
|
|
355
|
+
})
|
|
356
|
+
}, [series, modes])
|
|
357
|
+
|
|
358
|
+
// Canonical point count comes from the longest series.
|
|
359
|
+
const count = useMemo(
|
|
360
|
+
() => resolvedSeries.reduce((max, s) => Math.max(max, s.points.length), 0),
|
|
361
|
+
[resolvedSeries]
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
// Domains.
|
|
365
|
+
const xDomain = useMemo<[number, number]>(() => {
|
|
366
|
+
let min = Infinity
|
|
367
|
+
let max = -Infinity
|
|
368
|
+
for (const s of resolvedSeries) {
|
|
369
|
+
const [lo, hi] = extent(s.points, 'x')
|
|
370
|
+
if (s.points.length) {
|
|
371
|
+
if (lo < min) min = lo
|
|
372
|
+
if (hi > max) max = hi
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
if (!Number.isFinite(min) || !Number.isFinite(max)) return [0, 0]
|
|
376
|
+
return [min, max]
|
|
377
|
+
}, [resolvedSeries])
|
|
378
|
+
|
|
379
|
+
const { yDomain, yTicks } = useMemo(() => {
|
|
380
|
+
let dataMin = Infinity
|
|
381
|
+
let dataMax = -Infinity
|
|
382
|
+
for (const s of resolvedSeries) {
|
|
383
|
+
const [lo, hi] = extent(s.points, 'y')
|
|
384
|
+
if (s.points.length) {
|
|
385
|
+
if (lo < dataMin) dataMin = lo
|
|
386
|
+
if (hi > dataMax) dataMax = hi
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
if (!Number.isFinite(dataMin) || !Number.isFinite(dataMax)) {
|
|
390
|
+
dataMin = 0
|
|
391
|
+
dataMax = 1
|
|
392
|
+
}
|
|
393
|
+
const lo = yMin !== undefined ? yMin : Math.min(0, dataMin)
|
|
394
|
+
const hi = yMax !== undefined ? yMax : dataMax
|
|
395
|
+
const ticks = niceTicks(lo, hi, numberOfTicks)
|
|
396
|
+
const domain: [number, number] =
|
|
397
|
+
ticks.length >= 2 ? [ticks[0], ticks[ticks.length - 1]] : [lo, hi === lo ? lo + 1 : hi]
|
|
398
|
+
return { yDomain: domain, yTicks: ticks }
|
|
399
|
+
}, [resolvedSeries, yMin, yMax, numberOfTicks])
|
|
400
|
+
|
|
401
|
+
// Scales.
|
|
402
|
+
const xScale = useMemo(
|
|
403
|
+
() =>
|
|
404
|
+
createLinearScale(
|
|
405
|
+
xDomain[0] === xDomain[1] ? [xDomain[0], xDomain[0] + 1] : xDomain,
|
|
406
|
+
[inset.left, Math.max(inset.left, plotWidth - inset.right)]
|
|
407
|
+
),
|
|
408
|
+
[xDomain, inset.left, inset.right, plotWidth]
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
const yScale = useMemo(
|
|
412
|
+
() => createLinearScale(yDomain, [height - inset.bottom, inset.top]),
|
|
413
|
+
[yDomain, height, inset.bottom, inset.top]
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
// Canonical x pixel positions (from the longest series).
|
|
417
|
+
const indexXs = useMemo(() => {
|
|
418
|
+
const base = resolvedSeries.find((s) => s.points.length === count)
|
|
419
|
+
if (!base) return []
|
|
420
|
+
return base.points.map((p) => xScale(p.x))
|
|
421
|
+
}, [resolvedSeries, count, xScale])
|
|
422
|
+
|
|
423
|
+
const ctx = useMemo<ChartContextValue>(
|
|
424
|
+
() => ({
|
|
425
|
+
width: plotWidth,
|
|
426
|
+
height,
|
|
427
|
+
inset,
|
|
428
|
+
xScale,
|
|
429
|
+
yScale,
|
|
430
|
+
yTicks,
|
|
431
|
+
indexXs,
|
|
432
|
+
count,
|
|
433
|
+
series: resolvedSeries,
|
|
434
|
+
curve,
|
|
435
|
+
activeIndex,
|
|
436
|
+
setActiveIndex,
|
|
437
|
+
xLabels,
|
|
438
|
+
formatX,
|
|
439
|
+
formatY,
|
|
440
|
+
showDots,
|
|
441
|
+
modes,
|
|
442
|
+
}),
|
|
443
|
+
[
|
|
444
|
+
plotWidth,
|
|
445
|
+
height,
|
|
446
|
+
inset,
|
|
447
|
+
xScale,
|
|
448
|
+
yScale,
|
|
449
|
+
yTicks,
|
|
450
|
+
indexXs,
|
|
451
|
+
count,
|
|
452
|
+
resolvedSeries,
|
|
453
|
+
curve,
|
|
454
|
+
activeIndex,
|
|
455
|
+
setActiveIndex,
|
|
456
|
+
xLabels,
|
|
457
|
+
formatX,
|
|
458
|
+
formatY,
|
|
459
|
+
showDots,
|
|
460
|
+
modes,
|
|
461
|
+
]
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
const isMultiSeries = resolvedSeries.length > 1
|
|
465
|
+
const resolvedFormatValue = formatValue ?? ((v: number) => formatY(v))
|
|
466
|
+
|
|
467
|
+
return (
|
|
468
|
+
<ChartContext.Provider value={ctx}>
|
|
469
|
+
<View
|
|
470
|
+
style={[styles.container, style]}
|
|
471
|
+
accessibilityRole="image"
|
|
472
|
+
accessibilityLabel={accessibilityLabel}
|
|
473
|
+
>
|
|
474
|
+
{showLegend && isMultiSeries ? <ChartLegend /> : null}
|
|
475
|
+
|
|
476
|
+
<View style={styles.body}>
|
|
477
|
+
{showYAxis ? <ChartYAxis /> : null}
|
|
478
|
+
|
|
479
|
+
<View style={styles.plotColumn}>
|
|
480
|
+
<View style={[styles.plot, { height }]} onLayout={handlePlotLayout}>
|
|
481
|
+
{plotWidth > 0 ? (
|
|
482
|
+
<>
|
|
483
|
+
{showGrid ? <ChartGrid /> : null}
|
|
484
|
+
<ChartSeriesLayer />
|
|
485
|
+
{goalPin ? (
|
|
486
|
+
<ChartGoalPin
|
|
487
|
+
value={goalPin.value}
|
|
488
|
+
atIndex={goalPin.atIndex}
|
|
489
|
+
seriesIndex={goalPin.seriesIndex}
|
|
490
|
+
/>
|
|
491
|
+
) : null}
|
|
492
|
+
{children}
|
|
493
|
+
{interactive ? (
|
|
494
|
+
<ChartInteractionLayer
|
|
495
|
+
formatValue={resolvedFormatValue}
|
|
496
|
+
series={series}
|
|
497
|
+
/>
|
|
498
|
+
) : null}
|
|
499
|
+
</>
|
|
500
|
+
) : null}
|
|
501
|
+
</View>
|
|
502
|
+
|
|
503
|
+
{showXAxis ? <ChartXAxis /> : null}
|
|
504
|
+
</View>
|
|
505
|
+
</View>
|
|
506
|
+
</View>
|
|
507
|
+
</ChartContext.Provider>
|
|
508
|
+
)
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// --- Series layer (areas + lines + static dots) ---------------------------
|
|
512
|
+
|
|
513
|
+
function ChartSeriesLayer() {
|
|
514
|
+
const { width, height, series, xScale, yScale, yDomainBaseline, curve, showDots } =
|
|
515
|
+
useChartWithBaseline()
|
|
516
|
+
|
|
517
|
+
return (
|
|
518
|
+
<Svg style={StyleSheet.absoluteFill} width={width} height={height}>
|
|
519
|
+
{/* Areas first so lines always sit on top. */}
|
|
520
|
+
{series.map((s) =>
|
|
521
|
+
s.showArea && s.points.length ? (
|
|
522
|
+
<Path
|
|
523
|
+
key={`area-${s.key}`}
|
|
524
|
+
d={buildAreaPath(toPixelPoints(s.points, xScale, yScale), yDomainBaseline, curve)}
|
|
525
|
+
fill={s.areaColor}
|
|
526
|
+
/>
|
|
527
|
+
) : null
|
|
528
|
+
)}
|
|
529
|
+
|
|
530
|
+
{/* Lines (split into solid / dashed runs). */}
|
|
531
|
+
{series.map((s) => {
|
|
532
|
+
if (!s.showLine || s.points.length < 2) return null
|
|
533
|
+
const pixelPoints = toPixelPoints(s.points, xScale, yScale)
|
|
534
|
+
const segments = buildLineSegments(pixelPoints, curve)
|
|
535
|
+
return segments.map((seg, i) => (
|
|
536
|
+
<Path
|
|
537
|
+
key={`line-${s.key}-${i}`}
|
|
538
|
+
d={seg.d}
|
|
539
|
+
stroke={s.lineColor}
|
|
540
|
+
strokeWidth={2}
|
|
541
|
+
fill="none"
|
|
542
|
+
strokeLinecap="round"
|
|
543
|
+
strokeLinejoin="round"
|
|
544
|
+
strokeDasharray={seg.dashed ? '5,4' : undefined}
|
|
545
|
+
/>
|
|
546
|
+
))
|
|
547
|
+
})}
|
|
548
|
+
|
|
549
|
+
{/* Optional dot on every point. */}
|
|
550
|
+
{showDots
|
|
551
|
+
? series.map((s) =>
|
|
552
|
+
s.points.map((p, i) => (
|
|
553
|
+
<Circle
|
|
554
|
+
key={`dot-${s.key}-${i}`}
|
|
555
|
+
cx={xScale(p.x)}
|
|
556
|
+
cy={yScale(p.y)}
|
|
557
|
+
r={4}
|
|
558
|
+
fill={s.lineColor}
|
|
559
|
+
/>
|
|
560
|
+
))
|
|
561
|
+
)
|
|
562
|
+
: null}
|
|
563
|
+
</Svg>
|
|
564
|
+
)
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// --- Grid ------------------------------------------------------------------
|
|
568
|
+
|
|
569
|
+
export type ChartGridProps = {
|
|
570
|
+
/** Which grid lines to draw. Defaults to `horizontal`. */
|
|
571
|
+
direction?: 'horizontal' | 'vertical' | 'both'
|
|
572
|
+
/** Stroke color. Defaults to a subtle grey. */
|
|
573
|
+
stroke?: string
|
|
574
|
+
/** Stroke width. Defaults to `1`. */
|
|
575
|
+
strokeWidth?: number
|
|
576
|
+
/** Dash pattern, e.g. `'4,4'`. Solid by default. */
|
|
577
|
+
strokeDasharray?: string
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** Background grid lines aligned to the y-ticks (horizontal) and x data points (vertical). */
|
|
581
|
+
function ChartGrid({
|
|
582
|
+
direction = 'horizontal',
|
|
583
|
+
stroke = 'rgba(0,0,0,0.08)',
|
|
584
|
+
strokeWidth = 1,
|
|
585
|
+
strokeDasharray,
|
|
586
|
+
}: ChartGridProps) {
|
|
587
|
+
const { width, height, inset, xScale, yScale, yTicks, indexXs } = useChart()
|
|
588
|
+
|
|
589
|
+
const showH = direction === 'horizontal' || direction === 'both'
|
|
590
|
+
const showV = direction === 'vertical' || direction === 'both'
|
|
591
|
+
|
|
592
|
+
return (
|
|
593
|
+
<Svg style={StyleSheet.absoluteFill} width={width} height={height} pointerEvents="none">
|
|
594
|
+
{showH
|
|
595
|
+
? yTicks.map((t) => {
|
|
596
|
+
const y = yScale(t)
|
|
597
|
+
return (
|
|
598
|
+
<Line
|
|
599
|
+
key={`gh-${t}`}
|
|
600
|
+
x1={inset.left}
|
|
601
|
+
x2={width - inset.right}
|
|
602
|
+
y1={y}
|
|
603
|
+
y2={y}
|
|
604
|
+
stroke={stroke}
|
|
605
|
+
strokeWidth={strokeWidth}
|
|
606
|
+
strokeDasharray={strokeDasharray}
|
|
607
|
+
/>
|
|
608
|
+
)
|
|
609
|
+
})
|
|
610
|
+
: null}
|
|
611
|
+
{showV
|
|
612
|
+
? indexXs.map((x, i) => (
|
|
613
|
+
<Line
|
|
614
|
+
key={`gv-${i}`}
|
|
615
|
+
x1={x}
|
|
616
|
+
x2={x}
|
|
617
|
+
y1={inset.top}
|
|
618
|
+
y2={height - inset.bottom}
|
|
619
|
+
stroke={stroke}
|
|
620
|
+
strokeWidth={strokeWidth}
|
|
621
|
+
strokeDasharray={strokeDasharray}
|
|
622
|
+
/>
|
|
623
|
+
))
|
|
624
|
+
: null}
|
|
625
|
+
</Svg>
|
|
626
|
+
)
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// --- Y axis ----------------------------------------------------------------
|
|
630
|
+
|
|
631
|
+
export type ChartYAxisProps = {
|
|
632
|
+
/** Show the tick text labels. Defaults to `true`. */
|
|
633
|
+
showLabels?: boolean
|
|
634
|
+
/** Show short tick marks next to the labels. Defaults to `false`. */
|
|
635
|
+
showTicks?: boolean
|
|
636
|
+
/** Length of a tick mark in px. Defaults to `4`. */
|
|
637
|
+
tickLength?: number
|
|
638
|
+
/** Show a vertical axis line. Defaults to `false`. */
|
|
639
|
+
showAxisLine?: boolean
|
|
640
|
+
/** Override the tick label formatter. */
|
|
641
|
+
formatLabel?: (value: number) => React.ReactNode
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/** Y-axis tick labels, vertically positioned to align with the grid. */
|
|
645
|
+
function ChartYAxis({
|
|
646
|
+
showLabels = true,
|
|
647
|
+
showTicks = false,
|
|
648
|
+
tickLength = 4,
|
|
649
|
+
showAxisLine = false,
|
|
650
|
+
formatLabel,
|
|
651
|
+
}: ChartYAxisProps) {
|
|
652
|
+
const { height, inset, yScale, yTicks, formatY, modes } = useChart()
|
|
653
|
+
const typo = useAxisTypography(modes)
|
|
654
|
+
const format = formatLabel ?? formatY
|
|
655
|
+
|
|
656
|
+
const lineHeight = typo.lineHeight
|
|
657
|
+
|
|
658
|
+
return (
|
|
659
|
+
<View style={{ height, justifyContent: 'flex-start', flexDirection: 'row' }}>
|
|
660
|
+
<View style={{ width: undefined, height }}>
|
|
661
|
+
{yTicks.map((t) => {
|
|
662
|
+
const y = yScale(t)
|
|
663
|
+
return (
|
|
664
|
+
<View
|
|
665
|
+
key={`yl-${t}`}
|
|
666
|
+
style={{
|
|
667
|
+
position: 'absolute',
|
|
668
|
+
right: showTicks ? tickLength + 4 : 0,
|
|
669
|
+
top: y - lineHeight / 2,
|
|
670
|
+
flexDirection: 'row',
|
|
671
|
+
alignItems: 'center',
|
|
672
|
+
}}
|
|
673
|
+
>
|
|
674
|
+
{showLabels ? (
|
|
675
|
+
<Text style={typo.style} numberOfLines={1}>
|
|
676
|
+
{format(t)}
|
|
677
|
+
</Text>
|
|
678
|
+
) : null}
|
|
679
|
+
</View>
|
|
680
|
+
)
|
|
681
|
+
})}
|
|
682
|
+
</View>
|
|
683
|
+
{showTicks ? (
|
|
684
|
+
<Svg width={tickLength} height={height} pointerEvents="none">
|
|
685
|
+
{yTicks.map((t) => {
|
|
686
|
+
const y = yScale(t)
|
|
687
|
+
return (
|
|
688
|
+
<Line
|
|
689
|
+
key={`yt-${t}`}
|
|
690
|
+
x1={0}
|
|
691
|
+
x2={tickLength}
|
|
692
|
+
y1={y}
|
|
693
|
+
y2={y}
|
|
694
|
+
stroke="rgba(0,0,0,0.2)"
|
|
695
|
+
strokeWidth={1}
|
|
696
|
+
/>
|
|
697
|
+
)
|
|
698
|
+
})}
|
|
699
|
+
{showAxisLine ? (
|
|
700
|
+
<Line
|
|
701
|
+
x1={tickLength}
|
|
702
|
+
x2={tickLength}
|
|
703
|
+
y1={inset.top}
|
|
704
|
+
y2={height - inset.bottom}
|
|
705
|
+
stroke="rgba(0,0,0,0.2)"
|
|
706
|
+
strokeWidth={1}
|
|
707
|
+
/>
|
|
708
|
+
) : null}
|
|
709
|
+
</Svg>
|
|
710
|
+
) : null}
|
|
711
|
+
</View>
|
|
712
|
+
)
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// --- X axis ----------------------------------------------------------------
|
|
716
|
+
|
|
717
|
+
export type ChartXAxisProps = {
|
|
718
|
+
/** Show the tick text labels. Defaults to `true`. */
|
|
719
|
+
showLabels?: boolean
|
|
720
|
+
/** Show short tick marks above the labels. Defaults to `false`. */
|
|
721
|
+
showTicks?: boolean
|
|
722
|
+
/** Length of a tick mark in px. Defaults to `4`. */
|
|
723
|
+
tickLength?: number
|
|
724
|
+
/** Make labels tap-to-select the nearest data point. Defaults to `true`. */
|
|
725
|
+
selectable?: boolean
|
|
726
|
+
/** Override the label formatter. */
|
|
727
|
+
formatLabel?: (label: string | number, index: number) => React.ReactNode
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/** X-axis labels, horizontally positioned to align with the data points. */
|
|
731
|
+
function ChartXAxis({
|
|
732
|
+
showLabels = true,
|
|
733
|
+
showTicks = false,
|
|
734
|
+
tickLength = 4,
|
|
735
|
+
selectable = true,
|
|
736
|
+
formatLabel,
|
|
737
|
+
}: ChartXAxisProps) {
|
|
738
|
+
const { width, inset, xScale, indexXs, count, xLabels, formatX, modes, activeIndex, setActiveIndex } =
|
|
739
|
+
useChart()
|
|
740
|
+
const typo = useAxisTypography(modes)
|
|
741
|
+
const format = formatLabel ?? formatX
|
|
742
|
+
const activeColor = (getVariableByName('dataViz/bg', {
|
|
743
|
+
...modes,
|
|
744
|
+
'Appearance / DataViz': 'Primary',
|
|
745
|
+
'Emphasis / DataViz': 'High',
|
|
746
|
+
}) as string | null) ?? '#5d00b5'
|
|
747
|
+
|
|
748
|
+
const labels = xLabels ?? indexXs.map((_, i) => i)
|
|
749
|
+
const labelCount = labels.length
|
|
750
|
+
|
|
751
|
+
return (
|
|
752
|
+
<View style={{ width: '100%', height: typo.lineHeight + (showTicks ? tickLength : 0) }}>
|
|
753
|
+
{showTicks ? (
|
|
754
|
+
<Svg style={StyleSheet.absoluteFill} width={width} height={tickLength} pointerEvents="none">
|
|
755
|
+
{indexXs.map((x, i) => (
|
|
756
|
+
<Line
|
|
757
|
+
key={`xt-${i}`}
|
|
758
|
+
x1={x}
|
|
759
|
+
x2={x}
|
|
760
|
+
y1={0}
|
|
761
|
+
y2={tickLength}
|
|
762
|
+
stroke="rgba(0,0,0,0.2)"
|
|
763
|
+
strokeWidth={1}
|
|
764
|
+
/>
|
|
765
|
+
))}
|
|
766
|
+
</Svg>
|
|
767
|
+
) : null}
|
|
768
|
+
|
|
769
|
+
{showLabels ? (
|
|
770
|
+
<View style={{ position: 'absolute', left: 0, right: 0, top: showTicks ? tickLength : 0 }}>
|
|
771
|
+
{labels.map((label, i) => {
|
|
772
|
+
// Map a label to its data index (handles fewer labels than points).
|
|
773
|
+
const dataIndex =
|
|
774
|
+
labelCount === count
|
|
775
|
+
? i
|
|
776
|
+
: Math.round((i / Math.max(1, labelCount - 1)) * (count - 1))
|
|
777
|
+
const x =
|
|
778
|
+
labelCount === count
|
|
779
|
+
? indexXs[i] ?? xScale(i)
|
|
780
|
+
: inset.left +
|
|
781
|
+
(i / Math.max(1, labelCount - 1)) * (width - inset.left - inset.right)
|
|
782
|
+
const isActive = activeIndex === dataIndex
|
|
783
|
+
const content = (
|
|
784
|
+
<Text
|
|
785
|
+
style={[
|
|
786
|
+
typo.style,
|
|
787
|
+
isActive ? { color: activeColor, fontWeight: '700' } : null,
|
|
788
|
+
]}
|
|
789
|
+
numberOfLines={1}
|
|
790
|
+
>
|
|
791
|
+
{format(label, i)}
|
|
792
|
+
</Text>
|
|
793
|
+
)
|
|
794
|
+
return (
|
|
795
|
+
<View
|
|
796
|
+
key={`xl-${i}`}
|
|
797
|
+
style={{
|
|
798
|
+
position: 'absolute',
|
|
799
|
+
left: x,
|
|
800
|
+
transform: [{ translateX: -50 }],
|
|
801
|
+
width: 100,
|
|
802
|
+
alignItems: 'center',
|
|
803
|
+
}}
|
|
804
|
+
>
|
|
805
|
+
{selectable ? (
|
|
806
|
+
<Pressable
|
|
807
|
+
onPress={() => setActiveIndex(isActive ? null : dataIndex)}
|
|
808
|
+
hitSlop={8}
|
|
809
|
+
>
|
|
810
|
+
{content}
|
|
811
|
+
</Pressable>
|
|
812
|
+
) : (
|
|
813
|
+
content
|
|
814
|
+
)}
|
|
815
|
+
</View>
|
|
816
|
+
)
|
|
817
|
+
})}
|
|
818
|
+
</View>
|
|
819
|
+
) : null}
|
|
820
|
+
</View>
|
|
821
|
+
)
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// --- Goal pin --------------------------------------------------------------
|
|
825
|
+
|
|
826
|
+
export type ChartGoalPinProps = GoalPinConfig & {
|
|
827
|
+
/** Pill background. Defaults to the series color. */
|
|
828
|
+
color?: string
|
|
829
|
+
/** Pill text color. Defaults to the `mode/Grey/2500` token (white). */
|
|
830
|
+
textColor?: string
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/** A pill marker anchored to a data point, with a dashed connector to the baseline. */
|
|
834
|
+
function ChartGoalPin({ value, atIndex, seriesIndex = 0, color, textColor }: ChartGoalPinProps) {
|
|
835
|
+
const { height, inset, xScale, yScale, series, count, modes } = useChart()
|
|
836
|
+
const s = series[seriesIndex] ?? series[0]
|
|
837
|
+
if (!s || s.points.length === 0) return null
|
|
838
|
+
|
|
839
|
+
const index = atIndex ?? count - 1
|
|
840
|
+
const point = s.points[Math.min(Math.max(0, index), s.points.length - 1)]
|
|
841
|
+
if (!point) return null
|
|
842
|
+
|
|
843
|
+
const x = xScale(point.x)
|
|
844
|
+
const y = yScale(point.y)
|
|
845
|
+
const pinColor = color ?? s.lineColor
|
|
846
|
+
const pinTextColor =
|
|
847
|
+
textColor ?? (getVariableByName('mode/Grey/2500', modes) as string | null) ?? '#ffffff'
|
|
848
|
+
|
|
849
|
+
const PIN_SIZE = 32
|
|
850
|
+
|
|
851
|
+
return (
|
|
852
|
+
<View style={StyleSheet.absoluteFill} pointerEvents="none">
|
|
853
|
+
{/* Dashed connector from the pin down to the baseline. */}
|
|
854
|
+
<Svg style={StyleSheet.absoluteFill} width="100%" height={height}>
|
|
855
|
+
<Line
|
|
856
|
+
x1={x}
|
|
857
|
+
x2={x}
|
|
858
|
+
y1={PIN_SIZE / 2}
|
|
859
|
+
y2={height - inset.bottom}
|
|
860
|
+
stroke={pinColor}
|
|
861
|
+
strokeWidth={1.5}
|
|
862
|
+
strokeDasharray="4,4"
|
|
863
|
+
/>
|
|
864
|
+
<Circle cx={x} cy={y} r={5} fill={pinColor} stroke="#ffffff" strokeWidth={2} />
|
|
865
|
+
</Svg>
|
|
866
|
+
|
|
867
|
+
{/* Pill */}
|
|
868
|
+
<View
|
|
869
|
+
style={{
|
|
870
|
+
position: 'absolute',
|
|
871
|
+
left: x - PIN_SIZE / 2,
|
|
872
|
+
top: 0,
|
|
873
|
+
width: PIN_SIZE,
|
|
874
|
+
height: PIN_SIZE,
|
|
875
|
+
borderRadius: 999,
|
|
876
|
+
backgroundColor: pinColor,
|
|
877
|
+
alignItems: 'center',
|
|
878
|
+
justifyContent: 'center',
|
|
879
|
+
paddingHorizontal: 4,
|
|
880
|
+
}}
|
|
881
|
+
>
|
|
882
|
+
<Text style={{ color: pinTextColor, fontSize: 10, lineHeight: 13, textAlign: 'center' }}>
|
|
883
|
+
{value}
|
|
884
|
+
</Text>
|
|
885
|
+
</View>
|
|
886
|
+
</View>
|
|
887
|
+
)
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// --- Interaction layer (crosshair + active dots + tooltip) ----------------
|
|
891
|
+
|
|
892
|
+
function ChartInteractionLayer({
|
|
893
|
+
formatValue,
|
|
894
|
+
series: rawSeries,
|
|
895
|
+
}: {
|
|
896
|
+
formatValue: (value: number, series: ChartSeries) => React.ReactNode
|
|
897
|
+
series: ChartSeries[]
|
|
898
|
+
}) {
|
|
899
|
+
const { width, height, inset, xScale, yScale, indexXs, series, activeIndex, setActiveIndex, modes } =
|
|
900
|
+
useChart()
|
|
901
|
+
const viewRef = useRef<View>(null)
|
|
902
|
+
|
|
903
|
+
const updateFromX = useCallback(
|
|
904
|
+
(locationX: number) => {
|
|
905
|
+
const idx = nearestIndex(indexXs, locationX)
|
|
906
|
+
if (idx >= 0) setActiveIndex(idx)
|
|
907
|
+
},
|
|
908
|
+
[indexXs, setActiveIndex]
|
|
909
|
+
)
|
|
910
|
+
|
|
911
|
+
const panResponder = useMemo(
|
|
912
|
+
() =>
|
|
913
|
+
PanResponder.create({
|
|
914
|
+
onStartShouldSetPanResponder: () => true,
|
|
915
|
+
onMoveShouldSetPanResponder: () => true,
|
|
916
|
+
onPanResponderGrant: (e: GestureResponderEvent) =>
|
|
917
|
+
updateFromX(e.nativeEvent.locationX),
|
|
918
|
+
onPanResponderMove: (e: GestureResponderEvent) =>
|
|
919
|
+
updateFromX(e.nativeEvent.locationX),
|
|
920
|
+
}),
|
|
921
|
+
[updateFromX]
|
|
922
|
+
)
|
|
923
|
+
|
|
924
|
+
// Web-only hover support (no button pressed) via DOM listeners.
|
|
925
|
+
useEffect(() => {
|
|
926
|
+
if (Platform.OS !== 'web') return
|
|
927
|
+
const node = viewRef.current as unknown as HTMLElement | null
|
|
928
|
+
if (!node) return
|
|
929
|
+
const onMove = (ev: MouseEvent) => {
|
|
930
|
+
const rect = node.getBoundingClientRect()
|
|
931
|
+
updateFromX(ev.clientX - rect.left)
|
|
932
|
+
}
|
|
933
|
+
const onLeave = () => setActiveIndex(null)
|
|
934
|
+
node.addEventListener('mousemove', onMove)
|
|
935
|
+
node.addEventListener('mouseleave', onLeave)
|
|
936
|
+
return () => {
|
|
937
|
+
node.removeEventListener('mousemove', onMove)
|
|
938
|
+
node.removeEventListener('mouseleave', onLeave)
|
|
939
|
+
}
|
|
940
|
+
}, [updateFromX, setActiveIndex])
|
|
941
|
+
|
|
942
|
+
const hasActive = activeIndex !== null && activeIndex >= 0
|
|
943
|
+
const activeX = hasActive ? indexXs[activeIndex!] : 0
|
|
944
|
+
|
|
945
|
+
const tooltipItems = useMemo(() => {
|
|
946
|
+
if (!hasActive) return []
|
|
947
|
+
return series
|
|
948
|
+
.map((s, sIndex) => {
|
|
949
|
+
const point = s.points[activeIndex!]
|
|
950
|
+
if (!point) return null
|
|
951
|
+
return {
|
|
952
|
+
key: String(s.key),
|
|
953
|
+
label: s.label ?? `Series ${sIndex + 1}`,
|
|
954
|
+
value: formatValue(point.y, rawSeries[sIndex]),
|
|
955
|
+
color: s.lineColor,
|
|
956
|
+
y: yScale(point.y),
|
|
957
|
+
}
|
|
958
|
+
})
|
|
959
|
+
.filter(Boolean) as Array<{
|
|
960
|
+
key: string
|
|
961
|
+
label: string
|
|
962
|
+
value: React.ReactNode
|
|
963
|
+
color: string
|
|
964
|
+
y: number
|
|
965
|
+
}>
|
|
966
|
+
}, [hasActive, series, activeIndex, formatValue, rawSeries, yScale])
|
|
967
|
+
|
|
968
|
+
return (
|
|
969
|
+
<>
|
|
970
|
+
{/* Touch / drag capture surface. */}
|
|
971
|
+
<View
|
|
972
|
+
ref={viewRef}
|
|
973
|
+
style={StyleSheet.absoluteFill}
|
|
974
|
+
{...panResponder.panHandlers}
|
|
975
|
+
/>
|
|
976
|
+
|
|
977
|
+
{hasActive ? (
|
|
978
|
+
<>
|
|
979
|
+
{/* Crosshair + active dots. */}
|
|
980
|
+
<Svg style={StyleSheet.absoluteFill} width={width} height={height} pointerEvents="none">
|
|
981
|
+
<Line
|
|
982
|
+
x1={activeX}
|
|
983
|
+
x2={activeX}
|
|
984
|
+
y1={inset.top}
|
|
985
|
+
y2={height - inset.bottom}
|
|
986
|
+
stroke="#0f0d0a"
|
|
987
|
+
strokeWidth={1}
|
|
988
|
+
/>
|
|
989
|
+
{tooltipItems.map((item) => (
|
|
990
|
+
<Circle
|
|
991
|
+
key={`active-${item.key}`}
|
|
992
|
+
cx={activeX}
|
|
993
|
+
cy={item.y}
|
|
994
|
+
r={6}
|
|
995
|
+
fill={item.color}
|
|
996
|
+
stroke="#ffffff"
|
|
997
|
+
strokeWidth={2}
|
|
998
|
+
/>
|
|
999
|
+
))}
|
|
1000
|
+
</Svg>
|
|
1001
|
+
|
|
1002
|
+
<ChartTooltip x={activeX} width={width} items={tooltipItems} modes={modes} />
|
|
1003
|
+
</>
|
|
1004
|
+
) : null}
|
|
1005
|
+
</>
|
|
1006
|
+
)
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
// --- Inline tooltip --------------------------------------------------------
|
|
1010
|
+
|
|
1011
|
+
function ChartTooltip({
|
|
1012
|
+
x,
|
|
1013
|
+
width,
|
|
1014
|
+
items,
|
|
1015
|
+
modes,
|
|
1016
|
+
}: {
|
|
1017
|
+
x: number
|
|
1018
|
+
width: number
|
|
1019
|
+
items: Array<{ key: string; label: string; value: React.ReactNode; color: string }>
|
|
1020
|
+
modes: Record<string, any>
|
|
1021
|
+
}) {
|
|
1022
|
+
const [size, setSize] = useState<{ width: number; height: number } | null>(null)
|
|
1023
|
+
|
|
1024
|
+
const bg = (getVariableByName('tooltip/background', modes) as string | null) ?? '#0f0d0a'
|
|
1025
|
+
const paddingH = toNumber(getVariableByName('tooltip/padding/horizontal', modes), 12)
|
|
1026
|
+
const paddingV = toNumber(getVariableByName('tooltip/padding/vertical', modes), 8)
|
|
1027
|
+
const radius = toNumber(getVariableByName('radius', modes), 8)
|
|
1028
|
+
const labelColor = (getVariableByName('tooltip/label/color', modes) as string | null) ?? '#ffffff'
|
|
1029
|
+
|
|
1030
|
+
if (items.length === 0) return null
|
|
1031
|
+
|
|
1032
|
+
// Horizontally clamp so the box stays inside the plot.
|
|
1033
|
+
const boxW = size?.width ?? 0
|
|
1034
|
+
const screenPad = 4
|
|
1035
|
+
let left = x - boxW / 2
|
|
1036
|
+
if (boxW > 0) {
|
|
1037
|
+
left = Math.max(screenPad, Math.min(left, width - boxW - screenPad))
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
return (
|
|
1041
|
+
<View
|
|
1042
|
+
pointerEvents="none"
|
|
1043
|
+
onLayout={(e) =>
|
|
1044
|
+
setSize({ width: e.nativeEvent.layout.width, height: e.nativeEvent.layout.height })
|
|
1045
|
+
}
|
|
1046
|
+
style={{
|
|
1047
|
+
position: 'absolute',
|
|
1048
|
+
top: 0,
|
|
1049
|
+
left,
|
|
1050
|
+
backgroundColor: bg,
|
|
1051
|
+
borderRadius: radius,
|
|
1052
|
+
paddingHorizontal: paddingH,
|
|
1053
|
+
paddingVertical: paddingV,
|
|
1054
|
+
gap: 4,
|
|
1055
|
+
opacity: size ? 1 : 0,
|
|
1056
|
+
shadowColor: '#000',
|
|
1057
|
+
shadowOffset: { width: 0, height: 2 },
|
|
1058
|
+
shadowOpacity: 0.25,
|
|
1059
|
+
shadowRadius: 3.84,
|
|
1060
|
+
elevation: 5,
|
|
1061
|
+
}}
|
|
1062
|
+
>
|
|
1063
|
+
{items.map((item) => (
|
|
1064
|
+
<MetricLegendItem
|
|
1065
|
+
key={item.key}
|
|
1066
|
+
label={item.label}
|
|
1067
|
+
value={item.value as React.ReactNode}
|
|
1068
|
+
indicatorColor={item.color}
|
|
1069
|
+
modes={modes}
|
|
1070
|
+
labelStyle={{ color: labelColor }}
|
|
1071
|
+
valueStyle={{ color: labelColor, fontWeight: '700' }}
|
|
1072
|
+
style={{ gap: 8 }}
|
|
1073
|
+
/>
|
|
1074
|
+
))}
|
|
1075
|
+
</View>
|
|
1076
|
+
)
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
// --- Legend ----------------------------------------------------------------
|
|
1080
|
+
|
|
1081
|
+
function ChartLegend() {
|
|
1082
|
+
const { series, modes } = useChart()
|
|
1083
|
+
return (
|
|
1084
|
+
<View style={styles.legend}>
|
|
1085
|
+
{series.map((s, i) => (
|
|
1086
|
+
<MetricLegendItem
|
|
1087
|
+
key={`legend-${s.key}`}
|
|
1088
|
+
label={s.label ?? `Series ${i + 1}`}
|
|
1089
|
+
indicatorColor={s.lineColor}
|
|
1090
|
+
indicatorShape={s.showArea === false ? 'line' : 'dot'}
|
|
1091
|
+
modes={modes}
|
|
1092
|
+
/>
|
|
1093
|
+
))}
|
|
1094
|
+
</View>
|
|
1095
|
+
)
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
// --- Shared hooks / utils --------------------------------------------------
|
|
1099
|
+
|
|
1100
|
+
/** Resolve `axisItem/*` typography tokens into a memoized text style. */
|
|
1101
|
+
function useAxisTypography(modes: Record<string, any>) {
|
|
1102
|
+
return useMemo(() => {
|
|
1103
|
+
const color = (getVariableByName('axisItem/color', modes) as string | null) ?? '#000000'
|
|
1104
|
+
const fontFamily =
|
|
1105
|
+
(getVariableByName('axisItem/fontFamily', modes) as string | null) ?? 'JioType Var'
|
|
1106
|
+
const fontSize = toNumber(getVariableByName('axisItem/fontSize', modes), 12)
|
|
1107
|
+
const lineHeight = toNumber(getVariableByName('axisItem/lineHeight', modes), 16)
|
|
1108
|
+
const fontWeight = toFontWeight(getVariableByName('axisItem/fontWeight', modes), '400')
|
|
1109
|
+
return {
|
|
1110
|
+
lineHeight,
|
|
1111
|
+
style: { color, fontFamily, fontSize, lineHeight, fontWeight } as TextStyle,
|
|
1112
|
+
}
|
|
1113
|
+
}, [modes])
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
/** Like `useChart` but also exposes the area baseline pixel-y. */
|
|
1117
|
+
function useChartWithBaseline() {
|
|
1118
|
+
const ctx = useChart()
|
|
1119
|
+
const yDomainBaseline = ctx.yScale(ctx.yScale.domain[0])
|
|
1120
|
+
return { ...ctx, yDomainBaseline }
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
const toPixelPoints = (
|
|
1124
|
+
points: ResolvedPoint[],
|
|
1125
|
+
xScale: LinearScale,
|
|
1126
|
+
yScale: LinearScale
|
|
1127
|
+
): PixelPoint[] =>
|
|
1128
|
+
points.map((p) => ({ x: xScale(p.x), y: yScale(p.y), projected: p.projected }))
|
|
1129
|
+
|
|
1130
|
+
const styles = StyleSheet.create({
|
|
1131
|
+
container: {
|
|
1132
|
+
width: '100%',
|
|
1133
|
+
gap: 8,
|
|
1134
|
+
},
|
|
1135
|
+
body: {
|
|
1136
|
+
flexDirection: 'row',
|
|
1137
|
+
gap: 8,
|
|
1138
|
+
},
|
|
1139
|
+
plotColumn: {
|
|
1140
|
+
flex: 1,
|
|
1141
|
+
minWidth: 0,
|
|
1142
|
+
gap: 8,
|
|
1143
|
+
},
|
|
1144
|
+
plot: {
|
|
1145
|
+
width: '100%',
|
|
1146
|
+
position: 'relative',
|
|
1147
|
+
},
|
|
1148
|
+
legend: {
|
|
1149
|
+
flexDirection: 'row',
|
|
1150
|
+
flexWrap: 'wrap',
|
|
1151
|
+
gap: 12,
|
|
1152
|
+
},
|
|
1153
|
+
})
|
|
1154
|
+
|
|
1155
|
+
// Attach reusable sub-components.
|
|
1156
|
+
AreaLineChart.Grid = ChartGrid
|
|
1157
|
+
AreaLineChart.XAxis = ChartXAxis
|
|
1158
|
+
AreaLineChart.YAxis = ChartYAxis
|
|
1159
|
+
AreaLineChart.GoalPin = ChartGoalPin
|
|
1160
|
+
|
|
1161
|
+
export default AreaLineChart
|