终端升级:MiniSparkline 替换为 Recharts 交互图表,新增概率分布柱状对比图

This commit is contained in:
2569718930@qq.com
2026-05-25 00:45:50 +08:00
parent e9cc9a5eb1
commit a4d8c1aa39
2 changed files with 120 additions and 34 deletions
@@ -19,6 +19,17 @@ import {
UserRound, UserRound,
} from "lucide-react"; } from "lucide-react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import {
Area,
AreaChart,
Bar,
BarChart as ReBarChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import type { ProAccessState, ScanOpportunityRow } from "@/lib/dashboard-types"; import type { ProAccessState, ScanOpportunityRow } from "@/lib/dashboard-types";
import { getInitialLocaleFromNavigator } from "@/lib/i18n"; import { getInitialLocaleFromNavigator } from "@/lib/i18n";
import { isBrowserLocalFullAccess } from "@/lib/local-dev-access"; import { isBrowserLocalFullAccess } from "@/lib/local-dev-access";
@@ -114,23 +125,91 @@ function decisionClass(label: string) {
return "border-amber-200 bg-amber-50 text-amber-700"; return "border-amber-200 bg-amber-50 text-amber-700";
} }
function MiniSparkline({ tone = "blue" }: { tone?: "blue" | "emerald" | "red" }) { function SparkArea({
const stroke = color = "#2563eb",
tone === "emerald" ? "#059669" : tone === "red" ? "#dc2626" : "#2563eb"; data,
}: {
color?: string;
data: { v: number }[];
}) {
if (!data.length) {
return (
<div className="flex h-full items-center justify-center text-xs text-slate-400">
No data
</div>
);
}
return ( return (
<svg viewBox="0 0 140 48" className="h-12 w-full" aria-hidden="true"> <ResponsiveContainer width="100%" height="100%">
<path <AreaChart data={data} margin={{ top: 2, right: 0, left: 0, bottom: 2 }}>
d="M0 34 C18 16 30 38 45 24 S74 12 89 25 114 37 140 15" <defs>
fill="none" <linearGradient id={`fill-${color}`} x1="0" y1="0" x2="0" y2="1">
stroke={stroke} <stop offset="0%" stopColor={color} stopOpacity={0.18} />
strokeWidth="2.4" <stop offset="100%" stopColor={color} stopOpacity={0.02} />
/> </linearGradient>
<path </defs>
d="M0 34 C18 16 30 38 45 24 S74 12 89 25 114 37 140 15 V48 H0 Z" <Area
fill={stroke} type="monotone"
opacity="0.08" dataKey="v"
/> stroke={color}
</svg> strokeWidth={2}
fill={`url(#fill-${color})`}
dot={false}
isAnimationActive={false}
/>
</AreaChart>
</ResponsiveContainer>
);
}
function ProbabilityDistributionChart({
points,
}: {
points?: ScanOpportunityRow["distribution_preview"];
}) {
if (!points?.length) {
return (
<div className="flex h-full items-center justify-center text-xs text-slate-400">
No distribution data
</div>
);
}
const chartData = points.map((p) => ({
label: p.label || "",
model: Number((p.model_probability ?? 0) * 100),
market: Number((p.market_probability ?? 0) * 100),
}));
return (
<ResponsiveContainer width="100%" height="100%">
<ReBarChart data={chartData} margin={{ top: 4, right: 4, left: 0, bottom: 4 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis
dataKey="label"
tick={{ fontSize: 11, fill: "#64748b" }}
axisLine={{ stroke: "#e2e8f0" }}
tickLine={false}
/>
<YAxis
tick={{ fontSize: 11, fill: "#64748b" }}
axisLine={false}
tickLine={false}
tickFormatter={(v) => `${v}%`}
/>
<Tooltip
contentStyle={{
borderRadius: 4,
border: "1px solid #e2e8f0",
fontSize: 12,
fontFamily: "monospace",
}}
formatter={(value: unknown) =>
`${Number(value).toFixed(1)}%`
}
/>
<Bar dataKey="model" fill="#2563eb" radius={[2, 2, 0, 0]} name="Model" />
<Bar dataKey="market" fill="#059669" radius={[2, 2, 0, 0]} name="Market" />
</ReBarChart>
</ResponsiveContainer>
); );
} }
@@ -445,9 +524,23 @@ function KoyfinWeatherTerminal({
<div className="mb-2 text-xs font-black uppercase text-slate-500"> <div className="mb-2 text-xs font-black uppercase text-slate-500">
Intraday Performance Intraday Performance
</div> </div>
<MiniSparkline <div className="h-20">
tone={Number(selectedRow?.edge_percent || 0) >= 0 ? "emerald" : "red"} <SparkArea
/> color={
Number(selectedRow?.edge_percent || 0) >= 0
? "#059669"
: "#dc2626"
}
data={
selectedRow?.distribution_preview?.map((p) => ({
v:
typeof p === "number"
? p
: p.model_probability ?? 0,
})) || []
}
/>
</div>
<div className="mt-2 grid grid-cols-2 gap-2 text-xs"> <div className="mt-2 grid grid-cols-2 gap-2 text-xs">
<span className="rounded bg-white p-2"> <span className="rounded bg-white p-2">
Edge <b className={edgeClass(selectedRow?.edge_percent)}>{pct(selectedRow?.edge_percent)}</b> Edge <b className={edgeClass(selectedRow?.edge_percent)}>{pct(selectedRow?.edge_percent)}</b>
@@ -460,21 +553,14 @@ function KoyfinWeatherTerminal({
</div> </div>
</Panel> </Panel>
<Panel title="Probability & Price Factors"> <Panel title="Probability Distribution">
<div className="grid gap-2 p-3 md:grid-cols-3"> <div className="h-48 p-2">
{[ <ProbabilityDistributionChart
["Model Probability", selectedRow?.model_probability ?? selectedRow?.model_event_probability, "blue"], points={
["Market Probability", selectedRow?.market_probability ?? selectedRow?.market_event_probability, "emerald"], selectedRow?.distribution_preview ??
["Peak Probability", selectedRow?.peak_probability, "red"], selectedRow?.distribution_full
].map(([label, value, tone]) => ( }
<div key={String(label)} className="rounded border border-slate-200 bg-slate-50 p-3"> />
<div className="mb-2 flex justify-between text-xs font-black uppercase text-slate-500">
<span>{label}</span>
<span>{pct(value)}</span>
</div>
<MiniSparkline tone={tone as "blue" | "emerald" | "red"} />
</div>
))}
</div> </div>
</Panel> </Panel>
Binary file not shown.

After

Width:  |  Height:  |  Size: 773 KiB