diff --git a/apps/ui/src/components/ui/area-chart.tsx b/apps/ui/src/components/ui/area-chart.tsx index 8200590..a6e1872 100644 --- a/apps/ui/src/components/ui/area-chart.tsx +++ b/apps/ui/src/components/ui/area-chart.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "hono/jsx/dom"; +import { useEffect, useMemo, useRef, useState } from "hono/jsx/dom"; import { cx, type ClassName } from "./utils"; export type AreaChartPoint = { @@ -15,6 +15,49 @@ export type AreaChartProps = { class?: ClassName; }; +type PositionedPoint = AreaChartPoint & { + x: number; + y: number; +}; + +const buildNaturalPath = (points: PositionedPoint[]) => { + if (points.length === 0) { + return ""; + } + if (points.length === 1) { + return `M ${points[0].x} ${points[0].y}`; + } + if (points.length === 2) { + return `M ${points[0].x} ${points[0].y} L ${points[1].x} ${points[1].y}`; + } + const clamp = (value: number, min: number, max: number) => + Math.max(min, Math.min(max, value)); + const minY = Math.min(...points.map((point) => point.y)); + const maxY = Math.max(...points.map((point) => point.y)); + const tension = 0.78; + let path = `M ${points[0].x} ${points[0].y}`; + for (let index = 0; index < points.length - 1; index += 1) { + const previous = points[index - 1] ?? points[index]; + const current = points[index]; + const next = points[index + 1]; + const nextAfter = points[index + 2] ?? next; + const cp1x = current.x + ((next.x - previous.x) * tension) / 6; + const cp1y = clamp( + current.y + ((next.y - previous.y) * tension) / 6, + minY, + maxY, + ); + const cp2x = next.x - ((nextAfter.x - current.x) * tension) / 6; + const cp2y = clamp( + next.y - ((nextAfter.y - current.y) * tension) / 6, + minY, + maxY, + ); + path += ` C ${cp1x} ${cp1y} ${cp2x} ${cp2y} ${next.x} ${next.y}`; + } + return path; +}; + export const AreaChart = ({ data, height = 180, @@ -23,26 +66,87 @@ export const AreaChart = ({ class: className, }: AreaChartProps) => { const [activeIndex, setActiveIndex] = useState(null); + const chartContainerRef = useRef(null); + const [viewportRatio, setViewportRatio] = useState(2.4); + const viewBoxHeight = 100; + const viewBoxWidth = Math.max( + 100, + Math.round(viewBoxHeight * Math.max(1, viewportRatio)), + ); + const paddingX = 6; + const paddingTop = 7; + const paddingBottom = 8; + const areaBaselineY = viewBoxHeight - paddingBottom; + + useEffect(() => { + const node = chartContainerRef.current; + if (!node) { + return; + } + const updateRatio = () => { + const rect = node.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) { + return; + } + const nextRatio = rect.width / rect.height; + setViewportRatio((prev) => + Math.abs(prev - nextRatio) < 0.01 ? prev : nextRatio, + ); + }; + updateRatio(); + if (typeof ResizeObserver === "undefined") { + window.addEventListener("resize", updateRatio); + return () => { + window.removeEventListener("resize", updateRatio); + }; + } + const observer = new ResizeObserver(() => updateRatio()); + observer.observe(node); + return () => observer.disconnect(); + }, [height]); + const points = useMemo(() => { if (data.length === 0) { return []; } const maxValue = Math.max(...data.map((item) => item.value), 1); + const drawableWidth = Math.max(1, viewBoxWidth - paddingX * 2); + const drawableHeight = Math.max(1, viewBoxHeight - paddingTop - paddingBottom); return data.map((item, index) => { - const x = data.length === 1 ? 50 : (index / (data.length - 1)) * 100; - const y = 100 - (item.value / maxValue) * 100; + const x = + data.length === 1 + ? viewBoxWidth / 2 + : paddingX + (index / (data.length - 1)) * drawableWidth; + const y = areaBaselineY - (item.value / maxValue) * drawableHeight; return { ...item, x, y }; }); - }, [data]); + }, [ + areaBaselineY, + data, + paddingBottom, + paddingTop, + paddingX, + viewBoxHeight, + viewBoxWidth, + ]); const linePath = useMemo(() => { - if (points.length === 0) { + return buildNaturalPath(points); + }, [points]); + const areaPath = useMemo(() => { + if (!linePath || points.length === 0) { return ""; } - return points - .map((point, index) => `${index === 0 ? "M" : "L"} ${point.x} ${point.y}`) - .join(" "); - }, [points]); - const areaPath = linePath ? `${linePath} L 100 100 L 0 100 Z` : ""; + const start = points[0]; + const end = points[points.length - 1]; + return `${linePath} L ${end.x} ${areaBaselineY} L ${start.x} ${areaBaselineY} Z`; + }, [areaBaselineY, linePath, points]); + const horizontalGridLines = useMemo( + () => + [0.2, 0.4, 0.6, 0.8].map( + (step) => paddingTop + step * (viewBoxHeight - paddingTop - paddingBottom), + ), + [paddingBottom, paddingTop, viewBoxHeight], + ); const activePoint = activeIndex !== null && points[activeIndex] ? points[activeIndex] : null; @@ -67,9 +171,13 @@ export const AreaChart = ({ 暂无趋势数据 ) : ( -
+
- - + + + {horizontalGridLines.map((lineY) => ( + + ))} {activePoint ? ( ) : null} {activePoint ? (
{activePoint.label}