feat(web): components update
This commit is contained in:
306
web/src/components/Charts/AreaLineChart.tsx
Normal file
306
web/src/components/Charts/AreaLineChart.tsx
Normal file
@@ -0,0 +1,306 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-10 13:36:03
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-25 13:51:52
|
||||
*/
|
||||
/*
|
||||
* AreaLineChart Component
|
||||
*
|
||||
* A reusable area line chart component built with ECharts that displays time-series data
|
||||
* with gradient-filled areas under the lines. Supports multiple data series with
|
||||
* customizable colors and responsive behavior.
|
||||
*
|
||||
* Features:
|
||||
* - Multiple line series with gradient area fills
|
||||
* - Gradient line colors (white to color to white)
|
||||
* - Customizable x-axis key for flexible data structures
|
||||
* - Date-based x-axis with formatted labels (DD/MM)
|
||||
* - Responsive resizing using ResizeObserver
|
||||
* - Interactive tooltips on hover
|
||||
* - Customizable grid layout and colors
|
||||
* - Legend at the bottom for series identification
|
||||
* - Empty state when no data is available
|
||||
* - Smooth rendering with requestAnimationFrame
|
||||
*/
|
||||
import { type FC, useEffect, useRef, useMemo } from 'react'
|
||||
import ReactEcharts from 'echarts-for-react';
|
||||
import * as echarts from 'echarts';
|
||||
|
||||
import { formatDateTime } from '@/utils/format';
|
||||
import Empty from '@/components/Empty'
|
||||
|
||||
/** Base configuration for all line series */
|
||||
const SeriesConfig = {
|
||||
type: 'line',
|
||||
stack: 'Total',
|
||||
symbol: 'circle',
|
||||
symbolSize: 5,
|
||||
showSymbol: true,
|
||||
label: {
|
||||
show: false,
|
||||
position: 'top'
|
||||
},
|
||||
emphasis: {
|
||||
focus: 'series'
|
||||
},
|
||||
}
|
||||
|
||||
/** Default color palette for area line series */
|
||||
const Colors = ['#155EEF', '#FFB048', '#4DA8FF']
|
||||
|
||||
/**
|
||||
* Data structure for chart data points
|
||||
* Flexible structure allowing any string key with string or number values
|
||||
*
|
||||
* @interface ChartData
|
||||
* @property {string | number} [key: string] - Dynamic properties for x-axis and data series
|
||||
*/
|
||||
export interface ChartData {
|
||||
[key: string]: string | number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the AreaLineChart component
|
||||
*
|
||||
* @interface AreaLineChartProps
|
||||
* @property {string} xAxisKey - Key name in chartData to use for x-axis values
|
||||
* @property {ChartData[]} chartData - Array of data points with dynamic properties
|
||||
* @property {Record<string, string>} seriesList - Map of data keys to display names
|
||||
* @property {string} [className] - Additional CSS classes for the container
|
||||
* @property {number} [height] - Height of the chart in pixels
|
||||
* @property {string[]} [colors] - Custom color array for line series and gradients
|
||||
* @property {any} [grid] - ECharts grid configuration for chart positioning
|
||||
*/
|
||||
interface AreaLineChartProps {
|
||||
xAxisKey: string;
|
||||
chartData: ChartData[];
|
||||
seriesList: Record<string, string>;
|
||||
className?: string;
|
||||
height?: number;
|
||||
colors?: string[];
|
||||
grid?: any;
|
||||
lineStyle?: any;
|
||||
showLegend?: boolean;
|
||||
smooth?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* AreaLineChart Component
|
||||
*
|
||||
* Renders a multi-series area line chart with gradient fills.
|
||||
* The area gradient goes from the series color at the top to white at the bottom.
|
||||
* The line gradient goes from white to the series color and back to white.
|
||||
* Automatically resizes when container dimensions change.
|
||||
*
|
||||
* @param {AreaLineChartProps} props - Component props
|
||||
* @returns {JSX.Element} Rendered area line chart or empty state
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <AreaLineChart
|
||||
* xAxisKey="date"
|
||||
* chartData={[
|
||||
* { date: '2024-01-01', revenue: 1000, profit: 200 },
|
||||
* { date: '2024-01-02', revenue: 1500, profit: 300 }
|
||||
* ]}
|
||||
* seriesList={{ revenue: 'Revenue', profit: 'Profit' }}
|
||||
* height={300}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
const AreaLineChart: FC<AreaLineChartProps> = ({
|
||||
xAxisKey,
|
||||
chartData,
|
||||
seriesList,
|
||||
height,
|
||||
colors = Colors,
|
||||
grid = {
|
||||
top: 7,
|
||||
left: 4,
|
||||
right: 16,
|
||||
bottom: 32,
|
||||
containLabel: true
|
||||
},
|
||||
lineStyle,
|
||||
showLegend = true,
|
||||
smooth = true
|
||||
}) => {
|
||||
/** Reference to the ECharts instance for programmatic control */
|
||||
const chartRef = useRef<ReactEcharts>(null);
|
||||
/** Flag to prevent multiple simultaneous resize operations */
|
||||
const resizeScheduledRef = useRef(false)
|
||||
|
||||
/**
|
||||
* Generate series configuration for each data series with gradient effects
|
||||
* Creates area fills with vertical gradients (color to white)
|
||||
* and line colors with horizontal gradients (white to color to white)
|
||||
*
|
||||
* @returns {Array} Array of ECharts series configurations with gradient styles
|
||||
*/
|
||||
const getSeries = () => {
|
||||
return Object.entries(seriesList).map(([key, name], index) => ({
|
||||
...SeriesConfig,
|
||||
name: name,
|
||||
data: chartData.map(vo => vo[key as keyof ChartData]),
|
||||
areaStyle: {
|
||||
opacity: 0.8,
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{
|
||||
offset: 0,
|
||||
color: colors[index]
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: '#FFFFFF'
|
||||
}
|
||||
])
|
||||
},
|
||||
lineStyle: lineStyle || {
|
||||
width: 3,
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
|
||||
{
|
||||
offset: 0,
|
||||
color: '#FFFFFF'
|
||||
},
|
||||
{
|
||||
offset: 0.8,
|
||||
color: colors[index]
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: '#FFFFFF'
|
||||
}
|
||||
])
|
||||
},
|
||||
smooth
|
||||
}))
|
||||
}
|
||||
/**
|
||||
* Memoized legend data to prevent unnecessary recalculations
|
||||
* Formats series list for display in chart legend
|
||||
*/
|
||||
const formatSeriesList = useMemo(() => {
|
||||
return Object.entries(seriesList).map(([_key, name]) => ({
|
||||
...SeriesConfig,
|
||||
name: name,
|
||||
}))
|
||||
}, [seriesList])
|
||||
|
||||
/**
|
||||
* Set up responsive behavior using ResizeObserver
|
||||
* Resizes chart when parent container dimensions change
|
||||
*/
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (chartRef.current && !resizeScheduledRef.current) {
|
||||
resizeScheduledRef.current = true
|
||||
requestAnimationFrame(() => {
|
||||
chartRef.current?.getEchartsInstance().resize();
|
||||
resizeScheduledRef.current = false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver(handleResize)
|
||||
const chartElement = chartRef.current?.getEchartsInstance().getDom().parentElement
|
||||
if (chartElement) {
|
||||
resizeObserver.observe(chartElement)
|
||||
}
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
}, [chartData])
|
||||
|
||||
return (
|
||||
<div style={{ height: `${height}px` }}>
|
||||
{chartData && chartData.length > 0
|
||||
? <ReactEcharts
|
||||
ref={chartRef}
|
||||
option={{
|
||||
color: colors,
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
extraCssText: 'box-shadow: 0px 2px 6px 0px rgba(33,35,50,0.16); border-radius: 8px;',
|
||||
axisPointer: {
|
||||
type: 'line',
|
||||
crossStyle: {
|
||||
color: '#5F6266',
|
||||
},
|
||||
lineStyle: {
|
||||
color: '#5F6266',
|
||||
},
|
||||
label: {
|
||||
show: false
|
||||
}
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
show: showLegend,
|
||||
data: formatSeriesList,
|
||||
textStyle: {
|
||||
color: '#5B6167',
|
||||
fontFamily: 'PingFangSC, PingFang SC',
|
||||
lineHeight: 16,
|
||||
},
|
||||
itemGap: 8,
|
||||
padding: 0,
|
||||
itemWidth: 26,
|
||||
itemHeight: 10,
|
||||
bottom: 0,
|
||||
lineStyle: {
|
||||
width: 3,
|
||||
},
|
||||
},
|
||||
grid: grid,
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: chartData.map(item => formatDateTime(item[xAxisKey], 'DD/MM')),
|
||||
boundaryGap: false,
|
||||
axisLabel: {
|
||||
color: '#5B6167',
|
||||
fontFamily: 'PingFangSC, PingFang SC',
|
||||
lineHeight: 17,
|
||||
},
|
||||
axisLine: {
|
||||
show: false,
|
||||
lineStyle: {
|
||||
color: '#EBEBEB',
|
||||
}
|
||||
},
|
||||
splitLine: {
|
||||
show: false,
|
||||
},
|
||||
axisTick: {
|
||||
show: false
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLabel: {
|
||||
color: '#A8A9AA',
|
||||
fontFamily: 'PingFangSC, PingFang SC',
|
||||
align: 'right',
|
||||
lineHeight: 17,
|
||||
},
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: '#EBEBEB',
|
||||
}
|
||||
},
|
||||
},
|
||||
series: getSeries()
|
||||
}}
|
||||
style={{ height: `${height}px`, width: '100%', minWidth: '100%', boxSizing: 'border-box' }}
|
||||
opts={{ renderer: 'canvas' }}
|
||||
notMerge={true}
|
||||
lazyUpdate={true}
|
||||
/>
|
||||
: <Empty size={120} className="rb:h-full!" />
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AreaLineChart
|
||||
295
web/src/components/Charts/BarChart.tsx
Normal file
295
web/src/components/Charts/BarChart.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-10 13:36:03
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-25 13:49:04
|
||||
*/
|
||||
/*
|
||||
* BarChart Component
|
||||
*
|
||||
* A reusable area line chart component built with ECharts that displays time-series data
|
||||
* with gradient-filled areas under the lines. Supports multiple data series with
|
||||
* customizable colors and responsive behavior.
|
||||
*
|
||||
* Features:
|
||||
* - Multiple line series with gradient area fills
|
||||
* - Gradient line colors (white to color to white)
|
||||
* - Customizable x-axis key for flexible data structures
|
||||
* - Date-based x-axis with formatted labels (DD/MM)
|
||||
* - Responsive resizing using ResizeObserver
|
||||
* - Interactive tooltips on hover
|
||||
* - Customizable grid layout and colors
|
||||
* - Legend at the bottom for series identification
|
||||
* - Empty state when no data is available
|
||||
* - Smooth rendering with requestAnimationFrame
|
||||
*/
|
||||
import { type FC, useEffect, useRef, useMemo } from 'react'
|
||||
import ReactEcharts from 'echarts-for-react';
|
||||
import * as echarts from 'echarts';
|
||||
|
||||
import { formatDateTime } from '@/utils/format';
|
||||
import Empty from '@/components/Empty'
|
||||
|
||||
/** Base configuration for all line series */
|
||||
const SeriesConfig = {
|
||||
type: 'bar',
|
||||
stack: 'Total',
|
||||
symbol: 'circle',
|
||||
symbolSize: 5,
|
||||
showSymbol: true,
|
||||
label: {
|
||||
show: false,
|
||||
position: 'top'
|
||||
},
|
||||
emphasis: {
|
||||
focus: 'series'
|
||||
},
|
||||
showBackground: true,
|
||||
}
|
||||
|
||||
/** Default color palette for area line series */
|
||||
const Colors = ['#155EEF', '#FFB048', '#4DA8FF']
|
||||
|
||||
/**
|
||||
* Data structure for chart data points
|
||||
* Flexible structure allowing any string key with string or number values
|
||||
*
|
||||
* @interface ChartData
|
||||
* @property {string | number} [key: string] - Dynamic properties for x-axis and data series
|
||||
*/
|
||||
export interface ChartData {
|
||||
[key: string]: string | number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the BarChart component
|
||||
*
|
||||
* @interface BarChartProps
|
||||
* @property {string} xAxisKey - Key name in chartData to use for x-axis values
|
||||
* @property {ChartData[]} chartData - Array of data points with dynamic properties
|
||||
* @property {Record<string, string>} seriesList - Map of data keys to display names
|
||||
* @property {string} [className] - Additional CSS classes for the container
|
||||
* @property {number} [height] - Height of the chart in pixels
|
||||
* @property {string[]} [colors] - Custom color array for line series and gradients
|
||||
* @property {any} [grid] - ECharts grid configuration for chart positioning
|
||||
*/
|
||||
interface BarChartProps {
|
||||
xAxisKey: string;
|
||||
chartData: ChartData[];
|
||||
seriesList: Record<string, string>;
|
||||
className?: string;
|
||||
height?: number;
|
||||
colors?: string[];
|
||||
grid?: any;
|
||||
itemStyle?: any;
|
||||
showLegend?: boolean;
|
||||
showBackground?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* BarChart Component
|
||||
*
|
||||
* Renders a multi-series area line chart with gradient fills.
|
||||
* The area gradient goes from the series color at the top to white at the bottom.
|
||||
* The line gradient goes from white to the series color and back to white.
|
||||
* Automatically resizes when container dimensions change.
|
||||
*
|
||||
* @param {BarChartProps} props - Component props
|
||||
* @returns {JSX.Element} Rendered area line chart or empty state
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <BarChart
|
||||
* xAxisKey="date"
|
||||
* chartData={[
|
||||
* { date: '2024-01-01', revenue: 1000, profit: 200 },
|
||||
* { date: '2024-01-02', revenue: 1500, profit: 300 }
|
||||
* ]}
|
||||
* seriesList={{ revenue: 'Revenue', profit: 'Profit' }}
|
||||
* height={300}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
const BarChart: FC<BarChartProps> = ({
|
||||
xAxisKey,
|
||||
chartData,
|
||||
seriesList,
|
||||
height,
|
||||
colors = Colors,
|
||||
grid = {
|
||||
top: 7,
|
||||
left: 4,
|
||||
right: 16,
|
||||
bottom: 32,
|
||||
containLabel: true
|
||||
},
|
||||
itemStyle,
|
||||
showLegend = true,
|
||||
showBackground = true,
|
||||
}) => {
|
||||
/** Reference to the ECharts instance for programmatic control */
|
||||
const chartRef = useRef<ReactEcharts>(null);
|
||||
/** Flag to prevent multiple simultaneous resize operations */
|
||||
const resizeScheduledRef = useRef(false)
|
||||
|
||||
/**
|
||||
* Generate series configuration for each data series with gradient effects
|
||||
* Creates area fills with vertical gradients (color to white)
|
||||
* and line colors with horizontal gradients (white to color to white)
|
||||
*
|
||||
* @returns {Array} Array of ECharts series configurations with gradient styles
|
||||
*/
|
||||
const getSeries = () => {
|
||||
return Object.entries(seriesList).map(([key, name], index) => ({
|
||||
...SeriesConfig,
|
||||
name: name,
|
||||
data: chartData.map(vo => vo[key as keyof ChartData]),
|
||||
barWidth: 16,
|
||||
itemStyle: itemStyle || {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{
|
||||
offset: 0,
|
||||
color: colors[index]
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: '#FFFFFF'
|
||||
}
|
||||
]),
|
||||
},
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
}
|
||||
},
|
||||
barGap: '-100%',
|
||||
showBackground: showBackground,
|
||||
}))
|
||||
}
|
||||
/**
|
||||
* Memoized legend data to prevent unnecessary recalculations
|
||||
* Formats series list for display in chart legend
|
||||
*/
|
||||
const formatSeriesList = useMemo(() => {
|
||||
return Object.entries(seriesList).map(([_key, name]) => ({
|
||||
...SeriesConfig,
|
||||
name: name,
|
||||
}))
|
||||
}, [seriesList])
|
||||
|
||||
/**
|
||||
* Set up responsive behavior using ResizeObserver
|
||||
* Resizes chart when parent container dimensions change
|
||||
*/
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (chartRef.current && !resizeScheduledRef.current) {
|
||||
resizeScheduledRef.current = true
|
||||
requestAnimationFrame(() => {
|
||||
chartRef.current?.getEchartsInstance().resize();
|
||||
resizeScheduledRef.current = false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver(handleResize)
|
||||
const chartElement = chartRef.current?.getEchartsInstance().getDom().parentElement
|
||||
if (chartElement) {
|
||||
resizeObserver.observe(chartElement)
|
||||
}
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
}, [chartData])
|
||||
|
||||
return (
|
||||
<div style={{ height: `${height}px` }}>
|
||||
{chartData && chartData.length > 0
|
||||
? <ReactEcharts
|
||||
ref={chartRef}
|
||||
option={{
|
||||
color: colors,
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
extraCssText: 'box-shadow: 0px 2px 6px 0px rgba(33,35,50,0.16); border-radius: 8px;',
|
||||
axisPointer: {
|
||||
type: 'line',
|
||||
crossStyle: {
|
||||
color: '#5F6266',
|
||||
},
|
||||
lineStyle: {
|
||||
color: '#5F6266',
|
||||
},
|
||||
label: {
|
||||
show: false
|
||||
}
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
show: showLegend,
|
||||
data: formatSeriesList,
|
||||
textStyle: {
|
||||
color: '#5B6167',
|
||||
fontFamily: 'PingFangSC, PingFang SC',
|
||||
lineHeight: 16,
|
||||
},
|
||||
itemGap: 8,
|
||||
padding: 0,
|
||||
itemWidth: 26,
|
||||
itemHeight: 10,
|
||||
bottom: 0,
|
||||
itemStyle: {
|
||||
width: 3,
|
||||
},
|
||||
},
|
||||
grid: grid,
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: chartData.map(item => formatDateTime(item[xAxisKey], 'DD/MM')),
|
||||
boundaryGap: false,
|
||||
axisLabel: {
|
||||
color: '#5B6167',
|
||||
fontFamily: 'PingFangSC, PingFang SC',
|
||||
lineHeight: 17,
|
||||
},
|
||||
axisLine: {
|
||||
show: false,
|
||||
itemStyle: {
|
||||
color: '#EBEBEB',
|
||||
}
|
||||
},
|
||||
splitLine: {
|
||||
show: false,
|
||||
},
|
||||
axisTick: {
|
||||
show: false
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLabel: {
|
||||
color: '#A8A9AA',
|
||||
fontFamily: 'PingFangSC, PingFang SC',
|
||||
align: 'right',
|
||||
lineHeight: 17,
|
||||
},
|
||||
axisLine: {
|
||||
itemStyle: {
|
||||
color: '#EBEBEB',
|
||||
}
|
||||
},
|
||||
},
|
||||
series: getSeries()
|
||||
}}
|
||||
style={{ height: `${height}px`, width: '100%', minWidth: '100%', boxSizing: 'border-box' }}
|
||||
opts={{ renderer: 'canvas' }}
|
||||
notMerge={true}
|
||||
lazyUpdate={true}
|
||||
/>
|
||||
: <Empty size={120} className="rb:h-full!" />
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default BarChart
|
||||
200
web/src/components/Charts/GraphNetworkChart.tsx
Normal file
200
web/src/components/Charts/GraphNetworkChart.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-10 14:06:09
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-10 14:06:09
|
||||
*/
|
||||
/**
|
||||
* GraphNetworkChart Component
|
||||
*
|
||||
* A force-directed graph visualization component built with ECharts.
|
||||
* Displays nodes and edges in an interactive network diagram with physics-based layout.
|
||||
* Supports zooming, panning, dragging nodes, and click interactions.
|
||||
*/
|
||||
import { type FC, useEffect, useRef, type SetStateAction, type Dispatch } from 'react'
|
||||
import ReactEcharts from 'echarts-for-react';
|
||||
|
||||
import PageEmpty from '@/components/Empty/PageEmpty'
|
||||
|
||||
// Default color palette for node categories
|
||||
const Colors = ['#171719', '#155EEF', '#9C6FFF', '#FF8A4C']
|
||||
|
||||
/**
|
||||
* Node interface representing a graph node/vertex
|
||||
*/
|
||||
export interface Node {
|
||||
id: string; // Unique identifier for the node
|
||||
label: string; // Display label for the node
|
||||
category: number; // Category index for grouping and coloring
|
||||
symbolSize: number; // Size of the node symbol in pixels
|
||||
name: string; // Node name (used in ECharts)
|
||||
itemStyle: {
|
||||
color: string; // Custom color for this node
|
||||
}
|
||||
caption: string; // Additional description or caption
|
||||
[key: string]: any; // Allow additional custom properties
|
||||
}
|
||||
|
||||
/**
|
||||
* Edge interface representing a connection between two nodes
|
||||
*/
|
||||
export interface Edge {
|
||||
id: string; // Unique identifier for the edge
|
||||
source: string; // Source node ID
|
||||
target: string; // Target node ID
|
||||
type: string; // Type/category of the relationship
|
||||
caption: string; // Description of the relationship
|
||||
value: number; // Numeric value associated with the edge
|
||||
weight: number; // Weight/strength of the connection
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the GraphNetworkChart component
|
||||
*/
|
||||
interface GraphNetworkChartProps {
|
||||
nodes: Node[]; // Array of nodes to display in the graph
|
||||
links: Edge[]; // Array of edges connecting the nodes
|
||||
categories: { name: string }[]; // Category definitions for node grouping
|
||||
colors?: string[]; // Optional custom color palette (defaults to Colors)
|
||||
onNodeClick: Dispatch<SetStateAction<Node | null>>; // Callback when a node is clicked
|
||||
}
|
||||
|
||||
const GraphNetworkChart: FC<GraphNetworkChartProps> = ({
|
||||
nodes,
|
||||
links,
|
||||
categories,
|
||||
colors = Colors,
|
||||
onNodeClick,
|
||||
}) => {
|
||||
// Reference to the ECharts instance for programmatic control
|
||||
const chartRef = useRef<ReactEcharts>(null);
|
||||
|
||||
// Flag to prevent multiple simultaneous resize operations (debouncing)
|
||||
const resizeScheduledRef = useRef(false)
|
||||
|
||||
/**
|
||||
* Effect: Handle responsive chart resizing
|
||||
*
|
||||
* Uses ResizeObserver to detect container size changes and resize the chart accordingly.
|
||||
* Implements requestAnimationFrame for smooth, debounced resize operations.
|
||||
* Re-runs when nodes change to ensure proper sizing with new data.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (chartRef.current && !resizeScheduledRef.current) {
|
||||
resizeScheduledRef.current = true
|
||||
// Use requestAnimationFrame for smooth, optimized resize
|
||||
requestAnimationFrame(() => {
|
||||
chartRef.current?.getEchartsInstance().resize();
|
||||
resizeScheduledRef.current = false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Observe the chart container for size changes
|
||||
const resizeObserver = new ResizeObserver(handleResize)
|
||||
const chartElement = chartRef.current?.getEchartsInstance().getDom().parentElement
|
||||
if (chartElement) {
|
||||
resizeObserver.observe(chartElement)
|
||||
}
|
||||
|
||||
// Cleanup: disconnect observer when component unmounts
|
||||
return () => {
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
}, [nodes])
|
||||
|
||||
return (
|
||||
<div className="rb:w-full rb:h-full">
|
||||
{/* Render chart only if nodes exist, otherwise show empty state */}
|
||||
{nodes && nodes.length > 0
|
||||
? <ReactEcharts
|
||||
ref={chartRef}
|
||||
option={{
|
||||
// Color palette for node categories
|
||||
colors: colors,
|
||||
|
||||
// Disable default tooltip (custom interaction via onNodeClick)
|
||||
tooltip: {
|
||||
show: false
|
||||
},
|
||||
|
||||
// Hide legend (categories not displayed in legend)
|
||||
legend: {
|
||||
show: false,
|
||||
bottom: 12,
|
||||
},
|
||||
|
||||
series: [
|
||||
{
|
||||
type: 'graph', // Graph/network chart type
|
||||
layout: 'force', // Force-directed layout algorithm
|
||||
data: nodes || [], // Node data
|
||||
links: links || [], // Edge data
|
||||
categories: categories, // Category definitions
|
||||
roam: true, // Enable zoom and pan interactions
|
||||
|
||||
// Dynamic zoom level based on node count for better initial view
|
||||
zoom: nodes.length < 50 ? 3 : nodes.length < 100 ? 2 : 1,
|
||||
|
||||
// Node label configuration
|
||||
label: {
|
||||
show: true, // Display labels
|
||||
position: 'right', // Position label to the right of node
|
||||
formatter: '{b}', // Use node name as label text
|
||||
},
|
||||
|
||||
// Edge styling
|
||||
lineStyle: {
|
||||
color: '#5B6167', // Gray color for edges
|
||||
curveness: 0.3 // Slight curve for better visibility
|
||||
},
|
||||
|
||||
// Force-directed layout physics configuration
|
||||
force: {
|
||||
repulsion: 100, // Repulsion force between nodes
|
||||
edgeLength: 80, // Ideal distance between connected nodes
|
||||
gravity: 0.3, // Gravity pulling nodes to center
|
||||
layoutAnimation: true, // Animate layout changes
|
||||
preventOverlap: true, // Prevent nodes from overlapping
|
||||
edgeSymbol: ['none', 'arrow'], // Arrow on target end of edge
|
||||
edgeSymbolSize: [4, 10], // Size of edge symbols
|
||||
initLayout: 'force' // Use force-directed for initial layout
|
||||
},
|
||||
|
||||
selectedMode: 'single', // Allow selecting one node at a time
|
||||
draggable: true, // Enable dragging nodes
|
||||
animationDurationUpdate: 0, // Disable animation on data update for performance
|
||||
|
||||
// Styling for selected nodes
|
||||
select: {
|
||||
itemStyle: {
|
||||
borderWidth: 2, // Thicker border when selected
|
||||
borderColor: '#ffffff', // White border for contrast
|
||||
shadowBlur: 10, // Glow effect on selection
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}}
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
notMerge={false} // Merge options instead of replacing (better performance)
|
||||
lazyUpdate={true} // Batch updates for better performance
|
||||
|
||||
// Event handlers
|
||||
onEvents={{
|
||||
click: (params: { dataType: string; data: Node; name: string }) => {
|
||||
// Only trigger callback for node clicks (not edges or background)
|
||||
if (params.dataType === 'node') {
|
||||
onNodeClick(params.data)
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
: <PageEmpty />
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default GraphNetworkChart
|
||||
260
web/src/components/Charts/LineChart.tsx
Normal file
260
web/src/components/Charts/LineChart.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-10 13:35:55
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-10 13:35:55
|
||||
*/
|
||||
/*
|
||||
* LineChart Component
|
||||
*
|
||||
* A reusable line chart component built with ECharts for displaying time-series data
|
||||
* with multiple data series. Supports customizable colors, responsive behavior,
|
||||
* and interactive tooltips.
|
||||
*
|
||||
* Features:
|
||||
* - Multiple line series with different colors
|
||||
* - Date-based x-axis with formatted labels (DD/MM)
|
||||
* - Responsive resizing using ResizeObserver
|
||||
* - Interactive tooltips on hover
|
||||
* - Customizable grid layout and colors
|
||||
* - Legend at the bottom for series identification
|
||||
* - Empty state when no data is available
|
||||
* - Smooth rendering with requestAnimationFrame
|
||||
*/
|
||||
import { type FC, useEffect, useRef, useMemo } from 'react'
|
||||
import ReactEcharts from 'echarts-for-react';
|
||||
|
||||
import { formatDateTime } from '@/utils/format';
|
||||
import Empty from '@/components/Empty'
|
||||
|
||||
/** Base configuration for all line series */
|
||||
const SeriesConfig = {
|
||||
type: 'line',
|
||||
stack: 'Total',
|
||||
symbol: 'circle',
|
||||
symbolSize: 5,
|
||||
showSymbol: true,
|
||||
label: {
|
||||
show: false,
|
||||
position: 'top'
|
||||
},
|
||||
emphasis: {
|
||||
focus: 'series'
|
||||
},
|
||||
}
|
||||
|
||||
/** Default color palette for line series */
|
||||
const Colors = ['#171719', '#155EEF', '#FF5D34']
|
||||
|
||||
/**
|
||||
* Data structure for chart data points
|
||||
*
|
||||
* @interface ChartData
|
||||
* @property {string | number} date - Date value for x-axis (timestamp or date string)
|
||||
* @property {string | number} [key: string] - Dynamic properties for different data series
|
||||
*/
|
||||
export interface ChartData {
|
||||
date: string | number;
|
||||
[key: string]: string | number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the LineChart component
|
||||
*
|
||||
* @interface LineChartProps
|
||||
* @property {ChartData[]} chartData - Array of data points with date and series values
|
||||
* @property {Record<string, string>} seriesList - Map of data keys to display names
|
||||
* @property {string} [className] - Additional CSS classes for the container
|
||||
* @property {number} [height] - Height of the chart in pixels
|
||||
* @property {string[]} [colors] - Custom color array for line series
|
||||
* @property {any} [grid] - ECharts grid configuration for chart positioning
|
||||
*/
|
||||
interface LineChartProps {
|
||||
chartData: ChartData[];
|
||||
seriesList: Record<string, string>;
|
||||
className?: string;
|
||||
height?: number;
|
||||
colors?: string[];
|
||||
grid?: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* LineChart Component
|
||||
*
|
||||
* Renders a multi-series line chart with date-based x-axis.
|
||||
* Automatically resizes when container dimensions change.
|
||||
*
|
||||
* @param {LineChartProps} props - Component props
|
||||
* @returns {JSX.Element} Rendered line chart or empty state
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <LineChart
|
||||
* chartData={[
|
||||
* { date: '2024-01-01', users: 100, sessions: 200 },
|
||||
* { date: '2024-01-02', users: 150, sessions: 250 }
|
||||
* ]}
|
||||
* seriesList={{ users: 'Active Users', sessions: 'Sessions' }}
|
||||
* height={300}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
const LineChart: FC<LineChartProps> = ({
|
||||
chartData,
|
||||
seriesList,
|
||||
height,
|
||||
colors = Colors,
|
||||
grid = {
|
||||
top: 7,
|
||||
right: 16,
|
||||
}
|
||||
}) => {
|
||||
/** Reference to the ECharts instance for programmatic control */
|
||||
const chartRef = useRef<ReactEcharts>(null);
|
||||
/** Flag to prevent multiple simultaneous resize operations */
|
||||
const resizeScheduledRef = useRef(false)
|
||||
|
||||
/**
|
||||
* Generate series configuration for each data series
|
||||
* Maps seriesList keys to chart series with corresponding data and colors
|
||||
*
|
||||
* @returns {Array} Array of ECharts series configurations
|
||||
*/
|
||||
const getSeries = () => {
|
||||
return Object.entries(seriesList).map(([key, name], index) => ({
|
||||
...SeriesConfig,
|
||||
name: name,
|
||||
data: chartData.map(vo => vo[key as keyof ChartData]),
|
||||
lineStyle: {
|
||||
width: 2,
|
||||
color: colors[index]
|
||||
},
|
||||
}))
|
||||
}
|
||||
/**
|
||||
* Memoized legend data to prevent unnecessary recalculations
|
||||
* Formats series list for display in chart legend
|
||||
*/
|
||||
const formatSeriesList = useMemo(() => {
|
||||
return Object.entries(seriesList).map(([_key, name]) => ({
|
||||
...SeriesConfig,
|
||||
name: name,
|
||||
}))
|
||||
}, [seriesList])
|
||||
|
||||
/**
|
||||
* Set up responsive behavior using ResizeObserver
|
||||
* Resizes chart when parent container dimensions change
|
||||
*/
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (chartRef.current && !resizeScheduledRef.current) {
|
||||
resizeScheduledRef.current = true
|
||||
requestAnimationFrame(() => {
|
||||
chartRef.current?.getEchartsInstance().resize();
|
||||
resizeScheduledRef.current = false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver(handleResize)
|
||||
const chartElement = chartRef.current?.getEchartsInstance().getDom().parentElement
|
||||
if (chartElement) {
|
||||
resizeObserver.observe(chartElement)
|
||||
}
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
}, [chartData])
|
||||
|
||||
return (
|
||||
<div style={{ height: `${height}px` }}>
|
||||
{chartData && chartData.length > 0
|
||||
? <ReactEcharts
|
||||
ref={chartRef}
|
||||
option={{
|
||||
color: colors,
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
extraCssText: 'box-shadow: 0px 2px 6px 0px rgba(33,35,50,0.16); border-radius: 8px;',
|
||||
axisPointer: {
|
||||
type: 'line',
|
||||
crossStyle: {
|
||||
color: '#5F6266',
|
||||
},
|
||||
lineStyle: {
|
||||
color: '#5F6266',
|
||||
},
|
||||
label: {
|
||||
show: false
|
||||
}
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
data: formatSeriesList,
|
||||
textStyle: {
|
||||
color: '#5B6167',
|
||||
fontFamily: 'PingFangSC, PingFang SC',
|
||||
lineHeight: 16,
|
||||
},
|
||||
itemGap: 8,
|
||||
padding: 0,
|
||||
itemWidth: 26,
|
||||
itemHeight: 10,
|
||||
bottom: 0,
|
||||
lineStyle: {
|
||||
width: 3,
|
||||
},
|
||||
},
|
||||
grid: grid,
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: chartData.map(item => formatDateTime(item.date, 'DD/MM')),
|
||||
boundaryGap: false,
|
||||
axisLabel: {
|
||||
color: '#5B6167',
|
||||
fontFamily: 'PingFangSC, PingFang SC',
|
||||
lineHeight: 17,
|
||||
},
|
||||
axisLine: {
|
||||
show: false,
|
||||
lineStyle: {
|
||||
color: '#EBEBEB',
|
||||
}
|
||||
},
|
||||
splitLine: {
|
||||
show: false,
|
||||
},
|
||||
axisTick: {
|
||||
show: false
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLabel: {
|
||||
color: '#A8A9AA',
|
||||
fontFamily: 'PingFangSC, PingFang SC',
|
||||
align: 'right',
|
||||
lineHeight: 17,
|
||||
},
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: '#EBEBEB',
|
||||
}
|
||||
},
|
||||
},
|
||||
series: getSeries()
|
||||
}}
|
||||
style={{ height: '100%', width: '100%', minWidth: '100%', boxSizing: 'border-box' }}
|
||||
opts={{ renderer: 'canvas' }}
|
||||
notMerge={true}
|
||||
lazyUpdate={true}
|
||||
/>
|
||||
: <Empty size={120} className="rb:h-full!" />
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LineChart
|
||||
193
web/src/components/Charts/PieChart.tsx
Normal file
193
web/src/components/Charts/PieChart.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-10 13:35:45
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-10 13:35:45
|
||||
*/
|
||||
/*
|
||||
* PieChart Component
|
||||
*
|
||||
* A reusable pie chart component built with ECharts that displays data distribution
|
||||
* in a donut chart format with customizable colors and responsive behavior.
|
||||
*
|
||||
* Features:
|
||||
* - Donut-style pie chart with percentage labels
|
||||
* - Customizable color palette
|
||||
* - Responsive resizing using ResizeObserver
|
||||
* - Hover tooltips showing percentage values
|
||||
* - Legend at the bottom with horizontal layout
|
||||
* - Empty state when no data is available
|
||||
* - Shadow effects for better visual depth
|
||||
*/
|
||||
import { type FC, useEffect, useRef } from 'react'
|
||||
import ReactEcharts from 'echarts-for-react';
|
||||
|
||||
import Empty from '@/components/Empty'
|
||||
|
||||
/** Default color palette for pie chart segments */
|
||||
const Colors = ['#171719', '#155EEF', '#4DA8FF', '#9C6FFF', '#ABEBFF', '#DFE4ED']
|
||||
|
||||
/**
|
||||
* Data structure for each pie chart segment
|
||||
*
|
||||
* @interface ChartData
|
||||
* @property {string} name - Label for the segment (displayed in legend)
|
||||
* @property {number} value - Numeric value for the segment (determines size)
|
||||
*/
|
||||
export interface ChartData {
|
||||
name: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the PieChart component
|
||||
*
|
||||
* @interface PieChartProps
|
||||
* @property {ChartData[]} chartData - Array of data points to display in the chart
|
||||
* @property {number} [height=260] - Height of the chart in pixels
|
||||
* @property {string[]} [colors] - Custom color array for chart segments (defaults to Colors)
|
||||
*/
|
||||
interface PieChartProps {
|
||||
chartData: ChartData[];
|
||||
height?: number;
|
||||
colors?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* PieChart Component
|
||||
*
|
||||
* Renders a donut-style pie chart with percentage labels and legend.
|
||||
* Automatically resizes when container dimensions change.
|
||||
*
|
||||
* @param {PieChartProps} props - Component props
|
||||
* @returns {JSX.Element} Rendered pie chart or empty state
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <PieChart
|
||||
* chartData={[
|
||||
* { name: 'Category A', value: 30 },
|
||||
* { name: 'Category B', value: 70 }
|
||||
* ]}
|
||||
* height={300}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
const PieChart: FC<PieChartProps> = ({
|
||||
chartData,
|
||||
height = 260,
|
||||
colors = Colors,
|
||||
}) => {
|
||||
/** Reference to the ECharts instance for programmatic control */
|
||||
const chartRef = useRef<ReactEcharts>(null);
|
||||
/** Flag to prevent multiple simultaneous resize operations */
|
||||
const resizeScheduledRef = useRef(false)
|
||||
|
||||
/**
|
||||
* Set up responsive behavior using ResizeObserver
|
||||
* Resizes chart when parent container dimensions change
|
||||
*/
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (chartRef.current && !resizeScheduledRef.current) {
|
||||
resizeScheduledRef.current = true
|
||||
// Use requestAnimationFrame for smooth resize performance
|
||||
requestAnimationFrame(() => {
|
||||
chartRef.current?.getEchartsInstance().resize();
|
||||
resizeScheduledRef.current = false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver(handleResize)
|
||||
const chartElement = chartRef.current?.getEchartsInstance().getDom().parentElement
|
||||
if (chartElement) {
|
||||
resizeObserver.observe(chartElement)
|
||||
}
|
||||
|
||||
// Cleanup: disconnect observer when component unmounts
|
||||
return () => {
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
}, [chartData])
|
||||
|
||||
return (
|
||||
<div style={{ height: `${height}px` }}>
|
||||
{chartData && chartData.length > 0
|
||||
? <ReactEcharts
|
||||
ref={chartRef}
|
||||
option={{
|
||||
color: colors,
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
textStyle: {
|
||||
color: '#5B6167',
|
||||
fontSize: 12,
|
||||
width: 27,
|
||||
height: 16,
|
||||
},
|
||||
formatter: '{d}%',
|
||||
padding: [8, 5],
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderColor: '#DFE4ED',
|
||||
extraCssText: 'width: 36px; height: 36px; box-shadow: 0px 2px 4px 0px rgba(33,35,50,0.12);border-radius: 36px;'
|
||||
},
|
||||
legend: {
|
||||
bottom: 0,
|
||||
padding: 0,
|
||||
itemWidth: 12,
|
||||
itemHeight: 12,
|
||||
borderRadius: 2,
|
||||
orient: 'horizontal',
|
||||
itemGap: 48,
|
||||
textStyle: {
|
||||
color: '#5B6167',
|
||||
fontFamily: 'PingFangSC, PingFang SC',
|
||||
lineHeight: 16,
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: ['60%', '100%'],
|
||||
avoidLabelOverlap: false,
|
||||
percentPrecision: 0,
|
||||
padAngle: 1,
|
||||
width: 182,
|
||||
height: 182,
|
||||
left: 'center',
|
||||
top: 24,
|
||||
itemStyle: {
|
||||
borderRadius: 2,
|
||||
shadowBlur: 4,
|
||||
shadowOffsetX: 0,
|
||||
shadowOffsetY: 2,
|
||||
shadowColor: 'rgba(0,0,0,0.25)',
|
||||
},
|
||||
label: {
|
||||
fontWeight: 'bold',
|
||||
color: '#171719',
|
||||
formatter: '{d}%',
|
||||
fontFamily: 'MiSans-Demibold',
|
||||
},
|
||||
labelLine: {
|
||||
lineStyle: {
|
||||
color: '#DFE4ED'
|
||||
}
|
||||
},
|
||||
data: chartData
|
||||
}
|
||||
]
|
||||
}}
|
||||
style={{ height: `${height}px`, width: '100%', minWidth: '100%', boxSizing: 'border-box' }}
|
||||
opts={{ renderer: 'canvas' }}
|
||||
notMerge={true}
|
||||
lazyUpdate={true}
|
||||
/>
|
||||
: <Empty size={120} className="rb:h-full!" />
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PieChart
|
||||
Reference in New Issue
Block a user