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("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 (
{/* ── 顶栏 ── */}

热点穿透

{/* 涨幅/热度切换 */}
{MODES.map((m) => ( ))}
{/* 图 / 列表切换 */}
热点股
{/* 统计条 */} {graph?.stats && !isLoading && (
题材 {graph.stats.themeCount} 股票 {graph.stats.stockCount} 穿透核心股 {graph.stats.coreCount} (覆盖≥2个题材)
)}
{/* ── 图主体 ── */}
{isLoading ? (

正在构建关系网络…

) : isError || !graph ? (

数据加载失败

) : graph.themes.length === 0 || graph.stocks.length === 0 ? (
暂无题材数据
) : view === "list" ? ( ) : ( <> {/* 图例(触屏紧凑横排) */} {/* 操作提示 */}
{isCoarse ? "点击节点查看 · 再点跳转 · 双指缩放 · 拖拽平移" : "悬停查看 · 滚轮/捏合缩放 · 拖拽平移 · 点击跳转"}
)}
); } /* ============================================================ 核心股列表:覆盖题材 > 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 (
{/* 列表说明 */}

覆盖 3 个及以上题材的核心股,按覆盖题材数降序(共 {core.length} 家)

{core.length === 0 ? (
当前题材榜中暂无覆盖 3 个及以上题材的核心股
) : ( core.map((s) => { const board = getStockBoard(s.securityCode); const isPos = (s.f3 ?? 0) >= 0; return (
{/* 首行:覆盖数徽章 + 名称 + 涨幅 */}
= 7 ? "bg-red-700" : s.coverCount >= 5 ? "bg-red-500" : "bg-orange-500" }`} > {s.coverCount}

{s.securityName}

{board.label && ( {board.label} )} {s.securityCode} {isPos ? "+" : ""}{(s.f3 ?? 0).toFixed(2)}%
{/* 次行:行业 + 现价 + 覆盖比例条 */}
{s.f100 && ( {s.f100} )} {s.f2 != null && 现价 {s.f2.toFixed(2)}} {s.themeCodes?.length ?? s.coverCount} 个题材
= 7 ? "bg-red-700" : s.coverCount >= 5 ? "bg-red-500" : "bg-orange-500"}`} style={{ width: `${(s.coverCount / Math.max(groupMax, 1)) * 100}%` }} />
); }) )}
); } /* ============================================================ 力导向图组件 ============================================================ */ 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 { 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 | 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 | 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 | 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(null); const mainCanvasRef = useRef(null); const staticCanvasRef = useRef(null); const [size, setSize] = useState({ w: 800, h: 600 }); const [hovered, setHovered] = useState(null); const [selectedId, setSelectedId] = useState(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({ x: 0, y: 0, k: 1 }); const nodesRef = useRef([]); const edgesRef = useRef([]); const stockByIdRef = useRef(new Map()); const activeIdRef = useRef(null); const hoverNeighborsRef = useRef | null>(null); const activeNodeRef = useRef(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()); const gestureRef = useRef({ 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() }; 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(); 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>(); 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(); 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(nodes) .alphaDecay(0.03) .force( "link", forceLink(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().strength((d) => (d.type === "theme" ? -650 : -35))) .force("center", forceCenter(size.w / 2, size.h / 2)) // 题材节点留出标签高度防文字重叠(标签在节点下方) .force("collide", forceCollide().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) => { (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) => { 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) => { 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) => { if (e.pointerType === "mouse") setHovered(null); pointersRef.current.delete(e.pointerId); }; return (
{/* 缩放控制:触屏/触控板兜底,点击不触发画布手势 */}
e.stopPropagation()} >
{/* 信息卡:桌面 hover 左上浮层 / 触屏选中底部浮层 */} {isCoarse ? ( activeNode && ( setSelectedId(null)} onGo={() => navigateToNode(activeNode)} /> ) ) : ( activeNode && )}
); } /* ============================================================ 信息卡 ============================================================ */ function InfoCard({ node, stockById }: { node: SimNode; stockById: Map }) { const stock = stockById.get(node.id); const isPos = (node.f3 ?? 0) >= 0; return (
{node.type === "stock" ? ( <>

{node.name} {node.code}

{isPos ? "+" : ""}{node.f3?.toFixed(2)}% {node.f2 != null && 现价 {node.f2.toFixed(2)}}

覆盖 {node.coverCount} 个题材 {node.coverCount! >= 2 && · 穿透核心}

{node.f62 != null && (

主力 {formatMoney(node.f62)}

)} {node.f100 &&

行业:{node.f100}

} {stock?.themeCodes?.length ? (

所属:{stock.themeCodes.length > 3 ? stock.themeCodes.slice(0, 3).join("、") + ` 等${stock.themeCodes.length}个` : stock.themeCodes.join("、")}

) : null} ) : ( <>

{node.name}

{node.bf3 != null && (

= 0 ? "text-blue-600" : "text-green-600"}`}> {node.bf3 >= 0 ? "+" : ""}{node.bf3.toFixed(2)}%

)}

代码 {node.code} · {node.coverCount} 只股票

)}
); } /* ============================================================ 底部浮层信息卡(触屏选中节点时展示) ============================================================ */ function BottomSheet({ node, stockById, onClose, onGo, }: { node: SimNode; stockById: Map; onClose: () => void; onGo: () => void; }) { const stock = stockById.get(node.id); const isPos = (node.f3 ?? 0) >= 0; return (
{/* 顶部拖动条 */}
{node.type === "stock" ? ( <>

{node.name} {node.code}

{isPos ? "+" : ""}{node.f3?.toFixed(2)}% {node.f2 != null && 现价 {node.f2.toFixed(2)}} {node.f62 != null && 主力 {formatMoney(node.f62)}}

覆盖 {node.coverCount} 个题材 {node.coverCount! >= 2 && ( · 穿透核心股 )} {node.f100 && · {node.f100}}

{stock?.themeCodes?.length ? (

所属:{stock.themeCodes.length > 4 ? stock.themeCodes.slice(0, 4).join("、") + ` 等${stock.themeCodes.length}个题材` : stock.themeCodes.join("、")}

) : null} ) : ( <>

{node.name}

{node.bf3 != null && (

= 0 ? "text-blue-600" : "text-green-600"}`}> {node.bf3 >= 0 ? "+" : ""}{node.bf3.toFixed(2)}%

)}

代码 {node.code} · 覆盖 {node.coverCount} 只股票

)}
{/* 跳转按钮 */}
); } /* ============================================================ 图例(compact:触屏横向紧凑,避免挤占图区) ============================================================ */ function Legend({ maxCover, compact }: { maxCover: number; compact: boolean }) { const high = maxCover >= 5 ? 5 : 4; if (compact) { return (
3–4 个 ≥5 核心 题材涨 题材跌
); } return (

图例

覆盖 3–4 个题材
覆盖 ≥{high} 个题材(核心股)
题材 · 涨幅为正
题材 · 涨幅为负
球大小随涨幅绝对值增大
); }