Chart
Chart primitives for Tile UI.
packages/react/src/components/chart/chart.tsx
import React, { createContext, useContext, useEffect, useId, useMemo, useRef, useState } from 'react';
import {
CHART_INITIAL_DIMENSION,
buildChartThemeCss,
chartStyleKeys,
computeChartLayout,
findChartNearestIndex,
getChartAreaPathD,
getChartBaselineY,
getChartBarRects,
getChartLegendItems,
getChartPathD,
getChartTooltipEntries,
getChartTooltipPosition,
} from '../lib/core';
import type { ChartConfig, ChartDatum, ChartLayout, ChartLegendItem, ChartSeriesItem, ChartTooltipEntry, ChartType } from '../lib/core';
import styles from './chart.module.scss';
interface ChartContextValue {
config: ChartConfig;
layout: ChartLayout;
data: ChartDatum[];
activeIndex: number | null;
}
const ChartContext = createContext<ChartContextValue | null>(null);
/**
* 读取 ChartContainer 提供的图表上下文
*/
function useChart(): ChartContextValue {
const context = useContext(ChartContext);
if (!context) {
throw new Error('useChart 必须在 <ChartContainer /> 内使用');
}
return context;
}
/**
* 生成主题相关的 CSS 变量 (--color-<key>)
*/
export interface ChartStyleProps {
id: string;
config: ChartConfig;
}
const ChartStyle = React.forwardRef<HTMLStyleElement, ChartStyleProps>(({ id, config }, ref) => {
const css = useMemo(() => buildChartThemeCss(id, config), [id, config]);
if (!css) {
return null;
}
return <style ref={ref} dangerouslySetInnerHTML={{ __html: css }} />;
});
ChartStyle.displayName = 'ChartStyle';
export interface ChartContainerProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'children'> {
/** 图表可访问名称,同时保留在容器并转发至 SVG。 */
'aria-label'?: string;
/** 引用外部可访问名称,同时转发至 SVG。 */
'aria-labelledby'?: string;
/** 引用外部可访问描述,同时转发至 SVG。 */
'aria-describedby'?: string;
/** SVG 标题,同时保留为容器 title 属性。 */
title?: string;
config: ChartConfig;
data?: ChartDatum[];
xKey?: string;
series?: ChartSeriesItem[];
type?: ChartType;
initialDimension?: { width: number; height: number };
showLegend?: boolean;
showTooltip?: boolean;
showGrid?: boolean;
showAxis?: boolean;
children?: (state: ChartContextValue) => React.ReactNode;
}
const ChartContainer = React.forwardRef<HTMLDivElement, ChartContainerProps>(
(
{
className = '',
title,
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledBy,
'aria-describedby': ariaDescribedBy,
tabIndex,
onKeyDown,
config,
data = [],
xKey = 'x',
series,
type = 'line',
initialDimension,
showLegend = true,
showTooltip = true,
showGrid = true,
showAxis = true,
children,
...props
},
ref,
) => {
const uniqueId = useId();
const chartId = `chart-${uniqueId.replace(/:/g, '')}`;
const containerRef = useRef<HTMLDivElement | null>(null);
const [size, setSize] = useState(initialDimension ?? CHART_INITIAL_DIMENSION);
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const [keyboardActive, setKeyboardActive] = useState(false);
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 });
useEffect(() => {
const element = containerRef.current;
if (!element) {
return;
}
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width } = entry.contentRect;
if (width > 0) {
// 高度固定:容器高度由 SVG 内容撑开,若跟随容器高度会形成正反馈循环导致无限放大
setSize((prev) => ({ ...prev, width }));
}
}
});
observer.observe(element);
return () => observer.disconnect();
}, []);
const layout = useMemo(
() => computeChartLayout({ config, data, series: series ?? [], xKey, type, width: size.width, height: size.height, cssVariableScope: chartId }),
[config, data, series, xKey, type, size, chartId],
);
const contextValue = useMemo<ChartContextValue>(() => ({ config, layout, data, activeIndex }), [config, layout, data, activeIndex]);
function setRef(element: HTMLDivElement | null) {
containerRef.current = element;
if (typeof ref === 'function') {
ref(element);
} else if (ref) {
ref.current = element;
}
}
function handleMouseMove(event: React.MouseEvent<SVGSVGElement>) {
const svg = event.currentTarget;
const rect = svg.getBoundingClientRect();
const x = ((event.clientX - rect.left) / rect.width) * layout.width;
const y = ((event.clientY - rect.top) / rect.height) * layout.height;
setKeyboardActive(false);
setMousePosition({ x, y });
setActiveIndex(findChartNearestIndex(layout, x));
}
function handleMouseLeave() {
setActiveIndex(null);
}
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
onKeyDown?.(event);
if (event.defaultPrevented || tabIndex === undefined || tabIndex < 0 || event.target !== event.currentTarget) return;
if (event.key === 'Escape') {
setKeyboardActive(false);
setActiveIndex(null);
return;
}
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
const count = layout.xLabels.length;
if (count === 0) return;
event.preventDefault();
setKeyboardActive(true);
setActiveIndex((current) => {
if (current === null) return event.key === 'ArrowLeft' ? count - 1 : 0;
return Math.min(count - 1, Math.max(0, current + (event.key === 'ArrowLeft' ? -1 : 1)));
});
}
const entries = useMemo(() => getChartTooltipEntries(layout, config, data, activeIndex), [layout, config, data, activeIndex]);
const legendItems = useMemo(() => getChartLegendItems(layout), [layout]);
const tooltipX = activeIndex !== null ? (layout.xTickX[activeIndex] ?? 0) : 0;
const tooltipLabel = activeIndex !== null ? layout.xLabels[activeIndex] : null;
const svgLabel = ariaLabel ?? (ariaLabelledBy ? undefined : (title ?? 'Chart'));
const statusText = keyboardActive && activeIndex !== null ? [tooltipLabel, ...entries.map((entry) => `${entry.name} ${entry.value.toLocaleString()}`)].join(', ') : '';
return (
<ChartContext.Provider value={contextValue}>
<div
ref={setRef}
data-slot="chart"
data-chart={chartId}
data-active-index={activeIndex ?? undefined}
className={`${styles[chartStyleKeys.root]} ${className}`}
title={title}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
aria-describedby={ariaDescribedBy}
tabIndex={tabIndex}
onKeyDown={handleKeyDown}
{...props}>
<ChartStyle id={chartId} config={config} />
<svg
className={styles[chartStyleKeys.surface]}
width={layout.width}
height={layout.height}
viewBox={`0 0 ${layout.width} ${layout.height}`}
role="img"
aria-label={svgLabel}
aria-labelledby={ariaLabelledBy}
aria-describedby={ariaDescribedBy}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}>
{title && <title>{title}</title>}
{showGrid &&
layout.yTickY.map((y, index) => (
<line
key={`y-${index}`}
className={styles[chartStyleKeys.gridLine]}
x1={layout.padding.left}
x2={layout.width - layout.padding.right}
y1={y}
y2={y}
/>
))}
{showGrid &&
layout.xTickX.map((x, index) => (
<line
key={`x-${index}`}
className={styles[chartStyleKeys.gridLine]}
x1={x}
x2={x}
y1={layout.padding.top}
y2={layout.height - layout.padding.bottom}
/>
))}
{showAxis &&
layout.xLabels.map((label, index) => (
<text
key={`label-${index}`}
x={layout.xTickX[index]}
y={layout.height - layout.padding.bottom / 2}
textAnchor="middle"
dominantBaseline="middle"
fontSize="10">
{String(label)}
</text>
))}
{layout.series.map((item, seriesIndex) => {
if (item.type === 'bar') {
const rects = getChartBarRects(layout, seriesIndex, layout.series.filter((s) => s.type === 'bar').length);
return (
<g key={item.key} data-series={item.key} data-type={item.type}>
{rects.map((rect) => (
<rect key={rect.index} data-index={rect.index} x={rect.x} y={rect.y} width={rect.width} height={rect.height} fill={item.color} rx={2} />
))}
</g>
);
}
const path = item.type === 'area' ? getChartAreaPathD(item.points, getChartBaselineY(layout)) : getChartPathD(item.points);
return (
<g key={item.key} data-series={item.key} data-type={item.type}>
{item.type === 'area' && <path data-area d={path} fill={item.color} fillOpacity={0.2} />}
<path d={getChartPathD(item.points)} fill="none" stroke={item.color} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />
{item.points.map((point) => (
<circle key={point.index} data-index={point.index} cx={point.x} cy={point.y} r={2.5} fill={item.color} />
))}
</g>
);
})}
{showTooltip && activeIndex !== null && (
<line
x1={tooltipX}
x2={tooltipX}
y1={layout.padding.top}
y2={layout.height - layout.padding.bottom}
stroke="currentColor"
strokeDasharray="3 3"
opacity={0.4}
/>
)}
</svg>
{showTooltip && activeIndex !== null && (
<ChartTooltipContent
active
payload={entries}
label={tooltipLabel}
className={styles[chartStyleKeys.tooltip]}
style={{
position: 'absolute',
...getChartTooltipPosition(layout, mousePosition.x, mousePosition.y),
}}
/>
)}
{showLegend && <ChartLegendContent payload={legendItems} />}
<div
data-slot="chart-status"
role="status"
aria-live="polite"
aria-atomic="true"
style={{ position: 'absolute', width: 1, height: 1, padding: 0, margin: -1, overflow: 'hidden', clipPath: 'inset(50%)', whiteSpace: 'nowrap', border: 0 }}>
{statusText}
</div>
{children?.(contextValue)}
</div>
</ChartContext.Provider>
);
},
);
ChartContainer.displayName = 'ChartContainer';
export interface ChartTooltipContentProps extends React.HTMLAttributes<HTMLDivElement> {
active?: boolean;
payload?: ChartTooltipEntry[];
label?: React.ReactNode;
indicator?: 'line' | 'dot' | 'dashed';
hideLabel?: boolean;
hideIndicator?: boolean;
labelFormatter?: (value: React.ReactNode, payload: ChartTooltipEntry[]) => React.ReactNode;
formatter?: (value: number, name: string, item: ChartTooltipEntry, index: number) => React.ReactNode;
color?: string;
nameKey?: string;
labelKey?: string;
labelClassName?: string;
}
const ChartTooltipContent = React.forwardRef<HTMLDivElement, ChartTooltipContentProps>(
(
{ active, payload, className = '', indicator = 'dot', hideLabel = false, hideIndicator = false, label, labelFormatter, labelClassName = '', formatter, color, ...props },
ref,
) => {
const { config } = useChart();
const items = payload ?? [];
if (!active || items.length === 0) {
return null;
}
const labelNode = !hideLabel && label != null && (
<div className={`${styles[chartStyleKeys.tooltipLabel]} ${labelClassName}`}>{labelFormatter ? labelFormatter(label, items) : String(label)}</div>
);
return (
<div ref={ref} className={className} {...props}>
{labelNode}
<div className={styles[chartStyleKeys.tooltipGrid]}>
{items.map((item, index) => {
const itemConfig = config[item.dataKey];
const indicatorColor = color ?? item.color;
return (
<div key={index} className={styles[chartStyleKeys.tooltipItem]} data-indicator={indicator}>
{!hideIndicator && (
<span
className={styles[chartStyleKeys.tooltipIndicator]}
data-indicator={indicator}
style={{ backgroundColor: indicatorColor, borderColor: indicatorColor }}
/>
)}
<div className={styles[chartStyleKeys.tooltipItemName]}>{itemConfig?.label ?? item.name}</div>
{formatter && item.value !== undefined ? (
formatter(item.value, item.name, item, index)
) : (
<div className={styles[chartStyleKeys.tooltipItemValue]}>
{typeof item.value === 'number' ? item.value.toLocaleString() : String(item.value)}
</div>
)}
</div>
);
})}
</div>
</div>
);
},
);
ChartTooltipContent.displayName = 'ChartTooltipContent';
export interface ChartLegendContentProps extends React.HTMLAttributes<HTMLDivElement> {
payload?: ChartLegendItem[];
hideIcon?: boolean;
verticalAlign?: 'top' | 'bottom';
}
const ChartLegendContent = React.forwardRef<HTMLDivElement, ChartLegendContentProps>(({ className = '', payload, hideIcon = false, verticalAlign = 'bottom', ...props }, ref) => {
const items = payload ?? [];
if (items.length === 0) {
return null;
}
return (
<div ref={ref} data-align={verticalAlign} className={`${styles[chartStyleKeys.legend]} ${className}`} {...props}>
{items.map((item, index) => (
<div key={index} className={styles[chartStyleKeys.legendItem]}>
{!hideIcon && <span className={styles[chartStyleKeys.legendSwatch]} style={{ backgroundColor: item.color }} />}
{item.name}
</div>
))}
</div>
);
});
ChartLegendContent.displayName = 'ChartLegendContent';
export interface ChartTooltipProps {
className?: string;
children?: (state: { active: boolean; payload: ChartTooltipEntry[]; label: React.ReactNode }) => React.ReactNode;
}
const ChartTooltip = ({ className = '', children }: ChartTooltipProps) => {
const { layout, config, data, activeIndex } = useChart();
const entries = getChartTooltipEntries(layout, config, data, activeIndex);
const label = activeIndex !== null ? layout.xLabels[activeIndex] : null;
if (children) {
return <>{children({ active: activeIndex !== null, payload: entries, label })}</>;
}
return <ChartTooltipContent className={className} active={activeIndex !== null} payload={entries} label={label} />;
};
ChartTooltip.displayName = 'ChartTooltip';
export interface ChartLegendProps extends Omit<ChartLegendContentProps, 'payload'> {}
const ChartLegend = (props: ChartLegendProps) => {
const { layout } = useChart();
return <ChartLegendContent {...props} payload={getChartLegendItems(layout)} />;
};
ChartLegend.displayName = 'ChartLegend';
export { ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, ChartLegendContent, ChartStyle };
export default ChartContainer;
No selection tags yet — empty means no evidence, not lack of support.