From 548ebee47f7c3b9d48aa9e7643ac23a1633fbd8a Mon Sep 17 00:00:00 2001 From: Sakurasan <26715255+Sakurasan@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:21:08 +0800 Subject: [PATCH] =?UTF-8?q?perf:=20=E4=BC=98=E5=8C=96=E7=83=AD=E7=82=B9?= =?UTF-8?q?=E7=A9=BF=E9=80=8F=E5=8A=A0=E8=BD=BD=E9=80=9F=E5=BA=A6=20+=20Ca?= =?UTF-8?q?nvas=20=E6=B8=B2=E6=9F=93=20+=20=E8=A7=A6=E6=8E=A7=E6=9D=BF/?= =?UTF-8?q?=E6=89=8B=E6=9C=BA=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端加载优化(盘中原本每次访问都重新聚合,耗时 10-30 秒): - 图聚合结果与题材股票子层盘中加 60 秒缓存 - 缓存过期时返回旧数据并后台幂等重建(stale-while-revalidate),打开即秒开 - 缓存基础支持秒级 TTL(set_cache 新增 ttl_seconds) - 响应体瘦身:移除 edges(前端从 stocks[].themeCodes 重建)、 themes 精简字段、stocks 按 limit 裁剪,JSON 从数 MB 降至数百 KB 前端 Canvas 渲染重构: - d3-force 布局保留,SVG 渲染层替换为 Canvas 双缓冲(静止态离屏层 drawImage) - tick 由每帧 setState 改为 rAF 合帧,拖拽/缩放期间零 React 重渲染,800 节点流畅 交互优化: - 手机:新增 +/−/适应 缩放按钮、命中半径放大至 22px、tap/drag 6px 阈值区分、 双指捏合缩放、触屏禁用 hover 避免与选中冲突 - Mac 触控板:双指滚动=平移、捏合(ctrlKey)=缩放、滚轮缩放保留、点空白取消选中 Co-Authored-By: Claude --- backend/routes/themes.py | 3 +- backend/services/cache.py | 6 +- backend/services/themes.py | 137 +++++-- src/lib/theme-api.ts | 31 +- src/routes/hot-map.tsx | 748 +++++++++++++++++++++++++++---------- 5 files changed, 696 insertions(+), 229 deletions(-) diff --git a/backend/routes/themes.py b/backend/routes/themes.py index b93da0f..a2be720 100644 --- a/backend/routes/themes.py +++ b/backend/routes/themes.py @@ -29,11 +29,12 @@ async def theme_list( async def theme_graph( sort_field: int = Query(1, description="题材排序:1=涨幅 4=热度"), top: int = Query(30, ge=1, le=60, description="题材数量"), + limit: int = Query(1000, ge=100, le=2000, description="下发的股票节点上限(按穿透度取前 N 只)"), ): if sort_field not in (1, 4): raise HTTPException(status_code=400, detail="排序字段仅支持 1(涨幅)/4(热度)") - result = await themes.fetch_theme_graph(sort_field, top) + result = await themes.fetch_theme_graph(sort_field, top, limit) return JSONResponse(result, headers=_NO_CACHE_HEADERS) diff --git a/backend/services/cache.py b/backend/services/cache.py index 86a246d..7927016 100644 --- a/backend/services/cache.py +++ b/backend/services/cache.py @@ -23,9 +23,9 @@ def get_cache(key: str) -> Optional[str]: conn.close() -def set_cache(key: str, value: str, ttl_hours: int = 6): - """写入缓存,过期时间 = now + ttl_hours""" - expires_at = (datetime.now() + timedelta(hours=ttl_hours)).isoformat() +def set_cache(key: str, value: str, ttl_hours: int = 6, ttl_seconds: int = 0): + """写入缓存,过期时间 = now + ttl_hours + ttl_seconds(支持秒级短 TTL)""" + expires_at = (datetime.now() + timedelta(hours=ttl_hours, seconds=ttl_seconds)).isoformat() conn = get_connection() try: conn.execute( diff --git a/backend/services/themes.py b/backend/services/themes.py index 248f87b..8bc4f20 100644 --- a/backend/services/themes.py +++ b/backend/services/themes.py @@ -217,9 +217,9 @@ async def fetch_theme_stocks(theme_code: str) -> dict: result = {"stockList": stock_list, "statistic": statistic, "total": total} if stock_list: - ttl = _dynamic_ttl() - if ttl > 0: - set_cache(cache_key, json.dumps(result, ensure_ascii=False), ttl_hours=ttl) + ttl_s = _graph_ttl_seconds() + if ttl_s > 0: + set_cache(cache_key, json.dumps(result, ensure_ascii=False), ttl_seconds=ttl_s) return result @@ -227,14 +227,45 @@ async def fetch_theme_stocks(theme_code: str) -> dict: # 合并采样:涨幅榜 Top N + 热度榜 Top N 去重合并, # 避免单一榜单导致股票覆盖题材数被低估(如有研新材覆盖 10+ 题材,仅涨幅榜只能采到 2 个)。 -_GRAPH_MERGE_TOP = 50 + +# 盘中图聚合结果/题材股票子层的缓存秒数。交易时段数据波动快,用 60s 短缓存; +# 非交易时段缓存 18 小时(覆盖到下一交易日)。 +_GRAPH_CACHE_SECONDS = 60 -async def fetch_theme_graph(sort_field: int = 1, top_n: int = 30) -> dict: - """构建题材-股票网状关系图数据(热点穿透) +def _graph_ttl_seconds() -> int: + """图聚合结果与题材股票子层的缓存秒数:盘中 60 秒,非盘中 18 小时""" + return _GRAPH_CACHE_SECONDS if _is_trading_time() else 18 * 3600 - 合并采样涨幅榜 + 热度榜 Top N 题材(去重),并发拉取每题材全部股票, - 统计每股覆盖的题材数(M:N 关系),返回前端可直接渲染的图结构。 + +# 后台重建锁:cache_key -> asyncio.Lock,幂等去重,防止并发重复聚合 +_REBUILD_LOCKS: dict[str, asyncio.Lock] = {} + + +def _set_graph_cache(cache_key: str, data: dict) -> None: + """写入图聚合缓存(存 data + built_at + expires_at,epoch 秒)""" + ttl_s = _graph_ttl_seconds() + if ttl_s <= 0: + return + now = time.time() + entry = {"data": data, "built_at": now, "expires_at": now + ttl_s} + set_cache(cache_key, json.dumps(entry, ensure_ascii=False), ttl_seconds=ttl_s) + + +def _get_graph_cache(cache_key: str) -> tuple[Optional[dict], Optional[float]]: + """读取图聚合缓存,返回 (data, expires_at);无缓存/损坏返回 (None, None)""" + cached = get_cache(cache_key) + if cached is None: + return None, None + try: + entry = json.loads(cached) + return entry.get("data"), entry.get("expires_at") + except (json.JSONDecodeError, TypeError): + return None, None + + +async def _build_theme_graph(sort_field: int, top_n: int) -> dict: + """构建完整图数据(全量统计,边由前端从 stocks[].themeCodes 重建) Args: sort_field: 题材排序 1=涨幅, 4=热度(当前榜在前,另一榜合并补充) @@ -242,17 +273,11 @@ async def fetch_theme_graph(sort_field: int = 1, top_n: int = 30) -> dict: Returns: { - "themes": [{themeCode, themeName, bf3, hotValue, strengthValue, stockCount}], + "themes": [{themeCode, themeName, stockCount}], "stocks": [{securityCode, securityName, coverCount, f3, f2, f62, f100, themeCodes[]}], - "edges": [{themeCode, securityCode}], "stats": {themeCount, stockCount, coreCount, maxCover} } """ - cache_key = f"theme_graph:{sort_field}:{top_n}" - cached = get_cache(cache_key) - if cached is not None: - return json.loads(cached) - # 1. 合并采样涨幅榜 + 热度榜(当前榜优先在前,另一榜补充),按 themeCode 去重 primary_list = await fetch_theme_list(sort_field, asc=False) other_field = 4 if sort_field == 1 else 1 @@ -264,7 +289,7 @@ async def fetch_theme_graph(sort_field: int = 1, top_n: int = 30) -> dict: merged.setdefault(t["themeCode"], t) themes = list(merged.values()) if not themes: - return {"themes": [], "stocks": [], "edges": [], "stats": {}} + return {"themes": [], "stocks": [], "stats": {}} # 2. 并发拉取每题材股票(限流保护) sem = asyncio.Semaphore(5) @@ -275,17 +300,15 @@ async def fetch_theme_graph(sort_field: int = 1, top_n: int = 30) -> dict: results = await asyncio.gather(*[_fetch_with_limit(t["themeCode"]) for t in themes]) - # 3. 构建图数据 + # 3. 构建 M:N 关系:统计每股覆盖的题材数 theme_map = {t["themeCode"]: t for t in themes} stock_map: dict[str, dict] = {} # securityCode -> stock dict - edges: list[dict] = [] for t, res in zip(themes, results): stock_list = res.get("stockList", []) theme_map[t["themeCode"]]["stockCount"] = len(stock_list) for s in stock_list: code = s["securityCode"] - edges.append({"themeCode": t["themeCode"], "securityCode": code}) if code not in stock_map: stock_map[code] = { "securityCode": code, @@ -310,14 +333,76 @@ async def fetch_theme_graph(sort_field: int = 1, top_n: int = 30) -> dict: "maxCover": max((s["coverCount"] for s in stocks), default=1), } - result = { - "themes": list(theme_map.values()), + return { + "themes": [ + {"themeCode": t["themeCode"], "themeName": t["themeName"], "stockCount": t["stockCount"]} + for t in theme_map.values() + ], "stocks": stocks, - "edges": edges, "stats": stats, } - ttl = _dynamic_ttl() - if ttl > 0: - set_cache(cache_key, json.dumps(result, ensure_ascii=False), ttl_hours=ttl) - return result + +def _trim_graph_result(result: dict, limit: int) -> dict: + """按 limit 裁剪 stocks(保留穿透度最高的 N 只),仅影响下发体积,不影响 coverCount 统计""" + return { + "themes": result.get("themes", []), + "stocks": result.get("stocks", [])[:limit], + "stats": result.get("stats", {}), + } + + +def _spawn_rebuild(cache_key: str, sort_field: int, top_n: int) -> None: + """幂等触发后台重建:已有重建任务在跑则跳过""" + lock = _REBUILD_LOCKS.setdefault(cache_key, asyncio.Lock()) + if lock.locked(): + return + asyncio.create_task(_rebuild_task(cache_key, sort_field, top_n, lock)) + + +async def _rebuild_task(cache_key: str, sort_field: int, top_n: int, lock: asyncio.Lock) -> None: + """后台重建:拿锁后 double-check 缓存是否已被刷新,避免重复聚合""" + async with lock: + try: + data, expires_at = _get_graph_cache(cache_key) + if data is not None and expires_at and expires_at > time.time(): + return # 已被其他任务刷新 + data = await _build_theme_graph(sort_field, top_n) + _set_graph_cache(cache_key, data) + print(f"[themes] graph 后台重建完成: {cache_key}") + except Exception as e: + print(f"[themes] graph 后台重建失败: {cache_key} {e}") + + +async def fetch_theme_graph(sort_field: int = 1, top_n: int = 30, limit: int = 1000) -> dict: + """获取热点穿透图数据(盘中 60s 缓存 + stale-while-revalidate) + + 缓存命中且未过期 → 直接返回;已过期 → 返回旧数据并后台异步重建(秒开); + 无缓存 → 同步构建(并发下加锁去重)。返回前按 limit 裁剪 stocks。 + + Args: + sort_field: 题材排序 1=涨幅, 4=热度 + top_n: 每个榜单的题材数量(1-60) + limit: 下发 stocks 上限(穿透度最高的 N 只) + """ + cache_key = f"theme_graph:{sort_field}:{top_n}" + + data, expires_at = _get_graph_cache(cache_key) + if data is not None: + # 有缓存:新鲜直接返回;过期返回旧数据并后台刷新 + if not (expires_at and expires_at > time.time()): + _spawn_rebuild(cache_key, sort_field, top_n) + return _trim_graph_result(data, limit) + + # 无缓存:同步构建(并发下加锁去重) + lock = _REBUILD_LOCKS.setdefault(cache_key, asyncio.Lock()) + async with lock: + data, expires_at = _get_graph_cache(cache_key) + if data is not None: + # 等待锁期间已被其他请求写入 + if not (expires_at and expires_at > time.time()): + _spawn_rebuild(cache_key, sort_field, top_n) + return _trim_graph_result(data, limit) + data = await _build_theme_graph(sort_field, top_n) + _set_graph_cache(cache_key, data) + return _trim_graph_result(data, limit) diff --git a/src/lib/theme-api.ts b/src/lib/theme-api.ts index e1990b0..28c64ab 100644 --- a/src/lib/theme-api.ts +++ b/src/lib/theme-api.ts @@ -164,9 +164,6 @@ export async function fetchThemeStocks(themeCode: string): Promise { +export async function fetchThemeGraph( + sortField: 1 | 4 = 1, + top: number = 30, + limit: number = 1000, +): Promise { const baseUrl = getApiBaseUrl(); - const url = `${baseUrl}/api/themes/graph?sort_field=${sortField}&top=${top}`; + const url = `${baseUrl}/api/themes/graph?sort_field=${sortField}&top=${top}&limit=${limit}`; try { const resp = await fetch(url, { method: "GET", cache: "no-store" }); diff --git a/src/routes/hot-map.tsx b/src/routes/hot-map.tsx index 16b1396..dfb7387 100644 --- a/src/routes/hot-map.tsx +++ b/src/routes/hot-map.tsx @@ -1,5 +1,5 @@ import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { forceSimulation, @@ -10,7 +10,12 @@ import { type SimulationNodeDatum, type SimulationLinkDatum, } from "d3-force"; -import { fetchThemeGraph, type ThemeGraph, type GraphStock } from "@/lib/theme-api"; +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"; @@ -157,7 +162,7 @@ function HotMapPage() {
{isCoarse ? "点击节点查看 · 再点跳转 · 双指缩放 · 拖拽平移" - : "悬停查看 · 滚轮缩放 · 拖拽平移 · 点击跳转"} + : "悬停查看 · 滚轮/捏合缩放 · 拖拽平移 · 点击跳转"}
)} @@ -267,13 +272,198 @@ interface SimEdge extends SimulationLinkDatum { 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; + ctx.beginPath(); + ctx.arc(n.x ?? 0, n.y ?? 0, r, 0, TAU); + ctx.fillStyle = isActive ? "#2563eb" : "#3b82f6"; + ctx.globalAlpha = dim ? 0.15 : 1; + ctx.fill(); + ctx.strokeStyle = "#1d4ed8"; + 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; + haloText(ctx, label, n.x ?? 0, (n.y ?? 0) + n.radius + 11, isCoarse ? 7.5 : 9, "#1e40af"); + } +} + 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 [view, setView] = useState({ x: 0, y: 0, k: 1 }); const [hovered, setHovered] = useState(null); const [selectedId, setSelectedId] = useState(null); - const [, setTick] = useState(0); const navigate = useNavigate(); /* 触屏检测:触屏无 hover,改用「tap 选中 → 底部浮层查看 → 按钮跳转」交互 */ @@ -282,30 +472,167 @@ function HotMapGraph({ graph }: { graph: ThemeGraph }) { [], ); - 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 + /* 绘制/手势状态放 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 = () => setSize({ w: el.clientWidth || 800, h: el.clientHeight || 600 }); + 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]); - /* 节点过滤:按覆盖题材数降序、同覆盖按涨幅降序,取穿透度最高的前 MAX 只 */ + /* 节点过滤 + 边重建(后端不再下发 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 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}`, @@ -328,9 +655,7 @@ function HotMapGraph({ graph }: { graph: ThemeGraph }) { 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 edgeList: SimEdge[] = buildEdgesFromStocks(kept); const stockByIdMap = new Map(); kept.forEach((s) => stockByIdMap.set(`s:${s.securityCode}`, s)); @@ -351,7 +676,35 @@ function HotMapGraph({ graph }: { graph: ThemeGraph }) { 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) @@ -374,79 +727,92 @@ function HotMapGraph({ graph }: { graph: ThemeGraph }) { d.type === "theme" ? d.radius + (isCoarse ? 20 : 16) : d.radius + 4, )); + simRunningRef.current = true; + staticReadyRef.current = false; sim.on("tick", () => { - setTick((t) => t + 1); - // 收敛后一次性自动 fit:让整图适配视口(缩小后题材标签隐藏,放大显示) + // 收敛后一次性自动 fit(兜底,防 end 不触发) 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; - } + 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]); + }, [nodes, edges, size.w, size.h, isCoarse, requestRender, buildStaticLayer, fitView]); - /* 激活节点:悬停(桌面)或选中(触屏) → 驱动高亮 */ - 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]); + /* 滚轮: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 activeNode = activeId ? nodes.find((n) => n.id === activeId) : null; + /* 命中检测:屏幕坐标 → 世界坐标,触屏扩大命中半径 */ + 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 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) => { + + /* 节点点击:触屏第一次 tap 选中查看,再 tap 同一节点跳转;桌面直接跳转 */ + const handleTap = (node: SimNode) => { if (isCoarse) { if (selectedId === node.id) navigateToNode(node); else setSelectedId(node.id); @@ -455,124 +821,130 @@ function HotMapGraph({ graph }: { graph: ThemeGraph }) { } }; + /* 指针手势: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()} > - - {/* 边 */} - {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 ( - - ); - })} - - {/* 股票节点 */} - {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 ( - setHovered(n.id)} - onMouseLeave={() => setHovered(null)} - onClick={() => handleNodeClick(n)} - > - = 2 ? "#f59e0b" : "#94a3b8"} - strokeWidth={cover >= 2 ? (cover >= 4 ? 2 : 1.5) : 0.5} - /> - = 2 ? 0.9 : 0.55} - /> - {isActive && ( - <> - = 2 ? "#f59e0b" : "#94a3b8"} strokeWidth={1} /> - - {n.name} - - {board.label && ( - - {board.label} - - )} - - )} - - ); - })} - - {/* 题材节点 */} - {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 ( - setHovered(n.id)} - onMouseLeave={() => setHovered(null)} - onClick={() => handleNodeClick(n)} - > - - {showLabel && ( - - {label} - - )} - - ); - })} - - + + + +
{/* 信息卡:桌面 hover 左上浮层 / 触屏选中底部浮层 */} {isCoarse ? (