diff --git a/src/routes/hot-map.tsx b/src/routes/hot-map.tsx index 2e0b455..56a1055 100644 --- a/src/routes/hot-map.tsx +++ b/src/routes/hot-map.tsx @@ -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 | 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()); const gestureRef = useRef({ 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() };