component — charts
Treemap
Hierarchical data as nested rectangles on the quebi Chart wrapper: every top-level branch takes one palette step and its descendants inherit it. Requires the recharts package.
- chart
- treemap
- hierarchy
- nested
- recharts
- data
- visualization
03 examples
Flat
A single level of leaves, each taking the next step of the ink palette. Area is the value.
Nested
A node with `children` is a branch: it is drawn as a frame around its leaves, and every leaf inherits the branch's shade, so shade says which branch and nesting says how deep.
Values and branch colours
`showValues` labels each leaf with its value through `valueFormatter` — a string context, so `formatNumber(value, locale)` rather than a bare `toLocaleString()`. A `config` entry named after a branch overrides that branch's colour.
source
treemap.tsxCopy this into your project. Resolve its dependencies from the registryDependencies in the component's API entry.
"use client"
import { type ComponentProps, useMemo } from "react"
import { Treemap as TreemapPrimitive, type TreemapNode } from "recharts"
import type { NameType, ValueType } from "recharts/types/component/DefaultTooltipContent"
import {
type BaseChartProps,
type ChartColorKeys,
Chart,
ChartTooltip,
ChartTooltipContent,
EXTENDED_COLORS,
getColorValue,
} from "@/components/chart"
/**
* Treemap — quebi design system
*
* Hierarchical data as nested rectangles, on the quebi `Chart` wrapper. Every
* top-level branch takes one step of the ink palette and its descendants
* inherit it, so depth reads as nesting and color reads as branch. Leaves are
* filled and labelled; a branch that contains other nodes is drawn as a frame.
* Override a branch's color with a `config` entry under its name.
*
* Requires the `recharts` npm package as a peer dependency.
*/
/** A node of the source tree: a name, a value, and optionally more nodes. */
export interface TreemapDatum extends Record<string, unknown> {
children?: TreemapDatum[]
}
/**
* Paint each top-level branch with one palette step and hand it down to
* every descendant. Recharts keeps unknown fields on a node as it computes the
* layout, so a `fill` written here is what the content renderer reads back.
*/
function paintBranches(
nodes: readonly TreemapDatum[],
colors: readonly (ChartColorKeys | (string & {}))[],
nameKey: string,
overrides: Record<string, string | undefined>,
inherited?: string,
): TreemapDatum[] {
return nodes.map((node, index) => {
const name = String(node[nameKey] ?? "")
const fill =
inherited ?? getColorValue(overrides[name] ?? colors[index % colors.length] ?? colors[0])
return {
...node,
fill,
children: node.children
? paintBranches(node.children, colors, nameKey, overrides, fill)
: undefined,
}
})
}
const readFill = (node: TreemapNode): string | undefined =>
typeof node.fill === "string" ? node.fill : undefined
export interface TreemapProps<TValue extends ValueType, TName extends NameType>
extends Omit<
BaseChartProps<TValue, TName>,
| "content"
| "data"
| "layout"
| "type"
| "legend"
| "legendProps"
| "hideGridLines"
| "hideXAxis"
| "hideYAxis"
| "xAxisProps"
| "yAxisProps"
| "cartesianGridProps"
| "displayEdgeLabelsOnly"
| "intervalType"
> {
/** The tree. A node with `children` is a branch; one without is a leaf. */
data: TreemapDatum[]
/** Key of the leaf value on every node. */
dataKey: string
/** Key of the node's name. Defaults to `"name"`. */
nameKey?: string
aspectRatio?: number
/** Gap between sibling rectangles, in px. */
nodeGap?: number
/** Inset between a branch and the nodes inside it, in px. */
nodeInset?: number
/** Show each leaf's value under its name, formatted with `valueFormatter`. */
showValues?: boolean
content?: ComponentProps<typeof TreemapPrimitive>["content"]
chartProps?: Omit<ComponentProps<typeof TreemapPrimitive>, "data" | "content">
}
export function Treemap<TValue extends ValueType, TName extends NameType>({
data = [],
dataKey,
nameKey = "name",
colors = EXTENDED_COLORS,
config,
children,
// Components
tooltip = true,
tooltipProps,
aspectRatio = 4 / 3,
nodeGap = 2,
nodeInset = 0,
showValues = false,
valueFormatter = (value: number) => value.toString(),
content,
chartProps,
...props
}: TreemapProps<TValue, TName>) {
const overrides = useMemo(() => {
const entries: Record<string, string | undefined> = {}
for (const [key, value] of Object.entries(config)) {
entries[key] = value.color
}
return entries
}, [config])
const painted = useMemo(
() => paintBranches(data, colors, nameKey, overrides),
[data, colors, nameKey, overrides],
)
const renderNode = useMemo(
() => (node: TreemapNode) => {
const { x, y, width, height, depth, name, children: nodeChildren, value } = node
const color = readFill(node) ?? getColorValue(colors[0])
const isBranch = Boolean(nodeChildren?.length)
// Recharts lays out the root of the tree as a node of its own, filling
// the whole chart. Drawing it would put a frame around everything and a
// wash of the first palette step behind every branch.
if (depth === 0) {
return <g />
}
// Recharts lays every node out, including ones squeezed to nothing.
if (!(width > 0) || !(height > 0)) {
return <g />
}
const fitsName = width > 46 && height > 22
const fitsValue = showValues && !isBranch && width > 46 && height > 40
return (
<g>
<rect
x={x}
y={y}
width={width}
height={height}
fill={color}
fillOpacity={isBranch ? 0 : 0.5}
// A leaf is separated from its neighbours by the chart surface
// showing through; a branch is outlined in its own ink. Both are
// set as attributes rather than as a class, because a stroke class
// would win over the attribute and paint every frame the same.
stroke={isBranch ? color : "var(--color-quebi-bg)"}
strokeOpacity={isBranch ? 0.45 : 1}
strokeWidth={isBranch ? 1.5 : 2}
/>
{fitsName && (
<text
x={x + 8}
y={y + (isBranch ? 16 : fitsValue ? height / 2 - 2 : height / 2 + 4)}
className={
isBranch
? "quebi-eyebrow fill-current"
: "fill-quebi-fg text-quebi-caption"
}
>
{name}
</text>
)}
{fitsValue && (
<text
x={x + 8}
y={y + height / 2 + 14}
className="fill-quebi-fg-muted font-mono text-quebi-caption tabular-nums"
>
{valueFormatter(Number(value) || 0)}
</text>
)}
</g>
)
},
[colors, showValues, valueFormatter],
)
return (
<Chart config={config} data={painted} dataKey={dataKey} {...props}>
<TreemapPrimitive
data={painted}
dataKey={dataKey}
nameKey={nameKey}
aspectRatio={aspectRatio}
nodeGap={nodeGap}
nodeInset={nodeInset}
isAnimationActive={false}
content={content ?? renderNode}
{...chartProps}
>
{tooltip && (
<ChartTooltip
content={
typeof tooltip === "boolean" ? (
<ChartTooltipContent hideLabel labelSeparator={false} nameKey={nameKey} />
) : (
tooltip
)
}
{...tooltipProps}
/>
)}
{children}
</TreemapPrimitive>
</Chart>
)
}