jfs-components 0.0.84 → 0.0.86

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.
Files changed (51) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/lib/commonjs/components/AllocationComparisonChart/AllocationComparisonChart.js +299 -0
  3. package/lib/commonjs/components/AppBar/AppBar.js +36 -22
  4. package/lib/commonjs/components/AreaLineChart/AreaLineChart.js +866 -0
  5. package/lib/commonjs/components/AreaLineChart/chartMath.js +252 -0
  6. package/lib/commonjs/components/Attached/Attached.js +34 -4
  7. package/lib/commonjs/components/BubbleChart/BubbleChart.js +191 -0
  8. package/lib/commonjs/components/BubbleChart/bubblePacking.js +378 -0
  9. package/lib/commonjs/components/ClusterBubble/ClusterBubble.js +272 -0
  10. package/lib/commonjs/components/FullscreenModal/FullscreenModal.js +52 -89
  11. package/lib/commonjs/components/MetricLegendItem/MetricLegendItem.js +7 -1
  12. package/lib/commonjs/components/index.js +34 -0
  13. package/lib/commonjs/design-tokens/Coin Variables-variables-full.json +1 -1
  14. package/lib/commonjs/icons/registry.js +1 -1
  15. package/lib/module/components/AllocationComparisonChart/AllocationComparisonChart.js +293 -0
  16. package/lib/module/components/AppBar/AppBar.js +36 -22
  17. package/lib/module/components/AreaLineChart/AreaLineChart.js +859 -0
  18. package/lib/module/components/AreaLineChart/chartMath.js +242 -0
  19. package/lib/module/components/Attached/Attached.js +34 -4
  20. package/lib/module/components/BubbleChart/BubbleChart.js +185 -0
  21. package/lib/module/components/BubbleChart/bubblePacking.js +370 -0
  22. package/lib/module/components/ClusterBubble/ClusterBubble.js +267 -0
  23. package/lib/module/components/FullscreenModal/FullscreenModal.js +53 -90
  24. package/lib/module/components/MetricLegendItem/MetricLegendItem.js +7 -1
  25. package/lib/module/components/index.js +4 -0
  26. package/lib/module/design-tokens/Coin Variables-variables-full.json +1 -1
  27. package/lib/module/icons/registry.js +1 -1
  28. package/lib/typescript/src/components/AllocationComparisonChart/AllocationComparisonChart.d.ts +118 -0
  29. package/lib/typescript/src/components/AreaLineChart/AreaLineChart.d.ts +212 -0
  30. package/lib/typescript/src/components/AreaLineChart/chartMath.d.ts +90 -0
  31. package/lib/typescript/src/components/BubbleChart/BubbleChart.d.ts +81 -0
  32. package/lib/typescript/src/components/BubbleChart/bubblePacking.d.ts +83 -0
  33. package/lib/typescript/src/components/ClusterBubble/ClusterBubble.d.ts +76 -0
  34. package/lib/typescript/src/components/FullscreenModal/FullscreenModal.d.ts +21 -25
  35. package/lib/typescript/src/components/MetricLegendItem/MetricLegendItem.d.ts +7 -1
  36. package/lib/typescript/src/components/index.d.ts +4 -0
  37. package/lib/typescript/src/icons/registry.d.ts +1 -1
  38. package/package.json +1 -1
  39. package/src/components/AllocationComparisonChart/AllocationComparisonChart.tsx +450 -0
  40. package/src/components/AppBar/AppBar.tsx +37 -24
  41. package/src/components/AreaLineChart/AreaLineChart.tsx +1161 -0
  42. package/src/components/AreaLineChart/chartMath.ts +265 -0
  43. package/src/components/Attached/Attached.tsx +36 -5
  44. package/src/components/BubbleChart/BubbleChart.tsx +319 -0
  45. package/src/components/BubbleChart/bubblePacking.ts +397 -0
  46. package/src/components/ClusterBubble/ClusterBubble.tsx +359 -0
  47. package/src/components/FullscreenModal/FullscreenModal.tsx +61 -119
  48. package/src/components/MetricLegendItem/MetricLegendItem.tsx +20 -6
  49. package/src/components/index.ts +4 -0
  50. package/src/design-tokens/Coin Variables-variables-full.json +1 -1
  51. package/src/icons/registry.ts +1 -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