Zum Inhalt springen
UI Kit
Accent

Heatmap

Werte als getönte Zellen in fünf Akzentstufen, als Kalender aus Wochen oder beschriftete Matrix. Pfeiltasten wandern durch die Zellen.

Eine von 7 Components in Charts

2 Variants

Variants

Kalender

Ein halbes Jahr in Wochen, Monate oben, Wochentage an der Seite. Auf dem Telefon bleiben die jüngsten Wochen.

Matrix

Wochentage mal Öffnungszeiten, Beschriftung an beiden Achsen, ein Tooltip je Zelle.

Details

Was Heatmap mitbringt, aus Registry und Quellcode gelesen.

Kategorie
Charts
Bereich
Elements
Variants
2
Version
1.0.0 · seit 1.0.0
Datei
components/ui/heatmap.tsx
Bringt mit
utilskitchart
npm-Pakete
Keine
Sprachen
Texte kommen über Props
Studio
Im Code anpassen

Props

Die Schnittstelle HeatmapProps aus components/ui/heatmap.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch. Nimmt außerdem alle Props von ChartA11y an.

  • variantoptional
    'calendar' | 'matrix'

    calendar: weeks by weekdays from dated values. matrix: any rows by columns.

  • daysoptional
    HeatmapDay[]

    calendar: one entry per day. Missing days count as empty.

  • weekStartoptional
    0 | 1

    calendar: first day of the week, 1 Monday, 0 Sunday. Default: Monday in German, Sunday in English.

  • rowsoptional
    string[]

    matrix: row labels, e.g. weekdays

  • columnsoptional
    string[]

    matrix: column labels, e.g. hours

  • valuesoptional
    number[][]

    matrix: values[row][column]

  • formatoptional
    Intl.NumberFormatOptions

    Value format in tooltip and table

  • maxoptional
    number

    Upper end of the scale. Default: the largest value.

  • rowLabeloptional
    string

    Header of the first table column (matrix), e.g. "Day"

  • legendoptional
    boolean

    Scale from less to more under the grid

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Heatmap reagiert selbst auf die Tasten Escape, Pos1 und Ende.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

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, 280 Zeilen
components/ui/heatmap.tsx
'use client';

import { useEffect, useRef, useState } from 'react';
import { cn, round, useTexts } from '@/lib/kit';
import { CHART_TEXTS, ChartRoot, ChartSurface, ChartTable, ChartTooltip, numberFormatter, parseDate, rampColor, textWidth, useEntrance, useIntlLocale, useWidth, type ChartA11y } from '@/components/ui/chart';

export interface HeatmapDay {
  /** "2026-09-14" */
  date: string;
  value: number;
}

export interface HeatmapProps extends ChartA11y {
  /** calendar: weeks by weekdays from dated values. matrix: any rows by columns. */
  variant?: 'calendar' | 'matrix';
  /** calendar: one entry per day. Missing days count as empty. */
  days?: HeatmapDay[];
  /** calendar: first day of the week, 1 Monday, 0 Sunday. Default: Monday in German, Sunday in English. */
  weekStart?: 0 | 1;
  /** matrix: row labels, e.g. weekdays */
  rows?: string[];
  /** matrix: column labels, e.g. hours */
  columns?: string[];
  /** matrix: values[row][column] */
  values?: number[][];
  /** Value format in tooltip and table */
  format?: Intl.NumberFormatOptions;
  /** Upper end of the scale. Default: the largest value. */
  max?: number;
  /** Header of the first table column (matrix), e.g. "Day" */
  rowLabel?: string;
  /** Scale from less to more under the grid */
  legend?: boolean;
  className?: string;
}

const GAP = 3;

/**
 * Values as a grid of tinted cells: a calendar of weeks, or a labelled matrix.
 *   <Heatmap variant="calendar" label="Bookings per day" days={[{ date: '2026-09-14', value: 6 }, …]} />
 *   <Heatmap variant="matrix" label="Guests by hour" rows={['Mon', …]} columns={['8', …]} values={[[…], …]} />
 */
export function Heatmap({ variant = 'calendar', days = [], weekStart, rows = [], columns = [], values = [], format, max, rowLabel, legend = true, label, summary, labels, animate = true, className }: HeatmapProps) {
  const intl = useIntlLocale();
  const t = useTexts(CHART_TEXTS, labels);
  const fmt = numberFormatter(intl, format);
  const [wrapRef, width] = useWidth<HTMLDivElement>(640);
  const [rootRef, anim] = useEntrance<HTMLDivElement>(animate);
  const [active, setActive] = useState<{ r: number; c: number } | null>(null);
  // The ring and the tooltip keep the last cell while they fade, and glide from cell to cell
  const lastRef = useRef<{ r: number; c: number } | null>(null);
  const wasOpen = useRef(false);
  if (active) lastRef.current = active;
  const jump = !wasOpen.current;
  useEffect(() => {
    wasOpen.current = active !== null;
  });

  // Build one grid for both variants: cells[r][c] with a value (or null), a title and a column label
  type Cell = { v: number | null; title: string } | null;
  let grid: Cell[][] = [];
  let rowNames: string[] = [];
  let colNames: string[] = [];
  let colMarks: (string | null)[] = [];
  let tableHead: string[] = [];
  let tableRows: string[][] = [];

  if (variant === 'calendar') {
    const start = weekStart ?? (intl === 'de-DE' ? 1 : 0);
    const byDate = new Map(days.map((d) => [d.date, d.value]));
    const dates = days.map((d) => parseDate(d.date)).filter((d): d is Date => !!d).sort((a, b) => a.getTime() - b.getTime());
    const first = dates[0] ?? new Date(Date.UTC(2026, 0, 1));
    const last = dates[dates.length - 1] ?? first;
    const offset = (first.getUTCDay() - start + 7) % 7;
    const origin = new Date(first.getTime() - offset * 864e5);
    const weeks = Math.max(1, Math.ceil(((last.getTime() - origin.getTime()) / 864e5 + 1) / 7));
    const long = new Intl.DateTimeFormat(intl, { timeZone: 'UTC', weekday: 'short', day: 'numeric', month: 'long' });
    const monthF = new Intl.DateTimeFormat(intl, { timeZone: 'UTC', month: 'short' });
    const dayF = new Intl.DateTimeFormat(intl, { timeZone: 'UTC', weekday: 'short' });
    // Label Monday, Wednesday and Friday, whatever day the week starts with
    rowNames = Array.from({ length: 7 }, (_, r) => ([1, 3, 5].includes((r + start) % 7) ? dayF.format(new Date(origin.getTime() + r * 864e5)) : ''));
    grid = Array.from({ length: 7 }, (_, r) =>
      Array.from({ length: weeks }, (_, c) => {
        const d = new Date(origin.getTime() + (c * 7 + r) * 864e5);
        if (d < first || d > last) return null;
        const key = d.toISOString().slice(0, 10);
        return { v: byDate.get(key) ?? null, title: long.format(d) };
      }),
    );
    colMarks = Array.from({ length: weeks }, (_, c) => {
      const d = new Date(origin.getTime() + c * 7 * 864e5);
      const top = new Date(Math.max(d.getTime(), first.getTime()));
      return c === 0 || top.getUTCDate() <= 7 ? monthF.format(top) : null;
    });
    colNames = colMarks.map((m) => m ?? '');
    tableHead = ['', label];
    tableRows = days.map((d) => [long.format(parseDate(d.date)!), fmt(d.value)]);
  } else {
    rowNames = rows;
    colNames = columns;
    colMarks = columns;
    grid = rows.map((_, r) => columns.map((col, c) => ({ v: values[r]?.[c] ?? null, title: `${rows[r]}, ${col}` })));
    tableHead = [rowLabel ?? '', ...columns];
    tableRows = rows.map((row, r) => [row, ...columns.map((_, c) => (typeof values[r]?.[c] === 'number' ? fmt(values[r][c]) : t.noData))]);
  }

  const nRows = grid.length;
  const nCols = grid[0]?.length ?? 0;
  const all = grid.flat().map((c) => c?.v ?? 0);
  const top = max ?? Math.max(1, ...all);
  const step = (v: number | null) => (v === null || v <= 0 ? -1 : Math.min(4, Math.max(0, Math.ceil((v / top) * 5) - 1)));

  // Layout
  const left = Math.max(...rowNames.map((s) => textWidth(s, 11)), 0) + (rowNames.some(Boolean) ? 10 : 0);
  const headH = 20;
  let cellW: number;
  let cellH: number;
  let firstCol = 0;
  if (variant === 'calendar') {
    const fit = Math.floor((width - left + GAP) / Math.max(1, nCols)) - GAP;
    cellW = Math.min(26, fit);
    // Too narrow for every week: keep the most recent weeks that fit at 10 px
    if (cellW < 10) {
      cellW = 10;
      firstCol = Math.max(0, nCols - Math.floor((width - left + GAP) / (cellW + GAP)));
    }
    cellH = cellW;
  } else {
    cellW = (width - left + GAP) / Math.max(1, nCols) - GAP;
    cellH = Math.max(18, Math.min(28, round(cellW * 0.7, 0)));
  }
  const visibleCols = nCols - firstCol;
  const height = headH + nRows * (cellH + GAP) - GAP;
  const cx = (c: number) => round(left + (c - firstCol) * (cellW + GAP));
  const cy = (r: number) => round(headH + r * (cellH + GAP));
  const markEvery = variant === 'matrix' ? Math.max(1, Math.ceil((Math.max(...colNames.map((s) => textWidth(s, 11)), 1) + 6) / (cellW + GAP))) : 1;

  // Cells enter as a diagonal wave from the top left corner (kitch-cell below)
  // Cells in reading order of time: column by column (weeks), then row by row
  const ordered = () => {
    const out: { r: number; c: number }[] = [];
    for (let c = firstCol; c < nCols; c++) for (let r = 0; r < nRows; r++) if (grid[r][c]) out.push({ r, c });
    return out;
  };
  const valid = (r: number, c: number) => r >= 0 && r < nRows && c >= firstCol && c < nCols && grid[r][c] !== null;
  const onKeyDown = (e: React.KeyboardEvent) => {
    const cur = active ?? { r: 0, c: firstCol };
    const moves: Record<string, [number, number]> = { ArrowRight: [0, 1], ArrowLeft: [0, -1], ArrowDown: [1, 0], ArrowUp: [-1, 0] };
    if (e.key === 'Escape') return setActive(null);
    if (e.key === 'Home' || e.key === 'End') {
      e.preventDefault();
      const flat = ordered();
      setActive(e.key === 'Home' ? flat[0] : flat[flat.length - 1]);
      return;
    }
    const m = moves[e.key];
    if (!m) return;
    e.preventDefault();
    if (!active) {
      const firstValid = ordered()[0];
      return setActive(firstValid ?? null);
    }
    let r = cur.r + m[0];
    let c = cur.c + m[1];
    // Skip the gaps at the start and end of a calendar
    while (r >= 0 && r < nRows && c >= firstCol && c < nCols && !valid(r, c)) {
      r += m[0];
      c += m[1];
    }
    if (valid(r, c)) setActive({ r, c });
  };
  const pick = (e: React.PointerEvent<HTMLDivElement>) => {
    const box = e.currentTarget.getBoundingClientRect();
    const px = ((e.clientX - box.left) / (box.width || width)) * width;
    const py = e.clientY - box.top;
    const c = Math.floor((px - left) / (cellW + GAP)) + firstCol;
    const r = Math.floor((py - headH) / (cellH + GAP));
    setActive(valid(r, c) ? { r, c } : null);
  };

  const act = active ? grid[active.r][active.c] : null;
  const shown = lastRef.current && grid[lastRef.current.r]?.[lastRef.current.c] && lastRef.current.c >= firstCol ? lastRef.current : null;
  const shownCell = shown ? grid[shown.r][shown.c] : null;
  const live = act ? `${act.title}, ${act.v === null ? t.noData : fmt(act.v)}` : '';
  const total = all.reduce((a, b) => a + b, 0);
  const peak = grid.flatMap((row) => row).reduce<Cell>((best, cell) => (cell && cell.v !== null && (!best || (best.v ?? -1) < cell.v) ? cell : best), null);
  const auto = peak ? `${t.total} ${fmt(total)}, max ${fmt(peak.v ?? 0)} (${peak.title})` : '';

  return (
    <ChartRoot anim={anim} rootRef={rootRef} className={className}>
      <div ref={wrapRef}>
        <ChartSurface
          label={label}
          summary={summary ?? auto}
          labels={labels}
          live={live}
          table={<ChartTable caption={label} head={tableHead} rows={tableRows} />}
          onKeyDown={onKeyDown}
          onFocus={(e) => {
            if (e.target === e.currentTarget && !active && e.currentTarget.matches(':focus-visible')) {
              const firstValid = ordered()[0];
              setActive(firstValid ?? null);
            }
          }}
          onBlur={() => setActive(null)}
          onPointerMove={pick}
          onPointerDown={pick}
          onPointerLeave={(e) => e.pointerType === 'mouse' && setActive(null)}
        >
          <svg width="100%" height={height} viewBox={`0 0 ${width} ${height}`} preserveAspectRatio="xMidYMid meet" aria-hidden className="block overflow-visible">
            <g className="text-[11px]" fill="var(--muted-foreground)">
              {rowNames.map((s, r) =>
                s ? (
                  <text key={r} x={left - 10} y={cy(r) + cellH / 2} dy="0.34em" textAnchor="end" className={cn(active?.r === r && 'fill-foreground')}>
                    {s}
                  </text>
                ) : null,
              )}
              {colMarks.map((s, c) =>
                s && c >= firstCol && (variant === 'calendar' || (c - firstCol) % markEvery === 0) && !(variant === 'calendar' && c > firstCol && colMarks.slice(Math.max(firstCol, c - 2), c).some(Boolean)) ? (
                  <text key={c} x={variant === 'calendar' ? cx(c) : round(cx(c) + cellW / 2)} y={12} textAnchor={variant === 'calendar' ? 'start' : 'middle'} className={cn(active?.c === c && 'fill-foreground')}>
                    {s}
                  </text>
                ) : null,
              )}
            </g>
            {grid.map((row, r) =>
              row.map((cell, c) => {
                if (!cell || c < firstCol) return null;
                const s = step(cell.v);
                return (
                  <rect
                    key={`${r}-${c}`}
                    x={cx(c)}
                    y={cy(r)}
                    width={round(cellW)}
                    height={round(cellH)}
                    rx={variant === 'calendar' ? 3 : 4}
                    fill={s < 0 ? 'var(--kitch-empty)' : rampColor(s)}
                    className={anim ? 'kitch-cell' : undefined}
                    style={anim ? ({ '--kitch-d': `${round(((c - firstCol + r) / Math.max(1, visibleCols + nRows - 2)) * 640, 0)}ms` } as React.CSSProperties) : undefined}
                  />
                );
              }),
            )}
            {shown && (
              <rect
                x={-0.75}
                y={-0.75}
                width={round(cellW + 1.5)}
                height={round(cellH + 1.5)}
                rx={variant === 'calendar' ? 3.5 : 4.5}
                fill="none"
                stroke="var(--foreground)"
                strokeWidth={1.5}
                className="kitch-glide pointer-events-none"
                data-jump={jump ? '' : undefined}
                style={{ transform: `translate(${cx(shown.c)}px, ${cy(shown.r)}px)`, opacity: active ? 1 : 0 }}
              />
            )}
          </svg>
          {shown && shownCell && (
            <ChartTooltip open={active !== null} jump={jump} x={round(cx(shown.c) + cellW / 2)} y={Math.max(0, Math.min(cy(shown.r) - 6, height - 56))} width={width} offset={cellW / 2 + 8} title={shownCell.title} rows={[{ key: 'v', label, value: shownCell.v === null ? t.noData : fmt(shownCell.v) }]} />
          )}
        </ChartSurface>
      </div>
      {legend && (
        <div className="mt-4 flex items-center justify-end gap-1.5 text-[11px] text-muted-foreground" aria-hidden>
          <span className="me-1">{t.less}</span>
          <span className="size-3 rounded-[3px]" style={{ background: 'var(--kitch-empty)' }} />
          {[0, 1, 2, 3, 4].map((s) => (
            <span key={s} className="size-3 rounded-[3px]" style={{ background: rampColor(s) }} />
          ))}
          <span className="ms-1">{t.more}</span>
        </div>
      )}
    </ChartRoot>
  );
}

Häufige Fragen

Wie installiere ich Heatmap?

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

Welche Varianten hat Heatmap?

2 Varianten, Kalender und Matrix. Du wählst sie mit der Prop variant, zum Beispiel variant="matrix".

Kann ein KI-Assistent Heatmap einbauen?

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

Oft gesucht

Direkt zu

In die Zwischenablage kopiert