컴포넌트
와플 차트
칸 하나가 전체의 한 조각을 뜻해서 비율을 셀 수 있게 보여 주는 차트예요.
- #arc
- #component
- #chart
- #waffle
- #proportion
- #data
- #grid
1280px
설치
이 명령 한 줄이면 필요한 파일과 패키지가 함께 설치돼요.
터미널터미널터미널bash
npx shadcn@latest add https://dsui.vercel.app/r/waffle-chart.json파일
components/arc/waffle-chart/waffle-chart.module.csscomponents/arc/waffle-chart/waffle-chart.tsx
npm 패키지
motion@^14.2.0
함께 오는 항목
waffle-chart.module.csswaffle-chart.module.csswaffle-chart.module.csscss
.figure { position: relative; min-width: 0; margin: 0; color: var(--foreground); font-family: var(--font-body); letter-spacing: var(--tracking-body); container-type: inline-size; }
/* Series colors (--series-1 to --series-4) come from registry/foundation.css. */
.body { display: grid; min-width: 0; grid-template-columns: minmax(0, 1fr); align-items: center; gap: var(--space-6); }
@container (min-width: 420px) {
.figure[data-legend] .body { grid-template-columns: minmax(0, 232px) minmax(0, 1fr); gap: var(--space-6); }
}
.board { min-width: 0; }
.grid { position: relative; width: 100%; max-width: 300px; margin-inline: auto; outline: none; touch-action: pan-y; user-select: none; -webkit-user-select: none; -webkit-tap-highlight-color: transparent; cursor: default; }
/* Each cell is one unit. Its colour changes mid-flight with the same stagger as its travel. */
.cell { --inner: 0 0 0 0 transparent; --outer: 0 0 0 0 transparent; position: absolute; top: 0; left: 0; border-radius: 22%; background: var(--cell); box-shadow: var(--inner), var(--outer); will-change: transform; transition: background-color var(--duration-considered) var(--ease-standard) var(--delay, 0s), border-radius var(--duration-considered) var(--ease-standard) var(--delay, 0s), box-shadow var(--duration-considered) var(--ease-standard) var(--delay, 0s), opacity var(--duration-fast) var(--ease-standard); }
.grid[data-active] .cell:not([data-on]) { opacity: .26 !important; transition: background-color var(--duration-considered) var(--ease-standard) var(--delay, 0s), border-radius var(--duration-considered) var(--ease-standard) var(--delay, 0s), opacity var(--duration-standard) var(--ease-standard); }
.cell[data-cursor] { --outer: 0 0 0 2px var(--surface), 0 0 0 3.5px color-mix(in oklab, var(--foreground) 55%, transparent); }
/* Shape is the colour-free second channel: a cell morphs between these as it changes category. */
.cell[data-shape="round"], .swatch[data-shape="round"], .tipSwatch[data-shape="round"] { border-radius: 50%; }
.cell[data-shape="framed"] { --inner: inset 0 0 0 2.5px var(--cell), inset 0 0 0 3.25px var(--surface); }
.swatch[data-shape="framed"], .tipSwatch[data-shape="framed"] { box-shadow: inset 0 0 0 2px var(--cell), inset 0 0 0 3px var(--surface); }
.tooltip { position: absolute; top: 0; left: 0; z-index: 2; display: grid; min-width: 150px; max-width: min(240px, 100%); gap: 2px; padding: 8px 12px; border: 1px solid var(--float-edge, var(--surface-raised)); border-radius: 14px; background: var(--surface-raised); box-shadow: var(--shadow-floating); opacity: 0; pointer-events: none; scale: .96; transition: opacity var(--duration-fast) var(--ease-standard), scale var(--duration-fast) var(--ease-standard); }
.tooltip[data-shown] { opacity: 1; scale: 1; }
.tipRow { display: flex; min-width: 0; align-items: center; gap: 8px; margin: 0; font-size: var(--text-sm); line-height: 1.3; }
.tipSwatch { width: 10px; height: 10px; flex: none; border-radius: 3px; background: var(--cell); transition: background-color var(--duration-fast) var(--ease-standard); }
.tipName { min-width: 0; flex: 1; overflow: hidden; color: var(--text-secondary); text-overflow: ellipsis; white-space: nowrap; }
.tipValue { flex: none; margin-left: 8px; font-weight: 500; font-variant-numeric: tabular-nums; }
.tipMeta { margin: 0 0 0 18px; color: var(--text-muted); font-size: var(--text-xs); line-height: var(--leading-body); font-variant-numeric: tabular-nums; white-space: nowrap; }
.message { display: grid; min-height: 160px; place-items: center; margin: 0; color: var(--text-muted); font-size: var(--text-sm); }
/* The legend doubles as the reading: each share rolls to its new value. */
.legend { display: grid; min-width: 0; grid-template-columns: repeat(auto-fill, minmax(132px, 1fr)); gap: 2px var(--space-2); margin: 0; padding: 0; list-style: none; }
@container (min-width: 420px) { .figure[data-legend] .legend { grid-template-columns: minmax(0, 1fr); } }
/* Below the side-by-side layout two columns would clip names like "Wind and solar", so the legend reads as one list. */
/* Stacked, the legend shares the grid's measure and its row padding hangs outside, so swatches and shares line up with the cell edges. */
@container (max-width: 419px) { .legend { grid-template-columns: minmax(0, 1fr); margin-inline: max(-10px, calc((100% - 320px) / 2)); } }
.item { display: grid; width: 100%; min-height: 36px; grid-template-columns: 10px minmax(0, 1fr) auto; align-items: center; gap: 10px; padding: 4px 10px; border: 0; border-radius: 12px; background: transparent; color: var(--text-secondary); cursor: pointer; font: inherit; font-size: var(--text-sm); line-height: var(--leading-body); text-align: left; -webkit-tap-highlight-color: transparent; transition: color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard), opacity var(--duration-fast) var(--ease-standard), transform var(--duration-instant) var(--ease-standard); }
.item:active { transform: scale(.98); }
.item[data-on] { color: var(--foreground); background: color-mix(in oklab, var(--foreground) 5%, transparent); }
.item[data-dim] { opacity: .5; }
.item[aria-pressed="true"] { color: var(--foreground); }
@media (hover: hover) and (pointer: fine) { .item:hover { color: var(--foreground); } }
.swatch { width: 10px; height: 10px; border-radius: 3px; background: var(--cell); }
.itemLabel { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.itemShare { color: var(--foreground); font-size: var(--text-base); font-weight: 500; font-variant-numeric: tabular-nums; text-align: right; min-width: 3.2em; }
.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
@media (prefers-reduced-motion: reduce) {
.cell, .grid[data-active] .cell:not([data-on]), .tooltip, .tipSwatch, .item { transition: none; }
}
waffle-chart.tsxwaffle-chart.tsxwaffle-chart.tsxtsx
"use client";
import { useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type CSSProperties, type KeyboardEvent, type PointerEvent, type Ref } from "react";
import { animate, motion, useInView, useMotionValue, useSpring, useTransform } from "motion/react";
import { useReducedMotion } from "../lib/use-reduced-motion";
import { motionTokens } from "../lib/motion-tokens";
import styles from "./waffle-chart.module.css";
export interface WaffleCategory {
/** Stable identity. Cells keep following their category when the data changes, so a new dataset flows instead of repainting. */
key: string;
/** Name in the legend, the tooltip, and the data table. */
label: string;
/** Raw amount. Shares are computed from the total; negatives count as zero. */
value: number;
/** Any CSS color. Defaults to the first series for accentKey, the supporting series for the next three, then neutral steps. */
color?: string;
}
/** A unit chart: every cell is one share of the whole, so parts of a total read as countable squares. */
export interface WaffleChartProps {
/** Categories in fill order. The first fills from the bottom-left corner, column by column. */
data: WaffleCategory[];
/** What the whole is, such as "2023년 발전원별 전력 생산". Names the chart for assistive technology. */
label: string;
/** Unit after each raw value in the tooltip and table, such as "TWh". */
unit?: string;
formatValue?: (value: number, category: WaffleCategory) => string;
/** Grid rows. rows × columns cells make the whole; 10 × 10 means one cell per percent. */
rows?: number;
columns?: number;
/** The category painted in the accent. Pass null to keep every category neutral. Defaults to the first category. */
accentKey?: string | null;
/** Controlled focused category. Others dim while one is focused. */
activeKey?: string | null;
defaultActiveKey?: string | null;
onActiveChange?: (key: string | null) => void;
/** Category list with shares that roll to their new value. */
legend?: boolean;
/** Decimal places for shares. */
decimals?: number;
emptyLabel?: string;
ref?: Ref<HTMLElement>;
className?: string;
}
type Cell = { id: number; key: string | null; slot: number };
type Pointer = { key: string; slot: number } | null;
const { spring } = motionTokens;
const GAP = 3;
/** accentKey takes the first series and the next three categories the supporting series. Anything past that falls back to neutral steps that alternate in lightness. */
const SHADES = [40, 20, 30, 14];
/** A second, colour-free channel: neutral categories cycle through square, round, and framed cells, so neighbours differ in shape as well as lightness. */
const SHAPES = ["square", "round", "framed"] as const;
const clamp = (value: number, low: number, high: number) => Math.min(high, Math.max(low, value));
const grouped = new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 1 });
const subscribeNothing = () => () => {};
function useReducedMotionSafe() {
const hydrated = useSyncExternalStore(subscribeNothing, () => true, () => false);
return !!useReducedMotion() && hydrated;
}
/** Whole cells per category by largest remainder, so the counts always add up to the grid. */
function allocate(data: WaffleCategory[], total: number, cells: number): number[] {
if (total <= 0) return data.map(() => 0);
const exact = data.map(item => Math.max(0, item.value) / total * cells);
const counts = exact.map(Math.floor);
let left = cells - counts.reduce((sum, count) => sum + count, 0);
const order = exact.map((value, index) => ({ index, rest: value - Math.floor(value) })).sort((a, b) => b.rest - a.rest);
for (let k = 0; left > 0 && k < order.length; k++, left--) counts[order[k]!.index]!++;
return counts;
}
/**
* Hands cells to categories while moving as few as possible: each category keeps the cells it already had,
* surplus cells join the categories that grew, and every block takes its cells in their previous order so paths never cross.
*/
function reassign(previous: Cell[], data: WaffleCategory[], counts: number[]): Cell[] {
const bySlot = [...previous].sort((a, b) => a.slot - b.slot);
const kept = new Map<string, Cell[]>();
const pool: Cell[] = [];
const want = new Map(data.map((item, index) => [item.key, counts[index]]));
for (const cell of bySlot) {
const list = cell.key !== null ? kept.get(cell.key) ?? [] : [];
if (cell.key !== null && list.length < (want.get(cell.key) ?? 0)) { list.push(cell); kept.set(cell.key, list); } else pool.push(cell);
}
const next: Cell[] = [];
let slot = 0;
data.forEach((item, index) => {
const own = kept.get(item.key) ?? [];
while (own.length < counts[index]! && pool.length) own.push(pool.shift()!);
own.sort((a, b) => a.slot - b.slot).forEach(cell => next.push({ id: cell.id, key: item.key, slot: slot++ }));
});
pool.forEach(cell => next.push({ id: cell.id, key: null, slot: slot++ }));
return next.sort((a, b) => a.id - b.id);
}
/** A share that rolls to its new value on a spring and holds its width with tabular figures. */
function Rolling({ value, decimals, reduced }: { value: number; decimals: number; reduced: boolean }) {
const shown = useMotionValue(value);
const text = useTransform(shown, current => `${current.toFixed(decimals)}%`);
useEffect(() => {
if (reduced) { shown.jump(value); return; }
const controls = animate(shown, value, spring.smooth);
return () => controls.stop();
}, [reduced, shown, value]);
return <motion.span>{text}</motion.span>;
}
export function WaffleChart({ data, label, unit = "", formatValue, rows = 10, columns = 10, accentKey, activeKey, defaultActiveKey = null, onActiveChange, legend = true, decimals = 0, emptyLabel = "데이터가 없어요", ref, className }: WaffleChartProps) {
const reduced = useReducedMotionSafe();
const figure = useRef<HTMLElement>(null);
const grid = useRef<HTMLDivElement>(null);
const tip = useRef<HTMLDivElement>(null);
useImperativeHandle(ref, () => figure.current as HTMLElement);
const inView = useInView(figure, { once: true, amount: .35 });
const count = Math.max(1, rows * columns);
const total = data.reduce((sum, item) => sum + Math.max(0, item.value), 0);
const empty = !data.length || total <= 0;
const accent = accentKey === undefined ? data[0]?.key ?? null : accentKey;
// Cells are persistent objects; each data change reassigns them so they travel instead of repainting.
const signature = `${count}|${data.map(item => `${item.key}:${item.value}`).join(",")}`;
const counts = useMemo(() => allocate(data, total, count), [signature]); // eslint-disable-line react-hooks/exhaustive-deps
const [layout, setLayout] = useState(() => ({ cells: reassign(Array.from({ length: count }, (_, id) => ({ id, key: null, slot: id })), data, counts), moved: new Set<number>(), version: 0 }));
const [seen, setSeen] = useState(signature);
if (seen !== signature) {
setSeen(signature);
const base = Array.from({ length: count }, (_, id) => layout.cells[id] ?? { id, key: null, slot: id });
const next = reassign(base, data, counts);
setLayout({ cells: next, moved: new Set(next.filter(cell => base[cell.id]!.slot !== cell.slot || base[cell.id]!.key !== cell.key).map(cell => cell.id)), version: layout.version + 1 });
}
const { cells, moved, version } = layout;
const [ownActive, setOwnActive] = useState<string | null>(defaultActiveKey);
const pinned = activeKey !== undefined ? activeKey : ownActive;
const setPinned = (key: string | null) => { if (activeKey === undefined) setOwnActive(key); onActiveChange?.(key); };
const [pointer, setPointer] = useState<Pointer>(null);
const [preview, setPreview] = useState<string | null>(null);
const focusKey = pointer?.key ?? preview ?? pinned;
const active = focusKey && data.some(item => item.key === focusKey) ? focusKey : null;
const [side, setSide] = useState(0);
useLayoutEffect(() => {
const node = grid.current;
if (!node) return;
const read = () => setSide(node.clientWidth);
read();
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(read);
observer.observe(node);
return () => observer.disconnect();
}, []);
const size = side ? (side - GAP * (columns - 1)) / columns : 0;
const height = size ? size * rows + GAP * (rows - 1) : 0;
// Column by column from the bottom-left corner, like a glass filling up.
const place = (slot: number) => ({ x: Math.floor(slot / rows) * (size + GAP), y: (rows - 1 - (slot % rows)) * (size + GAP) });
const colorOf = (key: string | null) => {
if (key === null) return "transparent";
const index = data.findIndex(item => item.key === key), item = data[index];
if (!item) return "transparent";
if (item.color) return item.color;
if (key === accent) return "var(--series-1)";
const rank = data.filter(entry => entry.key !== accent).findIndex(entry => entry.key === key);
return rank < 3 ? `var(--series-${rank + 2})` : `color-mix(in oklab, var(--foreground) ${SHADES[(rank - 3) % SHADES.length]}%, var(--surface))`;
};
const shapeOf = (key: string | null) => {
if (key === null || key === accent) return "square";
const rank = data.filter(entry => entry.key !== accent).findIndex(entry => entry.key === key);
return rank < 0 ? "square" : SHAPES[rank % SHAPES.length];
};
// The tooltip glides between cells on its own spring and stays inside the figure.
const tipX = useMotionValue(0), tipY = useMotionValue(0);
const follow = { stiffness: 520, damping: 44, restDelta: .01 };
const glideX = useSpring(tipX, follow), glideY = useSpring(tipY, follow);
const wasShown = useRef(false);
useLayoutEffect(() => {
const bubble = tip.current, host = figure.current, board = grid.current;
if (!pointer || !bubble || !host || !board || !size) { wasShown.current = false; return; }
const hostBox = host.getBoundingClientRect(), boardBox = board.getBoundingClientRect();
const { x, y } = place(pointer.slot);
const cx = boardBox.left - hostBox.left + x + size / 2, cy = boardBox.top - hostBox.top + y;
const tw = bubble.offsetWidth, th = bubble.offsetHeight;
let top = cy - th - 10;
if (top < 0) top = cy + size + 10;
const left = clamp(cx - tw / 2, 0, Math.max(0, hostBox.width - tw));
tipX.set(left); tipY.set(clamp(top, 0, Math.max(0, hostBox.height - th)));
if (!wasShown.current || reduced) { glideX.jump(tipX.get()); glideY.jump(tipY.get()); }
wasShown.current = true;
});
const slotAt = (clientX: number, clientY: number) => {
const box = grid.current?.getBoundingClientRect();
if (!box || !size) return null;
const col = clamp(Math.floor((clientX - box.left) / (size + GAP)), 0, columns - 1);
const row = clamp(Math.floor((clientY - box.top) / (size + GAP)), 0, rows - 1);
return col * rows + (rows - 1 - row);
};
const pointAt = (slot: number | null): Pointer => {
if (slot === null) return null;
const cell = cells.find(item => item.slot === slot);
return cell?.key ? { key: cell.key, slot } : null;
};
const onPointerMove = (event: PointerEvent<HTMLDivElement>) => { if (event.pointerType === "mouse" || event.buttons) setPointer(pointAt(slotAt(event.clientX, event.clientY))); };
const onPointerDown = (event: PointerEvent<HTMLDivElement>) => { if (event.pointerType !== "mouse") event.currentTarget.setPointerCapture?.(event.pointerId); setPointer(pointAt(slotAt(event.clientX, event.clientY))); };
// Arrows walk the grid cell by cell; Page keys jump between categories.
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (empty) return;
if (event.key === "Escape" && pointer) { event.preventDefault(); setPointer(null); return; }
const filled = cells.filter(cell => cell.key !== null).length;
const at = pointer?.slot ?? 0;
const col = Math.floor(at / rows), row = at % rows;
const blocks = counts.map((size, index) => ({ start: counts.slice(0, index).reduce((sum, value) => sum + value, 0), size })).filter(block => block.size > 0);
const block = Math.max(0, blocks.findIndex(item => at >= item.start && at < item.start + item.size));
const moves: Record<string, () => number> = {
ArrowUp: () => col * rows + Math.min(rows - 1, row + 1),
ArrowDown: () => col * rows + Math.max(0, row - 1),
ArrowRight: () => Math.min(columns - 1, col + 1) * rows + row,
ArrowLeft: () => Math.max(0, col - 1) * rows + row,
PageDown: () => blocks[Math.min(blocks.length - 1, block + 1)]!.start,
PageUp: () => at > blocks[block]!.start ? blocks[block]!.start : blocks[Math.max(0, block - 1)]!.start,
Home: () => 0,
End: () => filled - 1,
};
const move = moves[event.key];
if (!move) return;
event.preventDefault();
setPointer(pointAt(clamp(pointer ? move() : 0, 0, Math.max(0, filled - 1))));
};
const format = (value: number, item: WaffleCategory) => `${formatValue ? formatValue(value, item) : grouped.format(value)}${unit ? ` ${unit}` : ""}`;
const share = (item: WaffleCategory) => total > 0 ? Math.max(0, item.value) / total * 100 : 0;
const reading = pointer ? data.find(item => item.key === pointer.key) ?? null : null;
const announce = reading ? `${reading.label}, ${share(reading).toFixed(decimals)}%, ${format(reading.value, reading)}` : "";
const summary = empty ? `${label}. ${emptyLabel}.` : `${label}. ${data.map(item => `${item.label} ${share(item).toFixed(decimals)}%`).join(", ")}.`;
const shownX = reduced ? tipX : glideX, shownY = reduced ? tipY : glideY;
const settle = reduced ? { duration: 0 } : spring.morph;
return <figure ref={figure} className={[styles.figure, className].filter(Boolean).join(" ")} aria-label={label} data-legend={legend || undefined}>
<div className={styles.body}>
<div className={styles.board}>
<div ref={grid} className={styles.grid} style={{ height: height || undefined, aspectRatio: height ? undefined : `${columns} / ${rows}` }} data-active={active ? true : undefined}
role="group" tabIndex={empty ? -1 : 0} aria-roledescription="와플 차트" aria-label={`${label}. 칸 ${count}개. 화살표 키로 칸을 옮기고, Page Up과 Page Down으로 분류를 건너뛰어요.`}
onPointerMove={onPointerMove} onPointerDown={onPointerDown} onPointerUp={event => { if (event.pointerType !== "mouse") setPointer(null); }} onPointerCancel={() => setPointer(null)} onPointerLeave={event => { if (event.pointerType === "mouse") setPointer(null); }}
onKeyDown={onKeyDown} onBlur={() => setPointer(null)} onFocus={event => { if (event.currentTarget.matches(":focus-visible") && !empty) setPointer(current => current ?? pointAt(0)); }}>
{size > 0 && cells.map(cell => {
const { x, y } = place(cell.slot);
const travel = moved.has(cell.id) && !reduced;
const delay = reduced ? 0 : (cell.slot / count) * .32;
const on = cell.key !== null && cell.key === active;
const shown = inView || reduced;
// A travelling cell dips while it flies, so the flow reads as cells lifting out of one block and landing in another.
const dip = travel ? [1, .62 + (version % 2) * .001, 1] : 1;
return <motion.span key={cell.id} className={styles.cell} aria-hidden="true"
data-on={on || undefined} data-cursor={pointer?.slot === cell.slot || undefined} data-empty={cell.key === null || undefined} data-shape={shapeOf(cell.key)}
style={{ width: size, height: size, "--cell": colorOf(cell.key), "--delay": `${delay}s` } as CSSProperties}
initial={reduced ? false : { x, y, scale: .4, opacity: 0 }}
animate={shown ? { x, y, scale: dip, opacity: cell.key === null ? 0 : 1 } : { x, y, scale: .4, opacity: 0 }}
transition={reduced ? { duration: 0 } : {
x: { ...settle, delay }, y: { ...settle, delay },
scale: travel ? { duration: .62, times: [0, .42, 1], ease: motionTokens.ease.inOut, delay } : { ...spring.snappy, delay: delay * .6 },
opacity: { duration: motionTokens.duration.standard, ease: motionTokens.ease.standard, delay: delay * .6 },
}} />;
})}
</div>
{!empty && <motion.div ref={tip} className={styles.tooltip} data-shown={pointer ? true : undefined} style={{ x: shownX, y: shownY }} aria-hidden="true">
<p className={styles.tipRow}>
<span className={styles.tipSwatch} data-shape={shapeOf(reading?.key ?? null)} style={{ "--cell": colorOf(reading?.key ?? null) } as CSSProperties} />
<span className={styles.tipName}>{reading?.label ?? ""}</span>
<span className={styles.tipValue}>{reading ? `${share(reading).toFixed(decimals)}%` : ""}</span>
</p>
<p className={styles.tipMeta}>{reading ? format(reading.value, reading) : ""}</p>
</motion.div>}
{empty && <p className={styles.message}>{emptyLabel}</p>}
</div>
{legend && !empty && <ul className={styles.legend} aria-label={`${label} 분류`} onPointerLeave={() => setPreview(null)}>
{data.map(item => <li key={item.key}>
<button type="button" className={styles.item} aria-pressed={pinned === item.key} data-on={active === item.key || undefined} data-dim={(active !== null && active !== item.key) || undefined}
onClick={() => setPinned(pinned === item.key ? null : item.key)}
onPointerEnter={event => { if (event.pointerType === "mouse") setPreview(item.key); }} onFocus={event => { if (event.currentTarget.matches(":focus-visible")) setPreview(item.key); }} onBlur={() => setPreview(null)}>
<span className={styles.swatch} data-shape={shapeOf(item.key)} style={{ "--cell": colorOf(item.key) } as CSSProperties} aria-hidden="true" />
<span className={styles.itemLabel}>{item.label}</span>
<span className={styles.itemShare}><Rolling value={share(item)} decimals={decimals} reduced={reduced} /></span>
</button>
</li>)}
</ul>}
</div>
<p className={styles.srOnly} aria-live="polite" aria-atomic="true">{announce}</p>
<p className={styles.srOnly}>{summary}</p>
{!empty && <div className={styles.srOnly}><table>
<caption>{label}</caption>
<thead><tr><th scope="col">분류</th><th scope="col">값</th><th scope="col">비율</th><th scope="col">칸 수</th></tr></thead>
<tbody>{data.map((item, index) => <tr key={item.key}><th scope="row">{item.label}</th><td>{format(item.value, item)}</td><td>{share(item).toFixed(decimals)}%</td><td>{counts[index]}</td></tr>)}</tbody>
</table></div>}
</figure>;
}
export default WaffleChart;