feat: 股票小球心跳动画(覆盖≥5题材) + 涨幅正红光晕/负绿光晕
- 覆盖≥5题材股票球按正弦脉动(周期900ms, 幅度22%), 越大越醒目 - 涨幅≥0红色径向渐变光晕, <0绿色光晕, 随心跳一起脉动 - 架构: 静态层不再画股票节点(只画边+题材), 股票常驻动态层重绘 以支撑持续动画; 新增常驻rAF动画循环驱动 timeRef - 高亮态邻居淡出时, 光晕/心跳随 alpha 同步淡出 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+53
-11
@@ -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>() };
|
||||
|
||||
Reference in New Issue
Block a user