@@ -13,6 +13,7 @@ import {
1313 Legend ,
1414} from 'chart.js' ;
1515import zoomPlugin from 'chartjs-plugin-zoom' ;
16+ import { ImageDown , Copy , FileDown } from 'lucide-react' ;
1617import { getMinSteps } from "../utils/getMinSteps.js" ;
1718
1819ChartJS . register (
@@ -103,6 +104,59 @@ export default function ChartContainer({
103104 chartRefs . current . set ( id , inst ) ;
104105 } , [ ] ) ;
105106
107+ const exportChartPNG = useCallback ( ( id ) => {
108+ const chart = chartRefs . current . get ( id ) ;
109+ if ( ! chart ) return ;
110+ const url = chart . toBase64Image ( ) ;
111+ const link = document . createElement ( 'a' ) ;
112+ link . href = url ;
113+ link . download = `${ id } .png` ;
114+ link . click ( ) ;
115+ } , [ ] ) ;
116+
117+ const copyChartImage = useCallback ( async ( id ) => {
118+ const chart = chartRefs . current . get ( id ) ;
119+ if ( ! chart || ! navigator ?. clipboard ) return ;
120+ const url = chart . toBase64Image ( ) ;
121+ const res = await fetch ( url ) ;
122+ const blob = await res . blob ( ) ;
123+ try {
124+ await navigator . clipboard . write ( [
125+ new ClipboardItem ( { 'image/png' : blob } )
126+ ] ) ;
127+ } catch ( e ) {
128+ console . error ( '复制图片失败' , e ) ;
129+ }
130+ } , [ ] ) ;
131+
132+ const exportChartCSV = useCallback ( ( id ) => {
133+ const chart = chartRefs . current . get ( id ) ;
134+ if ( ! chart ) return ;
135+ const datasets = chart . data . datasets || [ ] ;
136+ const xValues = new Set ( ) ;
137+ datasets . forEach ( ds => {
138+ ( ds . data || [ ] ) . forEach ( p => xValues . add ( p . x ) ) ;
139+ } ) ;
140+ const sortedX = Array . from ( xValues ) . sort ( ( a , b ) => a - b ) ;
141+ const header = [ 'step' , ...datasets . map ( ds => ds . label || '' ) ] ;
142+ const rows = sortedX . map ( x => {
143+ const cols = [ x ] ;
144+ datasets . forEach ( ds => {
145+ const pt = ( ds . data || [ ] ) . find ( p => p . x === x ) ;
146+ cols . push ( pt ? pt . y : '' ) ;
147+ } ) ;
148+ return cols . join ( ',' ) ;
149+ } ) ;
150+ const csv = [ header . join ( ',' ) , ...rows ] . join ( '\n' ) ;
151+ const blob = new Blob ( [ csv ] , { type : 'text/csv;charset=utf-8;' } ) ;
152+ const url = URL . createObjectURL ( blob ) ;
153+ const link = document . createElement ( 'a' ) ;
154+ link . href = url ;
155+ link . download = `${ id } .csv` ;
156+ link . click ( ) ;
157+ URL . revokeObjectURL ( url ) ;
158+ } , [ ] ) ;
159+
106160 const syncHoverToAllCharts = useCallback ( ( step , sourceId ) => {
107161 if ( syncLockRef . current ) return ;
108162 syncLockRef . current = true ;
@@ -611,8 +665,39 @@ export default function ChartContainer({
611665 y : { ...chartOptions . scales . y , min : compRange . min , max : compRange . max }
612666 }
613667 } ;
668+ const compActions = (
669+ < >
670+ < button
671+ type = "button"
672+ className = "p-1 rounded-md text-gray-600 hover:text-blue-600 hover:bg-gray-100"
673+ onClick = { ( ) => exportChartPNG ( `metric-comp-${ idx } ` ) }
674+ aria-label = "导出 PNG"
675+ title = "导出 PNG"
676+ >
677+ < ImageDown size = { 16 } />
678+ </ button >
679+ < button
680+ type = "button"
681+ className = "p-1 rounded-md text-gray-600 hover:text-blue-600 hover:bg-gray-100"
682+ onClick = { ( ) => copyChartImage ( `metric-comp-${ idx } ` ) }
683+ aria-label = "复制图片"
684+ title = "复制图片"
685+ >
686+ < Copy size = { 16 } />
687+ </ button >
688+ < button
689+ type = "button"
690+ className = "p-1 rounded-md text-gray-600 hover:text-blue-600 hover:bg-gray-100"
691+ onClick = { ( ) => exportChartCSV ( `metric-comp-${ idx } ` ) }
692+ aria-label = "导出 CSV"
693+ title = "导出 CSV"
694+ >
695+ < FileDown size = { 16 } />
696+ </ button >
697+ </ >
698+ ) ;
614699 comparisonChart = (
615- < ResizablePanel title = { `⚖️ ${ key } 对比分析 (${ compareMode } )` } initialHeight = { 440 } >
700+ < ResizablePanel title = { `⚖️ ${ key } 对比分析 (${ compareMode } )` } initialHeight = { 440 } actions = { compActions } >
616701 < ChartWrapper
617702 chartId = { `metric-comp-${ idx } ` }
618703 onRegisterChart = { registerChart }
@@ -627,7 +712,41 @@ export default function ChartContainer({
627712
628713 return (
629714 < div key = { key } className = "flex flex-col gap-3" >
630- < ResizablePanel title = { key } initialHeight = { 440 } >
715+ < ResizablePanel
716+ title = { key }
717+ initialHeight = { 440 }
718+ actions = { (
719+ < >
720+ < button
721+ type = "button"
722+ className = "p-1 rounded-md text-gray-600 hover:text-blue-600 hover:bg-gray-100"
723+ onClick = { ( ) => exportChartPNG ( `metric-${ idx } ` ) }
724+ aria-label = "导出 PNG"
725+ title = "导出 PNG"
726+ >
727+ < ImageDown size = { 16 } />
728+ </ button >
729+ < button
730+ type = "button"
731+ className = "p-1 rounded-md text-gray-600 hover:text-blue-600 hover:bg-gray-100"
732+ onClick = { ( ) => copyChartImage ( `metric-${ idx } ` ) }
733+ aria-label = "复制图片"
734+ title = "复制图片"
735+ >
736+ < Copy size = { 16 } />
737+ </ button >
738+ < button
739+ type = "button"
740+ className = "p-1 rounded-md text-gray-600 hover:text-blue-600 hover:bg-gray-100"
741+ onClick = { ( ) => exportChartCSV ( `metric-${ idx } ` ) }
742+ aria-label = "导出 CSV"
743+ title = "导出 CSV"
744+ >
745+ < FileDown size = { 16 } />
746+ </ button >
747+ </ >
748+ ) }
749+ >
631750 < ChartWrapper
632751 chartId = { `metric-${ idx } ` }
633752 onRegisterChart = { registerChart }
0 commit comments