mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
Flow animatopn
This commit is contained in:
@@ -7,6 +7,8 @@ import {
|
|||||||
Handle,
|
Handle,
|
||||||
Position,
|
Position,
|
||||||
Controls,
|
Controls,
|
||||||
|
BaseEdge,
|
||||||
|
getBezierPath,
|
||||||
} from "@xyflow/react";
|
} from "@xyflow/react";
|
||||||
import "@xyflow/react/dist/style.css";
|
import "@xyflow/react/dist/style.css";
|
||||||
import { AI_PROVIDERS } from "@/shared/constants/providers";
|
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_TIMEOUT_MS = 60000;
|
||||||
const FE_ACTIVE_TICK_MS = 1000;
|
const FE_ACTIVE_TICK_MS = 1000;
|
||||||
|
|
||||||
|
// Kame + electric particles along active edges
|
||||||
|
const KAME_PARTICLE_COUNT = 6;
|
||||||
|
const SPARK_COUNT = 5;
|
||||||
|
|
||||||
function getProviderConfig(providerId) {
|
function getProviderConfig(providerId) {
|
||||||
return AI_PROVIDERS[providerId] || { color: "#6b7280", name: providerId };
|
return AI_PROVIDERS[providerId] || { color: "#6b7280", name: providerId };
|
||||||
}
|
}
|
||||||
@@ -88,19 +94,34 @@ ProviderNode.propTypes = {
|
|||||||
data: PropTypes.object.isRequired,
|
data: PropTypes.object.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Center 9Router node
|
// Center 9Router node — pulse/glow on card only (no expanding rings)
|
||||||
function RouterNode({ data }) {
|
function RouterNode({ data }) {
|
||||||
|
const powering = (data.activeCount || 0) > 0;
|
||||||
return (
|
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.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.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.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" />
|
<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" />
|
<img
|
||||||
<span className="text-sm font-bold text-primary">9Router</span>
|
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 && (
|
{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}
|
{data.activeCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -112,7 +133,131 @@ RouterNode.propTypes = {
|
|||||||
data: PropTypes.object.isRequired,
|
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 nodeTypes = { provider: ProviderNode, router: RouterNode };
|
||||||
|
const edgeTypes = { topology: TopologyEdge };
|
||||||
|
|
||||||
// Place N nodes evenly along an ellipse around the router center.
|
// Place N nodes evenly along an ellipse around the router center.
|
||||||
function buildLayout(providers, activeSet, lastSet, errorSet) {
|
function buildLayout(providers, activeSet, lastSet, errorSet) {
|
||||||
@@ -146,9 +291,9 @@ function buildLayout(providers, activeSet, lastSet, errorSet) {
|
|||||||
draggable: false,
|
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 (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 };
|
if (last) return { stroke: "#f59e0b", strokeWidth: 2, opacity: 0.7 };
|
||||||
return { stroke: "var(--color-border)", strokeWidth: 1, opacity: 0.3 };
|
return { stroke: "var(--color-border)", strokeWidth: 1, opacity: 0.3 };
|
||||||
};
|
};
|
||||||
@@ -194,12 +339,15 @@ function buildLayout(providers, activeSet, lastSet, errorSet) {
|
|||||||
|
|
||||||
edges.push({
|
edges.push({
|
||||||
id: `e-${nodeId}`,
|
id: `e-${nodeId}`,
|
||||||
|
type: "topology",
|
||||||
source: "router",
|
source: "router",
|
||||||
sourceHandle,
|
sourceHandle,
|
||||||
target: nodeId,
|
target: nodeId,
|
||||||
targetHandle,
|
targetHandle,
|
||||||
animated: active,
|
// Built-in animated uses stroke-dasharray (CPU-heavy); use particle beam instead
|
||||||
style: edgeStyle(active, last, error, config.color),
|
animated: false,
|
||||||
|
data: { active },
|
||||||
|
style: edgeStyle(active, last, error),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -252,7 +400,7 @@ export default function ProviderTopology({ providers = [], activeRequests = [],
|
|||||||
|
|
||||||
const { nodes, edges } = useMemo(
|
const { nodes, edges } = useMemo(
|
||||||
() => buildLayout(providers, activeSet, lastSet, errorSet),
|
() => buildLayout(providers, activeSet, lastSet, errorSet),
|
||||||
[providers, activeSet, lastKey, errorKey]
|
[providers, activeSet, lastSet, errorSet]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Stable key — only remount when provider list changes
|
// Stable key — only remount when provider list changes
|
||||||
@@ -300,6 +448,7 @@ export default function ProviderTopology({ providers = [], activeRequests = [],
|
|||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
edges={edges}
|
edges={edges}
|
||||||
nodeTypes={nodeTypes}
|
nodeTypes={nodeTypes}
|
||||||
|
edgeTypes={edgeTypes}
|
||||||
fitView
|
fitView
|
||||||
fitViewOptions={fitOpts}
|
fitViewOptions={fitOpts}
|
||||||
minZoom={0.1}
|
minZoom={0.1}
|
||||||
|
|||||||
@@ -442,6 +442,68 @@ button:disabled,
|
|||||||
opacity: 0.04;
|
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: match app theme */
|
||||||
.react-flow-controls-custom {
|
.react-flow-controls-custom {
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
|
|||||||
Reference in New Issue
Block a user