panelui-native 0.53.0 → 0.56.0
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 +4 -1
- package/lib/module/components/combobox/index.js +61 -7
- package/lib/module/components/combobox/index.js.map +1 -1
- package/lib/module/components/hex-chart/index.js +765 -0
- package/lib/module/components/hex-chart/index.js.map +1 -0
- package/lib/module/components/sortable/index.js +74 -12
- package/lib/module/components/sortable/index.js.map +1 -1
- package/lib/module/components/steps/index.js +95 -12
- package/lib/module/components/steps/index.js.map +1 -1
- package/lib/module/components/tag-input/index.js +435 -0
- package/lib/module/components/tag-input/index.js.map +1 -0
- package/lib/module/components/tour/index.js +661 -0
- package/lib/module/components/tour/index.js.map +1 -0
- package/lib/module/index.js +3 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/utils/chart.js +209 -0
- package/lib/module/utils/chart.js.map +1 -1
- package/lib/typescript/src/components/combobox/index.d.ts.map +1 -1
- package/lib/typescript/src/components/hex-chart/index.d.ts +277 -0
- package/lib/typescript/src/components/hex-chart/index.d.ts.map +1 -0
- package/lib/typescript/src/components/sortable/index.d.ts.map +1 -1
- package/lib/typescript/src/components/steps/index.d.ts +19 -0
- package/lib/typescript/src/components/steps/index.d.ts.map +1 -1
- package/lib/typescript/src/components/tag-input/index.d.ts +240 -0
- package/lib/typescript/src/components/tag-input/index.d.ts.map +1 -0
- package/lib/typescript/src/components/tour/index.d.ts +168 -0
- package/lib/typescript/src/components/tour/index.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +3 -0
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/utils/chart.d.ts +98 -0
- package/lib/typescript/src/utils/chart.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/combobox/index.tsx +65 -12
- package/src/components/hex-chart/index.tsx +1009 -0
- package/src/components/sortable/index.tsx +72 -16
- package/src/components/steps/index.tsx +103 -8
- package/src/components/tag-input/index.tsx +619 -0
- package/src/components/tour/index.tsx +933 -0
- package/src/index.ts +30 -0
- package/src/utils/chart.ts +236 -0
|
@@ -0,0 +1,765 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* HexChart — a whole broken into parts, counted out in cells.
|
|
5
|
+
*
|
|
6
|
+
* ```tsx
|
|
7
|
+
* <HexChart data={attribution}>
|
|
8
|
+
* <HexChart.Header title="Attributed revenue" value="$6,750" />
|
|
9
|
+
* <HexChart.Cells />
|
|
10
|
+
* <HexChart.Legend />
|
|
11
|
+
* </HexChart>
|
|
12
|
+
* ```
|
|
13
|
+
*
|
|
14
|
+
* ## What it is, against the pie beside it
|
|
15
|
+
*
|
|
16
|
+
* Both divide one total. The difference is what the reader has to do to read a
|
|
17
|
+
* share off it. A pie asks them to compare angles, which is the hardest
|
|
18
|
+
* quantity there is to judge by eye; this asks them to compare *counts*, and a
|
|
19
|
+
* count is something anyone can check by looking. A series holding a tenth of
|
|
20
|
+
* the cells looks like a tenth and can be confirmed as one, which is why this
|
|
21
|
+
* is the better shape for a split someone is going to quote.
|
|
22
|
+
*
|
|
23
|
+
* What it gives up is precision at the small end. Every cell is a whole unit,
|
|
24
|
+
* so a series worth half a cell either rounds up to a full one or vanishes. It
|
|
25
|
+
* is a chart for shares of a few percent and up, not for a long tail.
|
|
26
|
+
*
|
|
27
|
+
* ## The field
|
|
28
|
+
*
|
|
29
|
+
* The cells are pointy-top hexagons on an offset grid, so each row nests half a
|
|
30
|
+
* cell into the one above it. The unfilled cells are drawn too, in the muted
|
|
31
|
+
* token: the field is the denominator made visible, and a honeycomb floating on
|
|
32
|
+
* nothing gives the eye no total to read the coloured part against.
|
|
33
|
+
*
|
|
34
|
+
* `shape` decides how the filled cells are arranged. `grid` is reading order,
|
|
35
|
+
* which is the arrangement a reader can actually count off. `blob` grows the
|
|
36
|
+
* series out from the middle of the field instead — the smallest in the centre,
|
|
37
|
+
* each larger one wrapped around it — which counts for nothing but shows the
|
|
38
|
+
* shape of the split at a glance. The blob's edge is ragged by design, and
|
|
39
|
+
* ragged the *same way* every time: the nudge that roughens it is a hash of
|
|
40
|
+
* each cell's own coordinates rather than a random number, so a re-render is
|
|
41
|
+
* not an animation and the same data screenshots twice.
|
|
42
|
+
*
|
|
43
|
+
* Cell counts are apportioned by largest remainder, so they add up to the
|
|
44
|
+
* budget exactly. A honeycomb whose parts came to one less than the whole would
|
|
45
|
+
* have a cell in it that nothing in the data accounts for.
|
|
46
|
+
*
|
|
47
|
+
* ## Drawing and animating
|
|
48
|
+
*
|
|
49
|
+
* Two hundred cells is two hundred nodes if each one is drawn on its own, which
|
|
50
|
+
* is more than this needs to spend. Every cell belonging to a series is
|
|
51
|
+
* concatenated into a *single* path instead, so the whole chart is one node per
|
|
52
|
+
* series plus one for the unfilled field — six or seven, whatever the cell
|
|
53
|
+
* count.
|
|
54
|
+
*
|
|
55
|
+
* That is also why the reveal is a clip rather than a per-cell stagger: the
|
|
56
|
+
* cells are no longer separate things to stagger. An ellipse grows from the
|
|
57
|
+
* centre of the field, in the field's own proportions, so the honeycomb is
|
|
58
|
+
* uncovered in the order the blob grew in — which is the same effect a stagger
|
|
59
|
+
* would have given, for one animated value instead of two hundred. A `grid`
|
|
60
|
+
* wipes across instead, because that is the order its cells were filled in.
|
|
61
|
+
*
|
|
62
|
+
* Touch, not hover: a series is selected by pressing one of its cells, and
|
|
63
|
+
* pressing it again clears the selection. There is no equivalent of a pointer
|
|
64
|
+
* resting somewhere without committing, so a chart that only revealed its
|
|
65
|
+
* numbers on hover would never reveal them at all.
|
|
66
|
+
*
|
|
67
|
+
* Colours come from the `--color-chart-*` tokens, so a chart follows the active
|
|
68
|
+
* theme and is put on brand by overriding those five. Nothing here hardcodes a
|
|
69
|
+
* hex.
|
|
70
|
+
*/
|
|
71
|
+
import { Children, createContext, forwardRef, isValidElement, useContext, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
|
|
72
|
+
import { Pressable, View } from 'react-native';
|
|
73
|
+
import Animated, { Easing, useAnimatedProps, useReducedMotion, useSharedValue, withTiming } from 'react-native-reanimated';
|
|
74
|
+
import Svg, { ClipPath, Defs, Ellipse, G, Path, Rect } from 'react-native-svg';
|
|
75
|
+
import { useCSSVariable } from 'uniwind';
|
|
76
|
+
import { Text } from "../../primitives/text.js";
|
|
77
|
+
import { compactNumber, hexAt, hexCenter, hexFillOrder, hexMetrics, hexPath, hexRadiusFor, hexRowsFor, shareCounts, useSeriesColor } from "../../utils/chart.js";
|
|
78
|
+
import { cn } from "../../utils/cn.js";
|
|
79
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
80
|
+
const AnimatedEllipse = Animated.createAnimatedComponent(Ellipse);
|
|
81
|
+
const AnimatedRect = Animated.createAnimatedComponent(Rect);
|
|
82
|
+
|
|
83
|
+
/** Where a child is drawn: inside the SVG, over it, above it, or under it. */
|
|
84
|
+
|
|
85
|
+
/** Whether the chart is showing data or waiting for it. */
|
|
86
|
+
|
|
87
|
+
/** One series. Its share is worked out from the others, so there is no maximum. */
|
|
88
|
+
|
|
89
|
+
/** Everything the layout works out once, and every part then reads. */
|
|
90
|
+
|
|
91
|
+
const EMPTY_PLAN = {
|
|
92
|
+
paths: [],
|
|
93
|
+
field: '',
|
|
94
|
+
counts: [],
|
|
95
|
+
labelAnchors: [],
|
|
96
|
+
owners: []
|
|
97
|
+
};
|
|
98
|
+
const HexChartContext = /*#__PURE__*/createContext(null);
|
|
99
|
+
function useChart(component) {
|
|
100
|
+
const context = useContext(HexChartContext);
|
|
101
|
+
if (!context) {
|
|
102
|
+
throw new Error(`${component} must be used within a <HexChart>`);
|
|
103
|
+
}
|
|
104
|
+
return context;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The selected series and its share, for something rendered inside the chart. */
|
|
108
|
+
export function useHexChart() {
|
|
109
|
+
const {
|
|
110
|
+
data,
|
|
111
|
+
plan,
|
|
112
|
+
total,
|
|
113
|
+
activeIndex
|
|
114
|
+
} = useChart('useHexChart');
|
|
115
|
+
const series = activeIndex >= 0 ? data[activeIndex] ?? null : null;
|
|
116
|
+
return {
|
|
117
|
+
activeIndex,
|
|
118
|
+
activeSeries: series,
|
|
119
|
+
/** Its share of the whole, 0 to 1. */
|
|
120
|
+
activeFraction: series && total > 0 ? Math.max(0, series.value) / total : 0,
|
|
121
|
+
/** How many cells it was given, which is what the reader can count. */
|
|
122
|
+
activeCells: activeIndex >= 0 ? plan.counts[activeIndex] ?? 0 : 0
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Imperative handle: re-run the fill, for a "replay" control. */
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* How far past the field the reveal has to reach to cover its corners.
|
|
130
|
+
*
|
|
131
|
+
* An ellipse with semi-axes `a`, `b` contains the box `2w × 2h` only when
|
|
132
|
+
* `(w/a)² + (h/b)² ≤ 1`; growing both axes in the box's own proportions makes
|
|
133
|
+
* that `2/k² ≤ 1`. Stopping at the edges instead would leave the four corner
|
|
134
|
+
* cells of a `grid` uncovered for good.
|
|
135
|
+
*/
|
|
136
|
+
const CORNER_REACH = Math.SQRT2;
|
|
137
|
+
const HexChartRoot = /*#__PURE__*/forwardRef(function HexChartRoot({
|
|
138
|
+
className,
|
|
139
|
+
data,
|
|
140
|
+
columns = 21,
|
|
141
|
+
aspectRatio = 1.6,
|
|
142
|
+
density = 0.55,
|
|
143
|
+
shape = 'blob',
|
|
144
|
+
cellGap = 0.14,
|
|
145
|
+
animationDuration = 900,
|
|
146
|
+
status = 'ready',
|
|
147
|
+
activeIndex: activeIndexProp,
|
|
148
|
+
onActiveIndexChange,
|
|
149
|
+
children,
|
|
150
|
+
...props
|
|
151
|
+
}, ref) {
|
|
152
|
+
const [measured, setMeasured] = useState(0);
|
|
153
|
+
const [internalActive, setInternalActive] = useState(-1);
|
|
154
|
+
const reveal = useSharedValue(0);
|
|
155
|
+
const reducedMotion = useReducedMotion();
|
|
156
|
+
const clipId = useRef(`panelui-hex-${Math.random().toString(36).slice(2, 9)}`).current;
|
|
157
|
+
const controlled = activeIndexProp !== undefined;
|
|
158
|
+
const activeIndex = controlled ? activeIndexProp : internalActive;
|
|
159
|
+
const setActiveIndex = useMemo(() => index => {
|
|
160
|
+
if (!controlled) setInternalActive(index);
|
|
161
|
+
onActiveIndexChange?.(index);
|
|
162
|
+
}, [controlled, onActiveIndexChange]);
|
|
163
|
+
const width = measured;
|
|
164
|
+
const height = aspectRatio > 0 ? width / aspectRatio : 0;
|
|
165
|
+
const total = useMemo(() => data.reduce((sum, series) => sum + Math.max(0, series.value), 0), [data]);
|
|
166
|
+
const columnCount = Math.max(1, Math.round(columns));
|
|
167
|
+
const metrics = useMemo(() => hexMetrics(hexRadiusFor(width, columnCount)), [width, columnCount]);
|
|
168
|
+
const rows = useMemo(() => hexRowsFor(height, metrics), [height, metrics]);
|
|
169
|
+
|
|
170
|
+
// The field rarely comes out exactly the size of the box — the rows are a
|
|
171
|
+
// whole number and the height is not — so what is left over is split either
|
|
172
|
+
// side of it rather than left at the bottom.
|
|
173
|
+
const fieldWidth = metrics.stepX * (columnCount + 0.5);
|
|
174
|
+
const fieldHeight = (rows - 1) * metrics.stepY + metrics.height;
|
|
175
|
+
const left = (width - fieldWidth) / 2;
|
|
176
|
+
const top = (height - fieldHeight) / 2;
|
|
177
|
+
const loading = status === 'loading';
|
|
178
|
+
const plan = useMemo(() => {
|
|
179
|
+
if (metrics.radius <= 0 || rows <= 0 || !data.length) return EMPTY_PLAN;
|
|
180
|
+
const cells = hexFillOrder(columnCount, rows, shape);
|
|
181
|
+
const capacity = columnCount * rows;
|
|
182
|
+
// Loading has no split to show yet, so nothing is taken and the whole field
|
|
183
|
+
// is left for the skeleton to draw.
|
|
184
|
+
const budget = loading ? 0 : shape === 'grid' ? capacity : Math.max(0, Math.min(capacity, Math.round(capacity * density)));
|
|
185
|
+
const counts = shareCounts(data.map(series => series.value), budget);
|
|
186
|
+
|
|
187
|
+
/*
|
|
188
|
+
* Smallest first for a blob, so the series that is hardest to find gets the
|
|
189
|
+
* middle — where it is most findable — and each larger one wraps around it.
|
|
190
|
+
* Data order for a grid, where reading order is the entire point of the
|
|
191
|
+
* arrangement and reordering it would break the count.
|
|
192
|
+
*/
|
|
193
|
+
const sequence = data.map((_, index) => index);
|
|
194
|
+
if (shape === 'blob') {
|
|
195
|
+
sequence.sort((a, b) => counts[a] - counts[b] || a - b);
|
|
196
|
+
}
|
|
197
|
+
const radius = metrics.radius * (1 - Math.max(0, Math.min(cellGap, 0.5)));
|
|
198
|
+
const owners = new Array(capacity).fill(-1);
|
|
199
|
+
const paths = data.map(() => '');
|
|
200
|
+
const sums = data.map(() => ({
|
|
201
|
+
x: 0,
|
|
202
|
+
highest: Infinity
|
|
203
|
+
}));
|
|
204
|
+
let cursor = 0;
|
|
205
|
+
for (const series of sequence) {
|
|
206
|
+
for (let n = 0; n < counts[series]; n += 1) {
|
|
207
|
+
const cell = cells[cursor];
|
|
208
|
+
cursor += 1;
|
|
209
|
+
if (!cell) break;
|
|
210
|
+
owners[cell.row * columnCount + cell.column] = series;
|
|
211
|
+
const centre = hexCenter(cell.column, cell.row, metrics, left, top);
|
|
212
|
+
paths[series] += hexPath(centre.x, centre.y, radius);
|
|
213
|
+
sums[series].x += centre.x;
|
|
214
|
+
sums[series].highest = Math.min(sums[series].highest, centre.y);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
let field = '';
|
|
218
|
+
for (let row = 0; row < rows; row += 1) {
|
|
219
|
+
for (let column = 0; column < columnCount; column += 1) {
|
|
220
|
+
if (owners[row * columnCount + column] >= 0) continue;
|
|
221
|
+
const centre = hexCenter(column, row, metrics, left, top);
|
|
222
|
+
field += hexPath(centre.x, centre.y, radius);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
const labelAnchors = sums.map((sum, index) => {
|
|
226
|
+
const count = counts[index] ?? 0;
|
|
227
|
+
if (count <= 0) return {
|
|
228
|
+
x: left + fieldWidth / 2,
|
|
229
|
+
y: top
|
|
230
|
+
};
|
|
231
|
+
// The top edge of the series' highest cell, not its centre — the anchor
|
|
232
|
+
// is what the label is hung *from*, and half a cell of overlap is still
|
|
233
|
+
// overlap.
|
|
234
|
+
return {
|
|
235
|
+
x: sum.x / count,
|
|
236
|
+
y: sum.highest - metrics.radius
|
|
237
|
+
};
|
|
238
|
+
});
|
|
239
|
+
return {
|
|
240
|
+
paths,
|
|
241
|
+
field,
|
|
242
|
+
counts,
|
|
243
|
+
labelAnchors,
|
|
244
|
+
owners
|
|
245
|
+
};
|
|
246
|
+
}, [data, metrics, rows, columnCount, shape, density, cellGap, left, top, fieldWidth, fieldHeight, loading]);
|
|
247
|
+
const playReveal = useMemo(() => () => {
|
|
248
|
+
if (reducedMotion) {
|
|
249
|
+
reveal.value = 1;
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
reveal.value = 0;
|
|
253
|
+
reveal.value = withTiming(1, {
|
|
254
|
+
duration: animationDuration,
|
|
255
|
+
easing: Easing.bezier(0.85, 0, 0.15, 1)
|
|
256
|
+
});
|
|
257
|
+
}, [reducedMotion, animationDuration, reveal]);
|
|
258
|
+
const revealed = useRef(false);
|
|
259
|
+
useEffect(() => {
|
|
260
|
+
if (loading) {
|
|
261
|
+
revealed.current = false;
|
|
262
|
+
reveal.value = 0;
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
if (revealed.current || width <= 0 || !data.length) return;
|
|
266
|
+
revealed.current = true;
|
|
267
|
+
playReveal();
|
|
268
|
+
}, [loading, width, data.length, playReveal, reveal]);
|
|
269
|
+
useImperativeHandle(ref, () => ({
|
|
270
|
+
replay: playReveal
|
|
271
|
+
}), [playReveal]);
|
|
272
|
+
|
|
273
|
+
// Resolved here rather than inside the cells, so the legend, the header and
|
|
274
|
+
// the tooltip can name a series' colour without drawing one.
|
|
275
|
+
const c1 = useSeriesColor(undefined, 1);
|
|
276
|
+
const c2 = useSeriesColor(undefined, 2);
|
|
277
|
+
const c3 = useSeriesColor(undefined, 3);
|
|
278
|
+
const c4 = useSeriesColor(undefined, 4);
|
|
279
|
+
const c5 = useSeriesColor(undefined, 5);
|
|
280
|
+
const palette = useMemo(() => [c1, c2, c3, c4, c5], [c1, c2, c3, c4, c5]);
|
|
281
|
+
const colors = useMemo(() => data.map((series, index) => series.color ?? palette[index % palette.length]), [data, palette]);
|
|
282
|
+
const onLayout = event => {
|
|
283
|
+
const next = Math.round(event.nativeEvent.layout.width);
|
|
284
|
+
if (next !== measured) setMeasured(next);
|
|
285
|
+
props.onLayout?.(event);
|
|
286
|
+
};
|
|
287
|
+
const ellipseProps = useAnimatedProps(() => ({
|
|
288
|
+
rx: fieldWidth / 2 * CORNER_REACH * reveal.value,
|
|
289
|
+
ry: fieldHeight / 2 * CORNER_REACH * reveal.value
|
|
290
|
+
}));
|
|
291
|
+
const rectProps = useAnimatedProps(() => ({
|
|
292
|
+
width: width * reveal.value
|
|
293
|
+
}));
|
|
294
|
+
const context = useMemo(() => ({
|
|
295
|
+
data,
|
|
296
|
+
total,
|
|
297
|
+
width,
|
|
298
|
+
height,
|
|
299
|
+
metrics,
|
|
300
|
+
columns: columnCount,
|
|
301
|
+
rows,
|
|
302
|
+
left,
|
|
303
|
+
top,
|
|
304
|
+
plan,
|
|
305
|
+
colors,
|
|
306
|
+
status,
|
|
307
|
+
clipId,
|
|
308
|
+
activeIndex,
|
|
309
|
+
setActiveIndex
|
|
310
|
+
}), [data, total, width, height, metrics, columnCount, rows, left, top, plan, colors, status, clipId, activeIndex, setActiveIndex]);
|
|
311
|
+
const slots = {
|
|
312
|
+
svg: [],
|
|
313
|
+
overlay: [],
|
|
314
|
+
header: [],
|
|
315
|
+
footer: []
|
|
316
|
+
};
|
|
317
|
+
Children.forEach(children, (child, index) => {
|
|
318
|
+
if (! /*#__PURE__*/isValidElement(child)) return;
|
|
319
|
+
const slot = child.type.slot ?? 'overlay';
|
|
320
|
+
slots[slot in slots ? slot : 'overlay'].push(/*#__PURE__*/_jsx(ChildSlot, {
|
|
321
|
+
children: child
|
|
322
|
+
}, index));
|
|
323
|
+
});
|
|
324
|
+
return /*#__PURE__*/_jsx(HexChartContext.Provider, {
|
|
325
|
+
value: context,
|
|
326
|
+
children: /*#__PURE__*/_jsxs(View, {
|
|
327
|
+
...props,
|
|
328
|
+
style: props.style,
|
|
329
|
+
className: cn('w-full', className),
|
|
330
|
+
children: [slots.header, /*#__PURE__*/_jsx(View, {
|
|
331
|
+
onLayout: onLayout,
|
|
332
|
+
style: {
|
|
333
|
+
aspectRatio
|
|
334
|
+
},
|
|
335
|
+
className: "w-full",
|
|
336
|
+
children: width > 0 && height > 0 ? /*#__PURE__*/_jsxs(_Fragment, {
|
|
337
|
+
children: [/*#__PURE__*/_jsxs(Svg, {
|
|
338
|
+
width: width,
|
|
339
|
+
height: height,
|
|
340
|
+
children: [/*#__PURE__*/_jsx(Defs, {
|
|
341
|
+
children: /*#__PURE__*/_jsx(ClipPath, {
|
|
342
|
+
id: clipId,
|
|
343
|
+
children: shape === 'blob' ? /*#__PURE__*/_jsx(AnimatedEllipse, {
|
|
344
|
+
cx: left + fieldWidth / 2,
|
|
345
|
+
cy: top + fieldHeight / 2,
|
|
346
|
+
animatedProps: ellipseProps
|
|
347
|
+
}) : /*#__PURE__*/_jsx(AnimatedRect, {
|
|
348
|
+
x: 0,
|
|
349
|
+
y: 0,
|
|
350
|
+
height: height,
|
|
351
|
+
animatedProps: rectProps
|
|
352
|
+
})
|
|
353
|
+
})
|
|
354
|
+
}), slots.svg]
|
|
355
|
+
}), /*#__PURE__*/_jsx(View, {
|
|
356
|
+
pointerEvents: "box-none",
|
|
357
|
+
style: {
|
|
358
|
+
position: 'absolute',
|
|
359
|
+
width,
|
|
360
|
+
height
|
|
361
|
+
},
|
|
362
|
+
children: slots.overlay
|
|
363
|
+
})]
|
|
364
|
+
}) : null
|
|
365
|
+
}), slots.footer]
|
|
366
|
+
})
|
|
367
|
+
});
|
|
368
|
+
});
|
|
369
|
+
HexChartRoot.displayName = 'HexChart';
|
|
370
|
+
function ChildSlot({
|
|
371
|
+
children
|
|
372
|
+
}) {
|
|
373
|
+
return /*#__PURE__*/_jsx(_Fragment, {
|
|
374
|
+
children: children
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* The honeycomb: the unfilled field, and one path per series over it.
|
|
379
|
+
*
|
|
380
|
+
* One part rather than one per series. Every cell shares a radius, a gap and a
|
|
381
|
+
* grid by definition — a chart where one series' cells could be given a size of
|
|
382
|
+
* their own would be a chart drawing a lie, since the whole claim of the shape
|
|
383
|
+
* is that one cell means the same thing wherever it appears.
|
|
384
|
+
*
|
|
385
|
+
* The field is drawn outside the reveal's clip and the series inside it, so the
|
|
386
|
+
* total is there from the first frame and what fills in against it is the
|
|
387
|
+
* split. Uncovering both together would animate the denominator, which is not
|
|
388
|
+
* something that changed.
|
|
389
|
+
*/
|
|
390
|
+
function HexChartCells({
|
|
391
|
+
emptyColor,
|
|
392
|
+
dimOpacity = 0.25
|
|
393
|
+
}) {
|
|
394
|
+
const {
|
|
395
|
+
data,
|
|
396
|
+
plan,
|
|
397
|
+
total,
|
|
398
|
+
colors,
|
|
399
|
+
status,
|
|
400
|
+
clipId,
|
|
401
|
+
activeIndex
|
|
402
|
+
} = useChart('HexChart.Cells');
|
|
403
|
+
const token = useCSSVariable('--color-muted');
|
|
404
|
+
const empty = emptyColor ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.08)');
|
|
405
|
+
if (status === 'loading') return null;
|
|
406
|
+
return /*#__PURE__*/_jsxs(G, {
|
|
407
|
+
children: [plan.field ? /*#__PURE__*/_jsx(Path, {
|
|
408
|
+
d: plan.field,
|
|
409
|
+
fill: empty
|
|
410
|
+
}) : null, /*#__PURE__*/_jsx(G, {
|
|
411
|
+
clipPath: `url(#${clipId})`,
|
|
412
|
+
children: plan.paths.map((d, index) => {
|
|
413
|
+
const series = data[index];
|
|
414
|
+
if (!d || !series) return null;
|
|
415
|
+
const percent = total > 0 ? Math.round(Math.max(0, series.value) / total * 100) : 0;
|
|
416
|
+
return /*#__PURE__*/_jsx(Path, {
|
|
417
|
+
d: d,
|
|
418
|
+
fill: colors[index],
|
|
419
|
+
fillOpacity: activeIndex >= 0 && activeIndex !== index ? dimOpacity : 1
|
|
420
|
+
// An SVG node takes a label but not a role, so the series are
|
|
421
|
+
// named without being announced as buttons. `HexChart.Legend` is
|
|
422
|
+
// the properly wired way through the same selection, and the
|
|
423
|
+
// easier target of the two.
|
|
424
|
+
,
|
|
425
|
+
accessibilityLabel: `${series.label}, ${percent} percent`
|
|
426
|
+
}, series.label);
|
|
427
|
+
})
|
|
428
|
+
})]
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
HexChartCells.displayName = 'HexChart.Cells';
|
|
432
|
+
HexChartCells.slot = 'svg';
|
|
433
|
+
/**
|
|
434
|
+
* The loading state: the field, with nothing divided up yet.
|
|
435
|
+
*
|
|
436
|
+
* Deliberately undivided. Placeholder shares would be a made-up split, and a
|
|
437
|
+
* reader has no way to tell an invented one from a real one until it changes
|
|
438
|
+
* under them — which is worse than showing nothing, because it is showing
|
|
439
|
+
* something wrong.
|
|
440
|
+
*/
|
|
441
|
+
function HexChartSkeleton({
|
|
442
|
+
color
|
|
443
|
+
}) {
|
|
444
|
+
const {
|
|
445
|
+
plan,
|
|
446
|
+
status
|
|
447
|
+
} = useChart('HexChart.Skeleton');
|
|
448
|
+
const token = useCSSVariable('--color-skeleton');
|
|
449
|
+
const fill = color ?? (typeof token === 'string' ? token : 'rgba(128,128,128,0.2)');
|
|
450
|
+
if (status !== 'loading' || !plan.field) return null;
|
|
451
|
+
return /*#__PURE__*/_jsx(Path, {
|
|
452
|
+
d: plan.field,
|
|
453
|
+
fill: fill
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
HexChartSkeleton.displayName = 'HexChart.Skeleton';
|
|
457
|
+
HexChartSkeleton.slot = 'svg';
|
|
458
|
+
/** Clearance between the label and the cells it is naming. */
|
|
459
|
+
const LABEL_GAP = 6;
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* The press target over the honeycomb, and the label that names what was
|
|
463
|
+
* pressed.
|
|
464
|
+
*
|
|
465
|
+
* The label hangs *above* the selected series rather than on it: it is centred
|
|
466
|
+
* across that series' cells and sits clear of the highest one, so the cells
|
|
467
|
+
* being read are never underneath the thing reading them. Two other places it
|
|
468
|
+
* could go are both worse — under the finger is the one part of the chart
|
|
469
|
+
* nobody can see, and on the middle of the series covers the mass the reader
|
|
470
|
+
* just asked about.
|
|
471
|
+
*
|
|
472
|
+
* A series reaching the top of the field pushes the label back inside it, which
|
|
473
|
+
* is the one case it does overlap. That series is the largest one, and its
|
|
474
|
+
* topmost cells are the part of it a reader is least likely to be counting.
|
|
475
|
+
*
|
|
476
|
+
* A press on an unfilled cell clears the selection, the same as pressing the
|
|
477
|
+
* selected series again. Everything outside the honeycomb is "none of them",
|
|
478
|
+
* and making that gesture do nothing would leave a chart you can select in but
|
|
479
|
+
* not out of.
|
|
480
|
+
*/
|
|
481
|
+
function HexChartTooltip({
|
|
482
|
+
formatValue,
|
|
483
|
+
showCells = false,
|
|
484
|
+
className
|
|
485
|
+
}) {
|
|
486
|
+
const {
|
|
487
|
+
data,
|
|
488
|
+
plan,
|
|
489
|
+
total,
|
|
490
|
+
colors,
|
|
491
|
+
metrics,
|
|
492
|
+
columns,
|
|
493
|
+
rows,
|
|
494
|
+
left,
|
|
495
|
+
top,
|
|
496
|
+
width,
|
|
497
|
+
height,
|
|
498
|
+
status,
|
|
499
|
+
activeIndex,
|
|
500
|
+
setActiveIndex
|
|
501
|
+
} = useChart('HexChart.Tooltip');
|
|
502
|
+
const series = activeIndex >= 0 ? data[activeIndex] ?? null : null;
|
|
503
|
+
const format = formatValue ?? (value => compactNumber(value));
|
|
504
|
+
const onPress = (x, y) => {
|
|
505
|
+
// The shared hit test, called straight from the press handler: it is marked
|
|
506
|
+
// as a worklet for the charts that scrub on the UI thread, and a worklet is
|
|
507
|
+
// an ordinary function everywhere else. A press is one event, and the whole
|
|
508
|
+
// point of resolving it is to hand an index back to React anyway.
|
|
509
|
+
const cell = hexAt(x, y, metrics, left, top, columns, rows);
|
|
510
|
+
const owner = cell ? plan.owners[cell.row * columns + cell.column] ?? -1 : -1;
|
|
511
|
+
setActiveIndex(owner === activeIndex ? -1 : owner);
|
|
512
|
+
};
|
|
513
|
+
if (status === 'loading' || width <= 0) return null;
|
|
514
|
+
const anchor = series ? plan.labelAnchors[activeIndex] : null;
|
|
515
|
+
const percent = series && total > 0 ? Math.round(Math.max(0, series.value) / total * 100) : 0;
|
|
516
|
+
return /*#__PURE__*/_jsxs(_Fragment, {
|
|
517
|
+
children: [/*#__PURE__*/_jsx(Pressable, {
|
|
518
|
+
accessibilityLabel: "Select a series",
|
|
519
|
+
onPress: event => onPress(event.nativeEvent.locationX, event.nativeEvent.locationY),
|
|
520
|
+
style: {
|
|
521
|
+
position: 'absolute',
|
|
522
|
+
left: 0,
|
|
523
|
+
top: 0,
|
|
524
|
+
width,
|
|
525
|
+
height
|
|
526
|
+
}
|
|
527
|
+
}), series && anchor ?
|
|
528
|
+
/*#__PURE__*/
|
|
529
|
+
// Keyed on the selection so the label remounts, and measures itself
|
|
530
|
+
// again, whenever what it has to say changes length.
|
|
531
|
+
_jsx(HexChartLabel, {
|
|
532
|
+
anchor: anchor,
|
|
533
|
+
width: width,
|
|
534
|
+
height: height,
|
|
535
|
+
color: colors[activeIndex],
|
|
536
|
+
value: format(series.value, series),
|
|
537
|
+
detail: showCells ? `${percent}% · ${plan.counts[activeIndex] ?? 0} cells` : `${percent}%`,
|
|
538
|
+
className: className
|
|
539
|
+
}, activeIndex) : null]
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
HexChartTooltip.displayName = 'HexChart.Tooltip';
|
|
543
|
+
HexChartTooltip.slot = 'overlay';
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* The label itself, sized by its contents and placed once it knows its size.
|
|
547
|
+
*
|
|
548
|
+
* A fixed box would be simpler to place, and it is what a crosshair label on a
|
|
549
|
+
* time series gets away with — there the text is one formatted number and its
|
|
550
|
+
* width is known within a few points. Here it is a value, a percentage and
|
|
551
|
+
* sometimes a cell count, and a currency total in the millions is half again as
|
|
552
|
+
* wide as a compact one. A box that does not fit its text does not clip it in
|
|
553
|
+
* React Native, it lets it run out of the corner, so the width has to come from
|
|
554
|
+
* the text rather than the other way round.
|
|
555
|
+
*
|
|
556
|
+
* Which means one frame where the size is not known yet. That frame is spent at
|
|
557
|
+
* zero opacity rather than in the wrong place — the same trade the walkthrough
|
|
558
|
+
* card makes, and for the same reason: a label that arrives correct is better
|
|
559
|
+
* than one that arrives early and jumps.
|
|
560
|
+
*/
|
|
561
|
+
function HexChartLabel({
|
|
562
|
+
anchor,
|
|
563
|
+
width,
|
|
564
|
+
height,
|
|
565
|
+
color,
|
|
566
|
+
value,
|
|
567
|
+
detail,
|
|
568
|
+
className
|
|
569
|
+
}) {
|
|
570
|
+
const [size, setSize] = useState(null);
|
|
571
|
+
const placed = size ? {
|
|
572
|
+
// Centred across the series and hung above it, then clamped inside the
|
|
573
|
+
// field so one whose cells sit against an edge does not get a label
|
|
574
|
+
// hanging off it.
|
|
575
|
+
left: Math.max(0, Math.min(width - size.width, anchor.x - size.width / 2)),
|
|
576
|
+
top: Math.max(0, Math.min(height - size.height, anchor.y - LABEL_GAP - size.height))
|
|
577
|
+
} : {
|
|
578
|
+
left: 0,
|
|
579
|
+
top: 0
|
|
580
|
+
};
|
|
581
|
+
return /*#__PURE__*/_jsxs(View, {
|
|
582
|
+
pointerEvents: "none",
|
|
583
|
+
onLayout: event => {
|
|
584
|
+
const {
|
|
585
|
+
width: w,
|
|
586
|
+
height: h
|
|
587
|
+
} = event.nativeEvent.layout;
|
|
588
|
+
setSize(current => current && Math.abs(current.width - w) < 1 && Math.abs(current.height - h) < 1 ? current : {
|
|
589
|
+
width: w,
|
|
590
|
+
height: h
|
|
591
|
+
});
|
|
592
|
+
},
|
|
593
|
+
style: {
|
|
594
|
+
position: 'absolute',
|
|
595
|
+
...placed,
|
|
596
|
+
opacity: size ? 1 : 0,
|
|
597
|
+
// Never wider than the field, so a very long value wraps or ellipsises
|
|
598
|
+
// inside the label instead of widening it off the edge.
|
|
599
|
+
maxWidth: width
|
|
600
|
+
},
|
|
601
|
+
className: cn('flex-row items-center gap-1.5 rounded-lg border border-border bg-overlay px-2 py-1 shadow-md', className),
|
|
602
|
+
children: [/*#__PURE__*/_jsx(View, {
|
|
603
|
+
style: {
|
|
604
|
+
width: 8,
|
|
605
|
+
height: 8,
|
|
606
|
+
borderRadius: 4,
|
|
607
|
+
backgroundColor: color
|
|
608
|
+
}
|
|
609
|
+
}), /*#__PURE__*/_jsx(Text, {
|
|
610
|
+
size: "xs",
|
|
611
|
+
weight: "medium",
|
|
612
|
+
numberOfLines: 1,
|
|
613
|
+
className: "shrink",
|
|
614
|
+
children: value
|
|
615
|
+
}), /*#__PURE__*/_jsx(Text, {
|
|
616
|
+
size: "xs",
|
|
617
|
+
muted: true,
|
|
618
|
+
numberOfLines: 1,
|
|
619
|
+
className: "shrink",
|
|
620
|
+
children: detail
|
|
621
|
+
})]
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* A swatch, a name and a share per series, under the chart and across the width
|
|
626
|
+
* of it. Pressable in the same way the cells are — the legend is usually the
|
|
627
|
+
* easier target of the two, and a series worth a couple of percent is a handful
|
|
628
|
+
* of cells that may not be adjacent.
|
|
629
|
+
*
|
|
630
|
+
* It wraps rather than stacking, so five or six entries take two lines instead
|
|
631
|
+
* of six. A key is a lookup table, and a lookup table read down a column of one
|
|
632
|
+
* word each is a column the eye has to walk.
|
|
633
|
+
*/
|
|
634
|
+
function HexChartLegend({
|
|
635
|
+
className,
|
|
636
|
+
showValue = true,
|
|
637
|
+
...props
|
|
638
|
+
}) {
|
|
639
|
+
const {
|
|
640
|
+
data,
|
|
641
|
+
total,
|
|
642
|
+
colors,
|
|
643
|
+
activeIndex,
|
|
644
|
+
setActiveIndex
|
|
645
|
+
} = useChart('HexChart.Legend');
|
|
646
|
+
if (!data.length) return null;
|
|
647
|
+
return /*#__PURE__*/_jsx(View, {
|
|
648
|
+
...props,
|
|
649
|
+
className: cn('w-full flex-row flex-wrap items-center justify-center gap-x-3 gap-y-1.5 pt-3', className),
|
|
650
|
+
children: data.map((series, index) => {
|
|
651
|
+
const percent = total > 0 ? Math.round(Math.max(0, series.value) / total * 100) : 0;
|
|
652
|
+
const dimmed = activeIndex >= 0 && activeIndex !== index;
|
|
653
|
+
return /*#__PURE__*/_jsxs(Pressable, {
|
|
654
|
+
accessibilityRole: "button",
|
|
655
|
+
accessibilityLabel: `${series.label}, ${percent} percent`,
|
|
656
|
+
onPress: () => setActiveIndex(activeIndex === index ? -1 : index),
|
|
657
|
+
style: {
|
|
658
|
+
opacity: dimmed ? 0.4 : 1
|
|
659
|
+
},
|
|
660
|
+
className: "max-w-full flex-row items-center gap-1.5",
|
|
661
|
+
children: [/*#__PURE__*/_jsx(View, {
|
|
662
|
+
style: {
|
|
663
|
+
width: 8,
|
|
664
|
+
height: 8,
|
|
665
|
+
borderRadius: 4,
|
|
666
|
+
backgroundColor: colors[index]
|
|
667
|
+
}
|
|
668
|
+
}), /*#__PURE__*/_jsx(Text, {
|
|
669
|
+
size: "xs",
|
|
670
|
+
muted: true,
|
|
671
|
+
numberOfLines: 1,
|
|
672
|
+
className: "shrink",
|
|
673
|
+
children: series.label
|
|
674
|
+
}), showValue ? /*#__PURE__*/_jsxs(Text, {
|
|
675
|
+
size: "xs",
|
|
676
|
+
weight: "medium",
|
|
677
|
+
children: [percent, "%"]
|
|
678
|
+
}) : null]
|
|
679
|
+
}, series.label);
|
|
680
|
+
})
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
HexChartLegend.displayName = 'HexChart.Legend';
|
|
684
|
+
HexChartLegend.slot = 'footer';
|
|
685
|
+
/**
|
|
686
|
+
* The strip above the honeycomb: what the chart is of, what it reads, and what
|
|
687
|
+
* the colours mean.
|
|
688
|
+
*
|
|
689
|
+
* It belongs to the chart rather than to the card around it because it is about
|
|
690
|
+
* the *series* — the number changes as one is selected, and the legend is the
|
|
691
|
+
* list the chart itself is holding. The card's header is a caption on the tray
|
|
692
|
+
* the chart sits in; this is the chart introducing itself.
|
|
693
|
+
*
|
|
694
|
+
* The value is not derived here even though there is a total to derive it from,
|
|
695
|
+
* because the formatting is not the chart's to guess: a total of 6750 is a
|
|
696
|
+
* count, a currency or a percentage depending on what was counted, and only the
|
|
697
|
+
* caller knows which.
|
|
698
|
+
*/
|
|
699
|
+
function HexChartHeader({
|
|
700
|
+
className,
|
|
701
|
+
title,
|
|
702
|
+
value,
|
|
703
|
+
caption,
|
|
704
|
+
labels,
|
|
705
|
+
legend = false,
|
|
706
|
+
children,
|
|
707
|
+
...props
|
|
708
|
+
}) {
|
|
709
|
+
const {
|
|
710
|
+
data,
|
|
711
|
+
colors
|
|
712
|
+
} = useChart('HexChart.Header');
|
|
713
|
+
const trailing = children ?? (legend && data.length ? /*#__PURE__*/_jsx(View, {
|
|
714
|
+
className: "flex-row flex-wrap items-center justify-end gap-x-3 gap-y-1",
|
|
715
|
+
children: data.map((series, index) => /*#__PURE__*/_jsxs(View, {
|
|
716
|
+
className: "flex-row items-center gap-1.5",
|
|
717
|
+
children: [/*#__PURE__*/_jsx(View, {
|
|
718
|
+
style: {
|
|
719
|
+
width: 8,
|
|
720
|
+
height: 8,
|
|
721
|
+
borderRadius: 4,
|
|
722
|
+
backgroundColor: colors[index]
|
|
723
|
+
}
|
|
724
|
+
}), /*#__PURE__*/_jsx(Text, {
|
|
725
|
+
size: "xs",
|
|
726
|
+
muted: true,
|
|
727
|
+
numberOfLines: 1,
|
|
728
|
+
children: labels?.[series.label] ?? series.label
|
|
729
|
+
})]
|
|
730
|
+
}, series.label))
|
|
731
|
+
}) : null);
|
|
732
|
+
return /*#__PURE__*/_jsxs(View, {
|
|
733
|
+
...props,
|
|
734
|
+
className: cn('flex-row items-start justify-between gap-3 pb-3', className),
|
|
735
|
+
children: [/*#__PURE__*/_jsxs(View, {
|
|
736
|
+
className: "flex-1 gap-0.5",
|
|
737
|
+
children: [title ? /*#__PURE__*/_jsx(Text, {
|
|
738
|
+
size: "xs",
|
|
739
|
+
muted: true,
|
|
740
|
+
children: title
|
|
741
|
+
}) : null, value ? /*#__PURE__*/_jsx(Text, {
|
|
742
|
+
size: "xl",
|
|
743
|
+
weight: "bold",
|
|
744
|
+
children: value
|
|
745
|
+
}) : null, caption ? /*#__PURE__*/_jsx(Text, {
|
|
746
|
+
size: "xs",
|
|
747
|
+
muted: true,
|
|
748
|
+
children: caption
|
|
749
|
+
}) : null]
|
|
750
|
+
}), trailing ? /*#__PURE__*/_jsx(View, {
|
|
751
|
+
className: "max-w-[55%] shrink pt-1",
|
|
752
|
+
children: trailing
|
|
753
|
+
}) : null]
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
HexChartHeader.displayName = 'HexChart.Header';
|
|
757
|
+
HexChartHeader.slot = 'header';
|
|
758
|
+
export const HexChart = Object.assign(HexChartRoot, {
|
|
759
|
+
Header: HexChartHeader,
|
|
760
|
+
Cells: HexChartCells,
|
|
761
|
+
Tooltip: HexChartTooltip,
|
|
762
|
+
Legend: HexChartLegend,
|
|
763
|
+
Skeleton: HexChartSkeleton
|
|
764
|
+
});
|
|
765
|
+
//# sourceMappingURL=index.js.map
|