1+ "use client" ;
2+
3+ import React from 'react' ;
4+
5+ import {
6+ Chart as ChartJS ,
7+ CategoryScale ,
8+ LinearScale ,
9+ PointElement ,
10+ LineElement ,
11+ Title ,
12+ Tooltip ,
13+ Legend ,
14+ Filler ,
15+ } from 'chart.js' ;
16+ import { Line } from 'react-chartjs-2' ;
17+ import { useTheme } from 'next-themes' ;
18+
19+ ChartJS . register (
20+ CategoryScale ,
21+ LinearScale ,
22+ PointElement ,
23+ LineElement ,
24+ Title ,
25+ Tooltip ,
26+ Legend ,
27+ Filler ,
28+ ) ;
29+
30+ export type DualAxisDataset = {
31+ id : string ; // 数据集名称
32+ axis : 'left' | 'right' ; // 使用哪个y轴
33+ data : Array < {
34+ x : string ;
35+ y : number ;
36+ } > ;
37+ } ;
38+
39+ interface DualAxisChartProps {
40+ datasets : DualAxisDataset [ ] ;
41+ leftUnit : string ; // 左轴单位
42+ rightUnit : string ; // 右轴单位
43+ height ?: number ;
44+ timeRange ?: '1h' | '6h' | '12h' | '24h' ;
45+ showLegend ?: boolean ;
46+ }
47+
48+ // 双纵坐标折线图
49+ export const DualAxisChart : React . FC < DualAxisChartProps > = ( {
50+ datasets,
51+ leftUnit,
52+ rightUnit,
53+ height = 300 ,
54+ timeRange = '24h' ,
55+ showLegend = true ,
56+ } ) => {
57+ const { theme } = useTheme ( ) ;
58+ const isDark = theme === 'dark' ;
59+
60+ // 取第一条数据集的 labels 作为横坐标
61+ const labels = React . useMemo ( ( ) => {
62+ const first = datasets [ 0 ] ;
63+ return first ? first . data . map ( ( p ) => p . x ) : [ ] ;
64+ } , [ datasets ] ) ;
65+
66+ // 判断是否所有点在同一天
67+ const sameDay = React . useMemo ( ( ) => {
68+ if ( ! labels || labels . length === 0 ) return true ;
69+ const firstLabel = labels [ 0 ] ;
70+ const dateMatch = firstLabel . match ( / ( \d { 4 } - \d { 2 } - \d { 2 } ) / ) ;
71+ if ( ! dateMatch ) return true ;
72+ const targetDate = dateMatch [ 1 ] ;
73+ return labels . every ( ( lbl ) => lbl . startsWith ( targetDate ) ) ;
74+ } , [ labels ] ) ;
75+
76+ const chartData = React . useMemo ( ( ) => {
77+ const colors = [
78+ {
79+ bg : isDark ? 'rgba(59, 130, 246, 0.1)' : 'rgba(59, 130, 246, 0.1)' ,
80+ border : isDark ? 'rgb(59, 130, 246)' : 'rgb(59, 130, 246)' ,
81+ } ,
82+ {
83+ bg : isDark ? 'rgba(245, 101, 101, 0.1)' : 'rgba(245, 101, 101, 0.1)' ,
84+ border : isDark ? 'rgb(245, 101, 101)' : 'rgb(245, 101, 101)' ,
85+ } ,
86+ {
87+ bg : isDark ? 'rgba(16, 185, 129, 0.1)' : 'rgba(16, 185, 129, 0.1)' ,
88+ border : isDark ? 'rgb(16, 185, 129)' : 'rgb(16, 185, 129)' ,
89+ } ,
90+ {
91+ bg : isDark ? 'rgba(168, 85, 247, 0.1)' : 'rgba(168, 85, 247, 0.1)' ,
92+ border : isDark ? 'rgb(168, 85, 247)' : 'rgb(168, 85, 247)' ,
93+ } ,
94+ ] ;
95+
96+ return {
97+ labels,
98+ datasets : datasets . map ( ( series , index ) => {
99+ const color = colors [ index % colors . length ] ;
100+ return {
101+ label : series . id ,
102+ data : series . data . map ( ( p ) => p . y ) ,
103+ borderColor : color . border ,
104+ backgroundColor : color . bg ,
105+ borderWidth : 3 ,
106+ fill : false ,
107+ tension : 0.4 ,
108+ yAxisID : series . axis === 'right' ? 'yRight' : 'y' ,
109+ pointRadius : 0 ,
110+ pointHoverRadius : 6 ,
111+ pointHoverBackgroundColor : color . border ,
112+ pointHoverBorderColor : isDark ? '#1f2937' : '#ffffff' ,
113+ pointHoverBorderWidth : 2 ,
114+ } as const ;
115+ } ) ,
116+ } ;
117+ } , [ datasets , isDark , labels ] ) ;
118+
119+ const options = React . useMemo ( ( ) => {
120+ return {
121+ responsive : true ,
122+ maintainAspectRatio : false ,
123+ interaction : {
124+ mode : 'index' as const ,
125+ intersect : false ,
126+ } ,
127+ scales : {
128+ x : {
129+ grid : { display : false } ,
130+ ticks : {
131+ color : isDark ? 'rgb(156,163,175)' : 'rgb(75,85,99)' ,
132+ font : { size : 12 } ,
133+ maxRotation : 45 ,
134+ minRotation : 0 ,
135+ callback : ( v : any , index : number ) => {
136+ const lbl = labels [ index ] ;
137+ if ( ! lbl ) return '' ;
138+ const m = lbl . match ( / ( \d { 4 } ) - ( \d { 2 } ) - ( \d { 2 } ) \s + ( \d { 2 } ) : ( \d { 2 } ) / ) ;
139+ if ( ! m ) return lbl ;
140+ const [ , , month , day , hh , mm ] = m ;
141+ if ( sameDay ) {
142+ return `${ hh } :${ mm } ` ;
143+ }
144+ return `${ month } -${ day } ${ hh } :${ mm } ` ;
145+ } ,
146+ } ,
147+ } ,
148+ y : {
149+ type : 'linear' as const ,
150+ position : 'left' as const ,
151+ grid : {
152+ color : isDark ? 'rgba(75,85,99,0.2)' : 'rgba(209,213,219,0.2)' ,
153+ drawBorder : false ,
154+ } ,
155+ ticks : {
156+ color : isDark ? 'rgb(156,163,175)' : 'rgb(75,85,99)' ,
157+ font : { size : 12 } ,
158+ callback : ( v : any ) => `${ v } ${ leftUnit } ` ,
159+ } ,
160+ } ,
161+ yRight : {
162+ type : 'linear' as const ,
163+ position : 'right' as const ,
164+ grid : {
165+ drawOnChartArea : false , // 避免重复网格线
166+ } ,
167+ ticks : {
168+ color : isDark ? 'rgb(156,163,175)' : 'rgb(75,85,99)' ,
169+ font : { size : 12 } ,
170+ callback : ( v : any ) => `${ v } ${ rightUnit } ` ,
171+ } ,
172+ } ,
173+ } ,
174+ plugins : {
175+ legend : {
176+ display : showLegend ,
177+ position : 'top' as const ,
178+ align : 'end' as const ,
179+ labels : {
180+ usePointStyle : true ,
181+ pointStyle : 'circle' ,
182+ boxWidth : 8 ,
183+ boxHeight : 8 ,
184+ color : isDark ? 'rgb(209,213,219)' : 'rgb(75,85,99)' ,
185+ font : { size : 13 , weight : 500 } ,
186+ padding : 20 ,
187+ } ,
188+ } ,
189+ tooltip : {
190+ enabled : true ,
191+ backgroundColor : isDark ? 'rgba(17,24,39,0.95)' : 'rgba(255,255,255,0.95)' ,
192+ titleColor : isDark ? 'rgb(243,244,246)' : 'rgb(17,24,39)' ,
193+ bodyColor : isDark ? 'rgb(209,213,219)' : 'rgb(75,85,99)' ,
194+ borderColor : isDark ? 'rgba(75,85,99,0.3)' : 'rgba(209,213,219,0.3)' ,
195+ borderWidth : 1 ,
196+ cornerRadius : 8 ,
197+ displayColors : true ,
198+ callbacks : {
199+ title : ( ctx : any ) => {
200+ if ( ctx && ctx . length > 0 ) return labels [ ctx [ 0 ] . dataIndex ] || '' ;
201+ return '' ;
202+ } ,
203+ label : ( ctx : any ) => {
204+ const axis = ctx . dataset . yAxisID === 'yRight' ? rightUnit : leftUnit ;
205+ return `${ ctx . dataset . label } : ${ ctx . parsed . y } ${ axis } ` ;
206+ } ,
207+ } ,
208+ } ,
209+ } ,
210+ } as const ;
211+ } , [ isDark , leftUnit , rightUnit , labels , showLegend , sameDay ] ) ;
212+
213+ return (
214+ < div style = { { height, width : '100%' } } >
215+ < Line data = { chartData } options = { options } />
216+ </ div >
217+ ) ;
218+ } ;
0 commit comments