Files
auv/src/routes/hot-map.tsx
T
SakurasanandClaude 69535bc782 feat: 题材小球改平方根映射,拉大中小涨幅区分度
线性映射被极端涨幅拉平均,多数 0~3% 题材球挤在最小值附近。
改平方根映射:零涨幅 4px、1%≈10px、3%≈15px、极值 22px,
中等涨幅区间球径差显著放大。碰撞力半径动态跟随节点,无需额外适配。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 23:51:58 +08:00

1173 lines
46 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 }} />
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>
<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>覆盖 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 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>
);
}