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:
Sakurasan
2026-08-06 20:34:14 +08:00
co-authored by Claude
parent 45836f4a30
commit e28d1a2fd0
9 changed files with 1209 additions and 206 deletions
+759
View File
@@ -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 }} />
34
</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> 34 </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>
);
}