feat: 热点穿透题材节点按涨幅正蓝负绿、球大小随涨幅绝对值

- 后端题材列表下发 bf3(题材涨幅)
- 题材节点: 正涨幅蓝色/负涨幅绿色, 半径按 |bf3| 在 6~20 映射
- 题材标签文字颜色跟随正负
- 信息卡/底部浮层题材分支显示涨幅
- 图例拆分涨/跌两项并说明球大小含义

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
SakurasanandClaude committed 2026-08-10 23:34:38 +08:00
1 parent b29ca8413c
commit 254deb6a80
3 files changed
+49 -15

No files matched your search

+1 -1
View File
@@ -370,7 +370,7 @@ async def _build_theme_graph(sort_field: int, top_n: int) -> dict:
return { return {
"themes": [ "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() for t in theme_map.values()
], ],
"stocks": stocks, "stocks": stocks,
+1
View File
@@ -165,6 +165,7 @@ export interface GraphTheme {
themeCode: string; themeCode: string;
themeName: string; themeName: string;
stockCount: number; // 题材内股票数 stockCount: number; // 题材内股票数
bf3: number | null; // 题材涨幅
} }
export interface GraphStock { export interface GraphStock {
+47 -14
View File
@@ -35,6 +35,10 @@ const MODES: { key: 1 | 4; label: string }[] = [
/* 图视图股票节点上限:按覆盖题材数降序保留最高穿透度的核心股 */ /* 图视图股票节点上限:按覆盖题材数降序保留最高穿透度的核心股 */
const MAX_STOCK_NODES = 800; const MAX_STOCK_NODES = 800;
/* 题材节点半径范围:按题材涨幅绝对值映射(涨得越猛球越大) */
const THEME_MIN_RADIUS = 6;
const THEME_MAX_RADIUS = 20;
/* 视图切换:关系图 / 核心股列表 */ /* 视图切换:关系图 / 核心股列表 */
type ViewMode = "graph" | "list"; type ViewMode = "graph" | "list";
@@ -272,6 +276,8 @@ interface SimNode extends SimulationNodeDatum {
f100?: string; f100?: string;
themeCodes?: string[]; themeCodes?: string[];
code?: string; code?: string;
// theme 专属
bf3?: number | null; // 题材涨幅
} }
interface SimEdge extends SimulationLinkDatum<SimNode> { interface SimEdge extends SimulationLinkDatum<SimNode> {
@@ -404,7 +410,7 @@ function drawStockNodes(
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
} }
/** 题材节点:蓝色圆点 */ /** 题材节点:涨幅正蓝负绿,大小按涨幅绝对值映射 */
function drawThemeNodes( function drawThemeNodes(
ctx: CanvasRenderingContext2D, ctx: CanvasRenderingContext2D,
nodes: SimNode[], nodes: SimNode[],
@@ -416,12 +422,13 @@ function drawThemeNodes(
const isActive = activeId === n.id; const isActive = activeId === n.id;
const dim = activeSet ? !activeSet.has(n.id) : false; const dim = activeSet ? !activeSet.has(n.id) : false;
const r = isActive ? n.radius + 3 : n.radius; const r = isActive ? n.radius + 3 : n.radius;
const isPos = (n.bf3 ?? 0) >= 0;
ctx.beginPath(); ctx.beginPath();
ctx.arc(n.x ?? 0, n.y ?? 0, r, 0, TAU); 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.globalAlpha = dim ? 0.15 : 1;
ctx.fill(); ctx.fill();
ctx.strokeStyle = "#1d4ed8"; ctx.strokeStyle = isPos ? "#1d4ed8" : "#15803d";
ctx.lineWidth = 1.5; ctx.lineWidth = 1.5;
ctx.stroke(); ctx.stroke();
} }
@@ -460,7 +467,8 @@ function drawThemeLabels(
if (n.type !== "theme") continue; if (n.type !== "theme") continue;
if (!showAll && activeId !== n.id) continue; if (!showAll && activeId !== n.id) continue;
const label = isCoarse ? (n.name.length > 5 ? n.name.slice(0, 5) + "…" : n.name) : n.name; 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, themeCodes: s.themeCodes,
radius: Math.min(18, 4 + s.coverCount * 1.3), radius: Math.min(18, 4 + s.coverCount * 1.3),
})); }));
const themeNodes: SimNode[] = graph.themes.map((t) => ({ // 题材涨幅绝对值作为球大小的归一化基准(兜底 ≥1 防除零)
id: `t:${t.themeCode}`, const maxAbsBf3 = Math.max(1, ...graph.themes.map((t) => Math.abs(t.bf3 ?? 0)));
type: "theme" as const, const themeNodes: SimNode[] = graph.themes.map((t) => {
name: t.themeName, const bf3 = t.bf3 ?? 0;
code: t.themeCode, return {
coverCount: t.stockCount, id: `t:${t.themeCode}`,
radius: 10, 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 edgeList: SimEdge[] = buildEdgesFromStocks(kept);
const stockByIdMap = new Map<string, GraphStock>(); const stockByIdMap = new Map<string, GraphStock>();
@@ -1007,6 +1021,11 @@ function InfoCard({ node, stockById }: { node: SimNode; stockById: Map<string, G
) : ( ) : (
<> <>
<p className="font-semibold">{node.name}</p> <p className="font-semibold">{node.name}</p>
{node.bf3 != null && (
<p className={`text-xs font-bold ${node.bf3 >= 0 ? "text-blue-600" : "text-green-600"}`}>
{node.bf3 >= 0 ? "+" : ""}{node.bf3.toFixed(2)}%
</p>
)}
<p className="text-[10px] text-muted-foreground">代码 {node.code} · {node.coverCount} 只股票</p> <p className="text-[10px] text-muted-foreground">代码 {node.code} · {node.coverCount} 只股票</p>
</> </>
)} )}
@@ -1065,6 +1084,11 @@ function BottomSheet({
) : ( ) : (
<> <>
<p className="font-semibold text-sm">{node.name}</p> <p className="font-semibold text-sm">{node.name}</p>
{node.bf3 != null && (
<p className={`text-xs font-bold mt-0.5 ${node.bf3 >= 0 ? "text-blue-600" : "text-green-600"}`}>
{node.bf3 >= 0 ? "+" : ""}{node.bf3.toFixed(2)}%
</p>
)}
<p className="text-[10px] text-muted-foreground mt-0.5"> <p className="text-[10px] text-muted-foreground mt-0.5">
代码 {node.code} · 覆盖 {node.coverCount} 只股票 代码 {node.code} · 覆盖 {node.coverCount} 只股票
</p> </p>
@@ -1111,7 +1135,11 @@ function Legend({ maxCover, compact }: { maxCover: number; compact: boolean }) {
</span> </span>
<span className="inline-flex items-center gap-1"> <span className="inline-flex items-center gap-1">
<span className="inline-block rounded-full bg-blue-500" style={{ width: 8, height: 8 }} /> <span className="inline-block rounded-full bg-blue-500" style={{ width: 8, height: 8 }} />
题材 题材涨
</span>
<span className="inline-flex items-center gap-1">
<span className="inline-block rounded-full bg-green-500" style={{ width: 8, height: 8 }} />
题材跌
</span> </span>
</div> </div>
); );
@@ -1131,8 +1159,13 @@ function Legend({ maxCover, compact }: { maxCover: number; compact: boolean }) {
</div> </div>
<div className="flex items-center gap-2 pt-1 border-t border-border/40"> <div className="flex items-center gap-2 pt-1 border-t border-border/40">
<span className="inline-block rounded-full bg-blue-500" style={{ width: 10, height: 10 }} /> <span className="inline-block rounded-full bg-blue-500" style={{ width: 10, height: 10 }} />
<span>题材节点</span> <span>题材 · 涨幅为正</span>
</div> </div>
<div className="flex items-center gap-2">
<span className="inline-block rounded-full bg-green-500" style={{ width: 10, height: 10 }} />
<span>题材 · 涨幅为负</span>
</div>
<div className="text-[9px] text-muted-foreground pt-0.5">球大小随涨幅绝对值增大</div>
</div> </div>
); );
} }