feat: 热点穿透题材节点按涨幅正蓝负绿、球大小随涨幅绝对值
- 后端题材列表下发 bf3(题材涨幅) - 题材节点: 正涨幅蓝色/负涨幅绿色, 半径按 |bf3| 在 6~20 映射 - 题材标签文字颜色跟随正负 - 信息卡/底部浮层题材分支显示涨幅 - 图例拆分涨/跌两项并说明球大小含义 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
1 parent
b29ca8413c
commit
254deb6a80
3 files changed
+49
-15
No files matched your search
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user