Netzwerk-Graph
Knoten auf Ringen, verbunden durch Haarlinien. Ein Signal läuft von der Mitte nach außen und lässt jeden Knoten aufleuchten. Zeigen oder Tab zeigt Verbindungen.
Eine von 3 Components in Daten-Animationen
Details
Was Netzwerk-Graph mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Daten-Animationen
- Bereich
- Visuals
- Variants
- 3
- Version
- 1.0.0 · seit 1.0.0
- Datei
- components/ui/network-graph.tsx
- Bringt mit
- utilskitmotion-scene
- npm-Pakete
- Keine
- Sprachen
- Texte kommen über Props
- Studio
- Im Code anpassen
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle NetworkGraphProps aus components/ui/network-graph.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch. Nimmt außerdem alle Props von SceneMotionProps an.
nodesPflichtGraphNode[]linksPflicht[string, string][]Pairs of node ids
aspectoptionalstringScene ratio, width / height. Default "16 / 10".
intervaloptionalnumberSeconds between two signals at speed 1. Default 3.6.
seedoptionalnumberSeed for the small irregularities of the layout
interactiveoptionalbooleanNodes take focus and show their links. Default true.
labeloptionalstringAccessible description of the whole graph
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Setzt aria-label, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rolle group.
- Kommt ohne Client-State aus. Der Server schreibt sie vollständig, sie ist auch ohne JavaScript lesbar.
Bewegung
- Ohne Animationen, sie steht sofort ruhig da.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 207 Zeilen
'use client';
import { useState } from 'react';
import { cn, round } from '@/lib/kit';
import { PlayToggle, SCENE_CSS, keyframes, rand, useSceneId, useScenePlay, type SceneMotionProps } from '@/components/ui/motion-scene';
/*
* A connection graph: nodes on rings around a center, joined by hairlines.
* On entry the lines draw outward ring by ring. Then a signal leaves the
* center every few seconds as a ripple, and every node and line lights up
* when the ripple reaches it, so the light always travels outward. Pointing
* at a node or focusing it with Tab shows its name and its connections.
*/
export interface GraphNode {
id: string;
/** Shown on hover and focus, and read to screen readers */
label: string;
/** Avatar, icon or initials inside the node */
content?: React.ReactNode;
/** 0 center, 1 inner ring, 2 outer ring. Default: from the links, counted from the first node. */
ring?: number;
/** Explicit position in percent, overrides the ring layout */
x?: number;
y?: number;
}
export interface NetworkGraphProps extends SceneMotionProps {
nodes: GraphNode[];
/** Pairs of node ids */
links: [string, string][];
/** Scene ratio, width / height. Default "16 / 10". */
aspect?: string;
/** Seconds between two signals at speed 1. Default 3.6. */
interval?: number;
/** Seed for the small irregularities of the layout */
seed?: number;
/** Nodes take focus and show their links. Default true. */
interactive?: boolean;
/** Accessible description of the whole graph */
label?: string;
className?: string;
}
const VW = 1000;
export function NetworkGraph({ nodes, links, aspect = '16 / 10', interval = 3.6, seed = 2, interactive = true, speed = 1, autoPlay = 'in-view', controls = true, label, className }: NetworkGraphProps) {
const sid = useSceneId('kvng');
const play = useScenePlay<HTMLDivElement>(autoPlay);
const [active, setActive] = useState<string | null>(null);
const [aw, ah] = aspect.split('/').map((n) => Number(n.trim()) || 1);
const VH = round((VW * ah) / aw);
// rings from the links, breadth first from the first node
const ring = new Map<string, number>();
if (nodes[0]) ring.set(nodes[0].id, nodes[0].ring ?? 0);
for (let pass = 0; pass < 4; pass++) {
for (const [a, b] of links) {
if (ring.has(a) && !ring.has(b)) ring.set(b, (ring.get(a) ?? 0) + 1);
if (ring.has(b) && !ring.has(a)) ring.set(a, (ring.get(b) ?? 0) + 1);
}
}
const ringOf = (n: GraphNode) => n.ring ?? ring.get(n.id) ?? 2;
const groups = new Map<number, GraphNode[]>();
nodes.forEach((n) => groups.set(ringOf(n), [...(groups.get(ringOf(n)) ?? []), n]));
const pos = new Map<string, { x: number; y: number; r: number; dist: number }>();
// Inner ring evenly around the center; outer nodes gather around the angle of their parent
const angleOf = new Map<string, number>();
const inner = groups.get(1) ?? [];
inner.forEach((n, i) => angleOf.set(n.id, -90 + (360 / Math.max(1, inner.length)) * i + (rand(seed, i + 31) - 0.5) * 10));
const outer = groups.get(2) ?? [];
const parentOf = (n: GraphNode) => links.map(([a, b]) => (a === n.id ? b : b === n.id ? a : null)).find((p) => p && angleOf.has(p)) ?? null;
const kids = new Map<string, GraphNode[]>();
outer.forEach((n) => {
const p = parentOf(n) ?? '';
kids.set(p, [...(kids.get(p) ?? []), n]);
});
const spread = Math.min(26, 300 / Math.max(1, outer.length));
let orphan = 0;
for (const [p, list] of kids) {
list.forEach((n, j) => {
const baseAngle = angleOf.get(p) ?? -90 + (360 / Math.max(1, outer.length)) * orphan++;
angleOf.set(n.id, baseAngle + (j - (list.length - 1) / 2) * spread + (rand(seed + 9, j) - 0.5) * 6);
});
}
for (const [r, list] of groups) {
list.forEach((n, i) => {
if (n.x != null && n.y != null) {
const x = (n.x / 100) * VW;
const y = (n.y / 100) * VH;
pos.set(n.id, { x, y, r, dist: Math.hypot(x - VW / 2, (y - VH / 2) * (VW / VH)) / (VW / 2) });
return;
}
if (r === 0) {
pos.set(n.id, { x: VW / 2, y: VH / 2, r, dist: 0 });
return;
}
const a = ((angleOf.get(n.id) ?? -90 + (360 / list.length) * i) * Math.PI) / 180;
const rx = (r === 1 ? 0.24 : 0.43) * VW * (1 + (rand(seed + 3, i + r * 17) - 0.5) * 0.08);
const ry = (r === 1 ? 0.27 : 0.42) * VH * (1 + (rand(seed + 5, i + r * 13) - 0.5) * 0.08);
const x = VW / 2 + Math.cos(a) * rx;
const y = VH / 2 + Math.sin(a) * ry;
pos.set(n.id, { x: round(x), y: round(y), r, dist: r === 1 ? 0.5 : 1 });
});
}
const T = round(interval / Math.max(0.1, speed), 2);
const wave = 1.5; // seconds the ripple needs from center to the edge
const css = `${SCENE_CSS}
${keyframes(`${sid}-ripple`, [[0, 'opacity:.5;transform:translate(-50%,-50%) scale(.05)'], [(wave / interval) * 100, 'opacity:0;transform:translate(-50%,-50%) scale(1)'], [100, 'opacity:0;transform:translate(-50%,-50%) scale(1)']])}
${keyframes(`${sid}-lit`, [[0, 'opacity:0'], [6, 'opacity:1'], [34, 'opacity:0'], [100, 'opacity:0']])}
${keyframes(`${sid}-glow`, [[0, 'opacity:0;transform:scale(.8)'], [5, 'opacity:.9;transform:scale(1)'], [32, 'opacity:0;transform:scale(1.5)'], [100, 'opacity:0;transform:scale(1.5)']])}
${keyframes(`${sid}-draw`, [[0, 'stroke-dashoffset:1'], [100, 'stroke-dashoffset:0']])}
${keyframes(`${sid}-pop`, [[0, 'opacity:0;transform:translate(-50%,-50%) scale(.6)'], [100, 'opacity:1;transform:translate(-50%,-50%) scale(1)']])}
.${sid}:not([data-kv="off"]) .${sid}-ripple { animation: ${sid}-ripple ${T}s cubic-bezier(.2,.6,.3,1) 1.2s infinite both; }
.${sid}:not([data-kv="off"]) .${sid}-lit { animation: ${sid}-lit ${T}s ease-out var(--d) infinite both; }
.${sid}:not([data-kv="off"]) .${sid}-glow { animation: ${sid}-glow ${T}s ease-out var(--d) infinite both; }
.${sid}:not([data-kv="off"]) .${sid}-line { stroke-dasharray: 1 1; animation: ${sid}-draw .9s var(--ease-kit) var(--e) 1 both; }
.${sid}:not([data-kv="off"]) .${sid}-pop { animation: ${sid}-pop .6s var(--ease-kit) var(--e) 1 both; }`;
const linked = (id: string) => new Set(links.filter(([a, b]) => a === id || b === id).flatMap(([a, b]) => [a, b]));
const near = active ? linked(active) : null;
const delayOf = (dist: number) => round(1.2 + (wave * Math.min(1, dist)) * (1 / Math.max(0.1, speed)), 2);
return (
<div className={cn('relative w-full', className)}>
<div
ref={play.ref}
data-kv={play.state}
role="group"
aria-label={label}
className={cn(sid, '@container relative w-full')}
style={{ aspectRatio: aspect }}
>
<style dangerouslySetInnerHTML={{ __html: css }} />
<span
aria-hidden
className={`${sid}-ripple pointer-events-none absolute left-1/2 top-1/2 aspect-square w-[96%] rounded-full border border-[color-mix(in_oklab,var(--primary)_55%,transparent)] bg-[radial-gradient(closest-side,transparent_70%,color-mix(in_oklab,var(--primary)_12%,transparent))] opacity-0`}
/>
<svg key={play.cycle} viewBox={`0 0 ${VW} ${VH}`} className="absolute inset-0 size-full overflow-visible" aria-hidden>
{links.map(([a, b], i) => {
const p = pos.get(a);
const q = pos.get(b);
if (!p || !q) return null;
const on = near ? near.has(a) && near.has(b) && (a === active || b === active) : false;
const mid = (p.dist + q.dist) / 2;
const d = `M${p.x} ${p.y} L${q.x} ${q.y}`;
return (
<g key={i} style={{ '--e': `${round(0.1 + Math.min(p.r, q.r) * 0.25, 2)}s`, '--d': `${delayOf(mid)}s` } as React.CSSProperties}>
<path
className={`${sid}-line transition-[stroke,opacity] duration-300`}
pathLength={1}
d={d}
fill="none"
stroke={on ? 'var(--primary)' : 'color-mix(in oklab, var(--foreground) 15%, transparent)'}
strokeWidth={on ? 2 : 1.2}
opacity={near && !on ? 0.35 : 1}
vectorEffect="non-scaling-stroke"
/>
<path className={`${sid}-lit`} d={d} fill="none" stroke="var(--primary)" strokeWidth="1.8" opacity="0" vectorEffect="non-scaling-stroke" />
</g>
);
})}
</svg>
{nodes.map((n) => {
const p = pos.get(n.id);
if (!p) return null;
const center = p.r === 0;
const dim = near && !near.has(n.id);
const Tag = interactive ? 'button' : 'div';
return (
<Tag
key={n.id}
{...(interactive ? { type: 'button' as const, onFocus: () => setActive(n.id), onBlur: () => setActive(null), onPointerEnter: () => setActive(n.id), onPointerLeave: () => setActive(null), 'aria-label': n.label } : { 'aria-hidden': true })}
className={cn(`${sid}-pop group/node absolute z-10 rounded-full outline-none transition-opacity duration-300`, dim && 'opacity-40')}
style={{ left: `${round((p.x / VW) * 100, 2)}%`, top: `${round((p.y / VH) * 100, 2)}%`, transform: 'translate(-50%,-50%)', '--e': `${round(0.05 + p.r * 0.22 + rand(seed, p.x) * 0.1, 2)}s` } as React.CSSProperties}
>
<span aria-hidden className={`${sid}-glow pointer-events-none absolute -inset-1.5 rounded-full bg-[color-mix(in_oklab,var(--primary)_28%,transparent)] opacity-0 blur-[3px]`} style={{ '--d': `${delayOf(p.dist)}s` } as React.CSSProperties} />
<span
className={cn(
'relative grid place-items-center overflow-hidden rounded-full border shadow-[var(--highlight),var(--shadow-md)] transition-[transform,box-shadow,border-color] duration-300 ease-[var(--ease-kit)] group-hover/node:scale-110 group-focus-visible/node:scale-110 group-focus-visible/node:shadow-[0_0_0_3px_var(--ring)] [&_svg]:size-[46%]',
center ? 'size-16 border-[color-mix(in_oklab,var(--primary)_50%,var(--border))] bg-primary text-primary-foreground @max-[34rem]:size-12' : p.r === 1 ? 'size-12 border-border bg-card text-foreground @max-[34rem]:size-9' : 'size-9 border-border bg-card text-muted-foreground @max-[34rem]:size-7',
active === n.id && 'border-[var(--primary)]',
)}
>
{n.content ?? <span className="size-2 rounded-full bg-current" />}
</span>
{interactive && (
<span
aria-hidden
className="pointer-events-none absolute left-1/2 top-full z-20 mt-2 -translate-x-1/2 translate-y-1 whitespace-nowrap rounded-md border border-border bg-popover px-2 py-1 text-[11.5px] font-medium text-foreground opacity-0 shadow-[var(--shadow-md)] transition-[opacity,transform] duration-200 group-hover/node:translate-y-0 group-hover/node:opacity-100 group-focus-visible/node:translate-y-0 group-focus-visible/node:opacity-100"
>
{n.label}
</span>
)}
</Tag>
);
})}
</div>
{controls && (
<div className="mt-2 flex justify-end">
<PlayToggle inline state={play.state} reduced={play.reduced} onToggle={play.toggle} />
</div>
)}
</div>
);
}Häufige Fragen
Wie installiere ich Netzwerk-Graph?
Mit aiw add @ai-website/network-graph. Der Befehl legt die Datei components/ui/network-graph.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind motion-scene. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Netzwerk-Graph?
3 Varianten, Menschen, Verbundene Tools und Community.
Kann ein KI-Assistent Netzwerk-Graph einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Netzwerk-Graph mit aiw_components und installieren sie mit aiw_add @ai-website/network-graph.