统计图

This commit is contained in:
2026-06-19 09:28:23 +08:00 Unverified
parent 3adca3d226
commit 2b248921c7
6 changed files with 389 additions and 8 deletions
+138
View File
@@ -0,0 +1,138 @@
---
type ChartItem = {
label: string;
value: number;
href?: string;
};
interface Props {
title: string;
subtitle?: string;
type: 'pie' | 'line';
items: ChartItem[];
}
const { title, subtitle, type, items } = Astro.props;
const colors = ['#3eb8be', '#f2a65a', '#7f9cf5', '#68b684', '#f07f7f', '#b18ce8', '#e0b34f', '#5ca4d3'];
const safeItems = items.filter((item) => item.value > 0);
const total = safeItems.reduce((sum, item) => sum + item.value, 0);
const circumference = 100;
let offset = 0;
const pieItems = safeItems.map((item, index) => {
const percent = total ? item.value / total : 0;
const dash = percent * circumference;
const segment = {
...item,
color: colors[index % colors.length],
dash,
offset,
percentText: `${Math.round(percent * 100)}%`,
};
offset -= dash;
return segment;
});
const lineWidth = 640;
const lineHeight = 240;
const padding = { top: 24, right: 24, bottom: 42, left: 40 };
const plotWidth = lineWidth - padding.left - padding.right;
const plotHeight = lineHeight - padding.top - padding.bottom;
const maxValue = Math.max(1, ...safeItems.map((item) => item.value));
const linePoints = safeItems.map((item, index) => {
const x = safeItems.length === 1 ? padding.left + plotWidth / 2 : padding.left + (index / (safeItems.length - 1)) * plotWidth;
const y = padding.top + plotHeight - (item.value / maxValue) * plotHeight;
return { ...item, x, y };
});
const points = linePoints.map((point) => `${point.x},${point.y}`).join(' ');
const areaPoints = linePoints.length
? `${padding.left},${padding.top + plotHeight} ${points} ${padding.left + plotWidth},${padding.top + plotHeight}`
: '';
const labelStep = Math.max(1, Math.ceil(linePoints.length / 6));
---
<section class={`stats-chart stats-chart-${type}`} aria-labelledby={`${type}-chart-title`}>
<div class="stats-chart-head">
<h2 id={`${type}-chart-title`}>{title}</h2>
{subtitle && <p>{subtitle}</p>}
</div>
{type === 'pie' ? (
<div class="stats-chart-body">
<svg class="pie-chart" viewBox="0 0 120 120" role="img" aria-label={title}>
<circle class="pie-chart-track" cx="60" cy="60" r="42" pathLength={circumference}></circle>
{pieItems.map((item) => (
<circle
class="pie-chart-segment"
cx="60"
cy="60"
r="42"
pathLength={circumference}
stroke={item.color}
stroke-dasharray={`${item.dash} ${circumference - item.dash}`}
stroke-dashoffset={item.offset}
></circle>
))}
<text x="60" y="56" text-anchor="middle" class="pie-chart-total">{total}</text>
<text x="60" y="72" text-anchor="middle" class="pie-chart-label">篇文章</text>
</svg>
<div class="chart-legend">
{pieItems.map((item, index) => {
const content = (
<>
<span class="chart-legend-color" style={`--chart-color: ${item.color}`}></span>
<span class="chart-legend-label">{item.label}</span>
<strong>{item.value}</strong>
<span class="chart-legend-percent">{item.percentText}</span>
</>
);
return item.href ? (
<a href={item.href} class="chart-legend-item" style={`--item-index: ${index}`}>
{content}
</a>
) : (
<div class="chart-legend-item" style={`--item-index: ${index}`}>
{content}
</div>
);
})}
</div>
</div>
) : (
<div class="line-chart-wrap">
<svg class="line-chart" viewBox={`0 0 ${lineWidth} ${lineHeight}`} role="img" aria-label={title} preserveAspectRatio="none">
<line class="line-chart-axis" x1={padding.left} y1={padding.top + plotHeight} x2={padding.left + plotWidth} y2={padding.top + plotHeight}></line>
<line class="line-chart-axis" x1={padding.left} y1={padding.top} x2={padding.left} y2={padding.top + plotHeight}></line>
{[0, 0.5, 1].map((ratio) => (
<>
<line
class="line-chart-grid"
x1={padding.left}
y1={padding.top + plotHeight - ratio * plotHeight}
x2={padding.left + plotWidth}
y2={padding.top + plotHeight - ratio * plotHeight}
></line>
<text class="line-chart-y-label" x={padding.left - 12} y={padding.top + plotHeight - ratio * plotHeight + 4} text-anchor="end">
{Math.round(maxValue * ratio)}
</text>
</>
))}
{linePoints.length > 0 && <polygon class="line-chart-area" points={areaPoints}></polygon>}
{linePoints.length > 0 && <polyline class="line-chart-line" points={points}></polyline>}
{linePoints.map((point, index) => (
<>
<circle class="line-chart-dot" cx={point.x} cy={point.y} r="4"></circle>
<title>{`${point.label}: ${point.value} 篇`}</title>
{index % labelStep === 0 && (
<text class="line-chart-x-label" x={point.x} y={lineHeight - 12} text-anchor="middle">
{point.label}
</text>
)}
</>
))}
</svg>
</div>
)}
</section>