feat(docs): animate ingestion flow with running dots

Replace static smoothstep edges in the introduction page's ingestion
diagram with a custom animated edge that runs glowing cyan dots along
each path, conveying the source → stage → output flow. Dot duration
scales with path length and is hidden under prefers-reduced-motion.
This commit is contained in:
Andrey Avtomonov 2026-05-18 17:30:20 +02:00
parent 78527fdf59
commit d34c0a37f5
2 changed files with 80 additions and 2 deletions

View file

@ -3,6 +3,9 @@
import {
Background,
BackgroundVariant,
BaseEdge,
type EdgeProps,
getSmoothStepPath,
Handle,
MarkerType,
type Node,
@ -182,7 +185,7 @@ const flowEdges = [
})),
].map((edge) => ({
...edge,
type: "smoothstep" as const,
type: "animated" as const,
style: { stroke: EDGE_STROKE, strokeWidth: 1.5 },
markerEnd: {
type: MarkerType.ArrowClosed,
@ -337,12 +340,75 @@ function OutputNodeView({ data }: NodeProps<OutputNode>) {
);
}
const DOT_CORE_COLOR = "#67e8f9";
const DOT_GLOW_COLOR = "#22d3ee";
const DOT_SPEED_PX_PER_SEC = 110;
const DOT_MIN_DURATION_SEC = 0.7;
function AnimatedSmoothStepEdge({
id,
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
style,
markerEnd,
}: EdgeProps) {
const [path] = getSmoothStepPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
});
const pathId = `mechanics-flow-${id}`;
const approxLength =
Math.abs(targetX - sourceX) + Math.abs(targetY - sourceY);
const duration = Math.max(
DOT_MIN_DURATION_SEC,
approxLength / DOT_SPEED_PX_PER_SEC,
);
const durAttr = `${duration.toFixed(2)}s`;
const beginAttr = `-${(duration / 2).toFixed(2)}s`;
return (
<>
<BaseEdge id={pathId} path={path} style={style} markerEnd={markerEnd} />
<g className="mechanics-flow-dot">
<circle r={6.5} fill={DOT_GLOW_COLOR} opacity={0.22} />
<circle r={2.6} fill={DOT_CORE_COLOR} />
<animateMotion dur={durAttr} repeatCount="indefinite">
<mpath href={`#${pathId}`} />
</animateMotion>
</g>
<g className="mechanics-flow-dot">
<circle r={5} fill={DOT_GLOW_COLOR} opacity={0.14} />
<circle r={2} fill={DOT_CORE_COLOR} opacity={0.7} />
<animateMotion
dur={durAttr}
begin={beginAttr}
repeatCount="indefinite"
>
<mpath href={`#${pathId}`} />
</animateMotion>
</g>
</>
);
}
const nodeTypes = {
source: SourceNodeView,
stage: StageNodeView,
output: OutputNodeView,
};
const edgeTypes = {
animated: AnimatedSmoothStepEdge,
};
export function ProductMechanics() {
return (
<section
@ -396,6 +462,7 @@ export function ProductMechanics() {
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
fitView
fitViewOptions={{ padding: 0.04 }}
nodesDraggable={false}
@ -459,6 +526,15 @@ export function ProductMechanics() {
border: 0;
pointer-events: none;
}
.mechanics-canvas .mechanics-flow-dot {
pointer-events: none;
filter: drop-shadow(0 0 6px rgba(34, 211, 238, 0.45));
}
@media (prefers-reduced-motion: reduce) {
.mechanics-canvas .mechanics-flow-dot {
display: none;
}
}
`}</style>
</section>
);

View file

@ -86,7 +86,9 @@ test("product mechanics component explains ingestion outputs", async () => {
'"use client"',
"@xyflow/react",
"<ReactFlow",
"smoothstep",
"getSmoothStepPath",
"animateMotion",
"mechanics-flow-dot",
]) {
assert.ok(
component.includes(expectedText),