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:
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
+20
-11
@@ -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
@@ -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 ? (
|
||||
|
||||
Reference in New Issue
Block a user