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
Variants
Details
What Region map brings along, read from the registry and the source.
- Category
- KPI, live and map charts
- Area
- Elements
- 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
- Studio
- All 4 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
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.
labeloptionalstringAccessible name of the chart. Default: the title.
variantoptionalRegionVariantregionsdiverginghex-tilessymbols
regionsrequiredRegionValue[]shapesoptionalRecord<string, string>Own map: SVG path per id in a 400 by 300 box
centersoptionalRecord<string, [number, number]>Own map: label point per id
tilesoptionalRecord<string, [number, number]>hex-tiles: column and row of each id. Default: a layout for the built-in map.
formatoptionalIntl.NumberFormatOptionsunitLabeloptionalstringName 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
| Key | English | German |
|---|---|---|
| region | English Region | German Region |
| value | English Value | German Wert |
| change | English Change | German Veränderung |
| gain | English Gain | German Plus |
| loss | English Loss | German Minus |
| flat | English About the same | German Etwa gleich |
| sizeKey | English Circle area shows the value | German 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
'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.