panelui-native 0.60.0 → 0.61.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/lib/module/components/area-chart/index.js +86 -1
- package/lib/module/components/area-chart/index.js.map +1 -1
- package/lib/module/components/bar-chart/index.js +112 -2
- package/lib/module/components/bar-chart/index.js.map +1 -1
- package/lib/module/components/treemap-chart/index.js +957 -0
- package/lib/module/components/treemap-chart/index.js.map +1 -0
- package/lib/module/index.js +1 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/utils/chart.js +131 -4
- package/lib/module/utils/chart.js.map +1 -1
- package/lib/typescript/src/components/area-chart/index.d.ts +28 -1
- package/lib/typescript/src/components/area-chart/index.d.ts.map +1 -1
- package/lib/typescript/src/components/bar-chart/index.d.ts +33 -3
- package/lib/typescript/src/components/bar-chart/index.d.ts.map +1 -1
- package/lib/typescript/src/components/treemap-chart/index.d.ts +317 -0
- package/lib/typescript/src/components/treemap-chart/index.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +1 -0
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/utils/chart.d.ts +26 -1
- package/lib/typescript/src/utils/chart.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/area-chart/index.tsx +86 -1
- package/src/components/bar-chart/index.tsx +115 -4
- package/src/components/treemap-chart/index.tsx +1175 -0
- package/src/index.ts +17 -0
- package/src/utils/chart.ts +158 -4
|
@@ -0,0 +1,1175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TreemapChart — a total, cut into the things it is made of, by area.
|
|
3
|
+
*
|
|
4
|
+
* ```tsx
|
|
5
|
+
* <TreemapChart data={spend}>
|
|
6
|
+
* <TreemapChart.Header title="Spend" value="£48,200" />
|
|
7
|
+
* <TreemapChart.Tiles />
|
|
8
|
+
* <TreemapChart.Labels />
|
|
9
|
+
* <TreemapChart.Tooltip />
|
|
10
|
+
* </TreemapChart>
|
|
11
|
+
* ```
|
|
12
|
+
*
|
|
13
|
+
* ## What it is for, against the dial next door
|
|
14
|
+
*
|
|
15
|
+
* A pie and a treemap answer the same question — what is this total made of —
|
|
16
|
+
* and they fail at different sizes. A dial can carry five or six slices before
|
|
17
|
+
* the small ones become slivers with nowhere to put a name. A treemap keeps
|
|
18
|
+
* going, because a share is a rectangle rather than an angle: it can be read
|
|
19
|
+
* at a tenth the size, it tiles the box with nothing left over, and it has a
|
|
20
|
+
* flat side to write on.
|
|
21
|
+
*
|
|
22
|
+
* So the rule of thumb is the count. Up to about six parts, a `PieChart` is
|
|
23
|
+
* easier to read and more familiar. Past that, a treemap is the one that still
|
|
24
|
+
* works.
|
|
25
|
+
*
|
|
26
|
+
* The trade-off is precision. People compare angles badly and areas worse, so
|
|
27
|
+
* nobody should be reading values off the tiles — the layout is for *ranking
|
|
28
|
+
* and grouping* at a glance, and the numbers are in the labels.
|
|
29
|
+
*
|
|
30
|
+
* ## The layout
|
|
31
|
+
*
|
|
32
|
+
* Squarified. Tiles are laid in rows across whichever side of the remaining
|
|
33
|
+
* space is shorter, and a row takes another tile only while doing so makes its
|
|
34
|
+
* worst rectangle *less* elongated than it already is. The result is tiles
|
|
35
|
+
* close to square, which matters for two reasons: a square is the shape whose
|
|
36
|
+
* area the eye judges least badly, and it is the only shape with room for a
|
|
37
|
+
* name across it.
|
|
38
|
+
*
|
|
39
|
+
* It follows that the tiles are sorted, largest first, and the order is the
|
|
40
|
+
* chart's rather than the caller's. An unsorted treemap squarifies badly —
|
|
41
|
+
* rows end up mixing one large tile with several small ones, which is exactly
|
|
42
|
+
* the case the row test cannot rescue. Pass `sort={false}` where the given
|
|
43
|
+
* order carries meaning and the shapes may suffer for it.
|
|
44
|
+
*
|
|
45
|
+
* ## Too many parts
|
|
46
|
+
*
|
|
47
|
+
* A treemap of two hundred rows on a phone is a texture, not a chart. `maxTiles`
|
|
48
|
+
* keeps the largest few and gathers the rest into one tile, which is the honest
|
|
49
|
+
* summary of a long tail — the reader can see how much of the total it is worth
|
|
50
|
+
* instead of squinting at forty slivers that were never legible.
|
|
51
|
+
*
|
|
52
|
+
* ## Colour
|
|
53
|
+
*
|
|
54
|
+
* One hue, stepping down the ranking, rather than a colour per tile. The tiles
|
|
55
|
+
* are parts of one total and the area already says which is bigger, so a set of
|
|
56
|
+
* unrelated hues would be claiming a distinction that is not in the data — and
|
|
57
|
+
* a treemap has more parts than there are chart tokens, so they would repeat
|
|
58
|
+
* and two unrelated tiles would come out matching. A tile can still be given
|
|
59
|
+
* its own `color` where it means something, and that one is drawn at full
|
|
60
|
+
* strength against the ramp.
|
|
61
|
+
*/
|
|
62
|
+
import {
|
|
63
|
+
Children,
|
|
64
|
+
createContext,
|
|
65
|
+
forwardRef,
|
|
66
|
+
isValidElement,
|
|
67
|
+
useContext,
|
|
68
|
+
useEffect,
|
|
69
|
+
useImperativeHandle,
|
|
70
|
+
useMemo,
|
|
71
|
+
useRef,
|
|
72
|
+
useState,
|
|
73
|
+
type ReactNode,
|
|
74
|
+
} from 'react';
|
|
75
|
+
import { Pressable, View, type LayoutChangeEvent, type ViewProps } from 'react-native';
|
|
76
|
+
import Animated, {
|
|
77
|
+
Easing,
|
|
78
|
+
cancelAnimation,
|
|
79
|
+
useAnimatedProps,
|
|
80
|
+
useDerivedValue,
|
|
81
|
+
useReducedMotion,
|
|
82
|
+
useSharedValue,
|
|
83
|
+
withRepeat,
|
|
84
|
+
withTiming,
|
|
85
|
+
type SharedValue,
|
|
86
|
+
} from 'react-native-reanimated';
|
|
87
|
+
import Svg, { Defs, G, LinearGradient, Rect, Stop } from 'react-native-svg';
|
|
88
|
+
import { useCSSVariable } from 'uniwind';
|
|
89
|
+
import { Text } from '../../primitives/text';
|
|
90
|
+
import { compactNumber, inkOn, useSeriesColor } from '../../utils/chart';
|
|
91
|
+
import { cn } from '../../utils/cn';
|
|
92
|
+
|
|
93
|
+
const AnimatedRect = Animated.createAnimatedComponent(Rect);
|
|
94
|
+
const AnimatedLinearGradient = Animated.createAnimatedComponent(LinearGradient);
|
|
95
|
+
|
|
96
|
+
/** Width ÷ height of the box the tiles fill, when the caller does not say. */
|
|
97
|
+
const DEFAULT_ASPECT = 1.4;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The aspect ratio the row test aims each tile at.
|
|
101
|
+
*
|
|
102
|
+
* The golden ratio rather than 1. Aiming at a perfect square makes the test
|
|
103
|
+
* close rows early and leaves the leftovers to the last row, which is then the
|
|
104
|
+
* only badly shaped one on the chart. Aiming slightly wide spreads that cost
|
|
105
|
+
* over all of them.
|
|
106
|
+
*/
|
|
107
|
+
const TARGET_RATIO = (1 + Math.sqrt(5)) / 2;
|
|
108
|
+
|
|
109
|
+
/** How far the hue has faded by the smallest tile. */
|
|
110
|
+
const FADE = 0.45;
|
|
111
|
+
|
|
112
|
+
/** Milliseconds for a tile to dim as another is selected. */
|
|
113
|
+
const SELECT_DURATION = 180;
|
|
114
|
+
|
|
115
|
+
/** Milliseconds between one tile starting to grow and the next. */
|
|
116
|
+
const STAGGER = 26;
|
|
117
|
+
|
|
118
|
+
/** Below this, on either side, a tile has no room for a name. */
|
|
119
|
+
const DEFAULT_MIN_LABEL = 48;
|
|
120
|
+
|
|
121
|
+
/** Room the readout needs above the tile it names. */
|
|
122
|
+
const TOOLTIP_HEIGHT = 30;
|
|
123
|
+
|
|
124
|
+
/** Width the readout is laid out at, so it can be clamped inside the box. */
|
|
125
|
+
const TOOLTIP_WIDTH = 132;
|
|
126
|
+
|
|
127
|
+
/** Where a child is drawn: inside the SVG, over it, above it, or under it. */
|
|
128
|
+
type Slot = 'svg' | 'overlay' | 'header' | 'footer';
|
|
129
|
+
|
|
130
|
+
/** Whether the chart is showing data or waiting for it. */
|
|
131
|
+
export type TreemapChartStatus = 'loading' | 'ready';
|
|
132
|
+
|
|
133
|
+
/** One part of the total. */
|
|
134
|
+
export interface TreemapDatum {
|
|
135
|
+
/** Name of the part, for the label, the readout and the legend. */
|
|
136
|
+
label: string;
|
|
137
|
+
/** Its size. Negatives are treated as zero — an area cannot be less than none. */
|
|
138
|
+
value: number;
|
|
139
|
+
/** Explicit colour, drawn at full strength instead of the ramp. */
|
|
140
|
+
color?: string;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** A tile's box inside the chart, in points. */
|
|
144
|
+
export interface TreemapRect {
|
|
145
|
+
x: number;
|
|
146
|
+
y: number;
|
|
147
|
+
width: number;
|
|
148
|
+
height: number;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** One laid-out tile: its datum, its share of the total, and where it sits. */
|
|
152
|
+
export interface TreemapTile extends TreemapRect {
|
|
153
|
+
label: string;
|
|
154
|
+
value: number;
|
|
155
|
+
/** Its share of the whole chart, `0` to `1`. */
|
|
156
|
+
share: number;
|
|
157
|
+
color: string;
|
|
158
|
+
/** How far along the ramp it is drawn, `0` to `1`. `1` where a colour was given. */
|
|
159
|
+
strength: number;
|
|
160
|
+
/**
|
|
161
|
+
* Its row in `data`, or `-1` for the gathered tile `maxTiles` makes, which
|
|
162
|
+
* stands for several rows and so belongs to none of them.
|
|
163
|
+
*/
|
|
164
|
+
sourceIndex: number;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/* -------------------------------------------------------------------------- */
|
|
168
|
+
/* Layout */
|
|
169
|
+
/* -------------------------------------------------------------------------- */
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* One row of tiles laid across the top of the remaining box.
|
|
173
|
+
*
|
|
174
|
+
* The row is as deep as its share of the total and as wide as the box, and the
|
|
175
|
+
* tiles inside it divide that width between them.
|
|
176
|
+
*/
|
|
177
|
+
function diceRow(
|
|
178
|
+
values: number[],
|
|
179
|
+
from: number,
|
|
180
|
+
to: number,
|
|
181
|
+
sum: number,
|
|
182
|
+
out: TreemapRect[],
|
|
183
|
+
x0: number,
|
|
184
|
+
y0: number,
|
|
185
|
+
x1: number,
|
|
186
|
+
y1: number
|
|
187
|
+
) {
|
|
188
|
+
const scale = sum ? (x1 - x0) / sum : 0;
|
|
189
|
+
let x = x0;
|
|
190
|
+
for (let i = from; i < to; i += 1) {
|
|
191
|
+
const width = values[i]! * scale;
|
|
192
|
+
out[i] = { x, y: y0, width, height: y1 - y0 };
|
|
193
|
+
x += width;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** The same row, stood on its end down the left of the remaining box. */
|
|
198
|
+
function sliceRow(
|
|
199
|
+
values: number[],
|
|
200
|
+
from: number,
|
|
201
|
+
to: number,
|
|
202
|
+
sum: number,
|
|
203
|
+
out: TreemapRect[],
|
|
204
|
+
x0: number,
|
|
205
|
+
y0: number,
|
|
206
|
+
x1: number,
|
|
207
|
+
y1: number
|
|
208
|
+
) {
|
|
209
|
+
const scale = sum ? (y1 - y0) / sum : 0;
|
|
210
|
+
let y = y0;
|
|
211
|
+
for (let i = from; i < to; i += 1) {
|
|
212
|
+
const height = values[i]! * scale;
|
|
213
|
+
out[i] = { x: x0, y, width: x1 - x0, height };
|
|
214
|
+
y += height;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Squarified treemap layout: values in, rectangles out, in the same order.
|
|
220
|
+
*
|
|
221
|
+
* Written out rather than taken from a layout dependency, because it is sixty
|
|
222
|
+
* lines and the alternative is shipping a tree library to call one function of.
|
|
223
|
+
*
|
|
224
|
+
* The shape of it: take the remaining box, and start a row along whichever of
|
|
225
|
+
* its sides is shorter. Add tiles to that row one at a time, and after each,
|
|
226
|
+
* ask what the worst aspect ratio in the row now is. While that number keeps
|
|
227
|
+
* falling the row is getting better and the tile is kept; the first tile that
|
|
228
|
+
* makes it rise is put back, the row is closed and laid out, and the box
|
|
229
|
+
* shrinks by the strip the row took.
|
|
230
|
+
*
|
|
231
|
+
* Rows go along the *shorter* side because a row is divided along its length
|
|
232
|
+
* and is a fixed depth: dividing the long side gives thin tiles, and the whole
|
|
233
|
+
* point of the exercise is not to have any.
|
|
234
|
+
*
|
|
235
|
+
* @param values Tile sizes. Must be non-negative, and should be descending.
|
|
236
|
+
* @param ratio The aspect ratio the row test aims at.
|
|
237
|
+
*/
|
|
238
|
+
export function squarifyLayout(
|
|
239
|
+
values: number[],
|
|
240
|
+
width: number,
|
|
241
|
+
height: number,
|
|
242
|
+
ratio: number = TARGET_RATIO
|
|
243
|
+
): TreemapRect[] {
|
|
244
|
+
const count = values.length;
|
|
245
|
+
const out: TreemapRect[] = new Array(count);
|
|
246
|
+
if (!count || width <= 0 || height <= 0) return [];
|
|
247
|
+
|
|
248
|
+
let remaining = 0;
|
|
249
|
+
for (let i = 0; i < count; i += 1) remaining += values[i]!;
|
|
250
|
+
if (remaining <= 0) return [];
|
|
251
|
+
|
|
252
|
+
let x0 = 0;
|
|
253
|
+
let y0 = 0;
|
|
254
|
+
const x1 = width;
|
|
255
|
+
const y1 = height;
|
|
256
|
+
|
|
257
|
+
let start = 0;
|
|
258
|
+
let end = 0;
|
|
259
|
+
|
|
260
|
+
while (start < count) {
|
|
261
|
+
const dx = x1 - x0;
|
|
262
|
+
const dy = y1 - y0;
|
|
263
|
+
if (dx <= 0 || dy <= 0) break;
|
|
264
|
+
|
|
265
|
+
/*
|
|
266
|
+
* A run of zeros is stepped over rather than measured: they take no area,
|
|
267
|
+
* and dividing by one would make the row test meaningless for the tiles
|
|
268
|
+
* that follow it. They stay in the row and come out with no width.
|
|
269
|
+
*/
|
|
270
|
+
let sum = 0;
|
|
271
|
+
do {
|
|
272
|
+
sum = values[end]!;
|
|
273
|
+
end += 1;
|
|
274
|
+
} while (!sum && end < count);
|
|
275
|
+
|
|
276
|
+
let smallest = sum;
|
|
277
|
+
let largest = sum;
|
|
278
|
+
|
|
279
|
+
/*
|
|
280
|
+
* `alpha` folds everything that does not change inside the row — the box's
|
|
281
|
+
* shape and what is left of the total — into one factor, so the test below
|
|
282
|
+
* is a couple of multiplications per tile rather than a full aspect-ratio
|
|
283
|
+
* calculation.
|
|
284
|
+
*/
|
|
285
|
+
const alpha = Math.max(dy / dx, dx / dy) / (remaining * ratio);
|
|
286
|
+
let beta = sum * sum * alpha;
|
|
287
|
+
let worst = Math.max(largest / beta, beta / smallest);
|
|
288
|
+
|
|
289
|
+
for (; end < count; end += 1) {
|
|
290
|
+
const value = values[end]!;
|
|
291
|
+
sum += value;
|
|
292
|
+
if (value < smallest) smallest = value;
|
|
293
|
+
if (value > largest) largest = value;
|
|
294
|
+
beta = sum * sum * alpha;
|
|
295
|
+
const next = Math.max(largest / beta, beta / smallest);
|
|
296
|
+
// The first tile that makes the row worse is put back for the next one.
|
|
297
|
+
if (next > worst) {
|
|
298
|
+
sum -= value;
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
worst = next;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Along the shorter side: a tall box gets a row across the top, a wide one
|
|
305
|
+
// gets a column down the side.
|
|
306
|
+
if (dx < dy) {
|
|
307
|
+
const edge = remaining ? y0 + (dy * sum) / remaining : y1;
|
|
308
|
+
diceRow(values, start, end, sum, out, x0, y0, x1, edge);
|
|
309
|
+
y0 = edge;
|
|
310
|
+
} else {
|
|
311
|
+
const edge = remaining ? x0 + (dx * sum) / remaining : x1;
|
|
312
|
+
sliceRow(values, start, end, sum, out, x0, y0, edge, y1);
|
|
313
|
+
x0 = edge;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
remaining -= sum;
|
|
317
|
+
start = end;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// A box that ran out of room before the tiles did leaves holes in the array.
|
|
321
|
+
for (let i = 0; i < count; i += 1) {
|
|
322
|
+
if (!out[i]) out[i] = { x: x0, y: y0, width: 0, height: 0 };
|
|
323
|
+
}
|
|
324
|
+
return out;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/* -------------------------------------------------------------------------- */
|
|
328
|
+
/* Root */
|
|
329
|
+
/* -------------------------------------------------------------------------- */
|
|
330
|
+
|
|
331
|
+
interface TreemapChartContextValue {
|
|
332
|
+
tiles: TreemapTile[];
|
|
333
|
+
width: number;
|
|
334
|
+
height: number;
|
|
335
|
+
total: number;
|
|
336
|
+
cornerRadius: number;
|
|
337
|
+
minLabelSize: number;
|
|
338
|
+
/** `0` to `1` across the whole staggered entrance. */
|
|
339
|
+
reveal: SharedValue<number>;
|
|
340
|
+
/** Where in that each tile's own growth begins and ends. */
|
|
341
|
+
windows: { from: number; to: number }[];
|
|
342
|
+
status: TreemapChartStatus;
|
|
343
|
+
activeIndex: number;
|
|
344
|
+
setActiveIndex: (index: number) => void;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const TreemapChartContext = createContext<TreemapChartContextValue | null>(null);
|
|
348
|
+
|
|
349
|
+
function useChart(component: string): TreemapChartContextValue {
|
|
350
|
+
const context = useContext(TreemapChartContext);
|
|
351
|
+
if (!context) {
|
|
352
|
+
throw new Error(`${component} must be used within a <TreemapChart>`);
|
|
353
|
+
}
|
|
354
|
+
return context;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* The selected tile, for something rendered *inside* the chart. A readout in
|
|
359
|
+
* the card's header is outside this provider — use `onActiveIndexChange` there.
|
|
360
|
+
*/
|
|
361
|
+
export function useTreemapChart() {
|
|
362
|
+
const { tiles, total, activeIndex } = useChart('useTreemapChart');
|
|
363
|
+
return {
|
|
364
|
+
/** Index into the tiles **as laid out**, which is the sorted order. */
|
|
365
|
+
activeIndex,
|
|
366
|
+
activeTile: activeIndex >= 0 ? (tiles[activeIndex] ?? null) : null,
|
|
367
|
+
tiles,
|
|
368
|
+
total,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export interface TreemapChartProps extends ViewProps {
|
|
373
|
+
className?: string;
|
|
374
|
+
/** The parts of the total, in any order. Sorted by the chart unless told not to. */
|
|
375
|
+
data: TreemapDatum[];
|
|
376
|
+
/** Width ÷ height of the box the tiles fill. */
|
|
377
|
+
aspectRatio?: number;
|
|
378
|
+
/** Space between one tile and the next, in points. */
|
|
379
|
+
gap?: number;
|
|
380
|
+
/** Corner radius of a tile, in points. */
|
|
381
|
+
cornerRadius?: number;
|
|
382
|
+
/**
|
|
383
|
+
* Sort the tiles largest first.
|
|
384
|
+
*
|
|
385
|
+
* On by default, and worth leaving on. The row test assumes a descending run
|
|
386
|
+
* — given a large tile next to a small one it has no good row to make, and
|
|
387
|
+
* the chart comes out as slivers. Turn it off only where the given order is
|
|
388
|
+
* itself the message.
|
|
389
|
+
*/
|
|
390
|
+
sort?: boolean;
|
|
391
|
+
/**
|
|
392
|
+
* Keep the largest `maxTiles` and gather the rest into one.
|
|
393
|
+
*
|
|
394
|
+
* A phone-width treemap runs out of legible tiles somewhere around twenty.
|
|
395
|
+
* Past that the tail is texture, and one tile that says how much the tail is
|
|
396
|
+
* worth is more use than forty that cannot be read or hit.
|
|
397
|
+
*/
|
|
398
|
+
maxTiles?: number;
|
|
399
|
+
/** What the gathered tile is called. */
|
|
400
|
+
otherLabel?: string;
|
|
401
|
+
/** The ramp's hue. Defaults to the first chart token. */
|
|
402
|
+
color?: string;
|
|
403
|
+
/**
|
|
404
|
+
* Smallest side, in points, a tile needs before `Labels` writes on it.
|
|
405
|
+
*
|
|
406
|
+
* A name clipped to two letters is not a shorter name, it is a different
|
|
407
|
+
* word. Tiles under this are left blank and read through the readout.
|
|
408
|
+
*/
|
|
409
|
+
minLabelSize?: number;
|
|
410
|
+
/** Milliseconds for one tile to grow. */
|
|
411
|
+
animationDuration?: number;
|
|
412
|
+
/** Milliseconds between one tile starting and the next. `0` for all at once. */
|
|
413
|
+
staggerDelay?: number;
|
|
414
|
+
/** `loading` draws the box undivided until the data arrives. */
|
|
415
|
+
status?: TreemapChartStatus;
|
|
416
|
+
/** Selected tile, indexed as laid out. Leave unset to let the chart track it. */
|
|
417
|
+
activeIndex?: number;
|
|
418
|
+
/** Fires with the selected tile, or `-1` when the selection is cleared. */
|
|
419
|
+
onActiveIndexChange?: (index: number) => void;
|
|
420
|
+
children?: ReactNode;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** Imperative handle: re-run the entrance, for a "replay" control. */
|
|
424
|
+
export interface TreemapChartHandle {
|
|
425
|
+
replay: () => void;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const TreemapChartRoot = forwardRef<TreemapChartHandle, TreemapChartProps>(
|
|
429
|
+
function TreemapChartRoot(
|
|
430
|
+
{
|
|
431
|
+
className,
|
|
432
|
+
data,
|
|
433
|
+
aspectRatio = DEFAULT_ASPECT,
|
|
434
|
+
gap = 3,
|
|
435
|
+
cornerRadius = 6,
|
|
436
|
+
sort = true,
|
|
437
|
+
maxTiles,
|
|
438
|
+
otherLabel = 'Other',
|
|
439
|
+
color,
|
|
440
|
+
minLabelSize = DEFAULT_MIN_LABEL,
|
|
441
|
+
animationDuration = 520,
|
|
442
|
+
staggerDelay = STAGGER,
|
|
443
|
+
status = 'ready',
|
|
444
|
+
activeIndex: activeIndexProp,
|
|
445
|
+
onActiveIndexChange,
|
|
446
|
+
children,
|
|
447
|
+
...props
|
|
448
|
+
},
|
|
449
|
+
ref
|
|
450
|
+
) {
|
|
451
|
+
const [size, setSize] = useState({ width: 0, height: 0 });
|
|
452
|
+
const [internalActive, setInternalActive] = useState(-1);
|
|
453
|
+
const reveal = useSharedValue(0);
|
|
454
|
+
const reducedMotion = useReducedMotion();
|
|
455
|
+
|
|
456
|
+
const controlled = activeIndexProp !== undefined;
|
|
457
|
+
const activeIndex = controlled ? activeIndexProp : internalActive;
|
|
458
|
+
|
|
459
|
+
const setActiveIndex = useMemo(
|
|
460
|
+
() => (index: number) => {
|
|
461
|
+
if (!controlled) setInternalActive(index);
|
|
462
|
+
onActiveIndexChange?.(index);
|
|
463
|
+
},
|
|
464
|
+
[controlled, onActiveIndexChange]
|
|
465
|
+
);
|
|
466
|
+
|
|
467
|
+
/*
|
|
468
|
+
* The parts, cleaned up and put in order, before any of it is measured.
|
|
469
|
+
* Kept apart from the layout below so that resizing the card re-runs the
|
|
470
|
+
* geometry without re-running the sort and the gathering.
|
|
471
|
+
*/
|
|
472
|
+
const parts = useMemo(() => {
|
|
473
|
+
const cleaned = data.map((datum, index) => ({
|
|
474
|
+
label: datum.label,
|
|
475
|
+
// An area cannot be negative, and a treemap has no way to draw one.
|
|
476
|
+
value: Math.max(0, datum.value) || 0,
|
|
477
|
+
color: datum.color,
|
|
478
|
+
sourceIndex: index,
|
|
479
|
+
}));
|
|
480
|
+
|
|
481
|
+
const ordered = sort ? [...cleaned].sort((a, b) => b.value - a.value) : cleaned;
|
|
482
|
+
|
|
483
|
+
const limit = maxTiles && maxTiles > 0 ? Math.floor(maxTiles) : 0;
|
|
484
|
+
if (!limit || ordered.length <= limit) return ordered;
|
|
485
|
+
|
|
486
|
+
/*
|
|
487
|
+
* The tail is gathered rather than dropped. Dropping it would rescale
|
|
488
|
+
* everything that is left, so every remaining tile would silently claim a
|
|
489
|
+
* larger share of the total than it has.
|
|
490
|
+
*/
|
|
491
|
+
const kept = ordered.slice(0, Math.max(1, limit - 1));
|
|
492
|
+
const rest = ordered.slice(Math.max(1, limit - 1));
|
|
493
|
+
const tail = rest.reduce((sum, part) => sum + part.value, 0);
|
|
494
|
+
if (tail <= 0) return kept;
|
|
495
|
+
|
|
496
|
+
return [
|
|
497
|
+
...kept,
|
|
498
|
+
{ label: otherLabel, value: tail, color: undefined, sourceIndex: -1 },
|
|
499
|
+
];
|
|
500
|
+
}, [data, sort, maxTiles, otherLabel]);
|
|
501
|
+
|
|
502
|
+
const total = useMemo(
|
|
503
|
+
() => parts.reduce((sum, part) => sum + part.value, 0),
|
|
504
|
+
[parts]
|
|
505
|
+
);
|
|
506
|
+
|
|
507
|
+
const hue = useSeriesColor(color, 1);
|
|
508
|
+
|
|
509
|
+
const tiles = useMemo<TreemapTile[]>(() => {
|
|
510
|
+
if (!parts.length || size.width <= 0 || size.height <= 0) return [];
|
|
511
|
+
|
|
512
|
+
const rects = squarifyLayout(
|
|
513
|
+
parts.map((part) => part.value),
|
|
514
|
+
size.width,
|
|
515
|
+
size.height
|
|
516
|
+
);
|
|
517
|
+
if (!rects.length) return [];
|
|
518
|
+
|
|
519
|
+
const inset = Math.max(0, gap) / 2;
|
|
520
|
+
const last = Math.max(parts.length - 1, 1);
|
|
521
|
+
|
|
522
|
+
return parts.map((part, index) => {
|
|
523
|
+
const rect = rects[index] ?? { x: 0, y: 0, width: 0, height: 0 };
|
|
524
|
+
return {
|
|
525
|
+
label: part.label,
|
|
526
|
+
value: part.value,
|
|
527
|
+
share: total > 0 ? part.value / total : 0,
|
|
528
|
+
color: part.color ?? hue,
|
|
529
|
+
// Down the ranking rather than by value: two tiles of nearly equal
|
|
530
|
+
// size should still be told apart, and it is the order that is being
|
|
531
|
+
// shown, not a second copy of the area.
|
|
532
|
+
strength: part.color ? 1 : 1 - (index / last) * FADE,
|
|
533
|
+
sourceIndex: part.sourceIndex,
|
|
534
|
+
x: rect.x + inset,
|
|
535
|
+
y: rect.y + inset,
|
|
536
|
+
width: Math.max(0, rect.width - inset * 2),
|
|
537
|
+
height: Math.max(0, rect.height - inset * 2),
|
|
538
|
+
};
|
|
539
|
+
});
|
|
540
|
+
}, [parts, size.width, size.height, gap, total, hue]);
|
|
541
|
+
|
|
542
|
+
/*
|
|
543
|
+
* One clock for the whole entrance, with each tile given the slice of it
|
|
544
|
+
* that it grows in. A shared value per tile would be the same animation
|
|
545
|
+
* played `n` times and `n` more things for a replay to have to find.
|
|
546
|
+
*/
|
|
547
|
+
const stagger = Math.max(0, staggerDelay);
|
|
548
|
+
const span = animationDuration + Math.max(tiles.length - 1, 0) * stagger;
|
|
549
|
+
const windows = useMemo(
|
|
550
|
+
() =>
|
|
551
|
+
tiles.map((_unused, index) => {
|
|
552
|
+
const from = index * stagger;
|
|
553
|
+
return {
|
|
554
|
+
from: span > 0 ? from / span : 0,
|
|
555
|
+
to: span > 0 ? (from + animationDuration) / span : 1,
|
|
556
|
+
};
|
|
557
|
+
}),
|
|
558
|
+
[tiles, stagger, animationDuration, span]
|
|
559
|
+
);
|
|
560
|
+
|
|
561
|
+
const playReveal = useMemo(
|
|
562
|
+
() => () => {
|
|
563
|
+
if (reducedMotion) {
|
|
564
|
+
reveal.value = 1;
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
reveal.value = 0;
|
|
568
|
+
// Linear, because the shaping is per tile: each one eases inside its
|
|
569
|
+
// own window, and easing the clock as well would ease it twice.
|
|
570
|
+
reveal.value = withTiming(1, { duration: span, easing: Easing.linear });
|
|
571
|
+
},
|
|
572
|
+
[reducedMotion, span, reveal]
|
|
573
|
+
);
|
|
574
|
+
|
|
575
|
+
const loading = status === 'loading';
|
|
576
|
+
const revealed = useRef(false);
|
|
577
|
+
|
|
578
|
+
useEffect(() => {
|
|
579
|
+
if (loading) {
|
|
580
|
+
revealed.current = false;
|
|
581
|
+
reveal.value = 0;
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
if (revealed.current || !tiles.length) return;
|
|
585
|
+
revealed.current = true;
|
|
586
|
+
playReveal();
|
|
587
|
+
}, [loading, tiles.length, playReveal, reveal]);
|
|
588
|
+
|
|
589
|
+
useImperativeHandle(ref, () => ({ replay: playReveal }), [playReveal]);
|
|
590
|
+
|
|
591
|
+
// The caller's own `onLayout` is not forwarded from here: it is already on
|
|
592
|
+
// the outer view, and the box it wants is the whole chart's rather than the
|
|
593
|
+
// tiles' — which are different heights the moment there is a header.
|
|
594
|
+
const onLayout = (event: LayoutChangeEvent) => {
|
|
595
|
+
const next = {
|
|
596
|
+
width: Math.round(event.nativeEvent.layout.width),
|
|
597
|
+
height: Math.round(event.nativeEvent.layout.height),
|
|
598
|
+
};
|
|
599
|
+
if (next.width !== size.width || next.height !== size.height) setSize(next);
|
|
600
|
+
};
|
|
601
|
+
|
|
602
|
+
const context = useMemo<TreemapChartContextValue>(
|
|
603
|
+
() => ({
|
|
604
|
+
tiles,
|
|
605
|
+
width: size.width,
|
|
606
|
+
height: size.height,
|
|
607
|
+
total,
|
|
608
|
+
cornerRadius,
|
|
609
|
+
minLabelSize,
|
|
610
|
+
reveal,
|
|
611
|
+
windows,
|
|
612
|
+
status,
|
|
613
|
+
activeIndex,
|
|
614
|
+
setActiveIndex,
|
|
615
|
+
}),
|
|
616
|
+
[
|
|
617
|
+
tiles,
|
|
618
|
+
size.width,
|
|
619
|
+
size.height,
|
|
620
|
+
total,
|
|
621
|
+
cornerRadius,
|
|
622
|
+
minLabelSize,
|
|
623
|
+
reveal,
|
|
624
|
+
windows,
|
|
625
|
+
status,
|
|
626
|
+
activeIndex,
|
|
627
|
+
setActiveIndex,
|
|
628
|
+
]
|
|
629
|
+
);
|
|
630
|
+
|
|
631
|
+
const slots: Record<Slot, ReactNode[]> = {
|
|
632
|
+
svg: [],
|
|
633
|
+
overlay: [],
|
|
634
|
+
header: [],
|
|
635
|
+
footer: [],
|
|
636
|
+
};
|
|
637
|
+
Children.forEach(children, (child, index) => {
|
|
638
|
+
if (!isValidElement(child)) return;
|
|
639
|
+
const slot = (child.type as { slot?: Slot }).slot ?? 'overlay';
|
|
640
|
+
slots[slot in slots ? slot : 'overlay'].push(
|
|
641
|
+
<ChildSlot key={index}>{child}</ChildSlot>
|
|
642
|
+
);
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
return (
|
|
646
|
+
<TreemapChartContext.Provider value={context}>
|
|
647
|
+
<View {...props} style={props.style} className={cn('w-full', className)}>
|
|
648
|
+
{slots.header}
|
|
649
|
+
{/*
|
|
650
|
+
* The box is measured on its own view rather than the outer one, so a
|
|
651
|
+
* header or a legend cannot change how large the tiles think they are.
|
|
652
|
+
*/}
|
|
653
|
+
<View onLayout={onLayout} style={{ aspectRatio }} className="w-full">
|
|
654
|
+
{size.width > 0 && size.height > 0 ? (
|
|
655
|
+
<>
|
|
656
|
+
<Svg width={size.width} height={size.height}>
|
|
657
|
+
{slots.svg}
|
|
658
|
+
</Svg>
|
|
659
|
+
{/*
|
|
660
|
+
* Labels sit over the SVG rather than inside it: they are text,
|
|
661
|
+
* and SVG text ignores the platform's text scaling and the
|
|
662
|
+
* theme's font.
|
|
663
|
+
*/}
|
|
664
|
+
<View
|
|
665
|
+
pointerEvents="box-none"
|
|
666
|
+
style={{
|
|
667
|
+
position: 'absolute',
|
|
668
|
+
width: size.width,
|
|
669
|
+
height: size.height,
|
|
670
|
+
}}
|
|
671
|
+
>
|
|
672
|
+
{slots.overlay}
|
|
673
|
+
</View>
|
|
674
|
+
</>
|
|
675
|
+
) : null}
|
|
676
|
+
</View>
|
|
677
|
+
{slots.footer}
|
|
678
|
+
</View>
|
|
679
|
+
</TreemapChartContext.Provider>
|
|
680
|
+
);
|
|
681
|
+
}
|
|
682
|
+
);
|
|
683
|
+
TreemapChartRoot.displayName = 'TreemapChart';
|
|
684
|
+
|
|
685
|
+
function ChildSlot({ children }: { children: ReactNode }) {
|
|
686
|
+
return <>{children}</>;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/* -------------------------------------------------------------------------- */
|
|
690
|
+
/* SVG layer */
|
|
691
|
+
/* -------------------------------------------------------------------------- */
|
|
692
|
+
|
|
693
|
+
export interface TreemapChartTilesProps {
|
|
694
|
+
/** Opacity of the tiles that are not selected, once one is. */
|
|
695
|
+
dimOpacity?: number;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* Every tile, drawn in the order they were laid out.
|
|
700
|
+
*
|
|
701
|
+
* One part rather than one per datum: a tile's box is decided by every tile
|
|
702
|
+
* before it in the row, so they cannot be configured apart without the layout
|
|
703
|
+
* coming apart with them.
|
|
704
|
+
*/
|
|
705
|
+
function TreemapChartTiles({ dimOpacity = 0.35 }: TreemapChartTilesProps) {
|
|
706
|
+
const { tiles, cornerRadius, reveal, windows, status, activeIndex, setActiveIndex } =
|
|
707
|
+
useChart('TreemapChart.Tiles');
|
|
708
|
+
|
|
709
|
+
if (status === 'loading' || !tiles.length) return null;
|
|
710
|
+
|
|
711
|
+
return (
|
|
712
|
+
<G>
|
|
713
|
+
{tiles.map((tile, index) => {
|
|
714
|
+
const window = windows[index];
|
|
715
|
+
if (!window || tile.width <= 0 || tile.height <= 0) return null;
|
|
716
|
+
return (
|
|
717
|
+
<Tile
|
|
718
|
+
key={`${tile.label}-${index}`}
|
|
719
|
+
tile={tile}
|
|
720
|
+
radius={cornerRadius}
|
|
721
|
+
reveal={reveal}
|
|
722
|
+
window={window}
|
|
723
|
+
dimmed={activeIndex >= 0 && activeIndex !== index}
|
|
724
|
+
dimOpacity={dimOpacity}
|
|
725
|
+
onPress={() => setActiveIndex(activeIndex === index ? -1 : index)}
|
|
726
|
+
/>
|
|
727
|
+
);
|
|
728
|
+
})}
|
|
729
|
+
</G>
|
|
730
|
+
);
|
|
731
|
+
}
|
|
732
|
+
TreemapChartTiles.displayName = 'TreemapChart.Tiles';
|
|
733
|
+
TreemapChartTiles.slot = 'svg' as const;
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* One tile, growing out of its own centre.
|
|
737
|
+
*
|
|
738
|
+
* Out of the centre rather than up from an edge, because a treemap has no
|
|
739
|
+
* baseline for anything to grow from — every tile is surrounded by others, and
|
|
740
|
+
* a shared direction would read as the whole chart sliding.
|
|
741
|
+
*/
|
|
742
|
+
function Tile({
|
|
743
|
+
tile,
|
|
744
|
+
radius,
|
|
745
|
+
reveal,
|
|
746
|
+
window,
|
|
747
|
+
dimmed,
|
|
748
|
+
dimOpacity,
|
|
749
|
+
onPress,
|
|
750
|
+
}: {
|
|
751
|
+
tile: TreemapTile;
|
|
752
|
+
radius: number;
|
|
753
|
+
reveal: SharedValue<number>;
|
|
754
|
+
window: { from: number; to: number };
|
|
755
|
+
dimmed: boolean;
|
|
756
|
+
dimOpacity: number;
|
|
757
|
+
onPress: () => void;
|
|
758
|
+
}) {
|
|
759
|
+
const dim = useDerivedValue<number>(() =>
|
|
760
|
+
withTiming(dimmed ? 1 : 0, { duration: SELECT_DURATION })
|
|
761
|
+
);
|
|
762
|
+
|
|
763
|
+
const animatedProps = useAnimatedProps(() => {
|
|
764
|
+
const span = window.to - window.from || 1;
|
|
765
|
+
const raw = (reveal.value - window.from) / span;
|
|
766
|
+
const clamped = raw < 0 ? 0 : raw > 1 ? 1 : raw;
|
|
767
|
+
// Eased here rather than on the clock, so a tile's own growth is shaped
|
|
768
|
+
// while the order the tiles arrive in stays even.
|
|
769
|
+
const grown = 1 - (1 - clamped) * (1 - clamped) * (1 - clamped);
|
|
770
|
+
|
|
771
|
+
const width = tile.width * grown;
|
|
772
|
+
const height = tile.height * grown;
|
|
773
|
+
|
|
774
|
+
return {
|
|
775
|
+
x: tile.x + (tile.width - width) / 2,
|
|
776
|
+
y: tile.y + (tile.height - height) / 2,
|
|
777
|
+
width,
|
|
778
|
+
height,
|
|
779
|
+
opacity: tile.strength * (1 - dim.value * (1 - dimOpacity)),
|
|
780
|
+
};
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
const percent = Math.round(tile.share * 100);
|
|
784
|
+
|
|
785
|
+
return (
|
|
786
|
+
<AnimatedRect
|
|
787
|
+
animatedProps={animatedProps}
|
|
788
|
+
fill={tile.color}
|
|
789
|
+
// A tile is rounded only where it has room to be: a sliver with a 6-point
|
|
790
|
+
// radius is a lozenge, and a row of them reads as a scale rather than a
|
|
791
|
+
// set of areas.
|
|
792
|
+
rx={Math.max(0, Math.min(radius, tile.width / 2, tile.height / 2))}
|
|
793
|
+
onPress={onPress}
|
|
794
|
+
// An SVG node takes a label but not a role or a state, so the tiles are
|
|
795
|
+
// reachable and named without being announced as buttons.
|
|
796
|
+
// `TreemapChart.Labels` and `TreemapChart.Legend` are the properly wired
|
|
797
|
+
// way through the same selection, and the larger targets.
|
|
798
|
+
accessibilityLabel={`${tile.label}, ${compactNumber(tile.value)}, ${percent} percent`}
|
|
799
|
+
/>
|
|
800
|
+
);
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
export interface TreemapChartSkeletonProps {
|
|
804
|
+
/** Milliseconds for one pass of the sweep. */
|
|
805
|
+
duration?: number;
|
|
806
|
+
color?: string;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/**
|
|
810
|
+
* The loading state: the box as one plain rectangle, with a highlight
|
|
811
|
+
* travelling across it.
|
|
812
|
+
*
|
|
813
|
+
* Undivided on purpose. Placeholder tiles would be a made-up split, and a
|
|
814
|
+
* reader has no way to tell an invented one from a real one until it changes
|
|
815
|
+
* under them — which is worse than showing nothing, because it is showing
|
|
816
|
+
* something wrong.
|
|
817
|
+
*/
|
|
818
|
+
function TreemapChartSkeleton({ duration = 1400, color }: TreemapChartSkeletonProps) {
|
|
819
|
+
const { width, height, cornerRadius, status } = useChart('TreemapChart.Skeleton');
|
|
820
|
+
const token = useCSSVariable('--color-skeleton');
|
|
821
|
+
const base = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)');
|
|
822
|
+
const highlight = useSeriesColor(undefined, 1);
|
|
823
|
+
|
|
824
|
+
const sweep = useSharedValue(0);
|
|
825
|
+
const reducedMotion = useReducedMotion();
|
|
826
|
+
const loading = status === 'loading';
|
|
827
|
+
|
|
828
|
+
useEffect(() => {
|
|
829
|
+
if (!loading || reducedMotion) {
|
|
830
|
+
cancelAnimation(sweep);
|
|
831
|
+
sweep.value = 0;
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
sweep.value = 0;
|
|
835
|
+
sweep.value = withRepeat(withTiming(1, { duration, easing: Easing.linear }), -1, false);
|
|
836
|
+
return () => cancelAnimation(sweep);
|
|
837
|
+
}, [loading, reducedMotion, duration, sweep]);
|
|
838
|
+
|
|
839
|
+
// The band travels by moving the gradient's own endpoints, so the whole
|
|
840
|
+
// effect is two numbers changing on the UI thread.
|
|
841
|
+
const animatedProps = useAnimatedProps(() => ({
|
|
842
|
+
x1: `${(sweep.value * 1.4 - 0.4) * 100}%`,
|
|
843
|
+
x2: `${(sweep.value * 1.4 - 0.4 + 0.4) * 100}%`,
|
|
844
|
+
}));
|
|
845
|
+
|
|
846
|
+
if (!loading || width <= 0 || height <= 0) return null;
|
|
847
|
+
|
|
848
|
+
const gradientId = 'panelui-treemap-skeleton';
|
|
849
|
+
|
|
850
|
+
return (
|
|
851
|
+
<G>
|
|
852
|
+
<Defs>
|
|
853
|
+
<AnimatedLinearGradient id={gradientId} animatedProps={animatedProps} y1="0" y2="0">
|
|
854
|
+
<Stop offset="0" stopColor={base} />
|
|
855
|
+
<Stop offset="0.5" stopColor={highlight} stopOpacity={0.55} />
|
|
856
|
+
<Stop offset="1" stopColor={base} />
|
|
857
|
+
</AnimatedLinearGradient>
|
|
858
|
+
</Defs>
|
|
859
|
+
<Rect
|
|
860
|
+
x={0}
|
|
861
|
+
y={0}
|
|
862
|
+
width={width}
|
|
863
|
+
height={height}
|
|
864
|
+
rx={cornerRadius}
|
|
865
|
+
fill={`url(#${gradientId})`}
|
|
866
|
+
/>
|
|
867
|
+
</G>
|
|
868
|
+
);
|
|
869
|
+
}
|
|
870
|
+
TreemapChartSkeleton.displayName = 'TreemapChart.Skeleton';
|
|
871
|
+
TreemapChartSkeleton.slot = 'svg' as const;
|
|
872
|
+
|
|
873
|
+
/* -------------------------------------------------------------------------- */
|
|
874
|
+
/* Overlay layer */
|
|
875
|
+
/* -------------------------------------------------------------------------- */
|
|
876
|
+
|
|
877
|
+
export interface TreemapChartLabelsProps {
|
|
878
|
+
/** Show each tile's value under its name. */
|
|
879
|
+
showValue?: boolean;
|
|
880
|
+
/** Show each tile's share of the total under its name. */
|
|
881
|
+
showShare?: boolean;
|
|
882
|
+
/** Format the value. Defaults to a compact number. */
|
|
883
|
+
formatValue?: (value: number, tile: TreemapTile) => string;
|
|
884
|
+
className?: string;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/**
|
|
888
|
+
* The name and reading on each tile that has room for them.
|
|
889
|
+
*
|
|
890
|
+
* Real text over the SVG rather than SVG text, so the labels follow the theme's
|
|
891
|
+
* font and the platform's text scaling — SVG text does neither.
|
|
892
|
+
*
|
|
893
|
+
* A tile smaller than `minLabelSize` on either side is left blank. The
|
|
894
|
+
* alternative is a name clipped to its first two letters, which is not a
|
|
895
|
+
* shorter name but a different word, and a chart of those is a chart nobody can
|
|
896
|
+
* read. Those tiles are read through `Tooltip` instead.
|
|
897
|
+
*
|
|
898
|
+
* Each label takes its colour from the tile under it rather than from the
|
|
899
|
+
* theme. A tile is the chart's own hue, and a theme is free to set that hue
|
|
900
|
+
* anywhere on the scale — a fixed white label vanishes on a pale one, and the
|
|
901
|
+
* foreground token would be the wrong colour on half the tiles in either mode.
|
|
902
|
+
*/
|
|
903
|
+
function TreemapChartLabels({
|
|
904
|
+
showValue = true,
|
|
905
|
+
showShare = false,
|
|
906
|
+
formatValue,
|
|
907
|
+
className,
|
|
908
|
+
}: TreemapChartLabelsProps) {
|
|
909
|
+
const { tiles, minLabelSize, status, activeIndex, setActiveIndex } =
|
|
910
|
+
useChart('TreemapChart.Labels');
|
|
911
|
+
/*
|
|
912
|
+
* The tiles down the ramp are drawn part-transparent, so what a label sits on
|
|
913
|
+
* is the hue blended with whatever is behind the chart. Read against the
|
|
914
|
+
* background token: a chart on a card is a shade off it, and no tile's
|
|
915
|
+
* verdict turns on that much.
|
|
916
|
+
*/
|
|
917
|
+
const backdrop = useCSSVariable('--color-background');
|
|
918
|
+
|
|
919
|
+
if (status === 'loading' || !tiles.length) return null;
|
|
920
|
+
|
|
921
|
+
const behind = typeof backdrop === 'string' ? backdrop : undefined;
|
|
922
|
+
|
|
923
|
+
const format = formatValue ?? ((value: number) => compactNumber(value));
|
|
924
|
+
|
|
925
|
+
return (
|
|
926
|
+
<>
|
|
927
|
+
{tiles.map((tile, index) => {
|
|
928
|
+
if (tile.width < minLabelSize || tile.height < minLabelSize) return null;
|
|
929
|
+
const percent = Math.round(tile.share * 100);
|
|
930
|
+
const ink = inkOn(tile.color, behind, tile.strength);
|
|
931
|
+
return (
|
|
932
|
+
<Pressable
|
|
933
|
+
key={`${tile.label}-${index}`}
|
|
934
|
+
accessibilityRole="button"
|
|
935
|
+
accessibilityState={{ selected: activeIndex === index }}
|
|
936
|
+
accessibilityLabel={`${tile.label}, ${format(tile.value, tile)}, ${percent} percent`}
|
|
937
|
+
onPress={() => setActiveIndex(activeIndex === index ? -1 : index)}
|
|
938
|
+
style={{
|
|
939
|
+
position: 'absolute',
|
|
940
|
+
left: tile.x,
|
|
941
|
+
top: tile.y,
|
|
942
|
+
width: tile.width,
|
|
943
|
+
height: tile.height,
|
|
944
|
+
}}
|
|
945
|
+
className={cn('justify-start p-2', className)}
|
|
946
|
+
>
|
|
947
|
+
<Text
|
|
948
|
+
size="xs"
|
|
949
|
+
weight="semibold"
|
|
950
|
+
numberOfLines={1}
|
|
951
|
+
style={{ color: ink.color }}
|
|
952
|
+
>
|
|
953
|
+
{tile.label}
|
|
954
|
+
</Text>
|
|
955
|
+
{showValue ? (
|
|
956
|
+
<Text size="xs" numberOfLines={1} style={{ color: ink.muted }}>
|
|
957
|
+
{format(tile.value, tile)}
|
|
958
|
+
</Text>
|
|
959
|
+
) : null}
|
|
960
|
+
{showShare ? (
|
|
961
|
+
<Text size="xs" numberOfLines={1} style={{ color: ink.muted }}>
|
|
962
|
+
{percent}%
|
|
963
|
+
</Text>
|
|
964
|
+
) : null}
|
|
965
|
+
</Pressable>
|
|
966
|
+
);
|
|
967
|
+
})}
|
|
968
|
+
</>
|
|
969
|
+
);
|
|
970
|
+
}
|
|
971
|
+
TreemapChartLabels.displayName = 'TreemapChart.Labels';
|
|
972
|
+
TreemapChartLabels.slot = 'overlay' as const;
|
|
973
|
+
|
|
974
|
+
export interface TreemapChartTooltipProps {
|
|
975
|
+
/** Format the value. Defaults to a compact number. */
|
|
976
|
+
formatValue?: (value: number, tile: TreemapTile) => string;
|
|
977
|
+
className?: string;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
/**
|
|
981
|
+
* The readout for the selected tile, floating over the box.
|
|
982
|
+
*
|
|
983
|
+
* This is how the small tiles are read. They are the ones with no room for a
|
|
984
|
+
* label, so without it a treemap answers questions about its largest parts only
|
|
985
|
+
* — which is the half the reader could already see.
|
|
986
|
+
*/
|
|
987
|
+
function TreemapChartTooltip({ formatValue, className }: TreemapChartTooltipProps) {
|
|
988
|
+
const { tiles, width, height, activeIndex, status } = useChart('TreemapChart.Tooltip');
|
|
989
|
+
|
|
990
|
+
if (status === 'loading' || activeIndex < 0) return null;
|
|
991
|
+
|
|
992
|
+
const tile = tiles[activeIndex];
|
|
993
|
+
if (!tile) return null;
|
|
994
|
+
|
|
995
|
+
const format = formatValue ?? ((value: number) => compactNumber(value));
|
|
996
|
+
|
|
997
|
+
// Centred over the tile, then pushed back inside the box — a readout half
|
|
998
|
+
// off the edge is one the reader has to guess the rest of.
|
|
999
|
+
const left = Math.max(
|
|
1000
|
+
0,
|
|
1001
|
+
Math.min(width - TOOLTIP_WIDTH, tile.x + tile.width / 2 - TOOLTIP_WIDTH / 2)
|
|
1002
|
+
);
|
|
1003
|
+
// Above the tile where there is room for it, and inside the tile's own top
|
|
1004
|
+
// edge where there is not, which is the case for everything in the first row.
|
|
1005
|
+
const above = tile.y - TOOLTIP_HEIGHT - 4;
|
|
1006
|
+
const top = Math.max(0, Math.min(height - TOOLTIP_HEIGHT, above < 0 ? tile.y + 4 : above));
|
|
1007
|
+
|
|
1008
|
+
return (
|
|
1009
|
+
<View
|
|
1010
|
+
pointerEvents="none"
|
|
1011
|
+
style={{ position: 'absolute', left, top, width: TOOLTIP_WIDTH }}
|
|
1012
|
+
className={cn(
|
|
1013
|
+
'items-center rounded-lg border border-border bg-popover px-2 py-1 shadow-sm',
|
|
1014
|
+
className
|
|
1015
|
+
)}
|
|
1016
|
+
>
|
|
1017
|
+
<Text size="xs" weight="medium" numberOfLines={1}>
|
|
1018
|
+
{tile.label}
|
|
1019
|
+
</Text>
|
|
1020
|
+
<Text size="xs" muted numberOfLines={1}>
|
|
1021
|
+
{format(tile.value, tile)} · {Math.round(tile.share * 100)}%
|
|
1022
|
+
</Text>
|
|
1023
|
+
</View>
|
|
1024
|
+
);
|
|
1025
|
+
}
|
|
1026
|
+
TreemapChartTooltip.displayName = 'TreemapChart.Tooltip';
|
|
1027
|
+
TreemapChartTooltip.slot = 'overlay' as const;
|
|
1028
|
+
|
|
1029
|
+
/* -------------------------------------------------------------------------- */
|
|
1030
|
+
/* Footer layer */
|
|
1031
|
+
/* -------------------------------------------------------------------------- */
|
|
1032
|
+
|
|
1033
|
+
export interface TreemapChartLegendProps extends ViewProps {
|
|
1034
|
+
className?: string;
|
|
1035
|
+
/** How many tiles to name before stopping. The rest are left to the chart. */
|
|
1036
|
+
limit?: number;
|
|
1037
|
+
/** Show each tile's share beside its name. */
|
|
1038
|
+
showShare?: boolean;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
/**
|
|
1042
|
+
* A swatch and a name per tile, under the box. Pressable in the same way the
|
|
1043
|
+
* tiles are.
|
|
1044
|
+
*
|
|
1045
|
+
* Inline and wrapping, because the tiles are already in size order and the
|
|
1046
|
+
* legend is a lookup rather than a ranking — it is read by searching for a
|
|
1047
|
+
* name, not from the top down.
|
|
1048
|
+
*/
|
|
1049
|
+
function TreemapChartLegend({
|
|
1050
|
+
className,
|
|
1051
|
+
limit,
|
|
1052
|
+
showShare = true,
|
|
1053
|
+
...props
|
|
1054
|
+
}: TreemapChartLegendProps) {
|
|
1055
|
+
const { tiles, activeIndex, setActiveIndex } = useChart('TreemapChart.Legend');
|
|
1056
|
+
|
|
1057
|
+
if (!tiles.length) return null;
|
|
1058
|
+
|
|
1059
|
+
const shown = limit && limit > 0 ? tiles.slice(0, Math.floor(limit)) : tiles;
|
|
1060
|
+
|
|
1061
|
+
return (
|
|
1062
|
+
<View
|
|
1063
|
+
{...props}
|
|
1064
|
+
className={cn(
|
|
1065
|
+
'w-full flex-row flex-wrap items-center gap-x-3 gap-y-1.5 pt-3',
|
|
1066
|
+
className
|
|
1067
|
+
)}
|
|
1068
|
+
>
|
|
1069
|
+
{shown.map((tile, index) => {
|
|
1070
|
+
const percent = Math.round(tile.share * 100);
|
|
1071
|
+
const dimmed = activeIndex >= 0 && activeIndex !== index;
|
|
1072
|
+
return (
|
|
1073
|
+
<Pressable
|
|
1074
|
+
key={`${tile.label}-${index}`}
|
|
1075
|
+
accessibilityRole="button"
|
|
1076
|
+
accessibilityState={{ selected: activeIndex === index }}
|
|
1077
|
+
accessibilityLabel={`${tile.label}, ${percent} percent`}
|
|
1078
|
+
onPress={() => setActiveIndex(activeIndex === index ? -1 : index)}
|
|
1079
|
+
style={{ opacity: dimmed ? 0.4 : 1 }}
|
|
1080
|
+
className="max-w-full flex-row items-center gap-1.5"
|
|
1081
|
+
>
|
|
1082
|
+
<View
|
|
1083
|
+
style={{
|
|
1084
|
+
width: 8,
|
|
1085
|
+
height: 8,
|
|
1086
|
+
borderRadius: 2,
|
|
1087
|
+
backgroundColor: tile.color,
|
|
1088
|
+
opacity: tile.strength,
|
|
1089
|
+
}}
|
|
1090
|
+
/>
|
|
1091
|
+
<Text size="xs" muted numberOfLines={1} className="shrink">
|
|
1092
|
+
{tile.label}
|
|
1093
|
+
</Text>
|
|
1094
|
+
{showShare ? (
|
|
1095
|
+
<Text size="xs" weight="medium" numberOfLines={1}>
|
|
1096
|
+
{percent}%
|
|
1097
|
+
</Text>
|
|
1098
|
+
) : null}
|
|
1099
|
+
</Pressable>
|
|
1100
|
+
);
|
|
1101
|
+
})}
|
|
1102
|
+
</View>
|
|
1103
|
+
);
|
|
1104
|
+
}
|
|
1105
|
+
TreemapChartLegend.displayName = 'TreemapChart.Legend';
|
|
1106
|
+
TreemapChartLegend.slot = 'footer' as const;
|
|
1107
|
+
|
|
1108
|
+
/* -------------------------------------------------------------------------- */
|
|
1109
|
+
/* Header layer */
|
|
1110
|
+
/* -------------------------------------------------------------------------- */
|
|
1111
|
+
|
|
1112
|
+
export interface TreemapChartHeaderProps extends ViewProps {
|
|
1113
|
+
className?: string;
|
|
1114
|
+
/** Small line above the value — what the total is of. */
|
|
1115
|
+
title?: string;
|
|
1116
|
+
/** The readout. The largest thing on the card, and the first thing read. */
|
|
1117
|
+
value?: string;
|
|
1118
|
+
/** One muted line under the value — a period, a comparison, a caveat. */
|
|
1119
|
+
caption?: string;
|
|
1120
|
+
/** Trailing slot — a control, a badge, a range picker. */
|
|
1121
|
+
children?: ReactNode;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
/**
|
|
1125
|
+
* The strip above the box: what the total is of and what it reads.
|
|
1126
|
+
*
|
|
1127
|
+
* The value is not derived even though the chart knows the total, because the
|
|
1128
|
+
* formatting is not the chart's to guess: 48200 is a count, a currency or a
|
|
1129
|
+
* rate depending on what was counted.
|
|
1130
|
+
*/
|
|
1131
|
+
function TreemapChartHeader({
|
|
1132
|
+
className,
|
|
1133
|
+
title,
|
|
1134
|
+
value,
|
|
1135
|
+
caption,
|
|
1136
|
+
children,
|
|
1137
|
+
...props
|
|
1138
|
+
}: TreemapChartHeaderProps) {
|
|
1139
|
+
return (
|
|
1140
|
+
<View
|
|
1141
|
+
{...props}
|
|
1142
|
+
className={cn('flex-row items-start justify-between gap-3 pb-3', className)}
|
|
1143
|
+
>
|
|
1144
|
+
<View className="flex-1 gap-0.5">
|
|
1145
|
+
{title ? (
|
|
1146
|
+
<Text size="xs" muted>
|
|
1147
|
+
{title}
|
|
1148
|
+
</Text>
|
|
1149
|
+
) : null}
|
|
1150
|
+
{value ? (
|
|
1151
|
+
<Text size="xl" weight="bold">
|
|
1152
|
+
{value}
|
|
1153
|
+
</Text>
|
|
1154
|
+
) : null}
|
|
1155
|
+
{caption ? (
|
|
1156
|
+
<Text size="xs" muted>
|
|
1157
|
+
{caption}
|
|
1158
|
+
</Text>
|
|
1159
|
+
) : null}
|
|
1160
|
+
</View>
|
|
1161
|
+
{children ? <View className="max-w-[55%] shrink pt-1">{children}</View> : null}
|
|
1162
|
+
</View>
|
|
1163
|
+
);
|
|
1164
|
+
}
|
|
1165
|
+
TreemapChartHeader.displayName = 'TreemapChart.Header';
|
|
1166
|
+
TreemapChartHeader.slot = 'header' as const;
|
|
1167
|
+
|
|
1168
|
+
export const TreemapChart = Object.assign(TreemapChartRoot, {
|
|
1169
|
+
Header: TreemapChartHeader,
|
|
1170
|
+
Tiles: TreemapChartTiles,
|
|
1171
|
+
Labels: TreemapChartLabels,
|
|
1172
|
+
Tooltip: TreemapChartTooltip,
|
|
1173
|
+
Legend: TreemapChartLegend,
|
|
1174
|
+
Skeleton: TreemapChartSkeleton,
|
|
1175
|
+
});
|