/**
* TreemapChart — a total, cut into the things it is made of, by area.
*
* ```tsx
*
*
*
*
*
*
* ```
*
* ## What it is for, against the dial next door
*
* A pie and a treemap answer the same question — what is this total made of —
* and they fail at different sizes. A dial can carry five or six slices before
* the small ones become slivers with nowhere to put a name. A treemap keeps
* going, because a share is a rectangle rather than an angle: it can be read
* at a tenth the size, it tiles the box with nothing left over, and it has a
* flat side to write on.
*
* So the rule of thumb is the count. Up to about six parts, a `PieChart` is
* easier to read and more familiar. Past that, a treemap is the one that still
* works.
*
* The trade-off is precision. People compare angles badly and areas worse, so
* nobody should be reading values off the tiles — the layout is for *ranking
* and grouping* at a glance, and the numbers are in the labels.
*
* ## The layout
*
* Squarified. Tiles are laid in rows across whichever side of the remaining
* space is shorter, and a row takes another tile only while doing so makes its
* worst rectangle *less* elongated than it already is. The result is tiles
* close to square, which matters for two reasons: a square is the shape whose
* area the eye judges least badly, and it is the only shape with room for a
* name across it.
*
* It follows that the tiles are sorted, largest first, and the order is the
* chart's rather than the caller's. An unsorted treemap squarifies badly —
* rows end up mixing one large tile with several small ones, which is exactly
* the case the row test cannot rescue. Pass `sort={false}` where the given
* order carries meaning and the shapes may suffer for it.
*
* ## Too many parts
*
* A treemap of two hundred rows on a phone is a texture, not a chart. `maxTiles`
* keeps the largest few and gathers the rest into one tile, which is the honest
* summary of a long tail — the reader can see how much of the total it is worth
* instead of squinting at forty slivers that were never legible.
*
* ## Colour
*
* One hue, stepping down the ranking, rather than a colour per tile. The tiles
* are parts of one total and the area already says which is bigger, so a set of
* unrelated hues would be claiming a distinction that is not in the data — and
* a treemap has more parts than there are chart tokens, so they would repeat
* and two unrelated tiles would come out matching. A tile can still be given
* its own `color` where it means something, and that one is drawn at full
* strength against the ramp.
*/
import {
Children,
createContext,
forwardRef,
isValidElement,
useContext,
useEffect,
useId,
useImperativeHandle,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react';
import {
Pressable,
View,
type LayoutChangeEvent,
type PressableProps,
type ViewProps,
} from 'react-native';
import Animated, {
Easing,
cancelAnimation,
useAnimatedProps,
useDerivedValue,
useAnimatedStyle,
useReducedMotion,
useSharedValue,
withRepeat,
withTiming,
type SharedValue,
} from 'react-native-reanimated';
import Svg, { Defs, G, LinearGradient, Rect, Stop } from 'react-native-svg';
import { useCSSVariable } from 'uniwind';
import { Text } from '../../primitives/text';
import { compactNumber, inkOn, useSeriesColor } from '../../utils/chart';
import { cn } from '../../utils/cn';
import { useSkeletonHandoff } from '../../hooks/use-skeleton-handoff';
const AnimatedG = Animated.createAnimatedComponent(G);
const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
const AnimatedRect = Animated.createAnimatedComponent(Rect);
const AnimatedLinearGradient = Animated.createAnimatedComponent(LinearGradient);
/** Width ÷ height of the box the tiles fill, when the caller does not say. */
const DEFAULT_ASPECT = 1.4;
/**
* The aspect ratio the row test aims each tile at.
*
* The golden ratio rather than 1. Aiming at a perfect square makes the test
* close rows early and leaves the leftovers to the last row, which is then the
* only badly shaped one on the chart. Aiming slightly wide spreads that cost
* over all of them.
*/
const TARGET_RATIO = (1 + Math.sqrt(5)) / 2;
/** How far the hue has faded by the smallest tile. */
const FADE = 0.45;
/** Milliseconds for a tile to dim as another is selected. */
const SELECT_DURATION = 180;
/** Milliseconds between one tile starting to grow and the next. */
const STAGGER = 26;
/** Below this, on either side, a tile has no room for a name. */
const DEFAULT_MIN_LABEL = 48;
/** Room the readout needs above the tile it names. */
const TOOLTIP_HEIGHT = 30;
/** Width the readout is laid out at, so it can be clamped inside the box. */
const TOOLTIP_WIDTH = 132;
/** Where a child is drawn: inside the SVG, over it, above it, or under it. */
type Slot = 'svg' | 'overlay' | 'header' | 'footer';
/** Whether the chart is showing data or waiting for it. */
export type TreemapChartStatus = 'loading' | 'ready';
/** One part of the total. */
export interface TreemapDatum {
/** Name of the part, for the label, the readout and the legend. */
label: string;
/** Its size. Negatives are treated as zero — an area cannot be less than none. */
value: number;
/** Explicit colour, drawn at full strength instead of the ramp. */
color?: string;
}
/** A tile's box inside the chart, in points. */
export interface TreemapRect {
x: number;
y: number;
width: number;
height: number;
}
/** One laid-out tile: its datum, its share of the total, and where it sits. */
export interface TreemapTile extends TreemapRect {
label: string;
value: number;
/** Its share of the whole chart, `0` to `1`. */
share: number;
color: string;
/** How far along the ramp it is drawn, `0` to `1`. `1` where a colour was given. */
strength: number;
/**
* Its row in `data`, or `-1` for the gathered tile `maxTiles` makes, which
* stands for several rows and so belongs to none of them.
*/
sourceIndex: number;
}
/* -------------------------------------------------------------------------- */
/* Layout */
/* -------------------------------------------------------------------------- */
/**
* One row of tiles laid across the top of the remaining box.
*
* The row is as deep as its share of the total and as wide as the box, and the
* tiles inside it divide that width between them.
*/
function diceRow(
values: number[],
from: number,
to: number,
sum: number,
out: TreemapRect[],
x0: number,
y0: number,
x1: number,
y1: number
) {
const scale = sum ? (x1 - x0) / sum : 0;
let x = x0;
for (let i = from; i < to; i += 1) {
const width = values[i]! * scale;
out[i] = { x, y: y0, width, height: y1 - y0 };
x += width;
}
}
/** The same row, stood on its end down the left of the remaining box. */
function sliceRow(
values: number[],
from: number,
to: number,
sum: number,
out: TreemapRect[],
x0: number,
y0: number,
x1: number,
y1: number
) {
const scale = sum ? (y1 - y0) / sum : 0;
let y = y0;
for (let i = from; i < to; i += 1) {
const height = values[i]! * scale;
out[i] = { x: x0, y, width: x1 - x0, height };
y += height;
}
}
/**
* Squarified treemap layout: values in, rectangles out, in the same order.
*
* Written out rather than taken from a layout dependency, because it is sixty
* lines and the alternative is shipping a tree library to call one function of.
*
* The shape of it: take the remaining box, and start a row along whichever of
* its sides is shorter. Add tiles to that row one at a time, and after each,
* ask what the worst aspect ratio in the row now is. While that number keeps
* falling the row is getting better and the tile is kept; the first tile that
* makes it rise is put back, the row is closed and laid out, and the box
* shrinks by the strip the row took.
*
* Rows go along the *shorter* side because a row is divided along its length
* and is a fixed depth: dividing the long side gives thin tiles, and the whole
* point of the exercise is not to have any.
*
* @param values Tile sizes. Must be non-negative, and should be descending.
* @param ratio The aspect ratio the row test aims at.
*/
export function squarifyLayout(
values: number[],
width: number,
height: number,
ratio: number = TARGET_RATIO
): TreemapRect[] {
const count = values.length;
const out: TreemapRect[] = new Array(count);
if (!count || width <= 0 || height <= 0) return [];
let remaining = 0;
for (let i = 0; i < count; i += 1) remaining += values[i]!;
if (remaining <= 0) return [];
let x0 = 0;
let y0 = 0;
const x1 = width;
const y1 = height;
let start = 0;
let end = 0;
while (start < count) {
const dx = x1 - x0;
const dy = y1 - y0;
if (dx <= 0 || dy <= 0) break;
/*
* A run of zeros is stepped over rather than measured: they take no area,
* and dividing by one would make the row test meaningless for the tiles
* that follow it. They stay in the row and come out with no width.
*/
let sum = 0;
do {
sum = values[end]!;
end += 1;
} while (!sum && end < count);
let smallest = sum;
let largest = sum;
/*
* `alpha` folds everything that does not change inside the row — the box's
* shape and what is left of the total — into one factor, so the test below
* is a couple of multiplications per tile rather than a full aspect-ratio
* calculation.
*/
const alpha = Math.max(dy / dx, dx / dy) / (remaining * ratio);
let beta = sum * sum * alpha;
let worst = Math.max(largest / beta, beta / smallest);
for (; end < count; end += 1) {
const value = values[end]!;
sum += value;
if (value < smallest) smallest = value;
if (value > largest) largest = value;
beta = sum * sum * alpha;
const next = Math.max(largest / beta, beta / smallest);
// The first tile that makes the row worse is put back for the next one.
if (next > worst) {
sum -= value;
break;
}
worst = next;
}
// Along the shorter side: a tall box gets a row across the top, a wide one
// gets a column down the side.
if (dx < dy) {
const edge = remaining ? y0 + (dy * sum) / remaining : y1;
diceRow(values, start, end, sum, out, x0, y0, x1, edge);
y0 = edge;
} else {
const edge = remaining ? x0 + (dx * sum) / remaining : x1;
sliceRow(values, start, end, sum, out, x0, y0, edge, y1);
x0 = edge;
}
remaining -= sum;
start = end;
}
// A box that ran out of room before the tiles did leaves holes in the array.
for (let i = 0; i < count; i += 1) {
if (!out[i]) out[i] = { x: x0, y: y0, width: 0, height: 0 };
}
return out;
}
/* -------------------------------------------------------------------------- */
/* Root */
/* -------------------------------------------------------------------------- */
interface TreemapChartContextValue {
tiles: TreemapTile[];
width: number;
height: number;
total: number;
cornerRadius: number;
minLabelSize: number;
/** `0` to `1` across the whole staggered entrance. */
reveal: SharedValue;
/** Where in that each tile's own growth begins and ends. */
windows: { from: number; to: number }[];
status: TreemapChartStatus;
activeIndex: number;
setActiveIndex: (index: number) => void;
}
const TreemapChartContext = createContext(null);
function useChart(component: string): TreemapChartContextValue {
const context = useContext(TreemapChartContext);
if (!context) {
throw new Error(`${component} must be used within a `);
}
return context;
}
/**
* The selected tile, for something rendered *inside* the chart. A readout in
* the card's header is outside this provider — use `onActiveIndexChange` there.
*/
export function useTreemapChart() {
const { tiles, total, activeIndex } = useChart('useTreemapChart');
return {
/** Index into the tiles **as laid out**, which is the sorted order. */
activeIndex,
activeTile: activeIndex >= 0 ? (tiles[activeIndex] ?? null) : null,
tiles,
total,
};
}
export interface TreemapChartProps extends ViewProps {
className?: string;
/** The parts of the total, in any order. Sorted by the chart unless told not to. */
data: TreemapDatum[];
/** Width ÷ height of the box the tiles fill. */
aspectRatio?: number;
/** Space between one tile and the next, in points. */
gap?: number;
/** Corner radius of a tile, in points. */
cornerRadius?: number;
/**
* Sort the tiles largest first.
*
* On by default, and worth leaving on. The row test assumes a descending run
* — given a large tile next to a small one it has no good row to make, and
* the chart comes out as slivers. Turn it off only where the given order is
* itself the message.
*/
sort?: boolean;
/**
* Keep the largest `maxTiles` and gather the rest into one.
*
* A phone-width treemap runs out of legible tiles somewhere around twenty.
* Past that the tail is texture, and one tile that says how much the tail is
* worth is more use than forty that cannot be read or hit.
*/
maxTiles?: number;
/** What the gathered tile is called. */
otherLabel?: string;
/** The ramp's hue. Defaults to the first chart token. */
color?: string;
/**
* Smallest side, in points, a tile needs before `Labels` writes on it.
*
* A name clipped to two letters is not a shorter name, it is a different
* word. Tiles under this are left blank and read through the readout.
*/
minLabelSize?: number;
/** Milliseconds for one tile to grow. */
animationDuration?: number;
/** Milliseconds between one tile starting and the next. `0` for all at once. */
staggerDelay?: number;
/** `loading` draws the box undivided until the data arrives. */
status?: TreemapChartStatus;
/** Selected tile, indexed as laid out. Leave unset to let the chart track it. */
activeIndex?: number;
/** Fires with the selected tile, or `-1` when the selection is cleared. */
onActiveIndexChange?: (index: number) => void;
children?: ReactNode;
}
/** Imperative handle: re-run the entrance, for a "replay" control. */
export interface TreemapChartHandle {
replay: () => void;
}
const TreemapChartRoot = forwardRef(
function TreemapChartRoot(
{
className,
data,
aspectRatio = DEFAULT_ASPECT,
gap = 3,
cornerRadius = 6,
sort = true,
maxTiles,
otherLabel = 'Other',
color,
minLabelSize = DEFAULT_MIN_LABEL,
animationDuration = 520,
staggerDelay = STAGGER,
status = 'ready',
activeIndex: activeIndexProp,
onActiveIndexChange,
children,
...props
},
ref
) {
const [size, setSize] = useState({ width: 0, height: 0 });
const [internalActive, setInternalActive] = useState(-1);
const reveal = useSharedValue(0);
const reducedMotion = useReducedMotion();
const controlled = activeIndexProp !== undefined;
const activeIndex = controlled ? activeIndexProp : internalActive;
const setActiveIndex = useMemo(
() => (index: number) => {
if (!controlled) setInternalActive(index);
onActiveIndexChange?.(index);
},
[controlled, onActiveIndexChange]
);
/*
* The parts, cleaned up and put in order, before any of it is measured.
* Kept apart from the layout below so that resizing the card re-runs the
* geometry without re-running the sort and the gathering.
*/
const parts = useMemo(() => {
const cleaned = data.map((datum, index) => ({
label: datum.label,
// An area cannot be negative, and a treemap has no way to draw one.
value: Math.max(0, datum.value) || 0,
color: datum.color,
sourceIndex: index,
}));
const ordered = sort ? [...cleaned].sort((a, b) => b.value - a.value) : cleaned;
const limit = maxTiles && maxTiles > 0 ? Math.floor(maxTiles) : 0;
if (!limit || ordered.length <= limit) return ordered;
/*
* The tail is gathered rather than dropped. Dropping it would rescale
* everything that is left, so every remaining tile would silently claim a
* larger share of the total than it has.
*/
const kept = ordered.slice(0, Math.max(1, limit - 1));
const rest = ordered.slice(Math.max(1, limit - 1));
const tail = rest.reduce((sum, part) => sum + part.value, 0);
if (tail <= 0) return kept;
return [
...kept,
{ label: otherLabel, value: tail, color: undefined, sourceIndex: -1 },
];
}, [data, sort, maxTiles, otherLabel]);
const total = useMemo(
() => parts.reduce((sum, part) => sum + part.value, 0),
[parts]
);
const hue = useSeriesColor(color, 1);
const tiles = useMemo(() => {
if (!parts.length || size.width <= 0 || size.height <= 0) return [];
const rects = squarifyLayout(
parts.map((part) => part.value),
size.width,
size.height
);
if (!rects.length) return [];
const inset = Math.max(0, gap) / 2;
const last = Math.max(parts.length - 1, 1);
return parts.map((part, index) => {
const rect = rects[index] ?? { x: 0, y: 0, width: 0, height: 0 };
return {
label: part.label,
value: part.value,
share: total > 0 ? part.value / total : 0,
color: part.color ?? hue,
// Down the ranking rather than by value: two tiles of nearly equal
// size should still be told apart, and it is the order that is being
// shown, not a second copy of the area.
strength: part.color ? 1 : 1 - (index / last) * FADE,
sourceIndex: part.sourceIndex,
x: rect.x + inset,
y: rect.y + inset,
width: Math.max(0, rect.width - inset * 2),
height: Math.max(0, rect.height - inset * 2),
};
});
}, [parts, size.width, size.height, gap, total, hue]);
/*
* One clock for the whole entrance, with each tile given the slice of it
* that it grows in. A shared value per tile would be the same animation
* played `n` times and `n` more things for a replay to have to find.
*/
const stagger = Math.max(0, staggerDelay);
const span = animationDuration + Math.max(tiles.length - 1, 0) * stagger;
const windows = useMemo(
() =>
tiles.map((_unused, index) => {
const from = index * stagger;
return {
from: span > 0 ? from / span : 0,
to: span > 0 ? (from + animationDuration) / span : 1,
};
}),
[tiles, stagger, animationDuration, span]
);
const playReveal = useMemo(
() => () => {
if (reducedMotion) {
reveal.value = 1;
return;
}
reveal.value = 0;
// Linear, because the shaping is per tile: each one eases inside its
// own window, and easing the clock as well would ease it twice.
reveal.value = withTiming(1, { duration: span, easing: Easing.linear });
},
[reducedMotion, span, reveal]
);
const loading = status === 'loading';
const revealed = useRef(false);
useEffect(() => {
if (loading) {
revealed.current = false;
reveal.value = 0;
return;
}
if (revealed.current || !tiles.length) return;
revealed.current = true;
playReveal();
}, [loading, tiles.length, playReveal, reveal]);
useImperativeHandle(ref, () => ({ replay: playReveal }), [playReveal]);
// The caller's own `onLayout` is not forwarded from here: it is already on
// the outer view, and the box it wants is the whole chart's rather than the
// tiles' — which are different heights the moment there is a header.
const onLayout = (event: LayoutChangeEvent) => {
const next = {
width: Math.round(event.nativeEvent.layout.width),
height: Math.round(event.nativeEvent.layout.height),
};
if (next.width !== size.width || next.height !== size.height) setSize(next);
};
const context = useMemo(
() => ({
tiles,
width: size.width,
height: size.height,
total,
cornerRadius,
minLabelSize,
reveal,
windows,
status,
activeIndex,
setActiveIndex,
}),
[
tiles,
size.width,
size.height,
total,
cornerRadius,
minLabelSize,
reveal,
windows,
status,
activeIndex,
setActiveIndex,
]
);
const slots: Record = {
svg: [],
overlay: [],
header: [],
footer: [],
};
Children.forEach(children, (child, index) => {
if (!isValidElement(child)) return;
const slot = (child.type as { slot?: Slot }).slot ?? 'overlay';
slots[slot in slots ? slot : 'overlay'].push(
{child}
);
});
return (
{slots.header}
{/*
* The box is measured on its own view rather than the outer one, so a
* header or a legend cannot change how large the tiles think they are.
*/}
{size.width > 0 && size.height > 0 ? (
<>
{/*
* Labels sit over the SVG rather than inside it: they are text,
* and SVG text ignores the platform's text scaling and the
* theme's font.
*/}
{slots.overlay}
>
) : null}
{slots.footer}
);
}
);
TreemapChartRoot.displayName = 'TreemapChart';
function ChildSlot({ children }: { children: ReactNode }) {
return <>{children}>;
}
/* -------------------------------------------------------------------------- */
/* SVG layer */
/* -------------------------------------------------------------------------- */
export interface TreemapChartTilesProps {
/** Opacity of the tiles that are not selected, once one is. */
dimOpacity?: number;
}
/**
* Every tile, drawn in the order they were laid out.
*
* One part rather than one per datum: a tile's box is decided by every tile
* before it in the row, so they cannot be configured apart without the layout
* coming apart with them.
*/
function TreemapChartTiles({ dimOpacity = 0.35 }: TreemapChartTilesProps) {
const { tiles, cornerRadius, reveal, windows, status, activeIndex, setActiveIndex } =
useChart('TreemapChart.Tiles');
if (status === 'loading' || !tiles.length) return null;
return (
{tiles.map((tile, index) => {
const window = windows[index];
if (!window || tile.width <= 0 || tile.height <= 0) return null;
return (
= 0 && activeIndex !== index}
dimOpacity={dimOpacity}
onPress={() => setActiveIndex(activeIndex === index ? -1 : index)}
/>
);
})}
);
}
TreemapChartTiles.displayName = 'TreemapChart.Tiles';
TreemapChartTiles.slot = 'svg' as const;
/**
* One tile, growing out of its own centre.
*
* Out of the centre rather than up from an edge, because a treemap has no
* baseline for anything to grow from — every tile is surrounded by others, and
* a shared direction would read as the whole chart sliding.
*/
function Tile({
tile,
radius,
reveal,
window,
dimmed,
dimOpacity,
onPress,
}: {
tile: TreemapTile;
radius: number;
reveal: SharedValue;
window: { from: number; to: number };
dimmed: boolean;
dimOpacity: number;
onPress: () => void;
}) {
const dim = useDerivedValue(() =>
withTiming(dimmed ? 1 : 0, { duration: SELECT_DURATION })
);
const animatedProps = useAnimatedProps(() => {
const span = window.to - window.from || 1;
const raw = (reveal.value - window.from) / span;
const clamped = raw < 0 ? 0 : raw > 1 ? 1 : raw;
// Eased here rather than on the clock, so a tile's own growth is shaped
// while the order the tiles arrive in stays even.
const grown = 1 - (1 - clamped) * (1 - clamped) * (1 - clamped);
const width = tile.width * grown;
const height = tile.height * grown;
return {
x: tile.x + (tile.width - width) / 2,
y: tile.y + (tile.height - height) / 2,
width,
height,
opacity: tile.strength * (1 - dim.value * (1 - dimOpacity)),
};
});
const percent = Math.round(tile.share * 100);
return (
);
}
export interface TreemapChartSkeletonProps {
/** Milliseconds for one pass of the sweep. */
duration?: number;
color?: string;
}
/**
* The loading state: the box as one plain rectangle, with a highlight
* travelling across it.
*
* Undivided on purpose. Placeholder tiles would be a made-up split, and a
* reader has no way to tell an invented one from a real one until it changes
* under them — which is worse than showing nothing, because it is showing
* something wrong.
*/
function TreemapChartSkeleton({ duration = 1400, color }: TreemapChartSkeletonProps) {
const { width, height, cornerRadius, status } = useChart('TreemapChart.Skeleton');
const token = useCSSVariable('--color-skeleton');
const base = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)');
const highlight = useSeriesColor(undefined, 1);
// Unique per instance: a fixed id makes two of these on one screen share a
// gradient, and the second one to mount wins.
const gradientId = `panelui-treemap-skeleton-${useId().replace(/[^a-zA-Z0-9]/g, '')}`;
const sweep = useSharedValue(0);
const reducedMotion = useReducedMotion();
const loading = status === 'loading';
useEffect(() => {
if (!loading || reducedMotion) {
cancelAnimation(sweep);
sweep.value = 0;
return;
}
sweep.value = 0;
sweep.value = withRepeat(withTiming(1, { duration, easing: Easing.linear }), -1, false);
return () => cancelAnimation(sweep);
}, [loading, reducedMotion, duration, sweep]);
// The band travels by moving the gradient's own endpoints, so the whole
// effect is two numbers changing on the UI thread.
const animatedProps = useAnimatedProps(() => ({
x1: `${(sweep.value * 1.4 - 0.4) * 100}%`,
x2: `${(sweep.value * 1.4 - 0.4 + 0.4) * 100}%`,
}));
// Held through the fade rather than to the frame the data lands: the box has
// to dissolve while the tiles grow through it, or the panel is empty for as
// long as the first tile takes to become visible.
const { mounted, opacity } = useSkeletonHandoff(loading);
const fadeProps = useAnimatedProps(() => ({ opacity: opacity.value }));
if (!mounted || width <= 0 || height <= 0) return null;
return (
);
}
TreemapChartSkeleton.displayName = 'TreemapChart.Skeleton';
TreemapChartSkeleton.slot = 'svg' as const;
/* -------------------------------------------------------------------------- */
/* Overlay layer */
/* -------------------------------------------------------------------------- */
export interface TreemapChartLabelsProps {
/** Show each tile's value under its name. */
showValue?: boolean;
/** Show each tile's share of the total under its name. */
showShare?: boolean;
/** Format the value. Defaults to a compact number. */
formatValue?: (value: number, tile: TreemapTile) => string;
className?: string;
}
/**
* The name and reading on each tile that has room for them.
*
* Real text over the SVG rather than SVG text, so the labels follow the theme's
* font and the platform's text scaling — SVG text does neither.
*
* A tile smaller than `minLabelSize` on either side is left blank. The
* alternative is a name clipped to its first two letters, which is not a
* shorter name but a different word, and a chart of those is a chart nobody can
* read. Those tiles are read through `Tooltip` instead.
*
* Each label takes its colour from the tile under it rather than from the
* theme. A tile is the chart's own hue, and a theme is free to set that hue
* anywhere on the scale — a fixed white label vanishes on a pale one, and the
* foreground token would be the wrong colour on half the tiles in either mode.
*/
function TreemapChartLabels({
showValue = true,
showShare = false,
formatValue,
className,
}: TreemapChartLabelsProps) {
const { tiles, minLabelSize, reveal, windows, status, activeIndex, setActiveIndex } =
useChart('TreemapChart.Labels');
/*
* The tiles down the ramp are drawn part-transparent, so what a label sits on
* is the hue blended with whatever is behind the chart. Read against the
* background token: a chart on a card is a shade off it, and no tile's
* verdict turns on that much.
*/
const backdrop = useCSSVariable('--color-background');
if (status === 'loading' || !tiles.length) return null;
const behind = typeof backdrop === 'string' ? backdrop : undefined;
const format = formatValue ?? ((value: number) => compactNumber(value));
return (
<>
{tiles.map((tile, index) => {
if (tile.width < minLabelSize || tile.height < minLabelSize) return null;
const percent = Math.round(tile.share * 100);
const ink = inkOn(tile.color, behind, tile.strength);
return (
setActiveIndex(activeIndex === index ? -1 : index)}
style={{
position: 'absolute',
left: tile.x,
top: tile.y,
width: tile.width,
height: tile.height,
}}
className={cn('justify-start p-2', className)}
>
{tile.label}
{showValue ? (
{format(tile.value, tile)}
) : null}
{showShare ? (
{percent}%
) : null}
);
})}
>
);
}
const FULL_WINDOW = { from: 0, to: 1 };
/**
* One tile's text, faded in on that tile's own slice of the reveal.
*
* Held at full opacity from the frame the data lands — which is what it used to
* do — every name and number sits at its final position over tiles that have no
* size yet, and the chart spends its whole entrance looking like a wireframe of
* itself. The label belongs to the tile, so it arrives with it.
*/
function TreemapChartLabel({
reveal,
window,
children,
style,
...props
}: {
reveal: SharedValue;
window: { from: number; to: number };
} & Omit & { children: ReactNode; className?: string }) {
const animatedStyle = useAnimatedStyle(() => {
const span = window.to - window.from || 1;
const raw = (reveal.value - window.from) / span;
const clamped = raw < 0 ? 0 : raw > 1 ? 1 : raw;
// Squared, so the text is still faint through the first half of the tile's
// growth and only reads once there is a tile to read it on.
return { opacity: clamped * clamped };
});
return (
{children}
);
}
TreemapChartLabels.displayName = 'TreemapChart.Labels';
TreemapChartLabels.slot = 'overlay' as const;
export interface TreemapChartTooltipProps {
/** Format the value. Defaults to a compact number. */
formatValue?: (value: number, tile: TreemapTile) => string;
className?: string;
}
/**
* The readout for the selected tile, floating over the box.
*
* This is how the small tiles are read. They are the ones with no room for a
* label, so without it a treemap answers questions about its largest parts only
* — which is the half the reader could already see.
*/
function TreemapChartTooltip({ formatValue, className }: TreemapChartTooltipProps) {
const { tiles, width, height, activeIndex, status } = useChart('TreemapChart.Tooltip');
if (status === 'loading' || activeIndex < 0) return null;
const tile = tiles[activeIndex];
if (!tile) return null;
const format = formatValue ?? ((value: number) => compactNumber(value));
// Centred over the tile, then pushed back inside the box — a readout half
// off the edge is one the reader has to guess the rest of.
const left = Math.max(
0,
Math.min(width - TOOLTIP_WIDTH, tile.x + tile.width / 2 - TOOLTIP_WIDTH / 2)
);
// Above the tile where there is room for it, and inside the tile's own top
// edge where there is not, which is the case for everything in the first row.
const above = tile.y - TOOLTIP_HEIGHT - 4;
const top = Math.max(0, Math.min(height - TOOLTIP_HEIGHT, above < 0 ? tile.y + 4 : above));
return (
{tile.label}
{format(tile.value, tile)} · {Math.round(tile.share * 100)}%
);
}
TreemapChartTooltip.displayName = 'TreemapChart.Tooltip';
TreemapChartTooltip.slot = 'overlay' as const;
/* -------------------------------------------------------------------------- */
/* Footer layer */
/* -------------------------------------------------------------------------- */
export interface TreemapChartLegendProps extends ViewProps {
className?: string;
/** How many tiles to name before stopping. The rest are left to the chart. */
limit?: number;
/** Show each tile's share beside its name. */
showShare?: boolean;
}
/**
* A swatch and a name per tile, under the box. Pressable in the same way the
* tiles are.
*
* Inline and wrapping, because the tiles are already in size order and the
* legend is a lookup rather than a ranking — it is read by searching for a
* name, not from the top down.
*/
function TreemapChartLegend({
className,
limit,
showShare = true,
...props
}: TreemapChartLegendProps) {
const { tiles, activeIndex, setActiveIndex } = useChart('TreemapChart.Legend');
if (!tiles.length) return null;
const shown = limit && limit > 0 ? tiles.slice(0, Math.floor(limit)) : tiles;
return (
{shown.map((tile, index) => {
const percent = Math.round(tile.share * 100);
const dimmed = activeIndex >= 0 && activeIndex !== index;
return (
setActiveIndex(activeIndex === index ? -1 : index)}
style={{ opacity: dimmed ? 0.4 : 1 }}
className="max-w-full flex-row items-center gap-1.5"
>
{tile.label}
{showShare ? (
{percent}%
) : null}
);
})}
);
}
TreemapChartLegend.displayName = 'TreemapChart.Legend';
TreemapChartLegend.slot = 'footer' as const;
/* -------------------------------------------------------------------------- */
/* Header layer */
/* -------------------------------------------------------------------------- */
export interface TreemapChartHeaderProps extends ViewProps {
className?: string;
/** Small line above the value — what the total is of. */
title?: string;
/** The readout. The largest thing on the card, and the first thing read. */
value?: string;
/** One muted line under the value — a period, a comparison, a caveat. */
caption?: string;
/** Trailing slot — a control, a badge, a range picker. */
children?: ReactNode;
}
/**
* The strip above the box: what the total is of and what it reads.
*
* The value is not derived even though the chart knows the total, because the
* formatting is not the chart's to guess: 48200 is a count, a currency or a
* rate depending on what was counted.
*/
function TreemapChartHeader({
className,
title,
value,
caption,
children,
...props
}: TreemapChartHeaderProps) {
return (
{title ? (
{title}
) : null}
{value ? (
{value}
) : null}
{caption ? (
{caption}
) : null}
{children ? {children} : null}
);
}
TreemapChartHeader.displayName = 'TreemapChart.Header';
TreemapChartHeader.slot = 'header' as const;
export const TreemapChart = Object.assign(TreemapChartRoot, {
Header: TreemapChartHeader,
Tiles: TreemapChartTiles,
Labels: TreemapChartLabels,
Tooltip: TreemapChartTooltip,
Legend: TreemapChartLegend,
Skeleton: TreemapChartSkeleton,
});