diff --git a/backend/services/themes.py b/backend/services/themes.py index e66290e..0f6fc85 100644 --- a/backend/services/themes.py +++ b/backend/services/themes.py @@ -370,7 +370,7 @@ async def _build_theme_graph(sort_field: int, top_n: int) -> dict: return { "themes": [ - {"themeCode": t["themeCode"], "themeName": t["themeName"], "stockCount": t["stockCount"]} + {"themeCode": t["themeCode"], "themeName": t["themeName"], "stockCount": t["stockCount"], "bf3": t.get("bf3")} for t in theme_map.values() ], "stocks": stocks, diff --git a/src/lib/theme-api.ts b/src/lib/theme-api.ts index 28c64ab..6a43dbc 100644 --- a/src/lib/theme-api.ts +++ b/src/lib/theme-api.ts @@ -165,6 +165,7 @@ export interface GraphTheme { themeCode: string; themeName: string; stockCount: number; // 题材内股票数 + bf3: number | null; // 题材涨幅 } export interface GraphStock { diff --git a/src/routes/hot-map.tsx b/src/routes/hot-map.tsx index f84b5aa..541df80 100644 --- a/src/routes/hot-map.tsx +++ b/src/routes/hot-map.tsx @@ -35,6 +35,10 @@ const MODES: { key: 1 | 4; label: string }[] = [ /* 图视图股票节点上限:按覆盖题材数降序保留最高穿透度的核心股 */ const MAX_STOCK_NODES = 800; +/* 题材节点半径范围:按题材涨幅绝对值映射(涨得越猛球越大) */ +const THEME_MIN_RADIUS = 6; +const THEME_MAX_RADIUS = 20; + /* 视图切换:关系图 / 核心股列表 */ type ViewMode = "graph" | "list"; @@ -272,6 +276,8 @@ interface SimNode extends SimulationNodeDatum { f100?: string; themeCodes?: string[]; code?: string; + // theme 专属 + bf3?: number | null; // 题材涨幅 } interface SimEdge extends SimulationLinkDatum { @@ -404,7 +410,7 @@ function drawStockNodes( ctx.globalAlpha = 1; } -/** 题材节点:蓝色圆点 */ +/** 题材节点:涨幅正蓝负绿,大小按涨幅绝对值映射 */ function drawThemeNodes( ctx: CanvasRenderingContext2D, nodes: SimNode[], @@ -416,12 +422,13 @@ function drawThemeNodes( const isActive = activeId === n.id; const dim = activeSet ? !activeSet.has(n.id) : false; const r = isActive ? n.radius + 3 : n.radius; + const isPos = (n.bf3 ?? 0) >= 0; ctx.beginPath(); ctx.arc(n.x ?? 0, n.y ?? 0, r, 0, TAU); - ctx.fillStyle = isActive ? "#2563eb" : "#3b82f6"; + ctx.fillStyle = isActive ? (isPos ? "#2563eb" : "#16a34a") : isPos ? "#3b82f6" : "#22c55e"; ctx.globalAlpha = dim ? 0.15 : 1; ctx.fill(); - ctx.strokeStyle = "#1d4ed8"; + ctx.strokeStyle = isPos ? "#1d4ed8" : "#15803d"; ctx.lineWidth = 1.5; ctx.stroke(); } @@ -460,7 +467,8 @@ function drawThemeLabels( 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"); + const labelColor = (n.bf3 ?? 0) >= 0 ? "#1e40af" : "#15803d"; + haloText(ctx, label, n.x ?? 0, (n.y ?? 0) + n.radius + 11, isCoarse ? 7.5 : 9, labelColor); } } @@ -654,14 +662,20 @@ function HotMapGraph({ graph }: { graph: ThemeGraph }) { themeCodes: s.themeCodes, radius: Math.min(18, 4 + s.coverCount * 1.3), })); - const themeNodes: SimNode[] = graph.themes.map((t) => ({ - id: `t:${t.themeCode}`, - type: "theme" as const, - name: t.themeName, - code: t.themeCode, - coverCount: t.stockCount, - radius: 10, - })); + // 题材涨幅绝对值作为球大小的归一化基准(兜底 ≥1 防除零) + const maxAbsBf3 = Math.max(1, ...graph.themes.map((t) => Math.abs(t.bf3 ?? 0))); + const themeNodes: SimNode[] = graph.themes.map((t) => { + const bf3 = t.bf3 ?? 0; + return { + id: `t:${t.themeCode}`, + type: "theme" as const, + name: t.themeName, + code: t.themeCode, + coverCount: t.stockCount, + bf3: t.bf3, + radius: THEME_MIN_RADIUS + (Math.abs(bf3) / maxAbsBf3) * (THEME_MAX_RADIUS - THEME_MIN_RADIUS), + }; + }); const edgeList: SimEdge[] = buildEdgesFromStocks(kept); const stockByIdMap = new Map(); @@ -1007,6 +1021,11 @@ function InfoCard({ node, stockById }: { node: SimNode; stockById: Map

{node.name}

+ {node.bf3 != null && ( +

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

+ )}

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

)} @@ -1065,6 +1084,11 @@ function BottomSheet({ ) : ( <>

{node.name}

+ {node.bf3 != null && ( +

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

+ )}

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

@@ -1111,7 +1135,11 @@ function Legend({ maxCover, compact }: { maxCover: number; compact: boolean }) { - 题材 + 题材涨 + + + + 题材跌 ); @@ -1131,8 +1159,13 @@ function Legend({ maxCover, compact }: { maxCover: number; compact: boolean }) {
- 题材节点 + 题材 · 涨幅为正
+
+ + 题材 · 涨幅为负 +
+
球大小随涨幅绝对值增大
); }