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