返回 presentation-ai
chart.tsx
根目录 / src / components / ui / chart.tsx
1 "use client";
2
3 import * as React from "react";
4 import * as RechartsPrimitive from "recharts";
5 import type {
6 NameType,
7 ValueType,
8 } from "recharts/types/component/DefaultTooltipContent";
9
10 import { cn } from "@/lib/utils";
11
12 // Format: { THEME_NAME: CSS_SELECTOR }
13 const THEMES = { light: "", dark: ".dark" } as const;
14
15 export type ChartConfig = {
16 [k in string]: {
17 label?: React.ReactNode;
18 icon?: React.ComponentType;
19 } & (
20 | { color?: string; theme?: never }
21 | { color?: never; theme: Record<keyof typeof THEMES, string> }
22 );
23 };
24
25 type ChartContextProps = {
26 config: ChartConfig;
27 };
28
29 const ChartContext = React.createContext<ChartContextProps | null>(null);
30
31 function useChart() {
32 const context = React.useContext(ChartContext);
33
34 if (!context) {
35 throw new Error("useChart must be used within a <ChartContainer />");
36 }
37
38 return context;
39 }
40
41 const ChartContainer = React.forwardRef<
42 HTMLDivElement,
43 React.ComponentProps<"div"> & {
44 config: ChartConfig;
45 children: React.ComponentProps<
46 typeof RechartsPrimitive.ResponsiveContainer
47 >["children"];
48 }
49 >(({ id, className, children, config, ...props }, ref) => {
50 const uniqueId = React.useId();
51 const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
52
53 return (
54 <ChartContext.Provider value={{ config }}>
55 <div
56 data-chart={chartId}
57 ref={ref}
58 className={cn(
59 "flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
60 className,
61 )}
62 {...props}
63 >
64 <ChartStyle id={chartId} config={config} />
65 <RechartsPrimitive.ResponsiveContainer>
66 {children}
67 </RechartsPrimitive.ResponsiveContainer>
68 </div>
69 </ChartContext.Provider>
70 );
71 });
72 ChartContainer.displayName = "Chart";
73
74 const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
75 const colorConfig = Object.entries(config).filter(
76 ([, config]) => config.theme || config.color,
77 );
78
79 if (!colorConfig.length) {
80 return null;
81 }
82
83 return (
84 <style
85 // biome-ignore lint/security/noDangerouslySetInnerHtml: The code is safe
86 dangerouslySetInnerHTML={{
87 __html: Object.entries(THEMES)
88 .map(
89 ([theme, prefix]) => `
90 ${prefix} [data-chart=${id}] {
91 ${colorConfig
92 .map(([key, itemConfig]) => {
93 const color =
94 itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
95 itemConfig.color;
96 return color ? ` --color-${key}: ${color};` : null;
97 })
98 .join("\n")}
99 }
100 `,
101 )
102 .join("\n"),
103 }}
104 />
105 );
106 };
107
108 const ChartTooltip = RechartsPrimitive.Tooltip;
109
110 type ChartTooltipContentProps = React.ComponentProps<"div"> &
111 RechartsPrimitive.TooltipProps<ValueType, NameType> & {
112 hideLabel?: boolean;
113 hideIndicator?: boolean;
114 indicator?: "line" | "dot" | "dashed";
115 nameKey?: string;
116 labelKey?: string;
117 };
118
119 const ChartTooltipContent = React.forwardRef<
120 HTMLDivElement,
121 ChartTooltipContentProps
122 >(
123 (
124 {
125 active,
126 payload,
127 className,
128 indicator = "dot",
129 hideLabel = false,
130 hideIndicator = false,
131 label,
132 labelFormatter,
133 labelClassName,
134 formatter,
135 color,
136 nameKey,
137 labelKey,
138 },
139 ref,
140 ) => {
141 const { config } = useChart();
142
143 const tooltipLabel = React.useMemo(() => {
144 if (hideLabel || !payload?.length) {
145 return null;
146 }
147
148 const [item] = payload;
149 const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
150 const itemConfig = getPayloadConfigFromPayload(config, item, key);
151 const value =
152 !labelKey && typeof label === "string"
153 ? config[label as keyof typeof config]?.label || label
154 : itemConfig?.label;
155
156 if (labelFormatter) {
157 return (
158 <div className={cn("font-medium", labelClassName)}>
159 {labelFormatter(value, payload)}
160 </div>
161 );
162 }
163
164 if (!value) {
165 return null;
166 }
167
168 return <div className={cn("font-medium", labelClassName)}>{value}</div>;
169 }, [
170 label,
171 labelFormatter,
172 payload,
173 hideLabel,
174 labelClassName,
175 config,
176 labelKey,
177 ]);
178
179 if (!active || !payload?.length) {
180 return null;
181 }
182
183 const nestLabel = payload.length === 1 && indicator !== "dot";
184
185 return (
186 <div
187 ref={ref}
188 className={cn(
189 "grid min-w-32 items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
190 className,
191 )}
192 >
193 {!nestLabel ? tooltipLabel : null}
194 <div className="grid gap-1.5">
195 {payload.map((item, index) => {
196 const key = `${nameKey || item.name || item.dataKey || "value"}`;
197 const itemConfig = getPayloadConfigFromPayload(config, item, key);
198 const indicatorColor = color || item.payload.fill || item.color;
199
200 return (
201 <div
202 key={item.dataKey}
203 className={cn(
204 "flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
205 indicator === "dot" && "items-center",
206 )}
207 >
208 {formatter && item?.value !== undefined && item.name ? (
209 formatter(item.value, item.name, item, index, item.payload)
210 ) : (
211 <>
212 {itemConfig?.icon ? (
213 <itemConfig.icon />
214 ) : (
215 !hideIndicator && (
216 <div
217 className={cn(
218 "shrink-0 rounded-[2px] border-border bg-(--color-bg)",
219 {
220 "h-2.5 w-2.5": indicator === "dot",
221 "w-1": indicator === "line",
222 "w-0 border-[1.5px] border-dashed bg-transparent":
223 indicator === "dashed",
224 "my-0.5": nestLabel && indicator === "dashed",
225 },
226 )}
227 style={
228 {
229 "--color-bg": indicatorColor,
230 "--color-border": indicatorColor,
231 } as React.CSSProperties
232 }
233 />
234 )
235 )}
236 <div
237 className={cn(
238 "flex flex-1 justify-between leading-none",
239 nestLabel ? "items-end" : "items-center",
240 )}
241 >
242 <div className="grid gap-1.5">
243 {nestLabel ? tooltipLabel : null}
244 <span className="text-muted-foreground">
245 {itemConfig?.label || item.name}
246 </span>
247 </div>
248 {item.value && (
249 <span className="font-mono font-medium text-foreground tabular-nums">
250 {item.value.toLocaleString()}
251 </span>
252 )}
253 </div>
254 </>
255 )}
256 </div>
257 );
258 })}
259 </div>
260 </div>
261 );
262 },
263 );
264 ChartTooltipContent.displayName = "ChartTooltip";
265
266 const ChartLegend = RechartsPrimitive.Legend;
267
268 const ChartLegendContent = React.forwardRef<
269 HTMLDivElement,
270 React.ComponentProps<"div"> &
271 Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
272 hideIcon?: boolean;
273 nameKey?: string;
274 }
275 >(
276 (
277 { className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
278 ref,
279 ) => {
280 const { config } = useChart();
281
282 if (!payload?.length) {
283 return null;
284 }
285
286 return (
287 <div
288 ref={ref}
289 className={cn(
290 "flex items-center justify-center gap-4",
291 verticalAlign === "top" ? "pb-3" : "pt-3",
292 className,
293 )}
294 >
295 {payload.map((item) => {
296 const key = `${nameKey || item.dataKey || "value"}`;
297 const itemConfig = getPayloadConfigFromPayload(config, item, key);
298
299 return (
300 <div
301 key={item.value}
302 className={cn(
303 "flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground",
304 )}
305 >
306 {itemConfig?.icon && !hideIcon ? (
307 <itemConfig.icon />
308 ) : (
309 <div
310 className="h-2 w-2 shrink-0 rounded-[2px]"
311 style={{
312 backgroundColor: item.color,
313 }}
314 />
315 )}
316 {itemConfig?.label}
317 </div>
318 );
319 })}
320 </div>
321 );
322 },
323 );
324 ChartLegendContent.displayName = "ChartLegend";
325
326 // Helper to extract item config from a payload.
327 function getPayloadConfigFromPayload(
328 config: ChartConfig,
329 payload: unknown,
330 key: string,
331 ) {
332 if (typeof payload !== "object" || payload === null) {
333 return;
334 }
335
336 const payloadPayload =
337 "payload" in payload &&
338 typeof payload.payload === "object" &&
339 payload.payload !== null
340 ? payload.payload
341 : undefined;
342
343 let configLabelKey: string = key;
344
345 if (
346 key in payload &&
347 typeof payload[key as keyof typeof payload] === "string"
348 ) {
349 configLabelKey = payload[key as keyof typeof payload] as string;
350 } else if (
351 payloadPayload &&
352 key in payloadPayload &&
353 typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
354 ) {
355 configLabelKey = payloadPayload[
356 key as keyof typeof payloadPayload
357 ] as string;
358 }
359
360 return configLabelKey in config
361 ? config[configLabelKey]
362 : config[key as keyof typeof config];
363 }
364
365 export {
366 ChartContainer,
367 ChartLegend,
368 ChartLegendContent,
369 ChartStyle,
370 ChartTooltip,
371 ChartTooltipContent,
372 };
373
373 lines Plain Text