Zum Inhalt springen
UI Kit
Accent

Netzdiagramm

Mehrere Messgrößen einer Sache auf Speichen um eine Mitte, als Profil, als Vergleich, mit verknüpfter Liste, als kleine Vielfache auf einer Skala oder gegen ein Ziel. Pfeiltasten gehen die Speichen entlang.

Eine von 4 Components in Statistik-Charts

5 Variants

Variants

Profil

Eine Form auf Ringen ab null, jede Speiche antwortet dem Zeiger mit ihrem Wert.

Im Studio öffnen

Vergleich

Zwei Formen auf einem Netz. Ein Eintrag der Legende holt seine Form nach vorn, der Tooltip nennt beide Werte je Speiche.

Im Studio öffnen

Mit Liste

Form und eine Liste der Speichen mit Balken nebeneinander, auf dem Telefon untereinander. Ein Zeiger auf einer Seite hebt dieselbe Speiche hervor.

Im Studio öffnen

Kleine Vielfache

Ein kleines Netz je Eintrag auf derselben Skala, vorn ein Schlüssel mit den Namen der Speichen, neben jedem Namen der Schnitt.

Im Studio öffnen

Gegen ein Ziel

Die Form gegen eine gestrichelte Zielkontur. Speichen unter dem Ziel bekommen einen hohlen Punkt, die Anzahl steht darüber.

Im Studio öffnen

Details

Was Netzdiagramm mitbringt, aus Registry und Quellcode gelesen.

Bereich
Elements
Variants
5
Version
1.0.0 · seit 1.1.0
Datei
components/ui/radar-chart.tsx
Bringt mit
utilskitchartchart-figure
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 6 eigene Texte

Props

Die Schnittstelle RadarChartProps aus components/ui/radar-chart.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch. Nimmt außerdem alle Props von Omit<ChartA11y, 'label'>, ChartFigureProps an.

  • labeloptional
    string

    Accessible name of the chart. Default: the title.

  • variantoptional
    RadarVariant

    profilecomparewith-listmultiplestarget

  • axesPflicht
    string[]

    Names of the spokes, clockwise from twelve o'clock. Five to eight work best.

  • seriesPflicht
    RadarSeries[]

    profile, with-list, target: the first series. compare: two. multiples: one per small chart.

  • targetoptional
    number[]

    target: one target value per axis

  • maxoptional
    number

    Top of the scale. Default: a round value above the largest value.

  • formatoptional
    Intl.NumberFormatOptions

    Value format, e.g. { maximumFractionDigits: 1 }

  • targetLabeloptional
    string

    Name of the target outline in legend and tooltip

Eingebaute Texte

Diese Texte schreibt Netzdiagramm selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 6 Texte anzeigen
avgEnglisch avg. {v}Deutsch Ø {v}
targetEnglisch TargetDeutsch Ziel
keyEnglisch How to readDeutsch So lesen
ofEnglisch of {max}Deutsch von {max}
axisEnglisch AxisDeutsch Achse
gapEnglisch {n} of {total} below targetDeutsch {n} von {total} unter Ziel

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Netzdiagramm reagiert selbst auf die Tasten Escape, Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts, Pos1 und Ende.
  • Setzt aria-label und aria-pressed, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rolle list.
  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • Kommt ohne Client-State aus. Der Server schreibt sie vollständig, sie ist auch ohne JavaScript lesbar.

Bewegung

  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 1 Stellen im Code schalten Bewegung dann ab.
  • Ohne Animationen, sie steht sofort ruhig da.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 402 Zeilen
components/ui/radar-chart.tsx
'use client';

import { useState } from 'react';
import { cn, round, useTexts } from '@/lib/kit';
import { ChartRoot, ChartSurface, ChartTable, ChartTooltip, Swatch, fill, niceTicks, numberFormatter, polar, textWidth, useEntrance, useIntlLocale, useShown, useWidth, type ChartA11y, type TooltipRow } from '@/components/ui/chart';
import { ChartFigure, HALO, PickLegend, type ChartFigureProps } from '@/components/ui/chart-figure';

/*
 * Several measures of one thing on spokes around a center.
 *   profile    one shape, every spoke readable
 *   compare    two shapes, the legend brings one forward
 *   with-list  the shape and a list of the spokes with bars, both linked
 *   multiples  one small shape per item on the same scale, a key in front
 *   target     the shape against a target outline, misses marked
 *
 * All spokes share one scale from zero, so shapes stay comparable. The
 * pointer picks the nearest spoke by angle, arrow keys step around.
 * Readable without the shape: every value is in the table and the list.
 */

export type RadarVariant = 'profile' | 'compare' | 'with-list' | 'multiples' | 'target';

export interface RadarSeries {
  label: string;
  /** One value per axis, in axis order */
  values: number[];
}

export interface RadarChartProps extends Omit<ChartA11y, 'label'>, ChartFigureProps {
  /** Accessible name of the chart. Default: the title. */
  label?: string;
  variant?: RadarVariant;
  /** Names of the spokes, clockwise from twelve o'clock. Five to eight work best. */
  axes: string[];
  /** profile, with-list, target: the first series. compare: two. multiples: one per small chart. */
  series: RadarSeries[];
  /** target: one target value per axis */
  target?: number[];
  /** Top of the scale. Default: a round value above the largest value. */
  max?: number;
  /** Value format, e.g. { maximumFractionDigits: 1 } */
  format?: Intl.NumberFormatOptions;
  /** Name of the target outline in legend and tooltip */
  targetLabel?: string;
  className?: string;
}

const TEXTS = {
  en: { avg: 'avg. {v}', target: 'Target', key: 'How to read', of: 'of {max}', axis: 'Axis', gap: '{n} of {total} below target' },
  de: { avg: 'Ø {v}', target: 'Ziel', key: 'So lesen', of: 'von {max}', axis: 'Achse', gap: '{n} von {total} unter Ziel' },
};

const shapePath = (cx: number, cy: number, r: number, values: number[], max: number) =>
  values.map((v, i) => polar(cx, cy, (Math.max(0, Math.min(v, max)) / max) * r, (360 * i) / values.length)).map(([x, y], i) => `${i ? 'L' : 'M'}${x} ${y}`).join('') + 'Z';

const ringPath = (cx: number, cy: number, r: number, n: number) =>
  Array.from({ length: n }, (_, i) => polar(cx, cy, r, (360 * i) / n)).map(([x, y], i) => `${i ? 'L' : 'M'}${x} ${y}`).join('') + 'Z';

const SERIES_COLOR = ['var(--kitch-1)', 'var(--kitch-2)'];

/** Breaks a spoke label into lines no wider than maxW */
function wrap(text: string, maxW: number, size: number) {
  // Breaks at spaces and after hyphens (Preis-Leistung)
  const words = text.split(/ |(?<=-)/);
  const lines: string[] = [];
  let cur = '';
  for (const w of words) {
    const next = cur ? (cur.endsWith('-') ? cur + w : `${cur} ${w}`) : w;
    if (cur && textWidth(next, size) > maxW) {
      lines.push(cur);
      cur = w;
    } else cur = next;
  }
  if (cur) lines.push(cur);
  return lines;
}

interface RadarGeometry {
  width: number;
  height: number;
  cx: number;
  cy: number;
  r: number;
  font: number;
  lines: string[][];
}

/** Lays the web into a box of the given width: labels wrap, the radius takes what is left */
function geometry(axes: string[], width: number, { font = 12, maxR = 170, extraLine = false, minR = 48 } = {}): RadarGeometry {
  const n = axes.length;
  const maxW = width < 420 ? 76 : 120;
  const lines = axes.map((a) => (a ? wrap(a, maxW, font) : []));
  const lh = font + 3;
  let side = 0;
  let topPad = 10;
  let bottomPad = 10;
  axes.forEach((_, i) => {
    if (!lines[i].length) return;
    const deg = (360 * i) / n;
    const h = (lines[i].length + (extraLine ? 1 : 0)) * lh;
    const w = Math.max(...lines[i].map((l) => textWidth(l, font)));
    if (deg === 0) topPad = Math.max(topPad, h + 14);
    else if (deg === 180) bottomPad = Math.max(bottomPad, h + 14);
    else {
      side = Math.max(side, w + 16);
      // Labels near the top and bottom also need vertical room
      const c = Math.cos((deg * Math.PI) / 180);
      if (Math.abs(c) > 0.5) {
        if (c > 0) topPad = Math.max(topPad, h / 2 + 8);
        else bottomPad = Math.max(bottomPad, h / 2 + 8);
      }
    }
  });
  const r = Math.max(minR, Math.min(maxR, (width - 2 * side) / 2));
  return { width, height: Math.round(topPad + 2 * r + bottomPad), cx: Math.round(width / 2), cy: Math.round(topPad + r), r: Math.round(r), font, lines };
}

interface PlotProps {
  g: RadarGeometry;
  axes: string[];
  series: RadarSeries[];
  target?: number[];
  max: number;
  rings: number[];
  labels: boolean;
  active: number | null;
  focus: number | null;
  anim: boolean;
  fmt: (n: number) => string;
  ringLabels?: boolean;
  /** Value of the first series under each spoke label */
  values?: boolean;
  /** Only the web with its labels (key of the multiples) */
  bare?: boolean;
}

/** The web, rings, spokes, shapes and labels as one SVG */
function RadarPlot({ g, axes, series, target, max, rings, labels, active, focus, anim, fmt, ringLabels, values, bare }: PlotProps) {
  const n = axes.length;
  const { cx, cy, r } = g;
  const delay = (ms: number) => (anim ? ({ '--kitch-d': `${ms}ms` } as React.CSSProperties) : undefined);
  const lh = g.font + 3;
  // Ring values sit between the first two spokes, where shapes cross least
  const ringDeg = 180 / n;
  return (
    <svg width="100%" height={g.height} viewBox={`0 0 ${g.width} ${g.height}`} aria-hidden className="block overflow-visible">
      <g className={anim ? 'kitch-fade' : undefined}>
        {rings.map((v) => (
          <path key={v} d={ringPath(cx, cy, (v / max) * r, n)} fill="none" stroke={v === max ? 'var(--kitch-axis)' : 'var(--kitch-grid)'} strokeWidth="1" />
        ))}
        {axes.map((_, i) => {
          const [x, y] = polar(cx, cy, r, (360 * i) / n);
          return <line key={i} x1={cx} y1={cy} x2={x} y2={y} stroke={active === i ? 'var(--kitch-axis)' : 'var(--kitch-grid)'} strokeWidth={active === i ? 1.5 : 1} className="transition-[stroke] duration-200" />;
        })}
      </g>
      {target && !bare && (
        <path d={shapePath(cx, cy, r, target, max)} fill="none" stroke="var(--foreground)" strokeOpacity="0.7" strokeWidth="1.5" strokeDasharray="4 4" strokeLinejoin="round" className={anim ? 'kitch-fade' : undefined} style={delay(500)} />
      )}
      {!bare &&
        [...series.keys()].reverse().map((si) => {
          const s = series[si];
          const color = SERIES_COLOR[Math.min(si, 1)];
          const dim = focus !== null && focus !== si;
          return (
            <g key={si} className="kitch-series" opacity={dim ? 0.18 : 1}>
              <g className={anim ? 'kitch-pop' : undefined} style={{ ...delay(140 + si * 180), transformOrigin: `${cx}px ${cy}px`, transformBox: 'view-box' }}>
                <path d={shapePath(cx, cy, r, s.values, max)} fill={color} fillOpacity={si === 0 ? 0.16 : 0.1} stroke={color} strokeWidth="2" strokeLinejoin="round" />
              </g>
              {s.values.map((v, i) => {
                const [x, y] = polar(cx, cy, (Math.max(0, Math.min(v, max)) / max) * r, (360 * i) / n);
                const miss = target && si === 0 && v < target[i];
                return (
                  <circle key={i} cx={x} cy={y} r={active === i ? 5.5 : 4} fill={miss ? 'var(--kitch-surface)' : color} stroke={miss ? color : 'var(--kitch-surface)'} strokeWidth="2" className={cn('transition-[r] duration-200', anim && 'kitch-pop')} style={delay(500 + i * 50)} />
                );
              })}
            </g>
          );
        })}
      {ringLabels &&
        r >= 90 &&
        rings.map((v) => {
          const [x, y] = polar(cx, cy, (v / max) * r, ringDeg);
          return (
            <text key={v} x={x + 4} y={y} dy="0.34em" className="text-[10px] tabular-nums" fill="var(--muted-foreground)" style={HALO}>
              {fmt(v)}
            </text>
          );
        })}
      {labels &&
        axes.map((_, i) => {
          const deg = (360 * i) / n;
          const [x, y] = polar(cx, cy, r + 10, deg);
          const anchor = deg === 0 || deg === 180 ? 'middle' : deg < 180 ? 'start' : 'end';
          const lines = g.lines[i];
          const count = lines.length + (values ? 1 : 0);
          // Block of lines: above the top spoke, below the bottom one, centered beside the others
          const y0 = deg === 0 ? y - (count - 1) * lh - 4 : deg === 180 ? y + lh - 2 : y - ((count - 1) * lh) / 2;
          return (
            <text key={i} x={x} y={y0} dy="0.34em" textAnchor={anchor} style={{ fontSize: g.font }} className={cn('transition-[fill] duration-200', active === i ? 'fill-foreground' : 'fill-muted-foreground')}>
              {lines.map((l, k) => (
                <tspan key={k} x={x} dy={k ? lh : 0}>
                  {l}
                </tspan>
              ))}
              {values && series[0] && (
                <tspan x={x} dy={lh} className="fill-foreground font-semibold tabular-nums">
                  {fmt(series[0].values[i] ?? 0)}
                </tspan>
              )}
            </text>
          );
        })}
    </svg>
  );
}

/**
 * Spokes around a center: one profile, a comparison, a list beside it, small multiples or against a target.
 *   <RadarChart variant="profile" axes={['Cleanliness', 'Location', …]} series={[{ label: 'Guests', values: [9.1, 8.4, …] }]} max={10} />
 */
export function RadarChart(props: RadarChartProps) {
  const { variant = 'profile', axes, series, target, format, targetLabel, label, summary, labels, animate = true, title, value, delta, meta, source, framed, className } = props;
  const t = useTexts(TEXTS, undefined);
  const intl = useIntlLocale();
  const fmt = numberFormatter(intl, format);
  const [wrapRef, width] = useWidth<HTMLDivElement>(560);
  const [rootRef, anim] = useEntrance<HTMLDivElement>(animate);
  const [active, setActive] = useState<number | null>(null);
  const { shown, open, jump } = useShown(active);
  const [focus, setFocus] = useState<number | null>(null);

  const n = axes.length;
  const all = series.flatMap((s) => s.values).concat(target ?? []);
  const nice = niceTicks(0, props.max ?? Math.max(1, ...all), 5);
  const max = props.max ?? nice.max;
  const rings = props.max ? niceTicks(0, props.max, 5).ticks.filter((v) => v > 0 && v <= props.max!) : nice.ticks.filter((v) => v > 0);
  const name = label ?? title ?? axes.join(', ');
  const shownSeries = variant === 'compare' ? series.slice(0, 2) : series.slice(0, 1);

  const listBeside = variant === 'with-list' && width >= 620;
  const avail = Math.round(listBeside ? (width - 32) * 0.52 : width);
  const showValues = variant === 'profile' || variant === 'target';
  const g = geometry(axes, avail, { font: avail < 420 ? 11 : 12, extraLine: showValues });

  const rows = (i: number): TooltipRow[] => {
    const out: TooltipRow[] = shownSeries.map((s, si) => ({ key: s.label, label: s.label, value: fmt(s.values[i] ?? 0), color: SERIES_COLOR[Math.min(si, 1)], shape: 'line' }));
    if (target) out.push({ key: '__target', label: targetLabel ?? t.target, value: fmt(target[i] ?? 0), color: 'var(--foreground)', shape: 'line', dashed: true });
    return out;
  };

  const pick = (clientX: number, clientY: number, el: HTMLElement) => {
    const svg = el.querySelector('svg');
    if (!svg) return;
    const b = svg.getBoundingClientRect();
    const k = g.width / (b.width || g.width);
    const dx = (clientX - b.left) * k - g.cx;
    const dy = (clientY - b.top) * k - g.cy;
    if (Math.hypot(dx, dy) < 6) return;
    const deg = ((Math.atan2(dy, dx) * 180) / Math.PI + 90 + 360) % 360;
    setActive(Math.round(deg / (360 / n)) % n);
  };
  const onKeyDown = (e: React.KeyboardEvent) => {
    const cur = active ?? -1;
    let next: number | null = cur;
    if (e.key === 'ArrowRight' || e.key === 'ArrowDown') next = (cur + 1) % n;
    else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') next = cur < 0 ? n - 1 : (cur - 1 + n) % n;
    else if (e.key === 'Home') next = 0;
    else if (e.key === 'End') next = n - 1;
    else if (e.key === 'Escape') next = null;
    else return;
    e.preventDefault();
    setActive(next);
  };

  const auto = shownSeries.map((s) => `${s.label}: ${axes.map((a, i) => `${a} ${fmt(s.values[i] ?? 0)}`).join(', ')}`).join('. ');
  const live = active !== null ? `${axes[active]}. ${rows(active).map((r) => `${r.label} ${r.value}`).join(', ')}` : '';
  const table = <ChartTable caption={name} head={[t.axis, ...shownSeries.map((s) => s.label), ...(target ? [targetLabel ?? t.target] : [])]} rows={axes.map((a, i) => [a, ...shownSeries.map((s) => fmt(s.values[i] ?? 0)), ...(target ? [fmt(target[i] ?? 0)] : [])])} />;

  // Tooltip anchored at the outer end of the active spoke
  const tip = shown !== null ? polar(g.cx, g.cy, g.r, (360 * shown) / n) : ([0, 0] as [number, number]);

  const misses = target ? axes.filter((_, i) => (series[0]?.values[i] ?? 0) < target[i]).length : 0;

  /* ---------- multiples: a key and one small web per series ---------- */
  if (variant === 'multiples') {
    // The key spans two cells, so key plus series fill whole rows more often
    const cols = width >= 900 ? 6 : width >= 480 ? 3 : 2;
    const cell = Math.floor((width - (cols - 1) * 12) / cols) - 26;
    const keyG = geometry(axes, Math.min(2 * cell + 36, 420), { font: 11, maxR: 64, minR: 34 });
    const smallBase = geometry(axes.map(() => ''), cell, { maxR: 72, minR: 36 });
    const smallG = { ...smallBase, width: cell, height: Math.round(2 * smallBase.r + 16), cy: Math.round(smallBase.r + 8) };
    return (
      <ChartFigure title={title} value={value} delta={delta} meta={meta} source={source} framed={framed} className={className}>
        <ChartRoot anim={anim} rootRef={rootRef}>
          <div ref={wrapRef}>
            <ul role="list" aria-label={name} className="grid gap-3" style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` }}>
              <li className="col-span-2 flex flex-col rounded-lg border border-dashed border-border-strong px-3 pb-3 pt-2.5">
                <p className="text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">{t.key}</p>
                <div className="flex flex-1 items-center justify-center">
                  <div className="w-full" style={{ maxWidth: keyG.width }}>
                    <RadarPlot g={keyG} axes={axes} series={[]} max={max} rings={rings} labels active={null} focus={null} anim={false} fmt={fmt} bare />
                  </div>
                </div>
              </li>
              {series.map((s, si) => {
                const avg = s.values.reduce((a, v) => a + v, 0) / Math.max(1, s.values.length);
                return (
                  <li key={s.label} className={cn('group flex flex-col rounded-lg border border-border bg-card px-3 pb-3 pt-2.5 transition-[border-color,box-shadow] duration-300 hover:border-border-strong hover:shadow-[var(--shadow-sm)]', anim && 'kitch-rise')} style={anim ? ({ '--kitch-d': `${120 + si * 90}ms` } as React.CSSProperties) : undefined}>
                    {/* Two columns on phones: name and average stacked, so no name gets cut */}
                    <p className={cn('flex w-full text-[13px]', cols === 2 ? 'flex-col gap-0.5' : 'items-baseline justify-between gap-2')}>
                      <span className="min-w-0 truncate font-medium text-foreground">{s.label}</span>
                      <span className="whitespace-nowrap tabular-nums text-muted-foreground">{fill(t.avg, { v: fmt(round(avg, 1)) })}</span>
                    </p>
                    <div className="mt-2 transition-transform duration-500 ease-[var(--ease-kit)] group-hover:scale-[1.04] motion-reduce:transition-none">
                      <RadarPlot g={smallG} axes={axes} series={[s]} max={max} rings={rings} labels={false} active={null} focus={null} anim={!!anim} fmt={fmt} />
                    </div>
                    <span className="sr-only">{axes.map((a, i) => `${a} ${fmt(s.values[i] ?? 0)}`).join(', ')}</span>
                  </li>
                );
              })}
            </ul>
          </div>
          <ChartTable caption={name} head={[t.axis, ...series.map((s) => s.label)]} rows={axes.map((a, i) => [a, ...series.map((s) => fmt(s.values[i] ?? 0))])} />
        </ChartRoot>
      </ChartFigure>
    );
  }

  const legendItems =
    variant === 'compare'
      ? shownSeries.map((s, i) => ({ label: s.label, key: <Swatch shape="line" color={SERIES_COLOR[i]} /> }))
      : variant === 'target'
        ? [
            { label: series[0]?.label ?? '', key: <Swatch shape="line" color={SERIES_COLOR[0]} /> },
            { label: targetLabel ?? t.target, key: <Swatch shape="line" color="var(--foreground)" dashed /> },
          ]
        : [];

  return (
    <ChartFigure title={title} value={value} delta={delta} meta={meta} source={source} framed={framed} className={className}>
      <ChartRoot anim={anim} rootRef={rootRef}>
        {legendItems.length > 0 && (
          <div className="mb-3 flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
            <PickLegend active={variant === 'compare' ? focus : null} onActive={variant === 'compare' ? setFocus : () => {}} items={legendItems} />
            {variant === 'target' && <span className="text-[12px] text-muted-foreground">{fill(t.gap, { n: String(misses), total: String(n) })}</span>}
          </div>
        )}
        <div ref={wrapRef} className={cn(listBeside && 'grid grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)] items-center gap-8')}>
          <div>
            <ChartSurface
              label={name}
              summary={summary ?? auto}
              labels={labels}
              live={live}
              table={table}
              onKeyDown={onKeyDown}
              onFocus={(e) => e.target === e.currentTarget && active === null && e.currentTarget.matches(':focus-visible') && setActive(0)}
              onBlur={() => setActive(null)}
              onPointerMove={(e) => pick(e.clientX, e.clientY, e.currentTarget)}
              onPointerDown={(e) => pick(e.clientX, e.clientY, e.currentTarget)}
              onPointerLeave={(e) => e.pointerType === 'mouse' && setActive(null)}
              className="mx-auto"
              style={{ maxWidth: g.width }}
            >
              <RadarPlot g={g} axes={axes} series={shownSeries} target={variant === 'target' ? target : undefined} max={max} rings={rings} labels active={active} focus={focus} anim={!!anim} fmt={fmt} ringLabels values={showValues} />
              <ChartTooltip open={open} jump={jump} x={tip[0]} y={Math.max(0, tip[1] - 30)} width={g.width} offset={14} title={shown !== null ? axes[shown] : ''} rows={shown !== null ? rows(shown) : []} />
            </ChartSurface>
          </div>
          {variant === 'with-list' && (
            <ul className={cn('grid gap-1', !listBeside && 'mt-6')}>
              {axes.map((a, i) => {
                const v = series[0]?.values[i] ?? 0;
                return (
                  <li key={a}>
                    <button
                      type="button"
                      aria-pressed={active === i}
                      onPointerEnter={(e) => e.pointerType === 'mouse' && setActive(i)}
                      onPointerLeave={(e) => e.pointerType === 'mouse' && setActive(null)}
                      onFocus={() => setActive(i)}
                      onBlur={() => setActive(null)}
                      onClick={() => setActive(active === i ? null : i)}
                      className={cn('grid min-h-11 w-full grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 gap-y-1.5 rounded-md px-2 py-1.5 text-left text-[13px] transition-colors duration-200', active === i ? 'bg-accent' : 'hover:bg-accent/60')}
                    >
                      <span className={cn('truncate', active === i ? 'text-foreground' : 'text-muted-foreground')}>{a}</span>
                      <span className="font-medium tabular-nums text-foreground">
                        {fmt(v)} <span className="font-normal text-muted-foreground">{fill(t.of, { max: fmt(max) })}</span>
                      </span>
                      <span aria-hidden className="col-span-2 block h-1.5 overflow-hidden rounded-full bg-track">
                        <span className={cn('block h-full origin-left rounded-full bg-primary', anim && 'kitch-grow-x')} style={{ width: `${round((Math.min(v, max) / max) * 100, 1)}%`, ...(anim ? ({ '--kitch-d': `${200 + i * 70}ms` } as React.CSSProperties) : {}) }} />
                      </span>
                    </button>
                  </li>
                );
              })}
            </ul>
          )}
        </div>
      </ChartRoot>
    </ChartFigure>
  );
}

Häufige Fragen

Wie installiere ich Netzdiagramm?

Mit aiw add @ai-website/radar-chart. Der Befehl legt die Datei components/ui/radar-chart.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind chart und chart-figure. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Netzdiagramm?

5 Varianten, Profil, Vergleich, Mit Liste, Kleine Vielfache und Gegen ein Ziel. Du wählst sie mit der Prop variant, zum Beispiel variant="compare".

Kann ich die Texte von Netzdiagramm übersetzen?

Ja. Netzdiagramm bringt 6 eigene Texte auf Deutsch und Englisch mit, etwa „Ziel“, „So lesen“ und „Achse“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Netzdiagramm einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Netzdiagramm mit aiw_components und installieren sie mit aiw_add @ai-website/radar-chart. Nenn im Prompt die Variante, etwa variant="profile", dann schreibt der Assistent deine Inhalte direkt in die Props.

Kann ich Netzdiagramm ohne Code anpassen?

Ja, im Studio. Alle 5 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden RadarChart-Code zum Kopieren.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert