| 1 | "use client"; |
| 2 | |
| 3 | import { type EChartsOption } from "echarts"; |
| 4 | import { |
| 5 | BarChart, |
| 6 | BoxplotChart, |
| 7 | CandlestickChart, |
| 8 | CustomChart, |
| 9 | FunnelChart, |
| 10 | GaugeChart, |
| 11 | GraphChart, |
| 12 | HeatmapChart, |
| 13 | LineChart, |
| 14 | PieChart, |
| 15 | RadarChart, |
| 16 | SankeyChart, |
| 17 | ScatterChart, |
| 18 | SunburstChart, |
| 19 | TreemapChart, |
| 20 | } from "echarts/charts"; |
| 21 | import { |
| 22 | GraphicComponent, |
| 23 | GridComponent, |
| 24 | LegendComponent, |
| 25 | PolarComponent, |
| 26 | RadarComponent, |
| 27 | TitleComponent, |
| 28 | TooltipComponent, |
| 29 | VisualMapComponent, |
| 30 | } from "echarts/components"; |
| 31 | import * as echarts from "echarts/core"; |
| 32 | import { type EChartsType } from "echarts/core"; |
| 33 | import { CanvasRenderer, SVGRenderer } from "echarts/renderers"; |
| 34 | import { useCallback, useEffect, useRef, type CSSProperties } from "react"; |
| 35 | import { useInView } from "react-intersection-observer"; |
| 36 | |
| 37 | import { cn } from "@/lib/utils"; |
| 38 | |
| 39 | export type PresentationEChartsOption = EChartsOption; |
| 40 | type EChartsSeriesItem = Record<string, unknown>; |
| 41 | |
| 42 | echarts.use([ |
| 43 | BarChart, |
| 44 | BoxplotChart, |
| 45 | CandlestickChart, |
| 46 | CustomChart, |
| 47 | FunnelChart, |
| 48 | GaugeChart, |
| 49 | GraphChart, |
| 50 | HeatmapChart, |
| 51 | LineChart, |
| 52 | PieChart, |
| 53 | RadarChart, |
| 54 | SankeyChart, |
| 55 | ScatterChart, |
| 56 | SunburstChart, |
| 57 | TreemapChart, |
| 58 | GraphicComponent, |
| 59 | GridComponent, |
| 60 | LegendComponent, |
| 61 | PolarComponent, |
| 62 | RadarComponent, |
| 63 | TitleComponent, |
| 64 | TooltipComponent, |
| 65 | VisualMapComponent, |
| 66 | CanvasRenderer, |
| 67 | SVGRenderer, |
| 68 | ]); |
| 69 | |
| 70 | export interface EChartWrapperProps { |
| 71 | options: PresentationEChartsOption; |
| 72 | className?: string; |
| 73 | style?: CSSProperties; |
| 74 | renderer?: "svg" | "canvas"; |
| 75 | previewMode?: boolean; |
| 76 | animationReplayDelayMs?: number; |
| 77 | isPresenting?: boolean; |
| 78 | } |
| 79 | |
| 80 | function withoutEChartsAnimation( |
| 81 | options: PresentationEChartsOption, |
| 82 | ): PresentationEChartsOption { |
| 83 | const series = options.series; |
| 84 | const disableSeriesAnimation = (seriesItem: unknown): unknown => { |
| 85 | if ( |
| 86 | typeof seriesItem !== "object" || |
| 87 | seriesItem === null || |
| 88 | Array.isArray(seriesItem) |
| 89 | ) { |
| 90 | return seriesItem; |
| 91 | } |
| 92 | |
| 93 | return { |
| 94 | ...(seriesItem as EChartsSeriesItem), |
| 95 | animation: false, |
| 96 | animationDuration: 0, |
| 97 | animationDurationUpdate: 0, |
| 98 | }; |
| 99 | }; |
| 100 | |
| 101 | return { |
| 102 | ...options, |
| 103 | animation: false, |
| 104 | animationDuration: 0, |
| 105 | animationDurationUpdate: 0, |
| 106 | series: Array.isArray(series) |
| 107 | ? (series.map( |
| 108 | disableSeriesAnimation, |
| 109 | ) as PresentationEChartsOption["series"]) |
| 110 | : (disableSeriesAnimation(series) as PresentationEChartsOption["series"]), |
| 111 | }; |
| 112 | } |
| 113 | |
| 114 | export function EChartWrapper({ |
| 115 | options, |
| 116 | className, |
| 117 | style, |
| 118 | renderer = "svg", |
| 119 | previewMode = false, |
| 120 | animationReplayDelayMs = 0, |
| 121 | isPresenting = false, |
| 122 | }: EChartWrapperProps) { |
| 123 | const rootRef = useRef<HTMLDivElement | null>(null); |
| 124 | const chartRef = useRef<EChartsType | null>(null); |
| 125 | const { inView, ref: inViewRef } = useInView({ |
| 126 | threshold: 0.6, |
| 127 | }); |
| 128 | const setRootRef = useCallback( |
| 129 | (node: HTMLDivElement | null) => { |
| 130 | rootRef.current = node; |
| 131 | inViewRef(node); |
| 132 | }, |
| 133 | [inViewRef], |
| 134 | ); |
| 135 | |
| 136 | useEffect(() => { |
| 137 | const root = rootRef.current; |
| 138 | if (!root) return; |
| 139 | |
| 140 | const chart = |
| 141 | chartRef.current ?? |
| 142 | echarts.init(root, null, { |
| 143 | renderer, |
| 144 | }); |
| 145 | chartRef.current = chart; |
| 146 | |
| 147 | return () => { |
| 148 | chart.dispose(); |
| 149 | chartRef.current = null; |
| 150 | }; |
| 151 | }, [renderer]); |
| 152 | |
| 153 | useEffect(() => { |
| 154 | const chart = chartRef.current; |
| 155 | if (!chart) return; |
| 156 | const visiblePresentAnimation = isPresenting && inView; |
| 157 | |
| 158 | chart.setOption( |
| 159 | visiblePresentAnimation ? options : withoutEChartsAnimation(options), |
| 160 | { |
| 161 | notMerge: true, |
| 162 | lazyUpdate: false, |
| 163 | }, |
| 164 | ); |
| 165 | }, [inView, isPresenting, options]); |
| 166 | |
| 167 | useEffect(() => { |
| 168 | if (!isPresenting || !inView || options.animation !== true) return; |
| 169 | |
| 170 | const chart = chartRef.current; |
| 171 | if (!chart) return; |
| 172 | |
| 173 | let timeoutId: number | null = null; |
| 174 | let firstFrameId: number | null = null; |
| 175 | let secondFrameId: number | null = null; |
| 176 | |
| 177 | const replayAnimation = () => { |
| 178 | const currentChart = chartRef.current; |
| 179 | const root = rootRef.current; |
| 180 | if (!currentChart || !root) return; |
| 181 | |
| 182 | currentChart.resize(); |
| 183 | currentChart.clear(); |
| 184 | currentChart.setOption(options, { |
| 185 | notMerge: true, |
| 186 | lazyUpdate: false, |
| 187 | }); |
| 188 | }; |
| 189 | |
| 190 | timeoutId = window.setTimeout(() => { |
| 191 | firstFrameId = window.requestAnimationFrame(() => { |
| 192 | secondFrameId = window.requestAnimationFrame(replayAnimation); |
| 193 | }); |
| 194 | }, animationReplayDelayMs); |
| 195 | |
| 196 | return () => { |
| 197 | if (timeoutId !== null) { |
| 198 | window.clearTimeout(timeoutId); |
| 199 | } |
| 200 | if (firstFrameId !== null) { |
| 201 | window.cancelAnimationFrame(firstFrameId); |
| 202 | } |
| 203 | if (secondFrameId !== null) { |
| 204 | window.cancelAnimationFrame(secondFrameId); |
| 205 | } |
| 206 | }; |
| 207 | }, [animationReplayDelayMs, inView, isPresenting, options]); |
| 208 | |
| 209 | useEffect(() => { |
| 210 | const root = rootRef.current; |
| 211 | const chart = chartRef.current; |
| 212 | if (!root || !chart) return; |
| 213 | |
| 214 | const observer = new ResizeObserver(() => { |
| 215 | chart.resize(); |
| 216 | }); |
| 217 | observer.observe(root); |
| 218 | chart.resize(); |
| 219 | |
| 220 | return () => { |
| 221 | observer.disconnect(); |
| 222 | }; |
| 223 | }, []); |
| 224 | |
| 225 | return ( |
| 226 | <div |
| 227 | className={cn("h-full min-h-[inherit] w-full", className)} |
| 228 | data-echart="true" |
| 229 | data-echart-renderer={renderer} |
| 230 | data-echart-preview={previewMode ? "true" : "false"} |
| 231 | data-ppt-ignore="true" |
| 232 | style={{ |
| 233 | backgroundColor: "var(--presentation-background)", |
| 234 | color: "var(--presentation-text)", |
| 235 | ...style, |
| 236 | }} |
| 237 | > |
| 238 | <div ref={setRootRef} className="h-full min-h-[inherit] w-full" /> |
| 239 | </div> |
| 240 | ); |
| 241 | } |
| 242 | |
| 243 | export const CHART_COLORS = [ |
| 244 | "#2563eb", |
| 245 | "#16a34a", |
| 246 | "#ea580c", |
| 247 | "#8b5cf6", |
| 248 | "#ec4899", |
| 249 | "#14b8a6", |
| 250 | "#f59e0b", |
| 251 | "#ef4444", |
| 252 | "#06b6d4", |
| 253 | "#84cc16", |
| 254 | ]; |
| 255 | |
| 256 | export function getChartColor(index: number): string { |
| 257 | return CHART_COLORS[index % CHART_COLORS.length] ?? CHART_COLORS[0]!; |
| 258 | } |
| 259 | |
| 260 | export function getChartColors(): string[] { |
| 261 | return CHART_COLORS; |
| 262 | } |
| 263 | |
| 264 |