feat: 股票小球心跳动画(覆盖≥5题材) + 涨幅正红光晕/负绿光晕

- 覆盖≥5题材股票球按正弦脉动(周期900ms, 幅度22%), 越大越醒目
- 涨幅≥0红色径向渐变光晕, <0绿色光晕, 随心跳一起脉动
- 架构: 静态层不再画股票节点(只画边+题材), 股票常驻动态层重绘
  以支撑持续动画; 新增常驻rAF动画循环驱动 timeRef
- 高亮态邻居淡出时, 光晕/心跳随 alpha 同步淡出

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Sakurasan
2026-08-11 00:44:42 +08:00
co-authored by Claude
parent c4a0c50ed0
commit d473bbad6b
+53 -11
View File
@@ -364,12 +364,20 @@ function drawEdges(ctx: CanvasRenderingContext2D, edges: SimEdge[], activeSet: S
ctx.globalAlpha = 1;
}
/** 股票节点:覆盖数分级颜色 + 外圈淡填充 + 描边 + 内实心 */
/* 心跳周期 ms;0~1 相位,0 最小 / 0.5 最大 */
const PULSE_PERIOD = 900;
const PULSE_AMPLITUDE = 0.22; // 心跳半径脉动幅度(相对半径)
/**
* 股票节点:覆盖数分级颜色 + 心跳(≥5题材) + 涨跌光晕 + 描边 + 内实心
* time(ms) 由持续动画循环驱动;非动画源(t=0)时心跳相位取 0 的静止态。
*/
function drawStockNodes(
ctx: CanvasRenderingContext2D,
nodes: SimNode[],
activeSet: Set<string> | null,
activeId: string | null,
time: number,
) {
for (const n of nodes) {
if (n.type !== "stock") continue;
@@ -377,13 +385,31 @@ function drawStockNodes(
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;
// 外圈淡填充
const isPos = (n.f3 ?? 0) >= 0;
// 心跳:覆盖≥5 的核心股按正弦脉动半径(相位在 0 时静止,避免动画源缺省导致闪烁)
const heartbeat = cover >= 5 ? 0.5 + 0.5 * Math.sin((time / PULSE_PERIOD) * TAU - Math.PI / 2) : 0;
const r = isActive ? n.radius + 3 : n.radius;
const pr = r * (1 + heartbeat * PULSE_AMPLITUDE);
// 涨跌光晕:随心跳脉动的径向渐变,正=红 / 负=绿
const glowColor = isPos ? "rgba(239,68,68," : "rgba(34,197,94,"; // 红/绿
const glowR = pr * 1.9;
const glow = ctx.createRadialGradient(x, y, pr * 0.2, x, y, glowR);
glow.addColorStop(0, `${glowColor}${0.28 * alpha})`);
glow.addColorStop(1, `${glowColor}0)`);
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.arc(x, y, glowR, 0, TAU);
ctx.fillStyle = glow;
ctx.globalAlpha = 1;
ctx.fill();
// 外圈淡填充
const fill = cover >= 7 ? "#dc2626" : cover >= 5 ? "#ef4444" : "#f97316";
ctx.beginPath();
ctx.arc(x, y, pr, 0, TAU);
ctx.fillStyle = fill;
ctx.globalAlpha = 0.35 * alpha;
ctx.fill();
@@ -394,13 +420,13 @@ function drawStockNodes(
ctx.stroke();
// 内实心
ctx.beginPath();
ctx.arc(x, y, n.radius, 0, TAU);
ctx.arc(x, y, pr, 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.arc(x, y, pr + 3, 0, TAU);
ctx.globalAlpha = alpha;
ctx.strokeStyle = cover >= 2 ? "#f59e0b" : "#94a3b8";
ctx.lineWidth = 1;
@@ -518,6 +544,8 @@ function HotMapGraph({ graph }: { graph: ThemeGraph }) {
const simRunningRef = useRef(false);
const fitOnceRef = useRef(false);
const rafRef = useRef(0);
const timeRef = useRef(0); // 动画时钟(ms),心跳/光晕脉动用
const pulseStartRef = useRef(0); // 持续动画起始时间戳(ms)
const pointersRef = useRef(new Map<number, { x: number; y: number }>());
const gestureRef = useRef<Gesture>({ kind: "none" });
@@ -543,19 +571,20 @@ function HotMapGraph({ graph }: { graph: ThemeGraph }) {
if (activeSet) {
// 高亮态:全量重绘(邻居亮、非邻居淡出)
drawEdges(ctx, edges, activeSet);
drawStockNodes(ctx, nodes, activeSet, activeId);
drawStockNodes(ctx, nodes, activeSet, activeId, timeRef.current);
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);
drawStockNodes(ctx, nodes, null, null, timeRef.current);
} else {
// 模拟期或大比例放大:直接全量绘制(保证清晰)
drawEdges(ctx, edges, null);
drawStockNodes(ctx, nodes, null, null);
drawStockNodes(ctx, nodes, null, null, timeRef.current);
drawThemeNodes(ctx, nodes, null, null);
}
}
@@ -602,7 +631,7 @@ function HotMapGraph({ graph }: { graph: ThemeGraph }) {
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 };
@@ -659,6 +688,19 @@ function HotMapGraph({ graph }: { graph: ThemeGraph }) {
return () => ro.disconnect();
}, [requestRender]);
/* 持续动画循环:心跳 + 光晕脉动。力收敛后常驻 rAF,仅更新时间与触发重绘 */
useEffect(() => {
pulseStartRef.current = performance.now();
let anim = 0;
const loop = () => {
timeRef.current = performance.now() - pulseStartRef.current;
requestRender();
anim = requestAnimationFrame(loop);
};
anim = requestAnimationFrame(loop);
return () => cancelAnimationFrame(anim);
}, [requestRender]);
/* 节点过滤 + 边重建(后端不再下发 edges,由 stocks[].themeCodes 重建) */
const { nodes, edges, stockById } = useMemo(() => {
if (!graph) return { nodes: [] as SimNode[], edges: [] as SimEdge[], stockById: new Map<string, GraphStock>() };