Flow animatopn
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user