线性映射被极端涨幅拉平均,多数 0~3% 题材球挤在最小值附近。 改平方根映射:零涨幅 4px、1%≈10px、3%≈15px、极值 22px, 中等涨幅区间球径差显著放大。碰撞力半径动态跟随节点,无需额外适配。 Co-Authored-By: Claude <noreply@anthropic.com>
1173 lines
46 KiB
TypeScript
1173 lines
46 KiB
TypeScript
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
|
||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||
import { useQuery } from "@tanstack/react-query";
|
||
import {
|
||
forceSimulation,
|
||
forceLink,
|
||
forceManyBody,
|
||
forceCenter,
|
||
forceCollide,
|
||
type SimulationNodeDatum,
|
||
type SimulationLinkDatum,
|
||
} from "d3-force";
|
||
import {
|
||
buildEdgesFromStocks,
|
||
fetchThemeGraph,
|
||
type ThemeGraph,
|
||
type GraphStock,
|
||
} from "@/lib/theme-api";
|
||
import { getStockBoard } from "@/lib/stock-api";
|
||
import { formatMoney } from "@/lib/utils";
|
||
import { ArrowLeft, RefreshCw, Flame, TrendingUp, Target, Map as MapIcon, Search, List as ListIcon, Share2 } from "lucide-react";
|
||
|
||
export const Route = createFileRoute("/hot-map")({
|
||
component: HotMapPage,
|
||
});
|
||
|
||
/* ============================================================
|
||
排序维度:涨幅榜 / 热度榜
|
||
============================================================ */
|
||
const MODES: { key: 1 | 4; label: string }[] = [
|
||
{ key: 1, label: "涨幅" },
|
||
{ key: 4, label: "热度" },
|
||
];
|
||
|
||
/* 图视图股票节点上限:按覆盖题材数降序保留最高穿透度的核心股 */
|
||
const MAX_STOCK_NODES = 800;
|
||
|
||
/* 题材节点半径范围:按题材涨幅绝对值平方根映射(涨得越猛球越大,小涨幅区分更明显) */
|
||
const THEME_MIN_RADIUS = 4;
|
||
const THEME_MAX_RADIUS = 22;
|
||
|
||
/* 视图切换:关系图 / 核心股列表 */
|
||
type ViewMode = "graph" | "list";
|
||
|
||
function HotMapPage() {
|
||
const [mode, setMode] = useState<1 | 4>(1);
|
||
const [view, setView] = useState<ViewMode>("graph");
|
||
const isCoarse = useMemo(
|
||
() => typeof window !== "undefined" && !!window.matchMedia?.("(pointer: coarse)").matches,
|
||
[],
|
||
);
|
||
|
||
const { data: graph, isLoading, isFetching, isError, refetch } = useQuery({
|
||
queryKey: ["themeGraph", mode],
|
||
queryFn: () => fetchThemeGraph(mode, 50),
|
||
staleTime: 60_000,
|
||
retry: false,
|
||
});
|
||
|
||
return (
|
||
<div className="h-[100dvh] flex flex-col bg-background">
|
||
{/* ── 顶栏 ── */}
|
||
<header className="shrink-0 z-10 bg-background/95 backdrop-blur border-b">
|
||
<div className="max-w-6xl mx-auto px-4 py-2 flex flex-wrap items-center justify-between gap-x-3 gap-y-1.5">
|
||
<div className="flex items-center gap-3 shrink-0">
|
||
<Link to="/themes" className="hover:opacity-70 transition-opacity shrink-0">
|
||
<ArrowLeft className="h-5 w-5" />
|
||
</Link>
|
||
<h1 className="text-base font-semibold whitespace-nowrap">热点穿透</h1>
|
||
</div>
|
||
<div className="flex flex-wrap items-center justify-end gap-1.5">
|
||
{/* 涨幅/热度切换 */}
|
||
<div className="flex gap-0.5 text-xs border rounded-md overflow-hidden">
|
||
{MODES.map((m) => (
|
||
<button
|
||
key={m.key}
|
||
onClick={() => setMode(m.key)}
|
||
className={`px-2.5 py-1 flex items-center gap-1 transition-colors whitespace-nowrap ${
|
||
mode === m.key
|
||
? "bg-primary text-primary-foreground"
|
||
: "text-muted-foreground hover:text-foreground"
|
||
}`}
|
||
>
|
||
{m.key === 4 ? <Flame className="h-3 w-3" /> : <TrendingUp className="h-3 w-3" />}
|
||
{m.label}榜
|
||
</button>
|
||
))}
|
||
</div>
|
||
{/* 图 / 列表切换 */}
|
||
<div className="flex gap-0.5 text-xs border rounded-md overflow-hidden">
|
||
<button
|
||
onClick={() => setView("graph")}
|
||
className={`px-2.5 py-1 flex items-center gap-1 transition-colors whitespace-nowrap ${
|
||
view === "graph"
|
||
? "bg-primary text-primary-foreground"
|
||
: "text-muted-foreground hover:text-foreground"
|
||
}`}
|
||
>
|
||
<Share2 className="h-3 w-3" />
|
||
关系图
|
||
</button>
|
||
<button
|
||
onClick={() => setView("list")}
|
||
className={`px-2.5 py-1 flex items-center gap-1 transition-colors whitespace-nowrap ${
|
||
view === "list"
|
||
? "bg-primary text-primary-foreground"
|
||
: "text-muted-foreground hover:text-foreground"
|
||
}`}
|
||
>
|
||
<ListIcon className="h-3 w-3" />
|
||
核心股
|
||
</button>
|
||
</div>
|
||
<button
|
||
onClick={() => refetch()}
|
||
className="text-muted-foreground hover:text-foreground transition-colors shrink-0"
|
||
title="刷新"
|
||
>
|
||
<RefreshCw className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`} />
|
||
</button>
|
||
<Link
|
||
to="/core-stocks"
|
||
className="text-xs text-primary flex items-center gap-1 hover:opacity-80 transition-opacity whitespace-nowrap"
|
||
>
|
||
<Flame className="h-3.5 w-3.5" />
|
||
热点股
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 统计条 */}
|
||
{graph?.stats && !isLoading && (
|
||
<div className="max-w-6xl mx-auto px-4 pb-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[10px] text-muted-foreground">
|
||
<span className="inline-flex items-center gap-1 whitespace-nowrap">
|
||
<MapIcon className="h-3 w-3 text-blue-500" /> 题材 {graph.stats.themeCount}
|
||
</span>
|
||
<span className="inline-flex items-center gap-1 whitespace-nowrap">
|
||
<Target className="h-3 w-3 text-slate-400" /> 股票 {graph.stats.stockCount}
|
||
</span>
|
||
<span className="inline-flex items-center gap-1 text-red-500 whitespace-nowrap">
|
||
<Search className="h-3 w-3" /> 穿透核心股 {graph.stats.coreCount}
|
||
<span className="text-muted-foreground/70">(覆盖≥2个题材)</span>
|
||
</span>
|
||
</div>
|
||
)}
|
||
</header>
|
||
|
||
{/* ── 图主体 ── */}
|
||
<main className="flex-1 relative min-h-0">
|
||
{isLoading ? (
|
||
<div className="absolute inset-0 flex items-center justify-center">
|
||
<p className="text-sm text-muted-foreground">正在构建关系网络…</p>
|
||
</div>
|
||
) : isError || !graph ? (
|
||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3">
|
||
<p className="text-sm text-muted-foreground">数据加载失败</p>
|
||
<button onClick={() => refetch()} className="text-xs text-primary hover:underline">
|
||
点击重试
|
||
</button>
|
||
</div>
|
||
) : graph.themes.length === 0 || graph.stocks.length === 0 ? (
|
||
<div className="absolute inset-0 flex items-center justify-center text-sm text-muted-foreground">
|
||
暂无题材数据
|
||
</div>
|
||
) : view === "list" ? (
|
||
<CoreStockList stocks={graph.stocks} />
|
||
) : (
|
||
<>
|
||
<HotMapGraph graph={graph} />
|
||
{/* 图例(触屏紧凑横排) */}
|
||
<Legend maxCover={graph.stats.maxCover} compact={isCoarse} />
|
||
{/* 操作提示 */}
|
||
<div className="absolute bottom-3 left-1/2 -translate-x-1/2 text-[10px] text-muted-foreground/70 bg-background/80 backdrop-blur px-2.5 py-1 rounded-full border whitespace-nowrap">
|
||
{isCoarse
|
||
? "点击节点查看 · 再点跳转 · 双指缩放 · 拖拽平移"
|
||
: "悬停查看 · 滚轮/捏合缩放 · 拖拽平移 · 点击跳转"}
|
||
</div>
|
||
</>
|
||
)}
|
||
</main>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ============================================================
|
||
核心股列表:覆盖题材 > 2 的公司,按覆盖数降序
|
||
============================================================ */
|
||
function CoreStockList({ stocks }: { stocks: GraphStock[] }) {
|
||
// 覆盖题材数 > 2(即 ≥3),接口已按 coverCount 降序、同覆盖按涨幅降序
|
||
const core = stocks.filter((s) => s.coverCount > 2);
|
||
const groupMax = core.length ? core[0].coverCount : 0;
|
||
|
||
return (
|
||
<div className="absolute inset-0 overflow-y-auto">
|
||
<div className="max-w-6xl mx-auto px-4 py-3 pb-8 space-y-2">
|
||
{/* 列表说明 */}
|
||
<p className="text-[10px] text-muted-foreground px-1">
|
||
覆盖 3 个及以上题材的核心股,按覆盖题材数降序(共 {core.length} 家)
|
||
</p>
|
||
|
||
{core.length === 0 ? (
|
||
<div className="text-center py-16 text-sm text-muted-foreground">
|
||
当前题材榜中暂无覆盖 3 个及以上题材的核心股
|
||
</div>
|
||
) : (
|
||
core.map((s) => {
|
||
const board = getStockBoard(s.securityCode);
|
||
const isPos = (s.f3 ?? 0) >= 0;
|
||
return (
|
||
<Link
|
||
key={s.securityCode}
|
||
to="/stock/$code"
|
||
params={{ code: s.securityCode }}
|
||
className="block"
|
||
>
|
||
<div className="rounded-xl border bg-card hover:shadow-md transition-shadow px-3 py-2.5">
|
||
{/* 首行:覆盖数徽章 + 名称 + 涨幅 */}
|
||
<div className="flex items-center gap-2">
|
||
<span
|
||
className={`shrink-0 inline-flex items-center justify-center rounded-full text-[10px] font-bold text-white min-w-[26px] h-[22px] px-1.5 ${
|
||
s.coverCount >= 7 ? "bg-red-700" : s.coverCount >= 5 ? "bg-red-500" : "bg-orange-500"
|
||
}`}
|
||
>
|
||
{s.coverCount}
|
||
</span>
|
||
<p className="font-medium text-sm truncate">{s.securityName}</p>
|
||
{board.label && (
|
||
<span className={`inline-flex items-center justify-center w-3.5 h-3.5 rounded-sm text-[8px] font-bold leading-none shrink-0 ${board.className}`}>
|
||
{board.label}
|
||
</span>
|
||
)}
|
||
<span className="text-[10px] text-muted-foreground font-mono">{s.securityCode}</span>
|
||
<span className={`ml-auto shrink-0 text-xs font-bold tabular-nums ${isPos ? "text-red-500" : "text-green-500"}`}>
|
||
{isPos ? "+" : ""}{(s.f3 ?? 0).toFixed(2)}%
|
||
</span>
|
||
</div>
|
||
|
||
{/* 次行:行业 + 现价 + 覆盖比例条 */}
|
||
<div className="flex items-center gap-2 mt-1.5 text-[10px] text-muted-foreground">
|
||
{s.f100 && (
|
||
<span className="bg-muted rounded px-1.5 py-0.5 shrink-0">{s.f100}</span>
|
||
)}
|
||
{s.f2 != null && <span className="shrink-0 tabular-nums">现价 {s.f2.toFixed(2)}</span>}
|
||
<span className="shrink-0 tabular-nums">{s.themeCodes?.length ?? s.coverCount} 个题材</span>
|
||
<div className="flex-1 h-1 rounded-full bg-muted overflow-hidden">
|
||
<div
|
||
className={`h-full rounded-full ${s.coverCount >= 7 ? "bg-red-700" : s.coverCount >= 5 ? "bg-red-500" : "bg-orange-500"}`}
|
||
style={{ width: `${(s.coverCount / Math.max(groupMax, 1)) * 100}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Link>
|
||
);
|
||
})
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ============================================================
|
||
力导向图组件
|
||
============================================================ */
|
||
|
||
interface SimNode extends SimulationNodeDatum {
|
||
id: string;
|
||
type: "theme" | "stock";
|
||
name: string;
|
||
radius: number;
|
||
// stock 专属
|
||
coverCount?: number;
|
||
f3?: number | null;
|
||
f2?: number | null;
|
||
f62?: number | null;
|
||
f100?: string;
|
||
themeCodes?: string[];
|
||
code?: string;
|
||
// theme 专属
|
||
bf3?: number | null; // 题材涨幅
|
||
}
|
||
|
||
interface SimEdge extends SimulationLinkDatum<SimNode> {
|
||
source: string;
|
||
target: string;
|
||
}
|
||
|
||
type ViewState = { x: number; y: number; k: number };
|
||
|
||
/* 指针手势状态机:pending(可升级 pan)/ pan / pinch */
|
||
type Gesture =
|
||
| { kind: "none" }
|
||
| { kind: "pending"; id: number; sx: number; sy: number; v0: ViewState }
|
||
| { kind: "pan"; id: number; sx: number; sy: number; v0: ViewState }
|
||
| { kind: "pinch"; ids: [number, number]; d0: number; mid0: { x: number; y: number }; v0: ViewState };
|
||
|
||
/* ============================================================
|
||
Canvas 绘制工具(世界坐标输入,由调用方设置 transform)
|
||
============================================================ */
|
||
|
||
const TAU = Math.PI * 2;
|
||
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
|
||
const dist = (a: { x: number; y: number }, b: { x: number; y: number }) => Math.hypot(a.x - b.x, a.y - b.y);
|
||
const mid = (a: { x: number; y: number }, b: { x: number; y: number }) => ({
|
||
x: (a.x + b.x) / 2,
|
||
y: (a.y + b.y) / 2,
|
||
});
|
||
|
||
/** 白色描边 + 填色的文本(等价 SVG 的 paint-order: stroke) */
|
||
function haloText(
|
||
ctx: CanvasRenderingContext2D,
|
||
text: string,
|
||
x: number,
|
||
y: number,
|
||
fontSize: number,
|
||
color: string,
|
||
weight = 600,
|
||
) {
|
||
ctx.font = `${weight} ${fontSize}px system-ui, -apple-system, sans-serif`;
|
||
ctx.textAlign = "center";
|
||
ctx.textBaseline = "alphabetic";
|
||
ctx.lineJoin = "round";
|
||
ctx.strokeStyle = "rgba(255,255,255,0.95)";
|
||
ctx.lineWidth = 3;
|
||
ctx.strokeText(text, x, y);
|
||
ctx.fillStyle = color;
|
||
ctx.fillText(text, x, y);
|
||
}
|
||
|
||
/** 边:按「高亮 / 正常 / 淡出」三档批量绘制,避免逐边切换 context 状态 */
|
||
function drawEdges(ctx: CanvasRenderingContext2D, edges: SimEdge[], activeSet: Set<string> | null) {
|
||
ctx.lineWidth = 0.8;
|
||
ctx.lineCap = "round";
|
||
ctx.lineJoin = "round";
|
||
if (activeSet) {
|
||
// 高亮邻居边(两端都在邻居集)
|
||
ctx.strokeStyle = "#60a5fa";
|
||
ctx.globalAlpha = 0.85;
|
||
ctx.beginPath();
|
||
for (const e of edges) {
|
||
const s = e.source as unknown as SimNode;
|
||
const t = e.target as unknown as SimNode;
|
||
if (typeof s !== "object" || typeof t !== "object") continue;
|
||
if (activeSet.has(s.id) && activeSet.has(t.id)) {
|
||
ctx.moveTo(s.x ?? 0, s.y ?? 0);
|
||
ctx.lineTo(t.x ?? 0, t.y ?? 0);
|
||
}
|
||
}
|
||
ctx.stroke();
|
||
}
|
||
// 正常 / 淡出边
|
||
ctx.strokeStyle = "#3b82f6";
|
||
ctx.globalAlpha = activeSet ? 0.03 : 0.18;
|
||
ctx.beginPath();
|
||
for (const e of edges) {
|
||
const s = e.source as unknown as SimNode;
|
||
const t = e.target as unknown as SimNode;
|
||
if (typeof s !== "object" || typeof t !== "object") continue;
|
||
if (activeSet && activeSet.has(s.id) && activeSet.has(t.id)) continue;
|
||
ctx.moveTo(s.x ?? 0, s.y ?? 0);
|
||
ctx.lineTo(t.x ?? 0, t.y ?? 0);
|
||
}
|
||
ctx.stroke();
|
||
ctx.globalAlpha = 1;
|
||
}
|
||
|
||
/** 股票节点:覆盖数分级颜色 + 外圈淡填充 + 描边 + 内实心 */
|
||
function drawStockNodes(
|
||
ctx: CanvasRenderingContext2D,
|
||
nodes: SimNode[],
|
||
activeSet: Set<string> | null,
|
||
activeId: string | null,
|
||
) {
|
||
for (const n of nodes) {
|
||
if (n.type !== "stock") continue;
|
||
const cover = n.coverCount ?? 1;
|
||
const isActive = activeId === n.id;
|
||
const dim = activeSet ? !activeSet.has(n.id) : false;
|
||
const alpha = dim ? 0.08 : cover === 1 && !isActive ? 0.45 : 1;
|
||
const r = isActive ? n.radius + 3 : n.radius;
|
||
const fill = cover >= 7 ? "#dc2626" : cover >= 5 ? "#ef4444" : "#f97316";
|
||
const x = n.x ?? 0;
|
||
const y = n.y ?? 0;
|
||
// 外圈淡填充
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, r, 0, TAU);
|
||
ctx.fillStyle = fill;
|
||
ctx.globalAlpha = 0.35 * alpha;
|
||
ctx.fill();
|
||
// 描边
|
||
ctx.strokeStyle = cover >= 2 ? "#f59e0b" : "#94a3b8";
|
||
ctx.lineWidth = cover >= 2 ? (cover >= 4 ? 2 : 1.5) : 0.5;
|
||
ctx.globalAlpha = alpha;
|
||
ctx.stroke();
|
||
// 内实心
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, n.radius, 0, TAU);
|
||
ctx.globalAlpha = (cover >= 2 ? 0.9 : 0.55) * alpha;
|
||
ctx.fill();
|
||
// 选中外环
|
||
if (isActive) {
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, n.radius + 3, 0, TAU);
|
||
ctx.globalAlpha = alpha;
|
||
ctx.strokeStyle = cover >= 2 ? "#f59e0b" : "#94a3b8";
|
||
ctx.lineWidth = 1;
|
||
ctx.stroke();
|
||
}
|
||
}
|
||
ctx.globalAlpha = 1;
|
||
}
|
||
|
||
/** 题材节点:涨幅正蓝负绿,大小按涨幅绝对值映射 */
|
||
function drawThemeNodes(
|
||
ctx: CanvasRenderingContext2D,
|
||
nodes: SimNode[],
|
||
activeSet: Set<string> | null,
|
||
activeId: string | null,
|
||
) {
|
||
for (const n of nodes) {
|
||
if (n.type !== "theme") continue;
|
||
const isActive = activeId === n.id;
|
||
const dim = activeSet ? !activeSet.has(n.id) : false;
|
||
const r = isActive ? n.radius + 3 : n.radius;
|
||
const isPos = (n.bf3 ?? 0) >= 0;
|
||
ctx.beginPath();
|
||
ctx.arc(n.x ?? 0, n.y ?? 0, r, 0, TAU);
|
||
ctx.fillStyle = isActive ? (isPos ? "#2563eb" : "#16a34a") : isPos ? "#3b82f6" : "#22c55e";
|
||
ctx.globalAlpha = dim ? 0.15 : 1;
|
||
ctx.fill();
|
||
ctx.strokeStyle = isPos ? "#1d4ed8" : "#15803d";
|
||
ctx.lineWidth = 1.5;
|
||
ctx.stroke();
|
||
}
|
||
ctx.globalAlpha = 1;
|
||
}
|
||
|
||
/** 活动节点名牌:股票名 + 板块标签 */
|
||
function drawActiveNodeLabel(ctx: CanvasRenderingContext2D, activeNode: SimNode | null, isCoarse: boolean) {
|
||
if (!activeNode) return;
|
||
const x = activeNode.x ?? 0;
|
||
const y = activeNode.y ?? 0;
|
||
haloText(ctx, activeNode.name, x, y - activeNode.radius - 6, isCoarse ? 11 : 10, "#334155");
|
||
if (activeNode.type === "stock" && activeNode.code) {
|
||
const board = getStockBoard(activeNode.code);
|
||
if (board.label) {
|
||
const color = board.className.includes("red")
|
||
? "#dc2626"
|
||
: board.className.includes("purple")
|
||
? "#9333ea"
|
||
: "#ea580c";
|
||
haloText(ctx, board.label, x, y - activeNode.radius - 18, isCoarse ? 9 : 8, color, 700);
|
||
}
|
||
}
|
||
}
|
||
|
||
/** 题材标签:放大(k>=0.95)或选中时显示,触屏截断 */
|
||
function drawThemeLabels(
|
||
ctx: CanvasRenderingContext2D,
|
||
nodes: SimNode[],
|
||
k: number,
|
||
isCoarse: boolean,
|
||
activeId: string | null,
|
||
) {
|
||
const showAll = k >= 0.95;
|
||
for (const n of nodes) {
|
||
if (n.type !== "theme") continue;
|
||
if (!showAll && activeId !== n.id) continue;
|
||
const label = isCoarse ? (n.name.length > 5 ? n.name.slice(0, 5) + "…" : n.name) : n.name;
|
||
const labelColor = (n.bf3 ?? 0) >= 0 ? "#1e40af" : "#15803d";
|
||
haloText(ctx, label, n.x ?? 0, (n.y ?? 0) + n.radius + 11, isCoarse ? 7.5 : 9, labelColor);
|
||
}
|
||
}
|
||
|
||
function HotMapGraph({ graph }: { graph: ThemeGraph }) {
|
||
const containerRef = useRef<HTMLDivElement>(null);
|
||
const mainCanvasRef = useRef<HTMLCanvasElement>(null);
|
||
const staticCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
||
const [size, setSize] = useState({ w: 800, h: 600 });
|
||
const [hovered, setHovered] = useState<string | null>(null);
|
||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||
const navigate = useNavigate();
|
||
|
||
/* 触屏检测:触屏无 hover,改用「tap 选中 → 底部浮层查看 → 按钮跳转」交互 */
|
||
const isCoarse = useMemo(
|
||
() => typeof window !== "undefined" && !!window.matchMedia?.("(pointer: coarse)").matches,
|
||
[],
|
||
);
|
||
|
||
/* 绘制/手势状态放 ref:拖拽平移期间每帧只有 rAF 重绘,零 React 重渲染 */
|
||
const sizeRef = useRef({ w: 800, h: 600 });
|
||
const viewRef = useRef<ViewState>({ x: 0, y: 0, k: 1 });
|
||
const nodesRef = useRef<SimNode[]>([]);
|
||
const edgesRef = useRef<SimEdge[]>([]);
|
||
const stockByIdRef = useRef(new Map<string, GraphStock>());
|
||
const activeIdRef = useRef<string | null>(null);
|
||
const hoverNeighborsRef = useRef<Set<string> | null>(null);
|
||
const activeNodeRef = useRef<SimNode | null>(null);
|
||
const boundsRef = useRef({ minX: 0, minY: 0, w: 0, h: 0 });
|
||
const staticReadyRef = useRef(false);
|
||
const simRunningRef = useRef(false);
|
||
const fitOnceRef = useRef(false);
|
||
const rafRef = useRef(0);
|
||
const pointersRef = useRef(new Map<number, { x: number; y: number }>());
|
||
const gestureRef = useRef<Gesture>({ kind: "none" });
|
||
|
||
/* rAF 合并调度:任何变化只触发一帧重绘 */
|
||
const drawFrame = useCallback(() => {
|
||
const canvas = mainCanvasRef.current;
|
||
if (!canvas) return;
|
||
const ctx = canvas.getContext("2d");
|
||
if (!ctx) return;
|
||
const dpr = window.devicePixelRatio || 1;
|
||
const v = viewRef.current;
|
||
const { w, h } = sizeRef.current;
|
||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||
ctx.clearRect(0, 0, w, h);
|
||
ctx.translate(v.x, v.y);
|
||
ctx.scale(v.k, v.k);
|
||
|
||
const nodes = nodesRef.current;
|
||
const edges = edgesRef.current;
|
||
const activeId = activeIdRef.current;
|
||
const activeSet = activeId ? hoverNeighborsRef.current : null;
|
||
|
||
if (activeSet) {
|
||
// 高亮态:全量重绘(邻居亮、非邻居淡出)
|
||
drawEdges(ctx, edges, activeSet);
|
||
drawStockNodes(ctx, nodes, activeSet, activeId);
|
||
drawThemeNodes(ctx, nodes, activeSet, activeId);
|
||
drawActiveNodeLabel(ctx, activeNodeRef.current, isCoarse);
|
||
} else {
|
||
const b = boundsRef.current;
|
||
const sc = staticCanvasRef.current;
|
||
if (staticReadyRef.current && sc && b.w > 0 && b.h > 0 && v.k < 1.5) {
|
||
// 静止态:drawImage 静态层 + 动态层
|
||
ctx.drawImage(sc, b.minX, b.minY, b.w, b.h);
|
||
} else {
|
||
// 模拟期或大比例放大:直接全量绘制(保证清晰)
|
||
drawEdges(ctx, edges, null);
|
||
drawStockNodes(ctx, nodes, null, null);
|
||
drawThemeNodes(ctx, nodes, null, null);
|
||
}
|
||
}
|
||
drawThemeLabels(ctx, nodes, v.k, isCoarse, activeId);
|
||
}, [isCoarse]);
|
||
|
||
const requestRender = useCallback(() => {
|
||
if (rafRef.current === 0) {
|
||
rafRef.current = requestAnimationFrame(() => {
|
||
rafRef.current = 0;
|
||
drawFrame();
|
||
});
|
||
}
|
||
}, [drawFrame]);
|
||
|
||
/* 静态离屏层:力收敛后一次性绘制全部边 + 节点底图 */
|
||
const buildStaticLayer = useCallback(() => {
|
||
const nodes = nodesRef.current;
|
||
const edges = edgesRef.current;
|
||
if (!nodes.length) return;
|
||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||
for (const n of nodes) {
|
||
const x = n.x ?? 0, y = n.y ?? 0;
|
||
if (x < minX) minX = x;
|
||
if (y < minY) minY = y;
|
||
if (x > maxX) maxX = x;
|
||
if (y > maxY) maxY = y;
|
||
}
|
||
if (maxX <= minX || maxY <= minY) return;
|
||
const pad = 50;
|
||
const minX2 = minX - pad, minY2 = minY - pad;
|
||
const w = maxX - minX + pad * 2;
|
||
const h = maxY - minY + pad * 2;
|
||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||
const maxDim = 4096; // 防超大包围盒在 3x dpr 下爆内存
|
||
const c = Math.min(1, maxDim / Math.max(w, h));
|
||
const sw = Math.max(1, Math.round(w * dpr * c));
|
||
const sh = Math.max(1, Math.round(h * dpr * c));
|
||
const sc = (staticCanvasRef.current ??= document.createElement("canvas"));
|
||
sc.width = sw;
|
||
sc.height = sh;
|
||
const sctx = sc.getContext("2d");
|
||
if (!sctx) return;
|
||
sctx.setTransform(sw / w, 0, 0, sh / h, 0, 0);
|
||
sctx.translate(-minX2, -minY2);
|
||
drawEdges(sctx, edges, null);
|
||
drawStockNodes(sctx, nodes, null, null);
|
||
drawThemeNodes(sctx, nodes, null, null);
|
||
staticReadyRef.current = true;
|
||
boundsRef.current = { minX: minX2, minY: minY2, w, h };
|
||
}, []);
|
||
|
||
/* 自动/手动 fit:让整图适配视口 */
|
||
const fitView = useCallback(() => {
|
||
const nodes = nodesRef.current;
|
||
if (!nodes.length) return;
|
||
const xs = nodes.map((n) => n.x ?? 0);
|
||
const ys = nodes.map((n) => n.y ?? 0);
|
||
const bw = Math.max(...xs) - Math.min(...xs) + 100;
|
||
const bh = Math.max(...ys) - Math.min(...ys) + 100;
|
||
const { w, h } = sizeRef.current;
|
||
if (bw <= 0 || bh <= 0) return;
|
||
const k = Math.min(1.2, Math.max(0.25, Math.min(w / bw, h / bh) * 0.92));
|
||
viewRef.current = {
|
||
k,
|
||
x: w / 2 - ((Math.min(...xs) + Math.max(...xs)) / 2) * k,
|
||
y: h / 2 - ((Math.min(...ys) + Math.max(...ys)) / 2) * k,
|
||
};
|
||
requestRender();
|
||
}, [requestRender]);
|
||
|
||
/* 以屏幕坐标 (px,py) 为锚缩放 */
|
||
const zoomAt = useCallback((px: number, py: number, targetK: number) => {
|
||
const k = clamp(targetK, 0.3, 4);
|
||
const v = viewRef.current;
|
||
const kRatio = k / v.k;
|
||
viewRef.current = { k, x: px - (px - v.x) * kRatio, y: py - (py - v.y) * kRatio };
|
||
requestRender();
|
||
}, [requestRender]);
|
||
|
||
/* 监听容器尺寸:更新 canvas 物理尺寸(dpr)并触发重绘 */
|
||
useEffect(() => {
|
||
const el = containerRef.current;
|
||
if (!el) return;
|
||
const update = () => {
|
||
const w = el.clientWidth || 800;
|
||
const h = el.clientHeight || 600;
|
||
sizeRef.current = { w, h };
|
||
setSize({ w, h });
|
||
const canvas = mainCanvasRef.current;
|
||
if (canvas) {
|
||
const dpr = window.devicePixelRatio || 1;
|
||
canvas.width = Math.max(1, Math.round(w * dpr));
|
||
canvas.height = Math.max(1, Math.round(h * dpr));
|
||
}
|
||
requestRender();
|
||
};
|
||
update();
|
||
const ro = new ResizeObserver(update);
|
||
ro.observe(el);
|
||
return () => ro.disconnect();
|
||
}, [requestRender]);
|
||
|
||
/* 节点过滤 + 边重建(后端不再下发 edges,由 stocks[].themeCodes 重建) */
|
||
const { nodes, edges, stockById } = useMemo(() => {
|
||
if (!graph) return { nodes: [] as SimNode[], edges: [] as SimEdge[], stockById: new Map<string, GraphStock>() };
|
||
const kept = [...graph.stocks]
|
||
.sort((a, b) => b.coverCount - a.coverCount || (b.f3 ?? 0) - (a.f3 ?? 0))
|
||
.slice(0, MAX_STOCK_NODES);
|
||
|
||
const stockNodes: SimNode[] = kept.map((s) => ({
|
||
id: `s:${s.securityCode}`,
|
||
type: "stock" as const,
|
||
name: s.securityName,
|
||
code: s.securityCode,
|
||
coverCount: s.coverCount,
|
||
f3: s.f3,
|
||
f2: s.f2,
|
||
f62: s.f62,
|
||
f100: s.f100,
|
||
themeCodes: s.themeCodes,
|
||
radius: Math.min(18, 4 + s.coverCount * 1.3),
|
||
}));
|
||
// 题材涨幅绝对值作为球大小的归一化基准(兜底 ≥1 防除零)
|
||
const maxAbsBf3 = Math.max(1, ...graph.themes.map((t) => Math.abs(t.bf3 ?? 0)));
|
||
const themeNodes: SimNode[] = graph.themes.map((t) => {
|
||
const bf3 = t.bf3 ?? 0;
|
||
return {
|
||
id: `t:${t.themeCode}`,
|
||
type: "theme" as const,
|
||
name: t.themeName,
|
||
code: t.themeCode,
|
||
coverCount: t.stockCount,
|
||
bf3: t.bf3,
|
||
// 平方根映射:同一个小涨幅区间内球径差异更大(线性映射被极端涨幅拉平均)
|
||
radius: THEME_MIN_RADIUS + Math.sqrt(Math.abs(bf3) / maxAbsBf3) * (THEME_MAX_RADIUS - THEME_MIN_RADIUS),
|
||
};
|
||
});
|
||
const edgeList: SimEdge[] = buildEdgesFromStocks(kept);
|
||
|
||
const stockByIdMap = new Map<string, GraphStock>();
|
||
kept.forEach((s) => stockByIdMap.set(`s:${s.securityCode}`, s));
|
||
|
||
return { nodes: [...themeNodes, ...stockNodes], edges: edgeList, stockById: stockByIdMap };
|
||
}, [graph]);
|
||
|
||
/* 预计算相邻关系:nodeId -> Set<相邻nodeId> */
|
||
const adjacency = useMemo(() => {
|
||
const adj = new Map<string, Set<string>>();
|
||
nodes.forEach((n) => adj.set(n.id, new Set()));
|
||
edges.forEach((e) => {
|
||
const s = typeof e.source === "object" ? (e.source as SimNode).id : e.source;
|
||
const t = typeof e.target === "object" ? (e.target as SimNode).id : e.target;
|
||
adj.get(s)?.add(t);
|
||
adj.get(t)?.add(s);
|
||
});
|
||
return adj;
|
||
}, [nodes, edges]);
|
||
|
||
/* 激活节点:悬停(桌面)或选中(触屏) → 驱动高亮 */
|
||
const activeId = hovered ?? selectedId;
|
||
const hoverNeighbors = useMemo(() => {
|
||
if (!activeId) return null;
|
||
const set = new Set<string>();
|
||
const direct = adjacency.get(activeId);
|
||
direct?.forEach((id) => set.add(id));
|
||
// 二级邻居:让题材的邻接股票再扩散一层
|
||
direct?.forEach((id) => adjacency.get(id)?.forEach((nid) => set.add(nid)));
|
||
set.add(activeId);
|
||
return set;
|
||
}, [activeId, adjacency]);
|
||
|
||
const activeNode = activeId ? (nodes.find((n) => n.id === activeId) ?? null) : null;
|
||
|
||
/* 渲染期镜像 ref,供 rAF / 绘制函数读取 */
|
||
nodesRef.current = nodes;
|
||
edgesRef.current = edges;
|
||
stockByIdRef.current = stockById;
|
||
|
||
/* 高亮状态同步给绘制层 */
|
||
useEffect(() => {
|
||
activeIdRef.current = activeId;
|
||
hoverNeighborsRef.current = hoverNeighbors;
|
||
activeNodeRef.current = activeNode;
|
||
requestRender();
|
||
}, [activeId, hoverNeighbors, activeNode, requestRender]);
|
||
|
||
/* 力导向模拟:tick → rAF 合帧绘制,收敛后建静态层 */
|
||
useEffect(() => {
|
||
if (!nodes.length) return;
|
||
const sim = forceSimulation<SimNode>(nodes)
|
||
.alphaDecay(0.03)
|
||
.force(
|
||
"link",
|
||
forceLink<SimNode, SimEdge>(edges)
|
||
.id((d) => d.id)
|
||
.distance((d) => {
|
||
const s = d.source as unknown as SimNode;
|
||
const t = d.target as unknown as SimNode;
|
||
return s.type === "theme" && t.coverCount && t.coverCount >= 3 ? 60 : 40;
|
||
})
|
||
.strength(0.6),
|
||
)
|
||
.force("charge", forceManyBody<SimNode>().strength((d) => (d.type === "theme" ? -650 : -35)))
|
||
.force("center", forceCenter(size.w / 2, size.h / 2))
|
||
// 题材节点留出标签高度防文字重叠(标签在节点下方)
|
||
.force("collide", forceCollide<SimNode>().radius((d) =>
|
||
d.type === "theme" ? d.radius + (isCoarse ? 20 : 16) : d.radius + 4,
|
||
));
|
||
|
||
simRunningRef.current = true;
|
||
staticReadyRef.current = false;
|
||
sim.on("tick", () => {
|
||
// 收敛后一次性自动 fit(兜底,防 end 不触发)
|
||
if (!fitOnceRef.current && sim.alpha() < 0.08) {
|
||
fitView();
|
||
fitOnceRef.current = true;
|
||
}
|
||
requestRender();
|
||
});
|
||
sim.on("end", () => {
|
||
simRunningRef.current = false;
|
||
buildStaticLayer();
|
||
if (!fitOnceRef.current) {
|
||
fitView();
|
||
fitOnceRef.current = true;
|
||
}
|
||
requestRender();
|
||
});
|
||
|
||
return () => {
|
||
sim.stop();
|
||
simRunningRef.current = false;
|
||
staticReadyRef.current = false;
|
||
fitOnceRef.current = false;
|
||
cancelAnimationFrame(rafRef.current);
|
||
rafRef.current = 0;
|
||
};
|
||
}, [nodes, edges, size.w, size.h, isCoarse, requestRender, buildStaticLayer, fitView]);
|
||
|
||
/* 滚轮:macOS 双指滚动=平移、捏合(ctrlKey)=缩放;非 macOS 滚轮=缩放 */
|
||
useEffect(() => {
|
||
const canvas = mainCanvasRef.current;
|
||
if (!canvas) return;
|
||
const onWheel = (e: WheelEvent) => {
|
||
e.preventDefault();
|
||
const rect = canvas.getBoundingClientRect();
|
||
const px = e.clientX - rect.left;
|
||
const py = e.clientY - rect.top;
|
||
let dy = e.deltaY;
|
||
if (e.deltaMode === 1) dy *= 16; // 行 → 像素
|
||
else if (e.deltaMode === 2) dy *= rect.height; // 页 → 像素
|
||
if (e.ctrlKey) {
|
||
// 触控板捏合(浏览器以 ctrl+wheel 派发)→ 以光标为锚缩放
|
||
zoomAt(px, py, viewRef.current.k * Math.exp(-dy * 0.01));
|
||
} else if (/Mac|iPhone|iPad|iPod/.test(navigator.userAgent)) {
|
||
// macOS/iOS 非 ctrl:双指滚动 = 平移
|
||
viewRef.current = { ...viewRef.current, x: viewRef.current.x - e.deltaX, y: viewRef.current.y - dy };
|
||
requestRender();
|
||
} else {
|
||
// 非 macOS 滚轮 → 缩放
|
||
zoomAt(px, py, viewRef.current.k * (dy < 0 ? 1.12 : 0.89));
|
||
}
|
||
};
|
||
canvas.addEventListener("wheel", onWheel, { passive: false });
|
||
return () => canvas.removeEventListener("wheel", onWheel);
|
||
}, [zoomAt, requestRender]);
|
||
|
||
/* 命中检测:屏幕坐标 → 世界坐标,触屏扩大命中半径 */
|
||
const hitTest = useCallback((cssX: number, cssY: number): SimNode | null => {
|
||
const v = viewRef.current;
|
||
const wx = (cssX - v.x) / v.k;
|
||
const wy = (cssY - v.y) / v.k;
|
||
const pad = (isCoarse ? 22 : 12) / v.k;
|
||
let best: SimNode | null = null;
|
||
let bestD = Infinity;
|
||
for (const n of nodesRef.current) {
|
||
const dx = (n.x ?? 0) - wx;
|
||
const dy = (n.y ?? 0) - wy;
|
||
const d = Math.sqrt(dx * dx + dy * dy);
|
||
if (d <= n.radius + pad && d < bestD) {
|
||
bestD = d;
|
||
best = n;
|
||
}
|
||
}
|
||
return best;
|
||
}, [isCoarse]);
|
||
|
||
/* 节点跳转 */
|
||
const navigateToNode = (node: SimNode) => {
|
||
if (node.type === "stock" && node.code) navigate({ to: "/stock/$code", params: { code: node.code } });
|
||
else if (node.type === "theme" && node.code) navigate({ to: "/theme/$code", params: { code: node.code } });
|
||
};
|
||
|
||
/* 节点点击:触屏第一次 tap 选中查看,再 tap 同一节点跳转;桌面直接跳转 */
|
||
const handleTap = (node: SimNode) => {
|
||
if (isCoarse) {
|
||
if (selectedId === node.id) navigateToNode(node);
|
||
else setSelectedId(node.id);
|
||
} else {
|
||
navigateToNode(node);
|
||
}
|
||
};
|
||
|
||
/* 指针手势:pending(移动超阈值升级 pan)/ pan / pinch,区分 tap 与拖拽 */
|
||
const onPointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||
(e.currentTarget as HTMLCanvasElement).setPointerCapture(e.pointerId);
|
||
pointersRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||
const ids = [...pointersRef.current.keys()];
|
||
if (ids.length === 1) {
|
||
gestureRef.current = {
|
||
kind: "pending",
|
||
id: e.pointerId,
|
||
sx: e.clientX,
|
||
sy: e.clientY,
|
||
v0: { ...viewRef.current },
|
||
};
|
||
if (isCoarse) setSelectedId(null); // 触屏点空白先取消选中
|
||
} else if (ids.length === 2) {
|
||
const [a, b] = [pointersRef.current.get(ids[0])!, pointersRef.current.get(ids[1])!];
|
||
gestureRef.current = {
|
||
kind: "pinch",
|
||
ids: [ids[0], ids[1]],
|
||
d0: dist(a, b),
|
||
mid0: mid(a, b),
|
||
v0: { ...viewRef.current },
|
||
};
|
||
}
|
||
};
|
||
|
||
const onPointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||
pointersRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||
const g = gestureRef.current;
|
||
if (g.kind === "pending" && Math.hypot(e.clientX - g.sx, e.clientY - g.sy) > 6) {
|
||
gestureRef.current = { kind: "pan", id: g.id, sx: g.sx, sy: g.sy, v0: g.v0 };
|
||
}
|
||
if (g.kind === "pan") {
|
||
const pt = pointersRef.current.get(g.id);
|
||
if (pt) {
|
||
viewRef.current = { ...g.v0, x: g.v0.x + (pt.x - g.sx), y: g.v0.y + (pt.y - g.sy) };
|
||
requestRender();
|
||
}
|
||
} else if (g.kind === "pinch") {
|
||
const a = pointersRef.current.get(g.ids[0]);
|
||
const b = pointersRef.current.get(g.ids[1]);
|
||
if (a && b) {
|
||
const d = dist(a, b);
|
||
const m = mid(a, b);
|
||
const k = clamp(g.v0.k * (d / g.d0), 0.3, 4);
|
||
const kRatio = k / g.v0.k;
|
||
viewRef.current = {
|
||
k,
|
||
x: m.x - (g.mid0.x - g.v0.x) * kRatio,
|
||
y: m.y - (g.mid0.y - g.v0.y) * kRatio,
|
||
};
|
||
requestRender();
|
||
}
|
||
} else if (g.kind === "none" && e.pointerType === "mouse" && !isCoarse) {
|
||
// 桌面 hover 命中检测(触屏永不 setHovered,避免与选中冲突)
|
||
const rect = e.currentTarget.getBoundingClientRect();
|
||
const node = hitTest(e.clientX - rect.left, e.clientY - rect.top);
|
||
setHovered(node?.id ?? null);
|
||
}
|
||
};
|
||
|
||
const onPointerUp = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||
const g = gestureRef.current;
|
||
pointersRef.current.delete(e.pointerId);
|
||
if (g.kind === "pending" && g.id === e.pointerId) {
|
||
// 无移动 → 判定为 tap
|
||
const rect = e.currentTarget.getBoundingClientRect();
|
||
const node = hitTest(g.sx - rect.left, g.sy - rect.top);
|
||
if (node) handleTap(node);
|
||
else if (isCoarse) setSelectedId(null);
|
||
}
|
||
// 双指抬起后剩一指 → 复位为 pending(继续可拖)
|
||
const remaining = [...pointersRef.current.entries()];
|
||
if (remaining.length === 1) {
|
||
const [id, p] = remaining[0];
|
||
gestureRef.current = { kind: "pending", id, sx: p.x, sy: p.y, v0: { ...viewRef.current } };
|
||
} else {
|
||
gestureRef.current = { kind: "none" };
|
||
}
|
||
};
|
||
|
||
const onPointerLeave = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||
if (e.pointerType === "mouse") setHovered(null);
|
||
pointersRef.current.delete(e.pointerId);
|
||
};
|
||
|
||
return (
|
||
<div ref={containerRef} className="absolute inset-0 overflow-hidden">
|
||
<canvas
|
||
ref={mainCanvasRef}
|
||
className="w-full h-full touch-none select-none cursor-grab active:cursor-grabbing"
|
||
onPointerDown={onPointerDown}
|
||
onPointerMove={onPointerMove}
|
||
onPointerUp={onPointerUp}
|
||
onPointerCancel={onPointerUp}
|
||
onPointerLeave={onPointerLeave}
|
||
/>
|
||
{/* 缩放控制:触屏/触控板兜底,点击不触发画布手势 */}
|
||
<div
|
||
className="absolute bottom-16 right-3 z-10 flex flex-col gap-1.5"
|
||
onPointerDown={(e) => e.stopPropagation()}
|
||
>
|
||
<button
|
||
onClick={() => zoomAt(size.w / 2, size.h / 2, viewRef.current.k * 1.4)}
|
||
className="flex h-9 w-9 items-center justify-center rounded-full border bg-background/90 text-base font-semibold text-muted-foreground shadow backdrop-blur hover:text-foreground active:scale-95"
|
||
aria-label="放大"
|
||
>
|
||
+
|
||
</button>
|
||
<button
|
||
onClick={() => zoomAt(size.w / 2, size.h / 2, viewRef.current.k / 1.4)}
|
||
className="flex h-9 w-9 items-center justify-center rounded-full border bg-background/90 text-base font-semibold text-muted-foreground shadow backdrop-blur hover:text-foreground active:scale-95"
|
||
aria-label="缩小"
|
||
>
|
||
−
|
||
</button>
|
||
<button
|
||
onClick={fitView}
|
||
className="flex h-9 items-center justify-center rounded-full border bg-background/90 px-2 text-[11px] font-medium text-muted-foreground shadow backdrop-blur hover:text-foreground active:scale-95"
|
||
aria-label="适应全图"
|
||
>
|
||
适应
|
||
</button>
|
||
</div>
|
||
|
||
{/* 信息卡:桌面 hover 左上浮层 / 触屏选中底部浮层 */}
|
||
{isCoarse ? (
|
||
activeNode && (
|
||
<BottomSheet
|
||
node={activeNode}
|
||
stockById={stockById}
|
||
onClose={() => setSelectedId(null)}
|
||
onGo={() => navigateToNode(activeNode)}
|
||
/>
|
||
)
|
||
) : (
|
||
activeNode && <InfoCard node={activeNode} stockById={stockById} />
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ============================================================
|
||
信息卡
|
||
============================================================ */
|
||
function InfoCard({ node, stockById }: { node: SimNode; stockById: Map<string, GraphStock> }) {
|
||
const stock = stockById.get(node.id);
|
||
const isPos = (node.f3 ?? 0) >= 0;
|
||
return (
|
||
<div className="absolute left-3 top-3 z-10 max-w-[220px] rounded-lg border bg-background/95 backdrop-blur p-3 shadow-lg space-y-1.5 text-sm">
|
||
{node.type === "stock" ? (
|
||
<>
|
||
<p className="font-semibold flex items-center gap-1.5">
|
||
{node.name}
|
||
<span className="text-[10px] text-muted-foreground font-normal">{node.code}</span>
|
||
</p>
|
||
<div className="flex items-center gap-2 text-xs">
|
||
<span className={`font-bold ${isPos ? "text-red-500" : "text-green-500"}`}>
|
||
{isPos ? "+" : ""}{node.f3?.toFixed(2)}%
|
||
</span>
|
||
{node.f2 != null && <span className="text-muted-foreground">现价 {node.f2.toFixed(2)}</span>}
|
||
</div>
|
||
<p className="text-xs">
|
||
<span className="text-red-500 font-semibold">覆盖 {node.coverCount} 个题材</span>
|
||
{node.coverCount! >= 2 && <span className="text-[10px] text-orange-500 ml-1">· 穿透核心</span>}
|
||
</p>
|
||
{node.f62 != null && (
|
||
<p className="text-[10px] text-muted-foreground">主力 {formatMoney(node.f62)}</p>
|
||
)}
|
||
{node.f100 && <p className="text-[10px] text-muted-foreground">行业:{node.f100}</p>}
|
||
{stock?.themeCodes?.length ? (
|
||
<p className="text-[10px] text-muted-foreground leading-relaxed pt-0.5 border-t border-border/40">
|
||
所属:{stock.themeCodes.length > 3 ? stock.themeCodes.slice(0, 3).join("、") + ` 等${stock.themeCodes.length}个` : stock.themeCodes.join("、")}
|
||
</p>
|
||
) : null}
|
||
</>
|
||
) : (
|
||
<>
|
||
<p className="font-semibold">{node.name}</p>
|
||
{node.bf3 != null && (
|
||
<p className={`text-xs font-bold ${node.bf3 >= 0 ? "text-blue-600" : "text-green-600"}`}>
|
||
{node.bf3 >= 0 ? "+" : ""}{node.bf3.toFixed(2)}%
|
||
</p>
|
||
)}
|
||
<p className="text-[10px] text-muted-foreground">代码 {node.code} · {node.coverCount} 只股票</p>
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ============================================================
|
||
底部浮层信息卡(触屏选中节点时展示)
|
||
============================================================ */
|
||
function BottomSheet({
|
||
node,
|
||
stockById,
|
||
onClose,
|
||
onGo,
|
||
}: {
|
||
node: SimNode;
|
||
stockById: Map<string, GraphStock>;
|
||
onClose: () => void;
|
||
onGo: () => void;
|
||
}) {
|
||
const stock = stockById.get(node.id);
|
||
const isPos = (node.f3 ?? 0) >= 0;
|
||
return (
|
||
<div className="absolute inset-x-0 bottom-0 z-20 rounded-t-xl border-t bg-background/95 backdrop-blur shadow-[0_-8px_30px_rgba(0,0,0,0.12)] px-4 pt-3 pb-[max(env(safe-area-inset-bottom),12px)]">
|
||
{/* 顶部拖动条 */}
|
||
<div className="mx-auto mb-2.5 h-1 w-10 rounded-full bg-muted" />
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="min-w-0 flex-1">
|
||
{node.type === "stock" ? (
|
||
<>
|
||
<p className="font-semibold flex items-center gap-1.5 text-sm">
|
||
{node.name}
|
||
<span className="text-[10px] text-muted-foreground font-normal">{node.code}</span>
|
||
</p>
|
||
<div className="flex items-center gap-2 text-xs mt-0.5">
|
||
<span className={`font-bold ${isPos ? "text-red-500" : "text-green-500"}`}>
|
||
{isPos ? "+" : ""}{node.f3?.toFixed(2)}%
|
||
</span>
|
||
{node.f2 != null && <span className="text-muted-foreground">现价 {node.f2.toFixed(2)}</span>}
|
||
{node.f62 != null && <span className="text-muted-foreground">主力 {formatMoney(node.f62)}</span>}
|
||
</div>
|
||
<p className="text-xs mt-1">
|
||
<span className="text-red-500 font-semibold">覆盖 {node.coverCount} 个题材</span>
|
||
{node.coverCount! >= 2 && (
|
||
<span className="text-[10px] text-orange-500 ml-1">· 穿透核心股</span>
|
||
)}
|
||
{node.f100 && <span className="text-[10px] text-muted-foreground ml-1">· {node.f100}</span>}
|
||
</p>
|
||
{stock?.themeCodes?.length ? (
|
||
<p className="text-[10px] text-muted-foreground leading-relaxed mt-1">
|
||
所属:{stock.themeCodes.length > 4 ? stock.themeCodes.slice(0, 4).join("、") + ` 等${stock.themeCodes.length}个题材` : stock.themeCodes.join("、")}
|
||
</p>
|
||
) : null}
|
||
</>
|
||
) : (
|
||
<>
|
||
<p className="font-semibold text-sm">{node.name}</p>
|
||
{node.bf3 != null && (
|
||
<p className={`text-xs font-bold mt-0.5 ${node.bf3 >= 0 ? "text-blue-600" : "text-green-600"}`}>
|
||
{node.bf3 >= 0 ? "+" : ""}{node.bf3.toFixed(2)}%
|
||
</p>
|
||
)}
|
||
<p className="text-[10px] text-muted-foreground mt-0.5">
|
||
代码 {node.code} · 覆盖 {node.coverCount} 只股票
|
||
</p>
|
||
</>
|
||
)}
|
||
</div>
|
||
<button
|
||
onClick={onClose}
|
||
className="shrink-0 p-1 text-muted-foreground hover:text-foreground transition-colors"
|
||
aria-label="关闭"
|
||
>
|
||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||
<path d="M18 6 6 18M6 6l12 12" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
|
||
{/* 跳转按钮 */}
|
||
<button
|
||
onClick={onGo}
|
||
className="mt-3 w-full rounded-lg bg-primary text-primary-foreground py-2.5 text-sm font-medium active:scale-[0.99] transition-transform"
|
||
>
|
||
查看{node.type === "stock" ? "股票" : "题材"}详情 →
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ============================================================
|
||
图例(compact:触屏横向紧凑,避免挤占图区)
|
||
============================================================ */
|
||
function Legend({ maxCover, compact }: { maxCover: number; compact: boolean }) {
|
||
const high = maxCover >= 5 ? 5 : 4;
|
||
if (compact) {
|
||
return (
|
||
<div className="absolute left-3 top-2.5 z-10 flex items-center gap-2 rounded-full bg-background/85 backdrop-blur border px-2.5 py-1 text-[9px] text-muted-foreground">
|
||
<span className="inline-flex items-center gap-1">
|
||
<span className="inline-block rounded-full bg-orange-500" style={{ width: 8, height: 8 }} />
|
||
3–4 个
|
||
</span>
|
||
<span className="inline-flex items-center gap-1">
|
||
<span className="inline-block rounded-full bg-red-600" style={{ width: 11, height: 11 }} />
|
||
≥5 核心
|
||
</span>
|
||
<span className="inline-flex items-center gap-1">
|
||
<span className="inline-block rounded-full bg-blue-500" style={{ width: 8, height: 8 }} />
|
||
题材涨
|
||
</span>
|
||
<span className="inline-flex items-center gap-1">
|
||
<span className="inline-block rounded-full bg-green-500" style={{ width: 8, height: 8 }} />
|
||
题材跌
|
||
</span>
|
||
</div>
|
||
);
|
||
}
|
||
return (
|
||
<div className="absolute right-3 top-3 z-10 rounded-lg border bg-background/90 backdrop-blur px-3 py-2 text-[10px] text-muted-foreground space-y-1.5">
|
||
<p className="font-semibold text-foreground">图例</p>
|
||
<div className="space-y-1">
|
||
<div className="flex items-center gap-2">
|
||
<span className="inline-block rounded-full bg-orange-500" style={{ width: 9, height: 9 }} />
|
||
<span>覆盖 3–4 个题材</span>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<span className="inline-block rounded-full bg-red-600" style={{ width: 12, height: 12 }} />
|
||
<span>覆盖 ≥{high} 个题材(核心股)</span>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-2 pt-1 border-t border-border/40">
|
||
<span className="inline-block rounded-full bg-blue-500" style={{ width: 10, height: 10 }} />
|
||
<span>题材 · 涨幅为正</span>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<span className="inline-block rounded-full bg-green-500" style={{ width: 10, height: 10 }} />
|
||
<span>题材 · 涨幅为负</span>
|
||
</div>
|
||
<div className="text-[9px] text-muted-foreground pt-0.5">球大小随涨幅绝对值增大</div>
|
||
</div>
|
||
);
|
||
}
|