Skip to content
UI Kit
Accent color

Region map

Values per region on a drawn map of the invented island Fernland, as tinted regions with a ranked list, change in two tones, equal hex tiles or sized circles. Pass your own SVG shapes for a real map.

One of 6 components in KPI, live and map charts

4 variants

Variants

Tinted regions

Five accent steps on equal bins, a ranked list beside the map. Pointing at either lights up the same region.

Open in Studio

Change

Gains and losses in two tones of three strengths, a grey middle for about the same, sign and arrow in the list.

Open in Studio

Hex tiles

Every region as one hexagon of the same size, so a small district counts as much as a large one. Name and value inside.

Open in Studio

Sized circles

Circles whose area is the value over a quiet map, the largest drawn first, a key for the size.

Open in Studio

Details

What Region map brings along, read from the registry and the source.

Variants
4
Version
1.0.0 · since 1.1.0
File
components/ui/region-map.tsx
Brings along
utilskiticonchartchart-figure
npm packages
None
Languages
English and German, 7 texts of its own

Props

The RegionMapProps interface from components/ui/region-map.tsx, with the comments from the code. Also takes every prop of Omit<ChartA11y, 'label'>, ChartFigureProps.

  • labeloptional
    string

    Accessible name of the chart. Default: the title.

  • variantoptional
    RegionVariant

    regionsdiverginghex-tilessymbols

  • regionsrequired
    RegionValue[]
  • shapesoptional
    Record<string, string>

    Own map: SVG path per id in a 400 by 300 box

  • centersoptional
    Record<string, [number, number]>

    Own map: label point per id

  • tilesoptional
    Record<string, [number, number]>

    hex-tiles: column and row of each id. Default: a layout for the built-in map.

  • formatoptional
    Intl.NumberFormatOptions
  • unitLabeloptional
    string

    Name of the value, e.g. "Members per 1,000"

Built-in texts

Region map writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 7 texts
regionEnglish RegionGerman Region
valueEnglish ValueGerman Wert
changeEnglish ChangeGerman Veränderung
gainEnglish GainGerman Plus
lossEnglish LossGerman Minus
flatEnglish About the sameGerman Etwa gleich
sizeKeyEnglish Circle area shows the valueGerman Die Kreisfläche zeigt den Wert

Accessibility

Read from the source of the component.

  • Region map handles the keys Escape, Arrow up, Arrow down, Arrow left and Arrow right itself.
  • Needs no client state. The server writes it in full, so it reads without JavaScript too.

Motion

  • No animations, it stands still from the start.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 342 lines
components/ui/region-map.tsx
'use client';

import { useState } from 'react';
import { cn, round, useTexts } from '@/lib/kit';
import { Icon } from '@/components/ui/icon';
import { ChartRoot, ChartSurface, ChartTable, numberFormatter, rampColor, textWidth, useEntrance, useIntlLocale, useWidth, type ChartA11y } from '@/components/ui/chart';
import { ChartFigure, HALO, type ChartFigureProps } from '@/components/ui/chart-figure';

/*
 * Values per region on a drawn map.
 *   regions   tinted regions in five accent steps, a ranked list beside
 *   diverging change per region, gains and losses in two tones, sign in the label
 *   hex-tiles every region as one hexagon of the same size, fair to small regions
 *   symbols   circles sized by the value over a quiet map
 *
 * The built-in map is invented: the island Fernland with nine districts,
 * drawn here as a Voronoi split of an outline, no real geography. Pass your
 * own `shapes` (SVG paths in a 400 by 300 box) and `centers` for a real map.
 */

export type RegionVariant = 'regions' | 'diverging' | 'hex-tiles' | 'symbols';

export interface RegionValue {
  /** Id of the shape, r1 to r9 for the built-in map */
  id: string;
  label: string;
  value: number;
  /** diverging: change as a fraction, 0.08 is +8 % */
  change?: number;
}

export interface RegionMapProps extends Omit<ChartA11y, 'label'>, ChartFigureProps {
  /** Accessible name of the chart. Default: the title. */
  label?: string;
  variant?: RegionVariant;
  regions: RegionValue[];
  /** Own map: SVG path per id in a 400 by 300 box */
  shapes?: Record<string, string>;
  /** Own map: label point per id */
  centers?: Record<string, [number, number]>;
  /** hex-tiles: column and row of each id. Default: a layout for the built-in map. */
  tiles?: Record<string, [number, number]>;
  format?: Intl.NumberFormatOptions;
  /** Name of the value, e.g. "Members per 1,000" */
  unitLabel?: string;
  className?: string;
}

const TEXTS = {
  en: { region: 'Region', value: 'Value', change: 'Change', gain: 'Gain', loss: 'Loss', flat: 'About the same', sizeKey: 'Circle area shows the value' },
  de: { region: 'Region', value: 'Wert', change: 'Veränderung', gain: 'Plus', loss: 'Minus', flat: 'Etwa gleich', sizeKey: 'Die Kreisfläche zeigt den Wert' },
};

/* ---------------- the invented map: Fernland ---------------- */

type Pt = [number, number];
const ISLAND: Pt[] = Array.from({ length: 56 }, (_, i) => {
  const a = (i / 56) * Math.PI * 2;
  const r = 1 + 0.11 * Math.sin(3 * a + 0.4) + 0.07 * Math.cos(5 * a + 1.2) + 0.04 * Math.sin(9 * a);
  return [200 + Math.cos(a) * 176 * r, 150 + Math.sin(a) * 118 * r] as Pt;
});
const SEEDS: Record<string, Pt> = { r1: [92, 110], r2: [170, 72], r3: [262, 70], r4: [330, 128], r5: [126, 188], r6: [206, 150], r7: [292, 206], r8: [196, 236], r9: [62, 170] };

/** Sutherland-Hodgman: keep the part of a polygon on the side of the bisector closer to a */
function clip(poly: Pt[], a: Pt, b: Pt): Pt[] {
  const mx = (a[0] + b[0]) / 2;
  const my = (a[1] + b[1]) / 2;
  const nx = b[0] - a[0];
  const ny = b[1] - a[1];
  const inside = (p: Pt) => (p[0] - mx) * nx + (p[1] - my) * ny <= 0;
  const out: Pt[] = [];
  for (let i = 0; i < poly.length; i++) {
    const p = poly[i];
    const q = poly[(i + 1) % poly.length];
    const pi = inside(p);
    const qi = inside(q);
    if (pi) out.push(p);
    if (pi !== qi) {
      const dp = (p[0] - mx) * nx + (p[1] - my) * ny;
      const dq = (q[0] - mx) * nx + (q[1] - my) * ny;
      const t = dp / (dp - dq);
      out.push([p[0] + (q[0] - p[0]) * t, p[1] + (q[1] - p[1]) * t]);
    }
  }
  return out;
}
const toPath = (poly: Pt[]) => poly.map(([x, y], i) => `${i ? 'L' : 'M'}${round(x, 1)} ${round(y, 1)}`).join('') + 'Z';
const centroid = (poly: Pt[]): Pt => {
  let a = 0;
  let cx = 0;
  let cy = 0;
  for (let i = 0; i < poly.length; i++) {
    const [x1, y1] = poly[i];
    const [x2, y2] = poly[(i + 1) % poly.length];
    const f = x1 * y2 - x2 * y1;
    a += f;
    cx += (x1 + x2) * f;
    cy += (y1 + y2) * f;
  }
  return a ? [round(cx / (3 * a), 1), round(cy / (3 * a), 1)] : poly[0];
};
const FERNLAND = (() => {
  const shapes: Record<string, string> = {};
  const centers: Record<string, Pt> = {};
  for (const [id, s] of Object.entries(SEEDS)) {
    let poly = ISLAND;
    for (const [other, o] of Object.entries(SEEDS)) if (other !== id) poly = clip(poly, s, o);
    shapes[id] = toPath(poly);
    centers[id] = centroid(poly);
  }
  return { shapes, centers, outline: toPath(ISLAND) };
})();
/** Hex tile layout of Fernland (column, row), odd rows shifted half a tile */
const TILES: Record<string, Pt> = { r9: [0, 1], r1: [1, 0], r2: [2, 0], r3: [3, 0], r4: [4, 1], r5: [1, 1], r6: [2, 1], r7: [3, 1], r8: [2, 2] };

const hexPath = (cx: number, cy: number, r: number) =>
  Array.from({ length: 6 }, (_, i) => {
    const a = (Math.PI / 3) * i - Math.PI / 6;
    return `${i ? 'L' : 'M'}${round(cx + r * Math.cos(a), 1)} ${round(cy + r * Math.sin(a), 1)}`;
  }).join('') + 'Z';

/**
 * Values on a map: tinted regions, change in two tones, equal hex tiles or sized circles.
 *   <RegionMap variant="regions" regions={[{ id: 'r1', label: 'Nordheide', value: 42 }]} unitLabel="Members per 1,000" />
 */
export function RegionMap(props: RegionMapProps) {
  const { variant = 'regions', regions, format, unitLabel = '', 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 pct = numberFormatter(intl, { style: 'percent', maximumFractionDigits: 1 });
  const signed = (n: number) => (n > 0 ? '+' : n < 0 ? '−' : '') + pct(Math.abs(n));
  const [wrapRef, width] = useWidth<HTMLDivElement>(720);
  const [rootRef, anim] = useEntrance<HTMLDivElement>(animate);
  const [active, setActive] = useState<number | null>(null);
  const shapes = props.shapes ?? FERNLAND.shapes;
  const centers = props.centers ?? FERNLAND.centers;
  const tiles = props.tiles ?? TILES;
  const name = label ?? title ?? unitLabel;
  const beside = width >= 720;
  // Map text in view box units so it renders at about 11 px whatever the map width
  const mapPx = beside ? ((width - 24) * 1.5) / 2.5 : width;
  const unit = 400 / Math.max(1, mapPx);
  const small = mapPx < 480;
  const d = (ms: number) => (anim ? ({ '--kitch-d': `${round(ms, 0)}ms` } as React.CSSProperties) : undefined);

  const vals = regions.map((r) => r.value);
  const lo = Math.min(...vals);
  const hi = Math.max(...vals);
  // Five equal bins between the lowest and the highest value
  const binOf = (v: number) => Math.min(4, Math.floor(((v - lo) / (hi - lo || 1)) * 5));
  const edges = Array.from({ length: 6 }, (_, i) => lo + ((hi - lo) * i) / 5);
  const toneOf = (r: RegionValue) => {
    if (variant === 'diverging') {
      const c = r.change ?? 0;
      if (Math.abs(c) < 0.02) return 'color-mix(in oklab, var(--muted-foreground) 22%, var(--card))';
      const k = Math.abs(c) < 0.06 ? 26 : Math.abs(c) < 0.12 ? 46 : 70;
      return `color-mix(in oklab, ${c > 0 ? 'var(--kitfg-up)' : 'var(--kitfg-down)'} ${k}%, var(--card))`;
    }
    if (variant === 'symbols') return 'color-mix(in oklab, var(--foreground) 6%, var(--card))';
    return rampColor(binOf(r.value));
  };
  // Text on a tinted region: dark text on light tints, light text on the two darkest accent steps
  // Measured per mode: light keeps dark text up to step 3, dark mode needs dark text from step 3
  const inkOf = (r: RegionValue) => {
    if (variant !== 'regions' && variant !== 'hex-tiles') return 'var(--foreground)';
    const b = binOf(r.value);
    return b === 4 ? 'var(--primary-foreground)' : b === 3 ? 'var(--rm-ink3)' : 'var(--foreground)';
  };

  const order = [...regions.keys()].sort((a, b) => (variant === 'diverging' ? (regions[b].change ?? 0) - (regions[a].change ?? 0) : regions[b].value - regions[a].value));
  const onKeyDown = (e: React.KeyboardEvent) => {
    const at = active === null ? -1 : order.indexOf(active);
    let next = at;
    if (e.key === 'ArrowDown' || e.key === 'ArrowRight') next = Math.min(order.length - 1, at + 1);
    else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') next = Math.max(0, at - 1);
    else if (e.key === 'Escape') return setActive(null);
    else return;
    e.preventDefault();
    setActive(order[next]);
  };
  const a = active !== null ? regions[active] : null;
  const live = a ? `${a.label}. ${fmt(a.value)}${a.change !== undefined && variant === 'diverging' ? `, ${t.change} ${signed(a.change)}` : ''}` : '';
  const table = <ChartTable caption={name} head={[t.region, unitLabel || t.value, ...(variant === 'diverging' ? [t.change] : [])]} rows={order.map((i) => [regions[i].label, fmt(regions[i].value), ...(variant === 'diverging' ? [signed(regions[i].change ?? 0)] : [])])} />;
  const handlers = (i: number) => ({
    onPointerEnter: (e: React.PointerEvent) => e.pointerType === 'mouse' && setActive(i),
    onPointerLeave: (e: React.PointerEvent) => e.pointerType === 'mouse' && setActive(null),
    onPointerDown: () => setActive(active === i ? null : i),
  });

  const mapW = 400;
  const mapH = 300;
  const sMax = Math.max(...vals, 1);
  const rMax = 30;

  const map = (
    <svg width="100%" viewBox={`0 0 ${mapW} ${mapH}`} aria-hidden className="block overflow-visible">
      {variant === 'hex-tiles' ? (
        (() => {
          const r = 38;
          const w = Math.sqrt(3) * r;
          const cols = Math.max(...Object.values(tiles).map((p) => p[0])) + 1.5;
          const rowsN = Math.max(...Object.values(tiles).map((p) => p[1])) + 1;
          const ox = (mapW - cols * w) / 2 + w / 2;
          const oy = (mapH - (rowsN - 1) * r * 1.5) / 2;
          return regions.map((reg, i) => {
            const p = tiles[reg.id];
            if (!p) return null;
            const x = ox + p[0] * w + (p[1] % 2 ? w / 2 : 0);
            const y = oy + p[1] * r * 1.5;
            const short = reg.label.length > 9 ? `${reg.label.slice(0, 8)}.` : reg.label;
            return (
              <g key={reg.id} {...handlers(i)} className={cn('cursor-default', anim && 'kitch-pop')} style={{ ...d(80 + i * 60), transformOrigin: `${x}px ${y}px`, transformBox: 'view-box' }}>
                <path d={hexPath(x, y, r - 2)} fill={toneOf(reg)} stroke={active === i ? 'var(--foreground)' : 'var(--kitch-surface)'} strokeWidth={active === i ? 2.5 : 2} strokeLinejoin="round" className="transition-[stroke] duration-200" />
                {!small && <text x={x} y={y - 6} textAnchor="middle" className="text-[11px] font-medium" fill={inkOf(reg)}>{short}</text>}
                <text x={x} y={small ? y : y + 11} dy={small ? '0.34em' : undefined} textAnchor="middle" className="font-semibold tabular-nums" fill={inkOf(reg)} style={{ fontSize: small ? round(13 * unit, 1) : 13 }}>{fmt(reg.value)}</text>
              </g>
            );
          });
        })()
      ) : (
        <>
          <path d={FERNLAND.outline} fill="none" stroke="var(--kitch-axis)" strokeWidth="1" transform="translate(0 4)" opacity="0.5" />
          {regions.map((reg, i) => (
            <path key={reg.id} {...handlers(i)} d={shapes[reg.id]} fill={toneOf(reg)} stroke={active === i ? 'var(--foreground)' : 'var(--kitch-surface)'} strokeWidth={active === i ? 2.5 : 2} strokeLinejoin="round" className={cn('cursor-default transition-[stroke,opacity] duration-200', anim && 'kitch-fade')} style={d(80 + i * 70)} opacity={active !== null && active !== i ? 0.75 : 1} />
          ))}
          {variant === 'symbols' &&
            [...regions.keys()]
              .sort((x, y) => regions[y].value - regions[x].value)
              .map((i) => {
                const reg = regions[i];
                const c = centers[reg.id];
                if (!c) return null;
                const r = round(Math.sqrt(reg.value / sMax) * rMax);
                return (
                  <circle key={reg.id} {...handlers(i)} cx={c[0]} cy={c[1]} r={Math.max(3, r)} fill="var(--primary)" fillOpacity={active === i ? 0.9 : 0.7} stroke="var(--kitch-surface)" strokeWidth="2" className={cn('cursor-default transition-[fill-opacity] duration-200', anim && 'kitch-pop')} style={{ ...d(400 + i * 80), transformOrigin: `${c[0]}px ${c[1]}px`, transformBox: 'view-box' }} />
                );
              })}
          {regions.map((reg, i) => {
            const c = centers[reg.id];
            if (!c) return null;
            if (small && active !== i) return null;
            const r = variant === 'symbols' ? Math.max(3, round(Math.sqrt(reg.value / sMax) * rMax)) : 0;
            const y = variant === 'symbols' ? c[1] + r + 12 : c[1];
            return (
              <text key={reg.id} x={c[0]} y={y} dy="0.34em" textAnchor="middle" className="pointer-events-none font-medium" fill={small || variant === 'symbols' ? 'var(--foreground)' : inkOf(reg)} style={{ fontSize: round(Math.max(10, 11 * unit), 1), ...(small || variant === 'symbols' ? HALO : {}) }}>
                {!small && textWidth(reg.label, 10) > 70 ? `${reg.label.slice(0, 9)}.` : reg.label}
              </text>
            );
          })}
        </>
      )}
    </svg>
  );

  const legend =
    variant === 'diverging' ? (
      <ul className="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-[12px] text-muted-foreground">
        {[
          { k: t.loss, c: 'var(--kitfg-down)', icon: 'arrow-down-right' as const },
          { k: t.flat, c: 'var(--muted-foreground)', icon: null },
          { k: t.gain, c: 'var(--kitfg-up)', icon: 'arrow-up-right' as const },
        ].map((x) => (
          <li key={x.k} className="flex items-center gap-1.5">
            <span aria-hidden className="flex h-3 overflow-hidden rounded-[3px]">
              {(x.icon ? [70, 46, 26] : [22]).map((k) => (
                <span key={k} className="w-2.5" style={{ background: `color-mix(in oklab, ${x.c} ${k}%, var(--card))` }} />
              ))}
            </span>
            {x.icon && <Icon name={x.icon} size={13} strokeWidth={2} />}
            {x.k}
          </li>
        ))}
      </ul>
    ) : variant === 'symbols' ? (
      <p className="flex items-center gap-2 text-[12px] text-muted-foreground">
        <svg width="34" height="22" viewBox="0 0 34 22" aria-hidden>
          <circle cx="11" cy="11" r="10" fill="var(--primary)" fillOpacity="0.7" />
          <circle cx="27" cy="15" r="6" fill="var(--primary)" fillOpacity="0.7" />
        </svg>
        {t.sizeKey}
      </p>
    ) : (
      <div className="text-[12px] text-muted-foreground">
        <div className="flex items-center gap-1">
          {[0, 1, 2, 3, 4].map((s) => (
            <span key={s} aria-hidden className="h-2.5 flex-1 rounded-[2px] first:rounded-l-[4px] last:rounded-r-[4px]" style={{ background: rampColor(s), minWidth: 28 }} />
          ))}
        </div>
        <div className="mt-1 flex justify-between tabular-nums">
          <span>{fmt(edges[0])}</span>
          <span>{fmt(edges[5])}</span>
        </div>
      </div>
    );

  return (
    <ChartFigure title={title} value={value} delta={delta} meta={meta} source={source} framed={framed} className={className}>
      <ChartRoot anim={anim} rootRef={rootRef} className="[--rm-ink3:var(--foreground)] dark:[--rm-ink3:var(--primary-foreground)]">
        <div ref={wrapRef} className={cn('grid gap-6', beside ? 'grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)] items-start' : 'grid-cols-1')}>
          <div>
            <ChartSurface label={name} summary={summary ?? order.slice(0, 3).map((i) => `${regions[i].label} ${fmt(regions[i].value)}`).join(', ')} labels={labels} live={live} table={table} onKeyDown={onKeyDown} onFocus={(e) => e.target === e.currentTarget && active === null && e.currentTarget.matches(':focus-visible') && setActive(order[0])} onBlur={() => setActive(null)}>
              {map}
            </ChartSurface>
            <div className="mt-4 max-w-xs">{legend}</div>
          </div>
          <ol className="grid content-start gap-0.5">
            {order.map((i, k) => {
              const reg = regions[i];
              return (
                <li key={reg.id}>
                  <button
                    type="button"
                    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('flex min-h-11 w-full items-center gap-3 rounded-md px-2.5 text-left text-[13px] transition-colors duration-200 sm:min-h-10', active === i ? 'bg-accent' : 'hover:bg-accent/60', anim && 'kitch-rise')}
                    style={d(200 + k * 50)}
                  >
                    <span className="w-4 text-right text-[12px] tabular-nums text-muted-foreground">{k + 1}</span>
                    <span aria-hidden className="size-3 shrink-0 rounded-[3px]" style={{ background: variant === 'symbols' ? 'var(--primary)' : toneOf(reg) }} />
                    <span className="min-w-0 flex-1 truncate text-foreground">{reg.label}</span>
                    {variant === 'diverging' ? (
                      <span className={cn('inline-flex items-center gap-0.5 font-medium tabular-nums', (reg.change ?? 0) > 0.02 ? 'text-success-foreground' : (reg.change ?? 0) < -0.02 ? 'text-destructive' : 'text-muted-foreground')}>
                        {Math.abs(reg.change ?? 0) >= 0.02 && <Icon name={(reg.change ?? 0) > 0 ? 'arrow-up-right' : 'arrow-down-right'} size={13} strokeWidth={2} />}
                        {signed(reg.change ?? 0)}
                      </span>
                    ) : (
                      <span className="font-medium tabular-nums text-foreground">{fmt(reg.value)}</span>
                    )}
                  </button>
                </li>
              );
            })}
          </ol>
        </div>
      </ChartRoot>
    </ChartFigure>
  );
}

Frequent questions

How do I install Region map?

With aiw add @ai-website/region-map. The command puts components/ui/region-map.tsx into your project, the code is yours from then on. It brings along icon, chart and chart-figure. It needs no further npm packages.

Which variants does Region map have?

4 variants, Tinted regions, Change, Hex tiles and Sized circles. You pick one with the variant prop, for example variant="diverging".

Can I translate the texts of Region map?

Yes. Region map carries 7 texts of its own in English and German, such as “Region”, “Value” and “Change”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Region map?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Region map with aiw_components and install it with aiw_add @ai-website/region-map. Name the variant in your prompt, for example variant="regions", and the assistant writes your content straight into the props.

Can I adjust Region map without code?

Yes, in the Studio. All 4 variants are editable there, texts, lists and switches. The Studio writes the matching RegionMap code to copy.

Often searched

Jump to

Copied to the clipboard