From d473bbad6b51113b0b98011e5b497a66e0a6c73e Mon Sep 17 00:00:00 2001 From: Sakurasan <26715255+Sakurasan@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:44:42 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=82=A1=E7=A5=A8=E5=B0=8F=E7=90=83?= =?UTF-8?q?=E5=BF=83=E8=B7=B3=E5=8A=A8=E7=94=BB(=E8=A6=86=E7=9B=96?= =?UTF-8?q?=E2=89=A55=E9=A2=98=E6=9D=90)=20+=20=E6=B6=A8=E5=B9=85=E6=AD=A3?= =?UTF-8?q?=E7=BA=A2=E5=85=89=E6=99=95/=E8=B4=9F=E7=BB=BF=E5=85=89?= =?UTF-8?q?=E6=99=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 覆盖≥5题材股票球按正弦脉动(周期900ms, 幅度22%), 越大越醒目 - 涨幅≥0红色径向渐变光晕, <0绿色光晕, 随心跳一起脉动 - 架构: 静态层不再画股票节点(只画边+题材), 股票常驻动态层重绘 以支撑持续动画; 新增常驻rAF动画循环驱动 timeRef - 高亮态邻居淡出时, 光晕/心跳随 alpha 同步淡出 Co-Authored-By: Claude --- src/routes/hot-map.tsx | 64 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 53 insertions(+), 11 deletions(-) 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() };