mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-13 10:26:38 +08:00
[修复] 修复遗漏文件
This commit is contained in:
@@ -0,0 +1,38 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import ReactEChartsCore from 'echarts-for-react/lib/core';
|
||||||
|
import * as echarts from 'echarts/core';
|
||||||
|
import {
|
||||||
|
BarChart,
|
||||||
|
LineChart,
|
||||||
|
type BarSeriesOption,
|
||||||
|
type LineSeriesOption,
|
||||||
|
} from 'echarts/charts';
|
||||||
|
import {
|
||||||
|
GridComponent,
|
||||||
|
LegendComponent,
|
||||||
|
TooltipComponent,
|
||||||
|
type GridComponentOption,
|
||||||
|
type LegendComponentOption,
|
||||||
|
type TooltipComponentOption,
|
||||||
|
} from 'echarts/components';
|
||||||
|
import { CanvasRenderer } from 'echarts/renderers';
|
||||||
|
|
||||||
|
echarts.use([
|
||||||
|
GridComponent,
|
||||||
|
LegendComponent,
|
||||||
|
TooltipComponent,
|
||||||
|
LineChart,
|
||||||
|
BarChart,
|
||||||
|
CanvasRenderer,
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type AppChartOption = echarts.ComposeOption<
|
||||||
|
| GridComponentOption
|
||||||
|
| LegendComponentOption
|
||||||
|
| TooltipComponentOption
|
||||||
|
| LineSeriesOption
|
||||||
|
| BarSeriesOption
|
||||||
|
>;
|
||||||
|
|
||||||
|
export { ReactEChartsCore, echarts };
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { formatCompactNumber } from '@/lib/utils/metrics';
|
||||||
|
|
||||||
|
type RankChartItem = {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number) {
|
||||||
|
return Math.min(Math.max(value, min), max);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RankChart({
|
||||||
|
items,
|
||||||
|
color,
|
||||||
|
valueFormatter = formatCompactNumber,
|
||||||
|
emptyMessage = '暂无数据',
|
||||||
|
}: {
|
||||||
|
items: RankChartItem[];
|
||||||
|
color: string;
|
||||||
|
valueFormatter?: (value: number) => string;
|
||||||
|
emptyMessage?: string;
|
||||||
|
}) {
|
||||||
|
const validItems = items.filter(
|
||||||
|
(item) => item.label.trim() !== '' && Number.isFinite(item.value),
|
||||||
|
);
|
||||||
|
const maxValue = Math.max(1, ...validItems.map((item) => item.value));
|
||||||
|
|
||||||
|
if (validItems.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-44 items-center justify-center rounded-3xl border border-dashed border-[var(--border-default)] bg-[var(--surface-elevated)] px-6 py-8 text-sm text-[var(--foreground-muted)]">
|
||||||
|
{emptyMessage}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{validItems.map((item, index) => {
|
||||||
|
const width = clamp((item.value / maxValue) * 100, 6, 100);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${item.label}-${index}`}
|
||||||
|
className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs tracking-[0.18em] text-[var(--foreground-muted)] uppercase">
|
||||||
|
#{index + 1}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 truncate text-sm font-medium text-[var(--foreground-primary)]">
|
||||||
|
{item.label}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="shrink-0 text-sm font-semibold text-[var(--foreground-primary)]">
|
||||||
|
{valueFormatter(item.value)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 h-2.5 overflow-hidden rounded-full bg-[var(--surface-muted)]">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full transition-[width]"
|
||||||
|
style={{
|
||||||
|
width: `${width}%`,
|
||||||
|
backgroundColor: color,
|
||||||
|
boxShadow: `0 0 24px ${color}33`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useId } from 'react';
|
||||||
|
|
||||||
|
import { calculateNiceAxisMax, formatCompactNumber } from '@/lib/utils/metrics';
|
||||||
|
|
||||||
|
type TrendSeries = {
|
||||||
|
label: string;
|
||||||
|
color: string;
|
||||||
|
values: number[];
|
||||||
|
fillColor?: string;
|
||||||
|
variant?: 'line' | 'area';
|
||||||
|
valueFormatter?: (value: number) => string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number) {
|
||||||
|
return Math.min(Math.max(value, min), max);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPath(points: Array<[number, number]>) {
|
||||||
|
if (points.length === 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return points
|
||||||
|
.map(([x, y], index) => `${index === 0 ? 'M' : 'L'} ${x} ${y}`)
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TrendChart({
|
||||||
|
labels,
|
||||||
|
series,
|
||||||
|
height = 240,
|
||||||
|
yAxisValueFormatter = formatCompactNumber,
|
||||||
|
}: {
|
||||||
|
labels: string[];
|
||||||
|
series: TrendSeries[];
|
||||||
|
height?: number;
|
||||||
|
yAxisValueFormatter?: (value: number) => string;
|
||||||
|
}) {
|
||||||
|
const chartId = useId();
|
||||||
|
const width = 720;
|
||||||
|
const paddingTop = 18;
|
||||||
|
const paddingRight = 18;
|
||||||
|
const paddingBottom = 42;
|
||||||
|
const paddingLeft = 54;
|
||||||
|
const innerWidth = width - paddingLeft - paddingRight;
|
||||||
|
const innerHeight = height - paddingTop - paddingBottom;
|
||||||
|
|
||||||
|
const normalizedSeries = series.filter((entry) =>
|
||||||
|
entry.values.some((value) => Number.isFinite(value)),
|
||||||
|
);
|
||||||
|
const pointCount = Math.max(
|
||||||
|
labels.length,
|
||||||
|
...normalizedSeries.map((entry) => entry.values.length),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const allValues = normalizedSeries.flatMap((entry) =>
|
||||||
|
entry.values.filter((value) => Number.isFinite(value)),
|
||||||
|
);
|
||||||
|
const axisMax = calculateNiceAxisMax(allValues);
|
||||||
|
const yTicks = 4;
|
||||||
|
|
||||||
|
if (normalizedSeries.length === 0 || pointCount === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-56 items-center justify-center rounded-3xl border border-dashed border-[var(--border-default)] bg-[var(--surface-elevated)] px-6 py-8 text-sm text-[var(--foreground-muted)]">
|
||||||
|
暂无趋势数据
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const xStep = pointCount > 1 ? innerWidth / (pointCount - 1) : innerWidth / 2;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="overflow-hidden rounded-[28px] border border-[var(--border-default)] bg-[var(--surface-elevated)] px-3 py-3">
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${width} ${height}`}
|
||||||
|
className="h-auto w-full"
|
||||||
|
role="img"
|
||||||
|
aria-label="趋势图"
|
||||||
|
>
|
||||||
|
{Array.from({ length: yTicks + 1 }, (_, index) => {
|
||||||
|
const value = axisMax - (axisMax / yTicks) * index;
|
||||||
|
const y = paddingTop + (innerHeight / yTicks) * index;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g key={`tick-${index}`}>
|
||||||
|
<line
|
||||||
|
x1={paddingLeft}
|
||||||
|
y1={y}
|
||||||
|
x2={width - paddingRight}
|
||||||
|
y2={y}
|
||||||
|
stroke="var(--border-default)"
|
||||||
|
strokeDasharray="4 6"
|
||||||
|
strokeOpacity="0.7"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={paddingLeft - 10}
|
||||||
|
y={y + 4}
|
||||||
|
textAnchor="end"
|
||||||
|
fontSize="11"
|
||||||
|
fill="var(--foreground-muted)"
|
||||||
|
>
|
||||||
|
{yAxisValueFormatter(value)}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{labels.map((label, index) => {
|
||||||
|
const x =
|
||||||
|
pointCount > 1
|
||||||
|
? paddingLeft + xStep * index
|
||||||
|
: paddingLeft + innerWidth / 2;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<text
|
||||||
|
key={`${label}-${index}`}
|
||||||
|
x={x}
|
||||||
|
y={height - 14}
|
||||||
|
textAnchor={index === 0 ? 'start' : index === labels.length - 1 ? 'end' : 'middle'}
|
||||||
|
fontSize="11"
|
||||||
|
fill="var(--foreground-muted)"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</text>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{normalizedSeries.map((entry, seriesIndex) => {
|
||||||
|
const points = entry.values.map((value, index) => {
|
||||||
|
const x =
|
||||||
|
pointCount > 1
|
||||||
|
? paddingLeft + xStep * index
|
||||||
|
: paddingLeft + innerWidth / 2;
|
||||||
|
const safeValue = Number.isFinite(value) ? value : 0;
|
||||||
|
const y =
|
||||||
|
paddingTop +
|
||||||
|
innerHeight -
|
||||||
|
(clamp(safeValue, 0, axisMax) / axisMax) * innerHeight;
|
||||||
|
return [x, y] as [number, number];
|
||||||
|
});
|
||||||
|
|
||||||
|
const linePath = buildPath(points);
|
||||||
|
const areaPath =
|
||||||
|
points.length > 0
|
||||||
|
? `${linePath} L ${points[points.length - 1][0]} ${paddingTop + innerHeight} L ${points[0][0]} ${paddingTop + innerHeight} Z`
|
||||||
|
: '';
|
||||||
|
const gradientId = `${chartId}-gradient-${seriesIndex}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g key={`${entry.label}-${seriesIndex}`}>
|
||||||
|
{entry.variant === 'area' && entry.fillColor ? (
|
||||||
|
<>
|
||||||
|
<defs>
|
||||||
|
<linearGradient
|
||||||
|
id={gradientId}
|
||||||
|
x1="0"
|
||||||
|
y1="0"
|
||||||
|
x2="0"
|
||||||
|
y2="1"
|
||||||
|
>
|
||||||
|
<stop offset="0%" stopColor={entry.fillColor} />
|
||||||
|
<stop offset="100%" stopColor="transparent" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<path d={areaPath} fill={`url(#${gradientId})`} />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<path
|
||||||
|
d={linePath}
|
||||||
|
fill="none"
|
||||||
|
stroke={entry.color}
|
||||||
|
strokeWidth="3"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{points.map(([x, y], pointIndex) => {
|
||||||
|
const value = entry.values[pointIndex] ?? 0;
|
||||||
|
const label = labels[pointIndex] ?? `点 ${pointIndex + 1}`;
|
||||||
|
const formatter = entry.valueFormatter ?? yAxisValueFormatter;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<circle key={`${entry.label}-${pointIndex}`} cx={x} cy={y} r="3.5" fill={entry.color}>
|
||||||
|
<title>{`${entry.label} · ${label} · ${formatter(value)}`}</title>
|
||||||
|
</circle>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{normalizedSeries.map((entry) => (
|
||||||
|
<div
|
||||||
|
key={entry.label}
|
||||||
|
className="inline-flex items-center gap-2 rounded-full border border-[var(--border-default)] bg-[var(--surface-elevated)] px-3 py-1.5 text-xs text-[var(--foreground-secondary)]"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="h-2.5 w-2.5 rounded-full"
|
||||||
|
style={{ backgroundColor: entry.color }}
|
||||||
|
/>
|
||||||
|
{entry.label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
{
|
||||||
|
"type": "FeatureCollection",
|
||||||
|
"features": [
|
||||||
|
{
|
||||||
|
"type": "Feature",
|
||||||
|
"properties": { "name": "Canada" },
|
||||||
|
"geometry": {
|
||||||
|
"type": "Polygon",
|
||||||
|
"coordinates": [[[-140, 55], [-52, 55], [-52, 70], [-140, 70], [-140, 55]]]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Feature",
|
||||||
|
"properties": { "name": "United States" },
|
||||||
|
"geometry": {
|
||||||
|
"type": "Polygon",
|
||||||
|
"coordinates": [[[-125, 25], [-66, 25], [-66, 49], [-125, 49], [-125, 25]]]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Feature",
|
||||||
|
"properties": { "name": "Mexico" },
|
||||||
|
"geometry": {
|
||||||
|
"type": "Polygon",
|
||||||
|
"coordinates": [[[-117, 14], [-86, 14], [-86, 33], [-117, 33], [-117, 14]]]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Feature",
|
||||||
|
"properties": { "name": "Brazil" },
|
||||||
|
"geometry": {
|
||||||
|
"type": "Polygon",
|
||||||
|
"coordinates": [[[-74, -34], [-34, -34], [-34, 5], [-74, 5], [-74, -34]]]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Feature",
|
||||||
|
"properties": { "name": "United Kingdom" },
|
||||||
|
"geometry": {
|
||||||
|
"type": "Polygon",
|
||||||
|
"coordinates": [[[-8, 50], [2, 50], [2, 59], [-8, 59], [-8, 50]]]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Feature",
|
||||||
|
"properties": { "name": "France" },
|
||||||
|
"geometry": {
|
||||||
|
"type": "Polygon",
|
||||||
|
"coordinates": [[[-5, 42], [8, 42], [8, 51], [-5, 51], [-5, 42]]]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Feature",
|
||||||
|
"properties": { "name": "Germany" },
|
||||||
|
"geometry": {
|
||||||
|
"type": "Polygon",
|
||||||
|
"coordinates": [[[5, 47], [16, 47], [16, 55], [5, 55], [5, 47]]]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Feature",
|
||||||
|
"properties": { "name": "Spain" },
|
||||||
|
"geometry": {
|
||||||
|
"type": "Polygon",
|
||||||
|
"coordinates": [[[-9, 36], [3, 36], [3, 44], [-9, 44], [-9, 36]]]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Feature",
|
||||||
|
"properties": { "name": "South Africa" },
|
||||||
|
"geometry": {
|
||||||
|
"type": "Polygon",
|
||||||
|
"coordinates": [[[16, -35], [33, -35], [33, -22], [16, -22], [16, -35]]]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Feature",
|
||||||
|
"properties": { "name": "India" },
|
||||||
|
"geometry": {
|
||||||
|
"type": "Polygon",
|
||||||
|
"coordinates": [[[68, 8], [89, 8], [89, 35], [68, 35], [68, 8]]]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Feature",
|
||||||
|
"properties": { "name": "China" },
|
||||||
|
"geometry": {
|
||||||
|
"type": "Polygon",
|
||||||
|
"coordinates": [[[73, 18], [135, 18], [135, 53], [73, 53], [73, 18]]]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Feature",
|
||||||
|
"properties": { "name": "Japan" },
|
||||||
|
"geometry": {
|
||||||
|
"type": "Polygon",
|
||||||
|
"coordinates": [[[129, 31], [146, 31], [146, 46], [129, 46], [129, 31]]]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Feature",
|
||||||
|
"properties": { "name": "Singapore" },
|
||||||
|
"geometry": {
|
||||||
|
"type": "Polygon",
|
||||||
|
"coordinates": [[[103.5, 1.1], [104.1, 1.1], [104.1, 1.6], [103.5, 1.6], [103.5, 1.1]]]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Feature",
|
||||||
|
"properties": { "name": "Indonesia" },
|
||||||
|
"geometry": {
|
||||||
|
"type": "Polygon",
|
||||||
|
"coordinates": [[[95, -11], [141, -11], [141, 6], [95, 6], [95, -11]]]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Feature",
|
||||||
|
"properties": { "name": "Australia" },
|
||||||
|
"geometry": {
|
||||||
|
"type": "Polygon",
|
||||||
|
"coordinates": [[[113, -44], [154, -44], [154, -10], [113, -10], [113, -44]]]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user