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

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Sakurasan
2026-08-10 23:34:38 +08:00
co-authored by Claude
parent b29ca8413c
commit 254deb6a80
3 changed files with 49 additions and 15 deletions
+1 -1
View File
@@ -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,
+1
View File
@@ -165,6 +165,7 @@ export interface GraphTheme {
themeCode: string;
themeName: string;
stockCount: number; // 题材内股票数
bf3: number | null; // 题材涨幅
}
export interface GraphStock {
+47 -14
View File
@@ -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<SimNode> {
@@ -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<string, GraphStock>();
@@ -1007,6 +1021,11 @@ function InfoCard({ node, stockById }: { node: SimNode; stockById: Map<string, G
) : (
<>
<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>
</>
)}
@@ -1065,6 +1084,11 @@ function BottomSheet({
) : (
<>
<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">
{node.code} · {node.coverCount}
</p>
@@ -1111,7 +1135,11 @@ function Legend({ maxCover, compact }: { maxCover: number; compact: boolean }) {
</span>
<span className="inline-flex items-center gap-1">
<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>
</div>
);
@@ -1131,8 +1159,13 @@ function Legend({ maxCover, compact }: { maxCover: number; compact: boolean }) {
</div>
<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></span>
<span> · </span>
</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>
);
}