perf: 优化热点穿透加载速度 + Canvas 渲染 + 触控板/手机交互

后端加载优化(盘中原本每次访问都重新聚合,耗时 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 <noreply@anthropic.com>
This commit is contained in:
Sakurasan
2026-08-07 13:21:08 +08:00
co-authored by Claude
parent e28d1a2fd0
commit 548ebee47f
5 changed files with 696 additions and 229 deletions
+2 -1
View File
@@ -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)
+3 -3
View File
@@ -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(
+111 -26
View File
@@ -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_atepoch 秒)"""
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)
+20 -11
View File
@@ -164,9 +164,6 @@ export async function fetchThemeStocks(themeCode: string): Promise<ThemeStocksRe
export interface GraphTheme {
themeCode: string;
themeName: string;
bf3: number | null; // 题材涨幅
hotValue: number;
strengthValue: number | null;
stockCount: number; // 题材内股票数
}
@@ -181,11 +178,6 @@ export interface GraphStock {
themeCodes: string[]; // 所属题材代码
}
export interface GraphEdge {
themeCode: string;
securityCode: string;
}
export interface GraphStats {
themeCount: number;
stockCount: number;
@@ -196,18 +188,35 @@ export interface GraphStats {
export interface ThemeGraph {
themes: GraphTheme[];
stocks: GraphStock[];
edges: GraphEdge[];
stats: GraphStats;
}
/**
* 从股票覆盖题材重建关系边(后端不再下发 edges,体积减半以上)
* 返回 d3-force 可直接使用的 source/target 节点 id"t:题材code" / "s:股票code"
*/
export function buildEdgesFromStocks(stocks: GraphStock[]): { source: string; target: string }[] {
const edges: { source: string; target: string }[] = [];
for (const s of stocks) {
const target = `s:${s.securityCode}`;
for (const tc of s.themeCodes) edges.push({ source: `t:${tc}`, target });
}
return edges;
}
/**
* 获取热点穿透图数据(题材-股票 M:N 网状关系)
* @param sortField 1=涨幅 4=热度
* @param top 题材数量
* @param limit 下发的股票节点上限(按穿透度取前 N 只)
*/
export async function fetchThemeGraph(sortField: 1 | 4 = 1, top: number = 30): Promise<ThemeGraph | null> {
export async function fetchThemeGraph(
sortField: 1 | 4 = 1,
top: number = 30,
limit: number = 1000,
): Promise<ThemeGraph | null> {
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" });
+560 -188
View File
@@ -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() {
<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>
</>
)}
@@ -267,13 +272,198 @@ interface SimEdge extends SimulationLinkDatum<SimNode> {
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;
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<HTMLDivElement>(null);
const mainCanvasRef = useRef<HTMLCanvasElement>(null);
const staticCanvasRef = useRef<HTMLCanvasElement | null>(null);
const [size, setSize] = useState({ w: 800, h: 600 });
const [view, setView] = useState({ x: 0, y: 0, k: 1 });
const [hovered, setHovered] = useState<string | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [, setTick] = useState(0);
const navigate = useNavigate();
/* 触屏检测:触屏无 hover,改用「tap 选中 → 底部浮层查看 → 按钮跳转」交互 */
@@ -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<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 = () => 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<string, GraphStock>() };
const kept = [...graph.stocks]
.sort((a, b) => b.coverCount - a.coverCount || (b.f3 ?? 0) - (a.f3 ?? 0))
.slice(0, MAX_STOCK_NODES);
const keptCodes = new Set(kept.map((s) => s.securityCode));
const keptThemes = new Set(graph.themes.map((t) => t.themeCode));
const stockNodes: SimNode[] = kept.map((s) => ({
id: `s:${s.securityCode}`,
@@ -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<string, GraphStock>();
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<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)
@@ -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<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]);
/* 滚轮: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<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">
<svg
className="w-full h-full cursor-grab active:cursor-grabbing touch-none"
onWheel={handleWheel}
<canvas
ref={mainCanvasRef}
className="w-full h-full touch-none select-none cursor-grab active:cursor-grabbing"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerLeave={onPointerUp}
onPointerCancel={onPointerUp}
onPointerLeave={onPointerLeave}
/>
{/* 缩放控制:触屏/触控板兜底,点击不触发画布手势 */}
<div
className="absolute bottom-16 right-3 z-10 flex flex-col gap-1.5"
onPointerDown={(e) => e.stopPropagation()}
>
<g transform={`translate(${view.x},${view.y}) scale(${view.k})`}>
{/* 边 */}
{edges.map((e, i) => {
const s = typeof e.source === "object" ? (e.source as SimNode) : null;
const t = typeof e.target === "object" ? (e.target as SimNode) : null;
if (!s || !t) return null;
const dim = hoverNeighbors ? (!hoverNeighbors.has(s.id) || !hoverNeighbors.has(t.id)) : false;
return (
<line
key={i}
x1={s.x ?? 0} y1={s.y ?? 0}
x2={t.x ?? 0} y2={t.y ?? 0}
stroke="#3b82f6"
strokeOpacity={dim ? 0.03 : 0.18}
strokeWidth={0.8}
/>
);
})}
{/* 股票节点 */}
{nodes.filter((n) => n.type === "stock").map((n) => {
const dim = hoverNeighbors ? !hoverNeighbors.has(n.id) : false;
const isHover = hovered === n.id;
const isActive = isHover || selectedId === n.id;
const cover = n.coverCount ?? 1;
const fill = cover >= 7 ? "#dc2626" : cover >= 5 ? "#ef4444" : "#f97316";
const board = getStockBoard(n.code ?? "");
return (
<g
key={n.id}
data-node
transform={`translate(${n.x ?? 0},${n.y ?? 0})`}
opacity={dim ? 0.08 : cover === 1 && !isActive ? 0.45 : 1}
style={{ cursor: "pointer", transition: "opacity 0.15s" }}
onMouseEnter={() => setHovered(n.id)}
onMouseLeave={() => setHovered(null)}
onClick={() => handleNodeClick(n)}
>
<circle
r={isActive ? n.radius + 3 : n.radius}
fill={fill}
fillOpacity={0.35}
stroke={cover >= 2 ? "#f59e0b" : "#94a3b8"}
strokeWidth={cover >= 2 ? (cover >= 4 ? 2 : 1.5) : 0.5}
/>
<circle
r={n.radius}
fill={fill}
fillOpacity={cover >= 2 ? 0.9 : 0.55}
/>
{isActive && (
<>
<circle r={n.radius + 3} fill="none" stroke={cover >= 2 ? "#f59e0b" : "#94a3b8"} strokeWidth={1} />
<text y={-n.radius - 6} textAnchor="middle" fontSize="10" fill="#334155" style={{ paintOrder: "stroke", stroke: "#fff", strokeWidth: 3, fontWeight: 600 }}>
{n.name}
</text>
{board.label && (
<text y={-n.radius - 18} textAnchor="middle" fontSize="8" fill={board.className.includes("red") ? "#dc2626" : board.className.includes("purple") ? "#9333ea" : "#ea580c"} style={{ paintOrder: "stroke", stroke: "#fff", strokeWidth: 3 }}>
{board.label}
</text>
)}
</>
)}
</g>
);
})}
{/* 题材节点 */}
{nodes.filter((n) => n.type === "theme").map((n) => {
const dim = hoverNeighbors ? !hoverNeighbors.has(n.id) : false;
const isActive = hovered === n.id || selectedId === n.id;
// 标签阈值:放大后显示(避免窄屏 30 个标签重叠),选中节点始终显示
const showLabel = isActive || view.k >= 0.95;
// 触屏标签更小且截断,减少窄屏文字重叠
const label = isCoarse ? (n.name.length > 5 ? n.name.slice(0, 5) + "…" : n.name) : n.name;
return (
<g
key={n.id}
data-node
transform={`translate(${n.x ?? 0},${n.y ?? 0})`}
opacity={dim ? 0.15 : 1}
style={{ cursor: "pointer", transition: "opacity 0.15s" }}
onMouseEnter={() => setHovered(n.id)}
onMouseLeave={() => setHovered(null)}
onClick={() => handleNodeClick(n)}
>
<circle
r={isActive ? n.radius + 3 : n.radius}
fill={isActive ? "#2563eb" : "#3b82f6"}
stroke="#1d4ed8"
strokeWidth={1.5}
/>
{showLabel && (
<text
y={n.radius + 11}
textAnchor="middle"
fontSize={isCoarse ? 7.5 : 9}
fill="#1e40af"
style={{ paintOrder: "stroke", stroke: "rgba(255,255,255,0.95)", strokeWidth: 3, fontWeight: 600 }}
>
{label}
</text>
)}
</g>
);
})}
</g>
</svg>
<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 ? (