fix:优化曲线图

This commit is contained in:
lsy committed 2026-03-18 19:17:13 +08:00
1 parent 8ddc64becb
commit d87610151c
1 file changed
+154 -19
+154 -19
View File
@@ -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<number | null>(null);
const chartContainerRef = useRef<HTMLDivElement | null>(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 = ({
暂无趋势数据
</div>
) : (
<div class="relative w-full" style={`height:${height}px`}>
<div
ref={chartContainerRef}
class="relative w-full"
style={`height:${height}px`}
>
<svg
viewBox="0 0 100 100"
viewBox={`0 0 ${viewBoxWidth} ${viewBoxHeight}`}
class="h-full w-full"
role="img"
aria-label={`${valueLabel}趋势`}
@@ -78,30 +186,57 @@ export const AreaChart = ({
>
<defs>
<linearGradient id="areaGradient" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor="rgba(10,132,255,0.35)" />
<stop offset="100%" stopColor="rgba(10,132,255,0.02)" />
<stop
offset="5%"
stopColor="var(--app-accent, #0a84ff)"
stopOpacity="0.55"
/>
<stop
offset="95%"
stopColor="var(--app-accent, #0a84ff)"
stopOpacity="0.08"
/>
</linearGradient>
</defs>
{horizontalGridLines.map((lineY) => (
<line
key={`grid-${lineY}`}
x1={0}
y1={lineY}
x2={viewBoxWidth}
y2={lineY}
stroke="rgba(148, 163, 184, 0.28)"
strokeWidth="1"
strokeDasharray="3 3"
vectorEffect="non-scaling-stroke"
/>
))}
<path d={areaPath} fill="url(#areaGradient)" />
<path
d={linePath}
fill="none"
stroke="rgba(10,132,255,0.9)"
strokeWidth="1.8"
stroke="var(--app-accent, #0a84ff)"
strokeWidth="1.35"
strokeLinecap="round"
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
/>
{activePoint ? (
<circle
cx={activePoint.x}
cy={activePoint.y}
r="2.5"
fill="#0a84ff"
r="3.4"
fill="var(--app-accent, #0a84ff)"
stroke="#ffffff"
strokeWidth="1.4"
vectorEffect="non-scaling-stroke"
/>
) : null}
</svg>
{activePoint ? (
<div
class="pointer-events-none absolute top-2 rounded-xl border border-white/80 bg-white/90 px-3 py-2 text-xs text-[color:var(--app-ink)] shadow-[0_12px_30px_rgba(15,23,42,0.12)]"
style={`left: ${activePoint.x}%; transform: translateX(-50%);`}
style={`left: ${(activePoint.x / viewBoxWidth) * 100}%; transform: translateX(-50%);`}
>
<div class="text-[11px] text-[color:var(--app-ink-muted)]">
{activePoint.label}