@@ -233,10 +233,59 @@ function InteractiveChart(props: InteractiveChartProps): React.JSX.Element {
233233 globalTsMin = Date . now ( ) - 3_600_000 ;
234234 globalTsMax = Date . now ( ) ;
235235 }
236- const valRange = globalValMax - globalValMin || 1 ;
236+ let valRange = globalValMax - globalValMin || 1 ;
237237 globalValMin -= valRange * 0.05 ;
238238 globalValMax += valRange * 0.05 ;
239239
240+ // Detect dual-axis mode: exactly 2 series with different, non-empty units
241+ const dualAxis =
242+ series . length === 2 && ! ! series [ 0 ] ?. unit && ! ! series [ 1 ] ?. unit && series [ 0 ] . unit !== series [ 1 ] . unit ;
243+
244+ let series1ValMin = 0 ;
245+ let series1ValMax = 1 ;
246+
247+ if ( dualAxis ) {
248+ // Override global range to only reflect series[0] (left axis)
249+ globalValMin = Infinity ;
250+ globalValMax = - Infinity ;
251+ for ( const p of series [ 0 ] . data ) {
252+ if ( p . val < globalValMin ) {
253+ globalValMin = p . val ;
254+ }
255+ if ( p . val > globalValMax ) {
256+ globalValMax = p . val ;
257+ }
258+ }
259+ if ( globalValMin === Infinity ) {
260+ globalValMin = 0 ;
261+ globalValMax = 1 ;
262+ }
263+ valRange = globalValMax - globalValMin || 1 ;
264+ globalValMin -= valRange * 0.05 ;
265+ globalValMax += valRange * 0.05 ;
266+
267+ // Compute series[1] range (right axis)
268+ series1ValMin = Infinity ;
269+ series1ValMax = - Infinity ;
270+ for ( const p of series [ 1 ] . data ) {
271+ if ( p . val < series1ValMin ) {
272+ series1ValMin = p . val ;
273+ }
274+ if ( p . val > series1ValMax ) {
275+ series1ValMax = p . val ;
276+ }
277+ }
278+ if ( series1ValMin === Infinity ) {
279+ series1ValMin = 0 ;
280+ series1ValMax = 1 ;
281+ }
282+ const range1 = series1ValMax - series1ValMin || 1 ;
283+ series1ValMin -= range1 * 0.05 ;
284+ series1ValMax += range1 * 0.05 ;
285+ }
286+
287+ const marginRight = dualAxis ? 52 : MARGIN . right ;
288+
240289 // Pan/zoom state: visible time range
241290 const [ viewStart , setViewStart ] = useState ( globalTsMin ) ;
242291 const [ viewEnd , setViewEnd ] = useState ( globalTsMax ) ;
@@ -278,7 +327,7 @@ function InteractiveChart(props: InteractiveChartProps): React.JSX.Element {
278327 [ onViewSettle ] ,
279328 ) ;
280329
281- const plotW = width - MARGIN . left - MARGIN . right ;
330+ const plotW = width - MARGIN . left - marginRight ;
282331 const plotH = height - MARGIN . top - MARGIN . bottom ;
283332
284333 // Scales
@@ -290,6 +339,10 @@ function InteractiveChart(props: InteractiveChartProps): React.JSX.Element {
290339 ( val : number ) => MARGIN . top + plotH - ( ( val - globalValMin ) / ( globalValMax - globalValMin ) ) * plotH ,
291340 [ globalValMin , globalValMax , plotH ] ,
292341 ) ;
342+ const valToY2 = useCallback (
343+ ( val : number ) => MARGIN . top + plotH - ( ( val - series1ValMin ) / ( series1ValMax - series1ValMin ) ) * plotH ,
344+ [ series1ValMin , series1ValMax , plotH ] ,
345+ ) ;
293346 const xToTs = useCallback (
294347 ( x : number ) => viewStart + ( ( x - MARGIN . left ) / plotW ) * ( viewEnd - viewStart ) ,
295348 [ viewStart , viewEnd , plotW ] ,
@@ -327,7 +380,7 @@ function InteractiveChart(props: InteractiveChartProps): React.JSX.Element {
327380 }
328381 const mx = e . clientX - rect . left ;
329382 const my = e . clientY - rect . top ;
330- if ( mx < MARGIN . left || mx > width - MARGIN . right || my < MARGIN . top || my > height - MARGIN . bottom ) {
383+ if ( mx < MARGIN . left || mx > width - marginRight || my < MARGIN . top || my > height - MARGIN . bottom ) {
331384 setTooltip ( null ) ;
332385 return ;
333386 }
@@ -346,18 +399,20 @@ function InteractiveChart(props: InteractiveChartProps): React.JSX.Element {
346399 }
347400 // Build entries for all series at bestTs (interpolate if needed)
348401 const entries : { val : number ; y : number ; color : string ; name ?: string ; unit ?: string } [ ] = [ ] ;
349- for ( const s of series ) {
402+ for ( let si = 0 ; si < series . length ; si ++ ) {
403+ const s = series [ si ] ;
350404 const val = interpolateAt ( s . data , bestTs ) ;
351405 if ( val != null ) {
352- entries . push ( { val, y : valToY ( val ) , color : s . color , name : s . name , unit : s . unit } ) ;
406+ const yFn = dualAxis && si === 1 ? valToY2 : valToY ;
407+ entries . push ( { val, y : yFn ( val ) , color : s . color , name : s . name , unit : s . unit } ) ;
353408 }
354409 }
355410 if ( entries . length ) {
356411 setTooltip ( { x : tsToX ( bestTs ) , ts : bestTs , entries } ) ;
357412 }
358413 }
359414 } ,
360- [ series , plotW , width , height , xToTs , tsToX , valToY ] ,
415+ [ series , plotW , width , height , xToTs , tsToX , valToY , dualAxis , valToY2 , marginRight ] ,
361416 ) ;
362417
363418 const handlePointerUp = useCallback (
@@ -412,15 +467,16 @@ function InteractiveChart(props: InteractiveChartProps): React.JSX.Element {
412467 continue ;
413468 }
414469 const smoothed = smoothing > 0 ? smoothData ( visible , smoothing ) : visible ;
415- const points = smoothed . map ( p => ( { x : tsToX ( p . ts ) , y : valToY ( p . val ) } ) ) ;
470+ const yFn = dualAxis && i === 1 ? valToY2 : valToY ;
471+ const points = smoothed . map ( p => ( { x : tsToX ( p . ts ) , y : yFn ( p . val ) } ) ) ;
416472 const d = buildLinePath ( points , chartType ) ;
417473 if ( ! d ) {
418474 continue ;
419475 }
420476 // Area fill — need first/last X for closing the area path
421477 const firstX = points [ 0 ] . x . toFixed ( 1 ) ;
422478 const lastX = points [ points . length - 1 ] . x . toFixed ( 1 ) ;
423- const baseY = valToY ( globalValMin ) . toFixed ( 1 ) ;
479+ const baseY = ( MARGIN . top + plotH ) . toFixed ( 1 ) ;
424480 paths . push (
425481 < React . Fragment key = { i } >
426482 < path
@@ -448,14 +504,24 @@ function InteractiveChart(props: InteractiveChartProps): React.JSX.Element {
448504 timeTicks . push ( t ) ;
449505 }
450506
451- // Value axis ticks
507+ // Value axis ticks (left axis)
452508 const valStep = niceStep ( globalValMax - globalValMin , Math . max ( 3 , Math . floor ( plotH / 40 ) ) ) ;
453509 const valTicks : number [ ] = [ ] ;
454510 const firstVal = Math . ceil ( globalValMin / valStep ) * valStep ;
455511 for ( let v = firstVal ; v <= globalValMax ; v += valStep ) {
456512 valTicks . push ( v ) ;
457513 }
458514
515+ // Right Y-axis ticks (dual-axis mode)
516+ const val2Ticks : number [ ] = [ ] ;
517+ if ( dualAxis ) {
518+ const val2Step = niceStep ( series1ValMax - series1ValMin , Math . max ( 3 , Math . floor ( plotH / 40 ) ) ) ;
519+ const firstVal2 = Math . ceil ( series1ValMin / val2Step ) * val2Step ;
520+ for ( let v = firstVal2 ; v <= series1ValMax ; v += val2Step ) {
521+ val2Ticks . push ( v ) ;
522+ }
523+ }
524+
459525 return (
460526 < svg
461527 ref = { svgRef }
@@ -484,7 +550,7 @@ function InteractiveChart(props: InteractiveChartProps): React.JSX.Element {
484550 < line
485551 key = { `vg${ v } ` }
486552 x1 = { MARGIN . left }
487- x2 = { width - MARGIN . right }
553+ x2 = { width - marginRight }
488554 y1 = { y }
489555 y2 = { y }
490556 stroke = "currentColor"
@@ -538,7 +604,7 @@ function InteractiveChart(props: InteractiveChartProps): React.JSX.Element {
538604 ) ;
539605 } ) }
540606
541- { /* Value axis labels */ }
607+ { /* Value axis labels (left) */ }
542608 { valTicks . map ( v => {
543609 const y = valToY ( v ) ;
544610 return (
@@ -548,14 +614,33 @@ function InteractiveChart(props: InteractiveChartProps): React.JSX.Element {
548614 y = { y + 4 }
549615 textAnchor = "end"
550616 fontSize = { 11 }
551- fill = " currentColor"
552- opacity = { 0.5 }
617+ fill = { dualAxis ? series [ 0 ] . color : ' currentColor' }
618+ opacity = { dualAxis ? 0.7 : 0.5 }
553619 >
554620 { formatValue ( v , isFloatComma ) }
555621 </ text >
556622 ) ;
557623 } ) }
558624
625+ { /* Value axis labels (right, dual-axis mode) */ }
626+ { dualAxis &&
627+ val2Ticks . map ( v => {
628+ const y = valToY2 ( v ) ;
629+ return (
630+ < text
631+ key = { `vr${ v } ` }
632+ x = { width - marginRight + 6 }
633+ y = { y + 4 }
634+ textAnchor = "start"
635+ fontSize = { 11 }
636+ fill = { series [ 1 ] . color }
637+ opacity = { 0.7 }
638+ >
639+ { formatValue ( v , isFloatComma ) }
640+ </ text >
641+ ) ;
642+ } ) }
643+
559644 { /* Tooltip crosshair + dots + label */ }
560645 { tooltip
561646 ? ( ( ) => {
@@ -587,8 +672,8 @@ function InteractiveChart(props: InteractiveChartProps): React.JSX.Element {
587672 if ( boxX < MARGIN . left ) {
588673 boxX = MARGIN . left ;
589674 }
590- if ( boxX + boxW > width - MARGIN . right ) {
591- boxX = width - MARGIN . right - boxW ;
675+ if ( boxX + boxW > width - marginRight ) {
676+ boxX = width - marginRight - boxW ;
592677 }
593678
594679 return (
0 commit comments