Flow animatopn

This commit is contained in:
decolua
2026-07-19 13:19:13 +07:00
parent ccb0842d0a
commit 0513bf393f
2 changed files with 221 additions and 10 deletions

View File

@@ -7,6 +7,8 @@ import {
Handle,
Position,
Controls,
BaseEdge,
getBezierPath,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { AI_PROVIDERS } from "@/shared/constants/providers";
@@ -16,6 +18,10 @@ import { getProviderIconSrc, markProviderIconMissing } from "@/shared/utils/prov
const FE_ACTIVE_TIMEOUT_MS = 60000;
const FE_ACTIVE_TICK_MS = 1000;
// Kame + electric particles along active edges
const KAME_PARTICLE_COUNT = 6;
const SPARK_COUNT = 5;
function getProviderConfig(providerId) {
return AI_PROVIDERS[providerId] || { color: "#6b7280", name: providerId };
}
@@ -88,19 +94,34 @@ ProviderNode.propTypes = {
data: PropTypes.object.isRequired,
};
// Center 9Router node
// Center 9Router node — pulse/glow on card only (no expanding rings)
function RouterNode({ data }) {
const powering = (data.activeCount || 0) > 0;
return (
<div className="flex items-center justify-center px-5 py-3 rounded-xl border-2 border-primary bg-primary/5 shadow-md min-w-[130px]">
<div
className={`relative z-[1] flex items-center justify-center px-5 py-3 rounded-xl border-2 min-w-[130px] ${
powering
? "topology-router-core border-yellow-300 bg-gradient-to-br from-primary/30 via-yellow-400/20 to-cyan-400/25"
: "border-primary bg-primary/5 shadow-md"
}`}
>
<Handle type="source" position={Position.Top} id="top" className="!bg-transparent !border-0 !w-0 !h-0" />
<Handle type="source" position={Position.Bottom} id="bottom" className="!bg-transparent !border-0 !w-0 !h-0" />
<Handle type="source" position={Position.Left} id="left" className="!bg-transparent !border-0 !w-0 !h-0" />
<Handle type="source" position={Position.Right} id="right" className="!bg-transparent !border-0 !w-0 !h-0" />
<img src="/favicon.svg" alt="9Router" className="w-6 h-6 mr-2" loading="lazy" decoding="async" />
<span className="text-sm font-bold text-primary">9Router</span>
<img
src="/favicon.svg"
alt="9Router"
className={`w-6 h-6 mr-2 ${powering ? "topology-router-icon" : ""}`}
loading="lazy"
decoding="async"
/>
<span className={`text-sm font-bold ${powering ? "topology-router-label text-yellow-300" : "text-primary"}`}>
9Router
</span>
{data.activeCount > 0 && (
<span className="ml-2 px-1.5 py-0.5 rounded-full bg-primary text-white text-xs font-bold">
<span className="ml-2 px-1.5 py-0.5 rounded-full bg-yellow-400 text-black text-xs font-bold topology-router-badge">
{data.activeCount}
</span>
)}
@@ -112,7 +133,131 @@ RouterNode.propTypes = {
data: PropTypes.object.isRequired,
};
// Active: electric kame beam (multi-layer stroke + sparks). Idle/last/error: solid BaseEdge.
function TopologyEdge({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
style = {},
data,
}) {
const [edgePath] = getBezierPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
});
const active = !!data?.active;
const stroke = style.stroke || "var(--color-border)";
const filterId = `topo-electric-${id}`;
if (!active) {
return <BaseEdge id={id} path={edgePath} style={{ ...style, stroke }} />;
}
return (
<g className="topology-edge-electric">
<defs>
<filter id={filterId} x="-40%" y="-40%" width="180%" height="180%">
<feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="2" seed="2" result="noise">
<animate attributeName="baseFrequency" values="0.8;1.4;0.8" dur="0.25s" repeatCount="indefinite" />
</feTurbulence>
<feDisplacementMap in="SourceGraphic" in2="noise" scale="3.5" xChannelSelector="R" yChannelSelector="G" />
</filter>
</defs>
{/* Outer electric halo */}
<path
d={edgePath}
fill="none"
stroke="#22d3ee"
strokeWidth={10}
strokeOpacity={0.35}
strokeLinecap="round"
filter={`url(#${filterId})`}
className="topology-edge-halo"
/>
{/* Mid plasma */}
<path
d={edgePath}
fill="none"
stroke="#4ade80"
strokeWidth={5}
strokeOpacity={0.85}
strokeLinecap="round"
filter={`url(#${filterId})`}
className="topology-edge-plasma"
/>
{/* Hot white core */}
<BaseEdge
id={id}
path={edgePath}
style={{ stroke: "#f8fafc", strokeWidth: 2.2, opacity: 1 }}
className="topology-edge-kame"
/>
{/* Energy orbs */}
{Array.from({ length: KAME_PARTICLE_COUNT }, (_, i) => (
<circle
key={`${id}-p-${i}`}
r={i % 2 === 0 ? 4 : 2.5}
fill={i % 3 === 0 ? "#fde047" : i % 3 === 1 ? "#67e8f9" : "#fff"}
opacity={0.95}
style={{ filter: "drop-shadow(0 0 4px #22d3ee)" }}
>
<animateMotion
dur={`${0.4 + i * 0.08}s`}
repeatCount="indefinite"
path={edgePath}
begin={`${i * 0.09}s`}
/>
</circle>
))}
{/* Electric sparks (short-lived blink along path) */}
{Array.from({ length: SPARK_COUNT }, (_, i) => (
<circle
key={`${id}-s-${i}`}
r={1.8}
fill="#e0f2fe"
opacity={0}
>
<animate
attributeName="opacity"
values="0;1;0;0;1;0"
dur={`${0.35 + (i % 3) * 0.1}s`}
begin={`${i * 0.07}s`}
repeatCount="indefinite"
/>
<animateMotion
dur={`${0.28 + i * 0.05}s`}
repeatCount="indefinite"
path={edgePath}
begin={`${i * 0.11}s`}
/>
</circle>
))}
</g>
);
}
TopologyEdge.propTypes = {
id: PropTypes.string,
sourceX: PropTypes.number,
sourceY: PropTypes.number,
targetX: PropTypes.number,
targetY: PropTypes.number,
sourcePosition: PropTypes.string,
targetPosition: PropTypes.string,
style: PropTypes.object,
data: PropTypes.object,
};
const nodeTypes = { provider: ProviderNode, router: RouterNode };
const edgeTypes = { topology: TopologyEdge };
// Place N nodes evenly along an ellipse around the router center.
function buildLayout(providers, activeSet, lastSet, errorSet) {
@@ -146,9 +291,9 @@ function buildLayout(providers, activeSet, lastSet, errorSet) {
draggable: false,
});
const edgeStyle = (active, last, error, color) => {
const edgeStyle = (active, last, error) => {
if (error) return { stroke: "#ef4444", strokeWidth: 2.5, opacity: 0.9 };
if (active) return { stroke: "#22c55e", strokeWidth: 2.5, opacity: 0.9 };
if (active) return { stroke: "#22d3ee", strokeWidth: 3.5, opacity: 1 };
if (last) return { stroke: "#f59e0b", strokeWidth: 2, opacity: 0.7 };
return { stroke: "var(--color-border)", strokeWidth: 1, opacity: 0.3 };
};
@@ -194,12 +339,15 @@ function buildLayout(providers, activeSet, lastSet, errorSet) {
edges.push({
id: `e-${nodeId}`,
type: "topology",
source: "router",
sourceHandle,
target: nodeId,
targetHandle,
animated: active,
style: edgeStyle(active, last, error, config.color),
// Built-in animated uses stroke-dasharray (CPU-heavy); use particle beam instead
animated: false,
data: { active },
style: edgeStyle(active, last, error),
});
});
@@ -252,7 +400,7 @@ export default function ProviderTopology({ providers = [], activeRequests = [],
const { nodes, edges } = useMemo(
() => buildLayout(providers, activeSet, lastSet, errorSet),
[providers, activeSet, lastKey, errorKey]
[providers, activeSet, lastSet, errorSet]
);
// Stable key — only remount when provider list changes
@@ -300,6 +448,7 @@ export default function ProviderTopology({ providers = [], activeRequests = [],
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
fitView
fitViewOptions={fitOpts}
minZoom={0.1}

View File

@@ -442,6 +442,68 @@ button:disabled,
opacity: 0.04;
}
/* Topology: router card pulse (no flying rings) + electric edge beam */
@keyframes topology-router-pulse {
0%, 100% {
transform: scale(1);
box-shadow:
0 0 8px #fde047,
0 0 20px color-mix(in srgb, var(--color-primary) 65%, transparent),
0 0 32px rgba(34, 211, 238, 0.4),
inset 0 0 10px rgba(253, 224, 71, 0.3);
}
50% {
transform: scale(1.06);
box-shadow:
0 0 14px #fef08a,
0 0 28px #facc15,
0 0 44px rgba(34, 211, 238, 0.6),
inset 0 0 14px rgba(255, 255, 255, 0.25);
}
}
@keyframes topology-router-icon-shake {
0%, 100% { transform: rotate(0deg) scale(1); filter: drop-shadow(0 0 2px #fde047); }
25% { transform: rotate(-5deg) scale(1.08); filter: drop-shadow(0 0 6px #facc15); }
75% { transform: rotate(5deg) scale(1.08); filter: drop-shadow(0 0 6px #22d3ee); }
}
@keyframes topology-router-label-flicker {
0%, 100% { text-shadow: 0 0 6px #fde047, 0 0 12px #22d3ee; }
50% { text-shadow: 0 0 10px #fff, 0 0 18px #facc15; }
}
@keyframes topology-edge-dash {
to { stroke-dashoffset: -36; }
}
@keyframes topology-edge-flicker {
0%, 100% { opacity: 0.85; }
40% { opacity: 1; }
55% { opacity: 0.55; }
70% { opacity: 1; }
}
.topology-router-core {
animation: topology-router-pulse 0.75s ease-in-out infinite;
border-color: #fde047 !important;
}
.topology-router-icon {
animation: topology-router-icon-shake 0.45s ease-in-out infinite;
}
.topology-router-label {
animation: topology-router-label-flicker 0.7s ease-in-out infinite;
}
.topology-router-badge {
box-shadow: 0 0 10px #fde047, 0 0 16px rgba(34, 211, 238, 0.6);
}
.topology-edge-kame {
stroke-dasharray: 8 6;
animation: topology-edge-dash 0.22s linear infinite;
}
.topology-edge-halo {
animation: topology-edge-flicker 0.18s steps(2) infinite;
}
.topology-edge-plasma {
stroke-dasharray: 14 10;
animation: topology-edge-dash 0.18s linear infinite, topology-edge-flicker 0.22s steps(3) infinite;
}
/* React Flow controls: match app theme */
.react-flow-controls-custom {
background: var(--color-surface);