Zum Inhalt springen
UI Kit
Accent

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

3 Variants

Variants

Menschen

Avatare um eine Mitte, zwei Ringe tief. Die Linien zeichnen sich beim Erscheinen nach außen.

Verbundene Tools

Eine Website in der Mitte und die Tools drumherum als Icons. Innerer und äußerer Ring kennen sich auch.

Community

Dreißig kleine Mitglieder auf dem äußeren Ring, zehn Gastgeber innen. Ruhig genug für den Platz hinter einer Headline.

Details

Was Netzwerk-Graph mitbringt, aus Registry und Quellcode gelesen.

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

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.

  • nodesPflicht
    GraphNode[]
  • linksPflicht
    [string, string][]

    Pairs of node ids

  • aspectoptional
    string

    Scene ratio, width / height. Default "16 / 10".

  • intervaloptional
    number

    Seconds between two signals at speed 1. Default 3.6.

  • seedoptional
    number

    Seed for the small irregularities of the layout

  • interactiveoptional
    boolean

    Nodes take focus and show their links. Default true.

  • labeloptional
    string

    Accessible 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
components/ui/network-graph.tsx
'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.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert