[修复] 修复地图显示

This commit is contained in:
ryan
2026-03-15 17:25:52 +08:00
parent b2eb4befba
commit 6ffe76dfa4
3 changed files with 289 additions and 365 deletions
@@ -1,77 +1,131 @@
'use client'; 'use client';
import { formatCompactNumber } from '@/lib/utils/metrics'; import { useMemo } from 'react';
import type { EChartsOption } from 'echarts';
import ReactECharts from 'echarts-for-react';
type RankChartItem = { type RankChartItem = {
label: string; label: string;
value: number; value: number;
}; };
function clamp(value: number, min: number, max: number) { type RankChartProps = {
return Math.min(Math.max(value, min), max);
}
export function RankChart({
items,
color,
valueFormatter = formatCompactNumber,
emptyMessage = '暂无数据',
}: {
items: RankChartItem[]; items: RankChartItem[];
color: string; color: string;
valueFormatter?: (value: number) => string; valueFormatter?: (value: number) => string;
emptyMessage?: 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) { const defaultFormatter = (value: number) => value.toLocaleString('zh-CN');
function getChartValue(params: unknown) {
if (typeof params !== 'object' || params === null || !('value' in params)) {
return 0;
}
const rawValue = (params as { value?: unknown }).value;
if (Array.isArray(rawValue)) {
const candidate = rawValue[0];
return typeof candidate === 'number' ? candidate : 0;
}
return typeof rawValue === 'number' ? rawValue : 0;
}
export function RankChart({
items,
color,
valueFormatter = defaultFormatter,
emptyMessage = '暂无分布数据',
}: RankChartProps) {
const option = useMemo<EChartsOption>(
() => ({
animationDuration: 400,
grid: {
left: 16,
right: 24,
top: 12,
bottom: 12,
containLabel: true,
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
backgroundColor: 'rgba(15, 23, 42, 0.92)',
borderWidth: 0,
textStyle: {
color: '#e2e8f0',
fontSize: 12,
},
formatter: (params: unknown) => {
const item = Array.isArray(params) ? params[0] : params;
const data = item as { name?: string; value?: number };
return `${data.name ?? ''}<br/>${valueFormatter(data.value ?? 0)}`;
},
},
xAxis: {
type: 'value',
axisLabel: {
color: '#94a3b8',
},
splitLine: {
lineStyle: {
color: 'rgba(148, 163, 184, 0.16)',
type: 'dashed',
},
},
},
yAxis: {
type: 'category',
data: items.map((item) => item.label),
axisTick: { show: false },
axisLine: { show: false },
axisLabel: {
color: '#cbd5e1',
width: 120,
overflow: 'truncate',
},
},
series: [
{
type: 'bar',
data: items.map((item) => item.value),
barWidth: 12,
showBackground: true,
backgroundStyle: {
color: 'rgba(148, 163, 184, 0.12)',
borderRadius: 999,
},
itemStyle: {
color,
borderRadius: 999,
},
label: {
show: true,
position: 'right',
color: '#e2e8f0',
formatter: (params: unknown) => valueFormatter(getChartValue(params)),
},
},
],
}),
[color, items, valueFormatter],
);
if (items.length === 0) {
return ( 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)]"> <div className="flex h-[220px] items-center justify-center rounded-3xl border border-dashed border-[var(--border-default)] bg-[var(--surface-muted)] text-sm text-[var(--foreground-secondary)]">
{emptyMessage} {emptyMessage}
</div> </div>
); );
} }
return ( return (
<div className="space-y-3"> <ReactECharts
{validItems.map((item, index) => { option={option}
const width = clamp((item.value / maxValue) * 100, 6, 100); notMerge
lazyUpdate
return ( style={{ height: Math.max(220, items.length * 44), width: '100%' }}
<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>
); );
} }
@@ -1,214 +1,208 @@
'use client'; 'use client';
import { useId } from 'react'; import { useMemo } from 'react';
import type { EChartsOption } from 'echarts';
import ReactECharts from 'echarts-for-react';
import { calculateNiceAxisMax, formatCompactNumber } from '@/lib/utils/metrics'; import { calculateNiceAxisMax, formatCompactNumber } from '@/lib/utils/metrics';
type TrendSeries = { type TrendChartSeries = {
label: string; label: string;
color: string; color: string;
values: number[];
fillColor?: string; fillColor?: string;
values: number[];
variant?: 'line' | 'area'; variant?: 'line' | 'area';
valueFormatter?: (value: number) => string; valueFormatter?: (value: number) => string;
}; };
function clamp(value: number, min: number, max: number) { type TrendChartProps = {
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[]; labels: string[];
series: TrendSeries[]; series: TrendChartSeries[];
height?: number; height?: number;
yAxisValueFormatter?: (value: number) => string; 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) => type TooltipParam = {
entry.values.some((value) => Number.isFinite(value)), axisValueLabel?: string;
); color?: string;
const pointCount = Math.max( seriesName?: string;
labels.length, value?: number | string | Array<number | string>;
...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) { const defaultFormatter = (value: number) => formatCompactNumber(value);
export function TrendChart({
labels,
series,
height = 220,
yAxisValueFormatter,
}: TrendChartProps) {
const option = useMemo<EChartsOption>(() => {
const axisFormatter = yAxisValueFormatter ?? defaultFormatter;
const maxValue = calculateNiceAxisMax(
series.flatMap((item) => item.values),
);
return {
animationDuration: 500,
animationEasing: 'cubicOut',
grid: {
left: 16,
right: 16,
top: 20,
bottom: 20,
containLabel: true,
},
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(15, 23, 42, 0.92)',
borderWidth: 0,
textStyle: {
color: '#e2e8f0',
fontSize: 12,
},
formatter: (params: unknown) => {
const items = Array.isArray(params) ? (params as TooltipParam[]) : [];
if (items.length === 0) {
return '';
}
const header = items[0]?.axisValueLabel ?? '';
const rows = items.map((item) => {
const matchedSeries = series.find(
(seriesItem) => seriesItem.label === item.seriesName,
);
const formatter =
matchedSeries?.valueFormatter ??
yAxisValueFormatter ??
defaultFormatter;
const rawValue = Array.isArray(item.value)
? item.value[1]
: item.value;
const numericValue =
typeof rawValue === 'number' ? rawValue : Number(rawValue ?? 0);
return [
'<span style="display:inline-flex;align-items:center;gap:8px;">',
`<span style="display:inline-block;width:8px;height:8px;border-radius:9999px;background:${item.color ?? '#94a3b8'};"></span>`,
`<span>${item.seriesName ?? ''}</span>`,
`<strong style="margin-left:8px;">${formatter(numericValue)}</strong>`,
'</span>',
].join('');
});
return [header, ...rows].join('<br/>');
},
},
legend: {
show: false,
},
xAxis: {
type: 'category',
boundaryGap: false,
data: labels,
axisLine: {
lineStyle: {
color: 'rgba(148, 163, 184, 0.24)',
},
},
axisTick: {
show: false,
},
axisLabel: {
color: '#94a3b8',
margin: 14,
},
},
yAxis: {
type: 'value',
min: 0,
max: maxValue,
splitNumber: 4,
axisLabel: {
color: '#94a3b8',
formatter: (value: number) => axisFormatter(value),
},
splitLine: {
lineStyle: {
color: 'rgba(148, 163, 184, 0.16)',
type: 'dashed',
},
},
},
series: series.map((item) => ({
name: item.label,
type: 'line',
smooth: true,
showSymbol: false,
symbol: 'circle',
symbolSize: 8,
lineStyle: {
color: item.color,
width: 3,
},
itemStyle: {
color: item.color,
},
areaStyle:
item.variant === 'area'
? {
color: item.fillColor ?? `${item.color}33`,
}
: undefined,
emphasis: {
focus: 'series',
scale: true,
},
data: item.values,
})),
};
}, [labels, series, yAxisValueFormatter]);
if (labels.length === 0 || series.length === 0) {
return ( 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 className="flex h-[220px] items-center justify-center rounded-3xl border border-dashed border-[var(--border-default)] bg-[var(--surface-muted)] text-sm text-[var(--foreground-secondary)]">
暂无趋势数据 暂无趋势数据
</div> </div>
); );
} }
const xStep = pointCount > 1 ? innerWidth / (pointCount - 1) : innerWidth / 2;
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="overflow-hidden rounded-[28px] border border-[var(--border-default)] bg-[var(--surface-elevated)] px-3 py-3"> <div className="flex flex-wrap gap-3">
<svg {series.map((item) => {
viewBox={`0 0 ${width} ${height}`} const latestValue = item.values[item.values.length - 1] ?? 0;
className="h-auto w-full" const formatter = item.valueFormatter ?? defaultFormatter;
role="img"
aria-label="趋势图"
>
{Array.from({ length: yTicks + 1 }, (_, index) => {
const value = axisMax - (axisMax / yTicks) * index;
const y = paddingTop + (innerHeight / yTicks) * index;
return ( return (
<g key={`tick-${index}`}> <div
<line key={item.label}
x1={paddingLeft} className="min-w-[140px] rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3"
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)} <div className="flex items-center gap-2">
</text> <span
</g> className="h-2.5 w-2.5 rounded-full"
); style={{ backgroundColor: item.color }}
})}
{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"
/> />
<p className="text-xs tracking-[0.18em] text-[var(--foreground-muted)] uppercase">
{points.map(([x, y], pointIndex) => { {item.label}
const value = entry.values[pointIndex] ?? 0; </p>
const label = labels[pointIndex] ?? `点 ${pointIndex + 1}`; </div>
const formatter = entry.valueFormatter ?? yAxisValueFormatter; <p className="mt-2 text-lg font-semibold text-[var(--foreground-primary)]">
{formatter(latestValue)}
return ( </p>
<circle key={`${entry.label}-${pointIndex}`} cx={x} cy={y} r="3.5" fill={entry.color}> </div>
<title>{`${entry.label} · ${label} · ${formatter(value)}`}</title>
</circle>
);
})}
</g>
); );
})} })}
</svg> </div>
</div>
<div className="flex flex-wrap gap-3"> <div className="overflow-hidden rounded-[28px] border border-[var(--border-default)] bg-[linear-gradient(180deg,rgba(255,255,255,0.03),rgba(255,255,255,0))] px-4 py-4">
{normalizedSeries.map((entry) => ( <ReactECharts
<div option={option}
key={entry.label} notMerge
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)]" lazyUpdate
> style={{ height, width: '100%' }}
<span />
className="h-2.5 w-2.5 rounded-full" </div>
style={{ backgroundColor: entry.color }}
/>
{entry.label}
</div>
))}
</div> </div>
</div>
); );
} }
File diff suppressed because one or more lines are too long