11/**
22 * ResponseTimeChart - SVG line chart with Reanimated path-draw animation.
33 *
4- * Renders a sparkline-style chart of response time over time.
5- * Animates the path drawing on mount.
4+ * Two render modes:
65 *
7- * Inputs: array of (timestamp, value) points.
8- * Optional: y-axis max (auto if not provided), color, height.
6+ * 1. **Single series** (legacy, used by design-system preview):
7+ * Pass `data: TimePoint[]`. One line is drawn. Average is shown as
8+ * a dashed reference line, latest value as a corner label.
99 *
10- * Theme: average-line color uses surface.border; chart label uses
11- * surface.sunken bg + surface.text color so it reads on both themes.
10+ * 2. **Multi-series (Kuma-style ping chart)**: Pass `series: Series[]`
11+ * where each series has a `kind` of 'min' | 'avg' | 'max' and its
12+ * own color. Up to three lines are drawn on the same y-axis,
13+ * themed like Uptime Kuma's web dashboard:
14+ * - min: dark green
15+ * - avg: light green (the same brand color as legacy single mode)
16+ * - max: bright green
17+ * An optional `status` overlay (red/blue/yellow segments along the
18+ * bottom) can be drawn underneath the lines to mirror Kuma's bar
19+ * chart overlay for down/maintenance/pending heartbeats.
20+ *
21+ * Theme: surface.sunken bg, surface.text for labels, surface.border
22+ * for grid/avg reference.
1223 */
1324
1425import { useEffect , useMemo } from 'react' ;
1526import { View , Text , StyleSheet } from 'react-native' ;
16- import Svg , { Path , Line , Circle , Defs , LinearGradient , Stop } from 'react-native-svg' ;
27+ import Svg , {
28+ Path ,
29+ Line ,
30+ Circle ,
31+ Defs ,
32+ LinearGradient ,
33+ Stop ,
34+ Rect ,
35+ } from 'react-native-svg' ;
1736import Animated , {
1837 useSharedValue ,
1938 useAnimatedProps ,
2039 withTiming ,
2140 Easing ,
2241} from 'react-native-reanimated' ;
23- import { spacing , typography , useAppTheme } from '@/theme' ;
42+ import { colors , spacing , typography , useAppTheme } from '@/theme' ;
2443import type { TimePoint } from '@/domain/models' ;
2544
2645const AnimatedPath = Animated . createAnimatedComponent ( Path ) ;
2746
28- interface ResponseTimeChartProps {
47+ export type SeriesKind = 'min' | 'avg' | 'max' ;
48+
49+ export interface Series {
50+ kind : SeriesKind ;
2951 data : TimePoint [ ] ;
52+ color : string ;
53+ label : string ;
54+ }
55+
56+ export interface StatusPoint {
57+ /** x position 0..1 of the chart width. */
58+ x : number ;
59+ /** Status color (red/blue/yellow/green). */
60+ color : string ;
61+ }
62+
63+ interface ResponseTimeChartProps {
64+ // Single-series mode (legacy)
65+ data ?: TimePoint [ ] ;
66+ // Multi-series mode (Kuma-style)
67+ series ?: Series [ ] ;
68+ /** Optional status overlay segments drawn at the bottom of the chart. */
69+ statusOverlay ?: StatusPoint [ ] ;
3070 width ?: number ;
3171 height ?: number ;
72+ /** Single-series color override (only used when `data` is passed, not `series`). */
3273 color ?: string ;
33- /** Show a subtle grid line at the average */
74+ /** Show a subtle grid line at the average (single-series only). */
3475 showAverage ?: boolean ;
35- /** Show the latest value as a label */
76+ /** Show the latest value as a label (single-series only). */
3677 showLatestLabel ?: boolean ;
3778 /** Empty state message */
3879 emptyMessage ?: string ;
3980}
4081
82+ const padX = 4 ;
83+ const padY = 8 ;
84+ const overlayHeight = 6 ; // height of the status bar overlay at the bottom
85+
4186export function ResponseTimeChart ( {
4287 data,
88+ series,
89+ statusOverlay,
4390 width = 320 ,
4491 height = 120 ,
4592 color : colorProp ,
@@ -48,65 +95,83 @@ export function ResponseTimeChart({
4895 emptyMessage = 'No data' ,
4996} : ResponseTimeChartProps ) {
5097 const { surface, brand } = useAppTheme ( ) ;
51- // Default to the theme-aware brand color (brand-400 in dark mode for
52- // better contrast on near-black surfaces; brand-500 in light mode).
53- const color = colorProp ?? brand ;
5498 const progress = useSharedValue ( 0 ) ;
5599
100+ // Normalize to series[] internally so both code paths share logic.
101+ const allSeries : Series [ ] = useMemo ( ( ) => {
102+ if ( series && series . length > 0 ) return series ;
103+ if ( data && data . length > 0 ) {
104+ return [
105+ {
106+ kind : 'avg' ,
107+ data,
108+ color : colorProp ?? brand ,
109+ label : 'avg' ,
110+ } ,
111+ ] ;
112+ }
113+ return [ ] ;
114+ } , [ series , data , colorProp , brand ] ) ;
115+
56116 useEffect ( ( ) => {
57117 progress . value = 0 ;
58118 progress . value = withTiming ( 1 , {
59119 duration : 800 ,
60120 easing : Easing . out ( Easing . cubic ) ,
61121 } ) ;
62- } , [ data , progress ] ) ;
122+ } , [ allSeries , progress ] ) ;
63123
64- const { path, points, avg, max, min, latest } = useMemo ( ( ) => {
65- if ( data . length === 0 ) {
66- return { path : '' , points : [ ] , avg : 0 , max : 0 , min : 0 , latest : 0 } ;
124+ // Compute y-axis bounds across ALL series so they share a scale.
125+ const layout = useMemo ( ( ) => {
126+ const allPoints = allSeries . flatMap ( ( s ) => s . data ) ;
127+ if ( allPoints . length === 0 ) {
128+ return {
129+ paths : [ ] as { kind : SeriesKind ; color : string ; d : string ; latest ?: { x : number ; y : number } } [ ] ,
130+ minVal : 0 ,
131+ maxVal : 0 ,
132+ avgVal : 0 ,
133+ latestVal : 0 ,
134+ } ;
67135 }
68-
69- const values = data . map ( ( d ) => d . value ) ;
136+ const values = allPoints . map ( ( p ) => p . value ) ;
70137 const minVal = Math . min ( ...values ) ;
71138 const maxVal = Math . max ( ...values ) ;
72139 const avgVal = values . reduce ( ( a , b ) => a + b , 0 ) / values . length ;
73140 const latestVal = values [ values . length - 1 ] ;
74141
75142 const range = maxVal - minVal || 1 ;
76- const padX = 4 ;
77- const padY = 8 ;
78143 const chartWidth = width - padX * 2 ;
79- const chartHeight = height - padY * 2 ;
144+ const chartHeight = height - padY * 2 - ( statusOverlay ? overlayHeight + 2 : 0 ) ;
80145
81- const pts = data . map ( ( d , i ) => {
82- const x = padX + ( i / Math . max ( 1 , data . length - 1 ) ) * chartWidth ;
83- const y = padY + chartHeight - ( ( d . value - minVal ) / range ) * chartHeight ;
84- return { x, y, value : d . value } ;
146+ const paths = allSeries . map ( ( s ) => {
147+ const pts = s . data . map ( ( d , i ) => {
148+ const x = padX + ( i / Math . max ( 1 , s . data . length - 1 ) ) * chartWidth ;
149+ const y = padY + chartHeight - ( ( d . value - minVal ) / range ) * chartHeight ;
150+ return { x, y } ;
151+ } ) ;
152+ if ( pts . length === 0 ) {
153+ return { kind : s . kind , color : s . color , d : '' } ;
154+ }
155+ let d = `M ${ pts [ 0 ] . x } ${ pts [ 0 ] . y } ` ;
156+ for ( let i = 1 ; i < pts . length ; i ++ ) {
157+ d += ` L ${ pts [ i ] . x } ${ pts [ i ] . y } ` ;
158+ }
159+ return {
160+ kind : s . kind ,
161+ color : s . color ,
162+ d,
163+ latest : pts [ pts . length - 1 ] ,
164+ } ;
85165 } ) ;
86166
87- // Build smooth path using a simple line (M, L, L, L...)
88- let d = `M ${ pts [ 0 ] . x } ${ pts [ 0 ] . y } ` ;
89- for ( let i = 1 ; i < pts . length ; i ++ ) {
90- d += ` L ${ pts [ i ] . x } ${ pts [ i ] . y } ` ;
91- }
92-
93- return {
94- path : d ,
95- points : pts ,
96- avg : avgVal ,
97- max : maxVal ,
98- min : minVal ,
99- latest : latestVal ,
100- } ;
101- } , [ data , width , height ] ) ;
167+ return { paths, minVal, maxVal, avgVal, latestVal } ;
168+ } , [ allSeries , width , height , statusOverlay ] ) ;
102169
103170 const animatedPathProps = useAnimatedProps ( ( ) => ( {
104- // Use stroke-dashoffset-style animation via opacity since
105- // SVG stroke-dasharray in react-native-svg doesn't support animation yet.
106171 opacity : progress . value ,
107172 } ) ) ;
108173
109- if ( data . length === 0 ) {
174+ if ( allSeries . length === 0 || allSeries . every ( ( s ) => s . data . length === 0 ) ) {
110175 return (
111176 < View style = { [ styles . empty , { width, height } ] } >
112177 < Text style = { [ typography . caption , { color : surface . textMuted } ] } >
@@ -116,23 +181,22 @@ export function ResponseTimeChart({
116181 ) ;
117182 }
118183
119- const padX = 4 ;
120- const padY = 8 ;
121- const chartHeight = height - padY * 2 ;
122- const avgY = padY + chartHeight - ( ( avg - min ) / ( max - min || 1 ) ) * chartHeight ;
184+ const chartHeight = height - padY * 2 - ( statusOverlay ? overlayHeight + 2 : 0 ) ;
185+ const avgY = padY + chartHeight - ( ( layout . avgVal - layout . minVal ) / ( layout . maxVal - layout . minVal || 1 ) ) * chartHeight ;
186+ const isMulti = allSeries . length > 1 ;
123187
124188 return (
125189 < View style = { { width, height } } >
126190 < Svg width = { width } height = { height } >
127191 < Defs >
128192 < LinearGradient id = "lineGradient" x1 = "0" y1 = "0" x2 = "0" y2 = "1" >
129- < Stop offset = "0%" stopColor = { color } stopOpacity = { 0.3 } />
130- < Stop offset = "100%" stopColor = { color } stopOpacity = { 0 } />
193+ < Stop offset = "0%" stopColor = { brand } stopOpacity = { 0.3 } />
194+ < Stop offset = "100%" stopColor = { brand } stopOpacity = { 0 } />
131195 </ LinearGradient >
132196 </ Defs >
133197
134- { /* Average line */ }
135- { showAverage && (
198+ { /* Average reference line (only in single-series mode) */ }
199+ { ! isMulti && showAverage && (
136200 < Line
137201 x1 = { padX }
138202 y1 = { avgY }
@@ -144,37 +208,123 @@ export function ResponseTimeChart({
144208 />
145209 ) }
146210
147- { /* Main line */ }
148- < AnimatedPath
149- d = { path }
150- stroke = { color }
151- strokeWidth = { 1.5 }
152- fill = "none"
153- animatedProps = { animatedPathProps }
154- />
211+ { /* Lines for each series */ }
212+ { layout . paths . map ( ( p ) => (
213+ < AnimatedPath
214+ key = { p . kind }
215+ d = { p . d }
216+ stroke = { p . color }
217+ strokeWidth = { isMulti ? 1.25 : 1.5 }
218+ fill = "none"
219+ animatedProps = { animatedPathProps }
220+ />
221+ ) ) }
155222
156- { /* Latest point dot */ }
157- { points . length > 0 && (
223+ { /* Latest- point dot for single-series mode only */ }
224+ { ! isMulti && layout . paths [ 0 ] ?. latest && (
158225 < Circle
159- cx = { points [ points . length - 1 ] . x }
160- cy = { points [ points . length - 1 ] . y }
226+ cx = { layout . paths [ 0 ] . latest . x }
227+ cy = { layout . paths [ 0 ] . latest . y }
161228 r = { 3 }
162- fill = { color }
229+ fill = { layout . paths [ 0 ] . color }
230+ />
231+ ) }
232+
233+ { /* Status bar overlay (Kuma-style) */ }
234+ { statusOverlay && statusOverlay . length > 0 && (
235+ < StatusOverlay
236+ points = { statusOverlay }
237+ x = { padX }
238+ y = { height - padY - overlayHeight }
239+ width = { width - padX * 2 }
240+ height = { overlayHeight }
163241 />
164242 ) }
165243 </ Svg >
166244
167- { showLatestLabel && (
245+ { ! isMulti && showLatestLabel && (
168246 < View style = { [ styles . label , { backgroundColor : surface . sunken } ] } >
169247 < Text style = { [ typography . caption , styles . labelText , { color : surface . text } ] } >
170- { latest < 1000 ? `${ Math . round ( latest ) } ms` : `${ ( latest / 1000 ) . toFixed ( 2 ) } s` }
248+ { layout . latestVal < 1000
249+ ? `${ Math . round ( layout . latestVal ) } ms`
250+ : `${ ( layout . latestVal / 1000 ) . toFixed ( 2 ) } s` }
171251 </ Text >
172252 </ View >
173253 ) }
174254 </ View >
175255 ) ;
176256}
177257
258+ // ---- Status overlay ----------------------------------------------------
259+
260+ /**
261+ * Status bar overlay drawn at the bottom of the chart.
262+ * Each point's x is in 0..1 (relative to width). The overlay paints
263+ * a thin colored segment at that x with the given width.
264+ */
265+ function StatusOverlay ( {
266+ points,
267+ x,
268+ y,
269+ width,
270+ height,
271+ } : {
272+ points : StatusPoint [ ] ;
273+ x : number ;
274+ y : number ;
275+ width : number ;
276+ height : number ;
277+ } ) {
278+ // Merge adjacent same-color points into runs for cleaner rendering.
279+ const runs : { fromX : number ; toX : number ; color : string } [ ] = [ ] ;
280+ for ( let i = 0 ; i < points . length ; i ++ ) {
281+ const p = points [ i ] ;
282+ const px = x + p . x * width ;
283+ const last = runs [ runs . length - 1 ] ;
284+ if ( last && last . color === p . color && Math . abs ( last . toX - px ) < 0.5 ) {
285+ last . toX = px + 1 ;
286+ } else {
287+ runs . push ( { fromX : px , toX : px + 1 , color : p . color } ) ;
288+ }
289+ }
290+ return (
291+ < >
292+ { runs . map ( ( r , i ) => (
293+ < Rect
294+ key = { i }
295+ x = { r . fromX }
296+ y = { y }
297+ width = { Math . max ( 1 , r . toX - r . fromX ) }
298+ height = { height }
299+ fill = { r . color }
300+ opacity = { 0.55 }
301+ />
302+ ) ) }
303+ </ >
304+ ) ;
305+ }
306+
307+ // ---- Kuma-style palette helpers ----------------------------------------
308+
309+ /**
310+ * Pick the three greens Kuma uses for min/avg/max lines.
311+ * Centralized here so the call site doesn't need to know the palette.
312+ *
313+ * Kuma's source: `min` = #126331, `avg` = #5CDD8B, `max` = #21b55a.
314+ * We map "avg" to the brand color so it always reads on both themes.
315+ */
316+ export function kumaPingColors ( brandColor : string ) : {
317+ min : string ;
318+ avg : string ;
319+ max : string ;
320+ } {
321+ return {
322+ min : colors . brand ?. [ 700 ] ?? '#047857' ,
323+ avg : brandColor ,
324+ max : colors . brand ?. [ 400 ] ?? '#34D399' ,
325+ } ;
326+ }
327+
178328const styles = StyleSheet . create ( {
179329 empty : {
180330 alignItems : 'center' ,
0 commit comments