@@ -11,6 +11,10 @@ export function LineChart({
1111 showAxes = true ,
1212 xTickCount,
1313 showOOCMarkers = true ,
14+ yAxisTitle,
15+ showBandLabels = true ,
16+ uclLabel = 'UCL' ,
17+ lclLabel = 'LCL' ,
1418} : {
1519 series : Point [ ] [ ]
1620 width ?: number
@@ -20,18 +24,25 @@ export function LineChart({
2024 showAxes ?: boolean
2125 xTickCount ?: number
2226 showOOCMarkers ?: boolean
27+ yAxisTitle ?: string
28+ showBandLabels ?: boolean
29+ uclLabel ?: string
30+ lclLabel ?: string
2331} ) {
2432 const allValues = series . flat ( ) . map ( ( p ) => p . value ) . filter ( ( n ) => Number . isFinite ( n ) )
2533 if ( allValues . length === 0 ) return < svg width = { width } height = { height } />
2634 const min = Math . min ( ...allValues )
2735 const max = Math . max ( ...allValues )
2836 const rng = max - min || 1
2937 const count = Math . max ( 0 , Math . max ( ...series . map ( ( s ) => s . length ) ) )
30- const padding = 32
31- const innerW = width - padding * 2
38+ const basePadding = 32
39+ // Add some extra left padding when a Y-axis title is present so it doesn't overlap ticks
40+ const leftPad = basePadding + ( yAxisTitle ? 28 : 0 )
41+ const padding = basePadding
42+ const innerW = width - leftPad - padding
3243 const innerH = height - padding * 2
3344
34- const x = ( i : number ) => padding + ( count <= 1 ? innerW / 2 : ( i * innerW ) / ( count - 1 ) )
45+ const x = ( i : number ) => leftPad + ( count <= 1 ? innerW / 2 : ( i * innerW ) / ( count - 1 ) )
3546 const y = ( v : number ) => padding + ( innerH - ( ( v - min ) / rng ) * innerH )
3647
3748 // dynamic x ticks; if xTickCount provided, force that count; otherwise 5 to 10 depending on width
@@ -61,14 +72,27 @@ export function LineChart({
6172 < svg width = { width } height = { height } className = "chart-grayscale" >
6273 { showAxes && (
6374 < g >
75+ { /* optional Y-axis title */ }
76+ { yAxisTitle && (
77+ < text
78+ x = { 12 }
79+ y = { height / 2 }
80+ transform = { `rotate(-90 12 ${ height / 2 } )` }
81+ textAnchor = "middle"
82+ fontSize = "11"
83+ fill = "#111827"
84+ >
85+ { yAxisTitle }
86+ </ text >
87+ ) }
6488 { /* axes */ }
65- < line x1 = { padding } y1 = { padding } x2 = { padding } y2 = { height - padding } stroke = "#e5e7eb" />
66- < line x1 = { padding } y1 = { height - padding } x2 = { width - padding } y2 = { height - padding } stroke = "#e5e7eb" />
89+ < line x1 = { leftPad } y1 = { padding } x2 = { leftPad } y2 = { height - padding } stroke = "#e5e7eb" />
90+ < line x1 = { leftPad } y1 = { height - padding } x2 = { width - padding } y2 = { height - padding } stroke = "#e5e7eb" />
6791 { /* y ticks */ }
6892 { yTickVals . map ( ( v , i ) => (
6993 < g key = { i } >
70- < line x1 = { padding - 4 } y1 = { y ( v ) } x2 = { padding } y2 = { y ( v ) } stroke = "#e5e7eb" />
71- < text x = { 8 } y = { y ( v ) } textAnchor = "start " alignmentBaseline = "middle" fontSize = "10" fill = "#6b7280" >
94+ < line x1 = { leftPad - 4 } y1 = { y ( v ) } x2 = { leftPad } y2 = { y ( v ) } stroke = "#e5e7eb" />
95+ < text x = { leftPad - 4 - 6 } y = { y ( v ) } textAnchor = "end " alignmentBaseline = "middle" fontSize = "10" fill = "#6b7280" >
7296 { v . toFixed ( 0 ) }
7397 </ text >
7498 </ g >
@@ -84,6 +108,109 @@ export function LineChart({
84108 ) ) }
85109 </ g >
86110 ) }
111+ { /* background bands: light blue/cyan between LCL and UCL, light red outside */ }
112+ { series . length >= 3 && (
113+ < g >
114+ { ( ( ) => {
115+ const lcl = series [ 1 ]
116+ const ucl = series [ 2 ]
117+ // helpers to build polygons for contiguous finite ranges
118+ const isFiniteAt = ( s : Point [ ] , i : number ) => Number . isFinite ( s ?. [ i ] ?. value )
119+ const buildBandPolys = ( ) => {
120+ const polys : string [ ] = [ ]
121+ let start = - 1
122+ const last = Math . max ( lcl . length , ucl . length ) - 1
123+ for ( let i = 0 ; i <= last ; i ++ ) {
124+ const ok = isFiniteAt ( lcl , i ) && isFiniteAt ( ucl , i )
125+ if ( ok && start === - 1 ) start = i
126+ if ( ( ! ok || i === last ) && start !== - 1 ) {
127+ const end = ok && i === last ? i : i - 1
128+ // build polygon from UCL (start->end) then back on LCL (end->start)
129+ const parts : string [ ] = [ ]
130+ parts . push ( `M${ x ( start ) . toFixed ( 2 ) } ,${ y ( ucl [ start ] . value ) . toFixed ( 2 ) } ` )
131+ for ( let k = start + 1 ; k <= end ; k ++ ) {
132+ parts . push ( `L${ x ( k ) . toFixed ( 2 ) } ,${ y ( ucl [ k ] . value ) . toFixed ( 2 ) } ` )
133+ }
134+ for ( let k = end ; k >= start ; k -- ) {
135+ parts . push ( `L${ x ( k ) . toFixed ( 2 ) } ,${ y ( lcl [ k ] . value ) . toFixed ( 2 ) } ` )
136+ }
137+ parts . push ( 'Z' )
138+ polys . push ( parts . join ( ' ' ) )
139+ start = - 1
140+ }
141+ }
142+ return polys
143+ }
144+ const buildTopPolys = ( ) => {
145+ const polys : string [ ] = [ ]
146+ let start = - 1
147+ const last = ucl . length - 1
148+ for ( let i = 0 ; i <= last ; i ++ ) {
149+ const ok = isFiniteAt ( ucl , i )
150+ if ( ok && start === - 1 ) start = i
151+ if ( ( ! ok || i === last ) && start !== - 1 ) {
152+ const end = ok && i === last ? i : i - 1
153+ const parts : string [ ] = [ ]
154+ // along top edge from start to end
155+ parts . push ( `M${ x ( start ) . toFixed ( 2 ) } ,${ padding . toFixed ( 2 ) } ` )
156+ parts . push ( `L${ x ( end ) . toFixed ( 2 ) } ,${ padding . toFixed ( 2 ) } ` )
157+ // back along UCL from end to start
158+ for ( let k = end ; k >= start ; k -- ) {
159+ parts . push ( `L${ x ( k ) . toFixed ( 2 ) } ,${ y ( ucl [ k ] . value ) . toFixed ( 2 ) } ` )
160+ }
161+ parts . push ( 'Z' )
162+ polys . push ( parts . join ( ' ' ) )
163+ start = - 1
164+ }
165+ }
166+ return polys
167+ }
168+ const buildBottomPolys = ( ) => {
169+ const polys : string [ ] = [ ]
170+ let start = - 1
171+ const last = lcl . length - 1
172+ for ( let i = 0 ; i <= last ; i ++ ) {
173+ const ok = isFiniteAt ( lcl , i )
174+ if ( ok && start === - 1 ) start = i
175+ if ( ( ! ok || i === last ) && start !== - 1 ) {
176+ const end = ok && i === last ? i : i - 1
177+ const parts : string [ ] = [ ]
178+ // along bottom edge from start to end
179+ const bottom = ( height - padding ) . toFixed ( 2 )
180+ parts . push ( `M${ x ( start ) . toFixed ( 2 ) } ,${ bottom } ` )
181+ parts . push ( `L${ x ( end ) . toFixed ( 2 ) } ,${ bottom } ` )
182+ // back along LCL from end to start
183+ for ( let k = end ; k >= start ; k -- ) {
184+ parts . push ( `L${ x ( k ) . toFixed ( 2 ) } ,${ y ( lcl [ k ] . value ) . toFixed ( 2 ) } ` )
185+ }
186+ parts . push ( 'Z' )
187+ polys . push ( parts . join ( ' ' ) )
188+ start = - 1
189+ }
190+ }
191+ return polys
192+ }
193+
194+ const greenPolys = buildBandPolys ( )
195+ const topRedPolys = buildTopPolys ( )
196+ const bottomRedPolys = buildBottomPolys ( )
197+
198+ return (
199+ < >
200+ { topRedPolys . map ( ( d , i ) => (
201+ < path key = { `rt-${ i } ` } d = { d } fill = "#EF4444" fillOpacity = { 0.06 } stroke = "none" />
202+ ) ) }
203+ { greenPolys . map ( ( d , i ) => (
204+ < path key = { `gn-${ i } ` } d = { d } fill = "#06B6D4" fillOpacity = { 0.06 } stroke = "none" />
205+ ) ) }
206+ { bottomRedPolys . map ( ( d , i ) => (
207+ < path key = { `rb-${ i } ` } d = { d } fill = "#EF4444" fillOpacity = { 0.06 } stroke = "none" />
208+ ) ) }
209+ </ >
210+ )
211+ } ) ( ) }
212+ </ g >
213+ ) }
87214 { /* series paths */ }
88215 { paths }
89216 { /* out-of-control markers: when main > UCL or main < LCL */ }
@@ -115,6 +242,56 @@ export function LineChart({
115242 } ) }
116243 </ g >
117244 ) }
245+ { /* UCL/LCL labels at right edge near the last points */ }
246+ { showBandLabels && series . length >= 3 && (
247+ < g >
248+ { ( ( ) => {
249+ // helper to find the last finite value index in a series
250+ const lastFiniteIndex = ( s : Point [ ] ) => {
251+ for ( let i = s . length - 1 ; i >= 0 ; i -- ) {
252+ if ( Number . isFinite ( s [ i ] ?. value ) ) return i
253+ }
254+ return - 1
255+ }
256+ const lclSeries = series [ 1 ] || [ ]
257+ const uclSeries = series [ 2 ] || [ ]
258+ const li = lastFiniteIndex ( lclSeries )
259+ const ui = lastFiniteIndex ( uclSeries )
260+ const nodes : React . ReactNode [ ] = [ ]
261+ if ( ui >= 0 ) {
262+ const xv = Math . min ( x ( ui ) + 6 , width - padding - 2 )
263+ const yv = y ( uclSeries [ ui ] . value )
264+ nodes . push (
265+ < text
266+ key = "ucl-label"
267+ x = { xv }
268+ y = { Math . max ( padding + 10 , yv - 6 ) }
269+ fontSize = { 10 }
270+ fill = "#374151"
271+ >
272+ { uclLabel }
273+ </ text >
274+ )
275+ }
276+ if ( li >= 0 ) {
277+ const xv = Math . min ( x ( li ) + 6 , width - padding - 2 )
278+ const yv = y ( lclSeries [ li ] . value )
279+ nodes . push (
280+ < text
281+ key = "lcl-label"
282+ x = { xv }
283+ y = { Math . min ( height - padding - 4 , Math . max ( padding + 10 , yv - 6 ) ) }
284+ fontSize = { 10 }
285+ fill = "#374151"
286+ >
287+ { lclLabel }
288+ </ text >
289+ )
290+ }
291+ return nodes
292+ } ) ( ) }
293+ </ g >
294+ ) }
118295 </ svg >
119296 )
120297}
0 commit comments