feat: 新增热点穿透网状关系图 + 移动端优化
- 新增 /hot-map 热点穿透页:d3-force 力导向网状图展示题材-股票 M:N 关系 - 股票节点面积/颜色按覆盖题材数分级,穿透核心股高亮 - 图/核心股列表双视图切换,列表覆盖题材>2 公司降序展示 - 悬停信息卡、缩放平移、点击跳转股票/题材详情 - 数据采样改为涨幅+热度双榜合并(各 Top50 去重=82 题材),修复覆盖数低估 (有研新材覆盖数 2→10,核心股 66→1124 家) - 移动端适配:触屏 tap 选中底部浮层、标签缩放阈值防重叠、自动 fit、紧凑图例 - 主页/题材列表增加热点穿透入口 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,759 @@
|
||||
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
|
||||
import { 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 { 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;
|
||||
|
||||
/* 视图切换:关系图 / 核心股列表 */
|
||||
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 h-12 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link to="/themes" className="hover:opacity-70 transition-opacity">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Link>
|
||||
<h1 className="text-base font-semibold">热点穿透</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 涨幅/热度切换 */}
|
||||
<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 ${
|
||||
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 ${
|
||||
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 ${
|
||||
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"
|
||||
title="刷新"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 统计条 */}
|
||||
{graph?.stats && !isLoading && (
|
||||
<div className="max-w-6xl mx-auto px-4 pb-1.5 flex items-center gap-3 text-[10px] text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<MapIcon className="h-3 w-3 text-blue-500" /> 题材 {graph.stats.themeCount}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Target className="h-3 w-3 text-slate-400" /> 股票 {graph.stats.stockCount}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1 text-red-500">
|
||||
<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;
|
||||
}
|
||||
|
||||
interface SimEdge extends SimulationLinkDatum<SimNode> {
|
||||
source: string;
|
||||
target: string;
|
||||
}
|
||||
|
||||
function HotMapGraph({ graph }: { graph: ThemeGraph }) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [size, setSize] = useState({ w: 800, h: 600 });
|
||||
const [view, setView] = useState({ x: 0, y: 0, k: 1 });
|
||||
const [hovered, setHovered] = useState<string | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [, setTick] = useState(0);
|
||||
const navigate = useNavigate();
|
||||
|
||||
/* 触屏检测:触屏无 hover,改用「tap 选中 → 底部浮层查看 → 按钮跳转」交互 */
|
||||
const isCoarse = useMemo(
|
||||
() => typeof window !== "undefined" && !!window.matchMedia?.("(pointer: coarse)").matches,
|
||||
[],
|
||||
);
|
||||
|
||||
const dragRef = useRef<{ active: boolean; sx: number; sy: number; px: number; py: number }>({
|
||||
active: false, sx: 0, sy: 0, px: 0, py: 0,
|
||||
});
|
||||
const fitOnceRef = useRef(false); // 力收敛后是否已自动 fit
|
||||
|
||||
/* 监听容器尺寸 */
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const update = () => setSize({ w: el.clientWidth || 800, h: el.clientHeight || 600 });
|
||||
update();
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
/* 节点过滤:按覆盖题材数降序、同覆盖按涨幅降序,取穿透度最高的前 MAX 只 */
|
||||
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 keptCodes = new Set(kept.map((s) => s.securityCode));
|
||||
const keptThemes = new Set(graph.themes.map((t) => t.themeCode));
|
||||
|
||||
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),
|
||||
}));
|
||||
const themeNodes: SimNode[] = graph.themes.map((t) => ({
|
||||
id: `t:${t.themeCode}`,
|
||||
type: "theme" as const,
|
||||
name: t.themeName,
|
||||
code: t.themeCode,
|
||||
coverCount: t.stockCount,
|
||||
radius: 10,
|
||||
}));
|
||||
const edgeList: SimEdge[] = graph.edges
|
||||
.filter((e) => keptCodes.has(e.securityCode) && keptThemes.has(e.themeCode))
|
||||
.map((e) => ({ source: `t:${e.themeCode}`, target: `s:${e.securityCode}` }));
|
||||
|
||||
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]);
|
||||
|
||||
/* 力导向模拟 */
|
||||
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,
|
||||
));
|
||||
|
||||
sim.on("tick", () => {
|
||||
setTick((t) => t + 1);
|
||||
// 收敛后一次性自动 fit:让整图适配视口(缩小后题材标签隐藏,放大显示)
|
||||
if (!fitOnceRef.current && sim.alpha() < 0.08) {
|
||||
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;
|
||||
if (bw > 0 && bh > 0) {
|
||||
const k = Math.min(1.2, Math.max(0.25, Math.min(size.w / bw, size.h / bh) * 0.92));
|
||||
setView({
|
||||
k,
|
||||
x: size.w / 2 - ((Math.min(...xs) + Math.max(...xs)) / 2) * k,
|
||||
y: size.h / 2 - ((Math.min(...ys) + Math.max(...ys)) / 2) * k,
|
||||
});
|
||||
fitOnceRef.current = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
sim.stop();
|
||||
fitOnceRef.current = false;
|
||||
};
|
||||
}, [nodes, edges, size.w, size.h, isCoarse]);
|
||||
|
||||
/* 激活节点:悬停(桌面)或选中(触屏) → 驱动高亮 */
|
||||
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;
|
||||
|
||||
/* 事件:滚轮缩放 / 拖拽平移 */
|
||||
const handleWheel = (e: React.WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const rect = (e.currentTarget as SVGSVGElement).getBoundingClientRect();
|
||||
const px = e.clientX - rect.left;
|
||||
const py = e.clientY - rect.top;
|
||||
setView((v) => {
|
||||
const k = Math.min(4, Math.max(0.3, v.k * (e.deltaY < 0 ? 1.12 : 0.89)));
|
||||
const kRatio = k / v.k;
|
||||
return { k, x: px - (px - v.x) * kRatio, y: py - (py - v.y) * kRatio };
|
||||
});
|
||||
};
|
||||
|
||||
const onPointerDown = (e: React.PointerEvent) => {
|
||||
if ((e.target as Element).closest("[data-node]")) return; // 点击节点不触发平移
|
||||
if (isCoarse) setSelectedId(null); // 触屏点击空白取消选中
|
||||
dragRef.current = { active: true, sx: view.x, sy: view.y, px: e.clientX, py: e.clientY };
|
||||
(e.currentTarget as SVGSVGElement).setPointerCapture(e.pointerId);
|
||||
};
|
||||
const onPointerMove = (e: React.PointerEvent) => {
|
||||
const d = dragRef.current;
|
||||
if (!d.active) return;
|
||||
setView((v) => ({ ...v, x: d.sx + (e.clientX - d.px), y: d.sy + (e.clientY - d.py) }));
|
||||
};
|
||||
const onPointerUp = () => { dragRef.current.active = false; };
|
||||
|
||||
/* 节点点击:触屏第一次 tap 选中查看,再 tap 同一节点跳转;桌面直接跳转 */
|
||||
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 } });
|
||||
};
|
||||
const handleNodeClick = (node: SimNode) => {
|
||||
if (isCoarse) {
|
||||
if (selectedId === node.id) navigateToNode(node);
|
||||
else setSelectedId(node.id);
|
||||
} else {
|
||||
navigateToNode(node);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="absolute inset-0 overflow-hidden">
|
||||
<svg
|
||||
className="w-full h-full cursor-grab active:cursor-grabbing touch-none"
|
||||
onWheel={handleWheel}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerLeave={onPointerUp}
|
||||
>
|
||||
<g transform={`translate(${view.x},${view.y}) scale(${view.k})`}>
|
||||
{/* 边 */}
|
||||
{edges.map((e, i) => {
|
||||
const s = typeof e.source === "object" ? (e.source as SimNode) : null;
|
||||
const t = typeof e.target === "object" ? (e.target as SimNode) : null;
|
||||
if (!s || !t) return null;
|
||||
const dim = hoverNeighbors ? (!hoverNeighbors.has(s.id) || !hoverNeighbors.has(t.id)) : false;
|
||||
return (
|
||||
<line
|
||||
key={i}
|
||||
x1={s.x ?? 0} y1={s.y ?? 0}
|
||||
x2={t.x ?? 0} y2={t.y ?? 0}
|
||||
stroke="#3b82f6"
|
||||
strokeOpacity={dim ? 0.03 : 0.18}
|
||||
strokeWidth={0.8}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 股票节点 */}
|
||||
{nodes.filter((n) => n.type === "stock").map((n) => {
|
||||
const dim = hoverNeighbors ? !hoverNeighbors.has(n.id) : false;
|
||||
const isHover = hovered === n.id;
|
||||
const isActive = isHover || selectedId === n.id;
|
||||
const cover = n.coverCount ?? 1;
|
||||
const fill = cover >= 7 ? "#dc2626" : cover >= 5 ? "#ef4444" : "#f97316";
|
||||
const board = getStockBoard(n.code ?? "");
|
||||
return (
|
||||
<g
|
||||
key={n.id}
|
||||
data-node
|
||||
transform={`translate(${n.x ?? 0},${n.y ?? 0})`}
|
||||
opacity={dim ? 0.08 : cover === 1 && !isActive ? 0.45 : 1}
|
||||
style={{ cursor: "pointer", transition: "opacity 0.15s" }}
|
||||
onMouseEnter={() => setHovered(n.id)}
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
onClick={() => handleNodeClick(n)}
|
||||
>
|
||||
<circle
|
||||
r={isActive ? n.radius + 3 : n.radius}
|
||||
fill={fill}
|
||||
fillOpacity={0.35}
|
||||
stroke={cover >= 2 ? "#f59e0b" : "#94a3b8"}
|
||||
strokeWidth={cover >= 2 ? (cover >= 4 ? 2 : 1.5) : 0.5}
|
||||
/>
|
||||
<circle
|
||||
r={n.radius}
|
||||
fill={fill}
|
||||
fillOpacity={cover >= 2 ? 0.9 : 0.55}
|
||||
/>
|
||||
{isActive && (
|
||||
<>
|
||||
<circle r={n.radius + 3} fill="none" stroke={cover >= 2 ? "#f59e0b" : "#94a3b8"} strokeWidth={1} />
|
||||
<text y={-n.radius - 6} textAnchor="middle" fontSize="10" fill="#334155" style={{ paintOrder: "stroke", stroke: "#fff", strokeWidth: 3, fontWeight: 600 }}>
|
||||
{n.name}
|
||||
</text>
|
||||
{board.label && (
|
||||
<text y={-n.radius - 18} textAnchor="middle" fontSize="8" fill={board.className.includes("red") ? "#dc2626" : board.className.includes("purple") ? "#9333ea" : "#ea580c"} style={{ paintOrder: "stroke", stroke: "#fff", strokeWidth: 3 }}>
|
||||
{board.label}
|
||||
</text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 题材节点 */}
|
||||
{nodes.filter((n) => n.type === "theme").map((n) => {
|
||||
const dim = hoverNeighbors ? !hoverNeighbors.has(n.id) : false;
|
||||
const isActive = hovered === n.id || selectedId === n.id;
|
||||
// 标签阈值:放大后显示(避免窄屏 30 个标签重叠),选中节点始终显示
|
||||
const showLabel = isActive || view.k >= 0.95;
|
||||
// 触屏标签更小且截断,减少窄屏文字重叠
|
||||
const label = isCoarse ? (n.name.length > 5 ? n.name.slice(0, 5) + "…" : n.name) : n.name;
|
||||
return (
|
||||
<g
|
||||
key={n.id}
|
||||
data-node
|
||||
transform={`translate(${n.x ?? 0},${n.y ?? 0})`}
|
||||
opacity={dim ? 0.15 : 1}
|
||||
style={{ cursor: "pointer", transition: "opacity 0.15s" }}
|
||||
onMouseEnter={() => setHovered(n.id)}
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
onClick={() => handleNodeClick(n)}
|
||||
>
|
||||
<circle
|
||||
r={isActive ? n.radius + 3 : n.radius}
|
||||
fill={isActive ? "#2563eb" : "#3b82f6"}
|
||||
stroke="#1d4ed8"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
{showLabel && (
|
||||
<text
|
||||
y={n.radius + 11}
|
||||
textAnchor="middle"
|
||||
fontSize={isCoarse ? 7.5 : 9}
|
||||
fill="#1e40af"
|
||||
style={{ paintOrder: "stroke", stroke: "rgba(255,255,255,0.95)", strokeWidth: 3, fontWeight: 600 }}
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
{/* 信息卡:桌面 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>
|
||||
<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>
|
||||
<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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user