orn-ui 0.2.8 → 0.2.9
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/AGENTS.md +6 -2
- package/README.md +6 -6
- package/lib/commonjs/atoms/ShinyText.js +188 -0
- package/lib/commonjs/atoms/ShinyText.js.map +1 -0
- package/lib/commonjs/atoms/index.js +7 -0
- package/lib/commonjs/atoms/index.js.map +1 -1
- package/lib/commonjs/organisms/Chart.js +947 -0
- package/lib/commonjs/organisms/Chart.js.map +1 -0
- package/lib/commonjs/organisms/OptionWheel.js +408 -0
- package/lib/commonjs/organisms/OptionWheel.js.map +1 -0
- package/lib/commonjs/organisms/Timeline.js +420 -0
- package/lib/commonjs/organisms/Timeline.js.map +1 -0
- package/lib/commonjs/organisms/chart/frame.js +373 -0
- package/lib/commonjs/organisms/chart/frame.js.map +1 -0
- package/lib/commonjs/organisms/chart/marks.js +316 -0
- package/lib/commonjs/organisms/chart/marks.js.map +1 -0
- package/lib/commonjs/organisms/chart/math.js +468 -0
- package/lib/commonjs/organisms/chart/math.js.map +1 -0
- package/lib/commonjs/organisms/chart/palette.js +120 -0
- package/lib/commonjs/organisms/chart/palette.js.map +1 -0
- package/lib/commonjs/organisms/index.js +21 -0
- package/lib/commonjs/organisms/index.js.map +1 -1
- package/lib/module/atoms/ShinyText.js +183 -0
- package/lib/module/atoms/ShinyText.js.map +1 -0
- package/lib/module/atoms/index.js +1 -0
- package/lib/module/atoms/index.js.map +1 -1
- package/lib/module/organisms/Chart.js +942 -0
- package/lib/module/organisms/Chart.js.map +1 -0
- package/lib/module/organisms/OptionWheel.js +403 -0
- package/lib/module/organisms/OptionWheel.js.map +1 -0
- package/lib/module/organisms/Timeline.js +415 -0
- package/lib/module/organisms/Timeline.js.map +1 -0
- package/lib/module/organisms/chart/frame.js +367 -0
- package/lib/module/organisms/chart/frame.js.map +1 -0
- package/lib/module/organisms/chart/marks.js +310 -0
- package/lib/module/organisms/chart/marks.js.map +1 -0
- package/lib/module/organisms/chart/math.js +445 -0
- package/lib/module/organisms/chart/math.js.map +1 -0
- package/lib/module/organisms/chart/palette.js +114 -0
- package/lib/module/organisms/chart/palette.js.map +1 -0
- package/lib/module/organisms/index.js +3 -0
- package/lib/module/organisms/index.js.map +1 -1
- package/lib/typescript/atoms/ShinyText.d.ts +48 -0
- package/lib/typescript/atoms/ShinyText.d.ts.map +1 -0
- package/lib/typescript/atoms/index.d.ts +2 -0
- package/lib/typescript/atoms/index.d.ts.map +1 -1
- package/lib/typescript/organisms/Chart.d.ts +208 -0
- package/lib/typescript/organisms/Chart.d.ts.map +1 -0
- package/lib/typescript/organisms/OptionWheel.d.ts +78 -0
- package/lib/typescript/organisms/OptionWheel.d.ts.map +1 -0
- package/lib/typescript/organisms/Timeline.d.ts +61 -0
- package/lib/typescript/organisms/Timeline.d.ts.map +1 -0
- package/lib/typescript/organisms/chart/frame.d.ts +85 -0
- package/lib/typescript/organisms/chart/frame.d.ts.map +1 -0
- package/lib/typescript/organisms/chart/marks.d.ts +88 -0
- package/lib/typescript/organisms/chart/marks.d.ts.map +1 -0
- package/lib/typescript/organisms/chart/math.d.ts +161 -0
- package/lib/typescript/organisms/chart/math.d.ts.map +1 -0
- package/lib/typescript/organisms/chart/palette.d.ts +6 -0
- package/lib/typescript/organisms/chart/palette.d.ts.map +1 -0
- package/lib/typescript/organisms/index.d.ts +6 -0
- package/lib/typescript/organisms/index.d.ts.map +1 -1
- package/llms.txt +3 -3
- package/package.json +23 -2
- package/registry/chart.json +35 -0
- package/registry/index.json +53 -0
- package/registry/manifest.json +28 -0
- package/registry/option-wheel.json +19 -0
- package/registry/shiny-text.json +19 -0
- package/registry/timeline.json +20 -0
- package/src/atoms/ShinyText.tsx +226 -0
- package/src/atoms/index.ts +2 -0
- package/src/organisms/Chart.tsx +1223 -0
- package/src/organisms/OptionWheel.tsx +476 -0
- package/src/organisms/Timeline.tsx +467 -0
- package/src/organisms/chart/frame.tsx +349 -0
- package/src/organisms/chart/marks.tsx +295 -0
- package/src/organisms/chart/math.ts +475 -0
- package/src/organisms/chart/palette.ts +112 -0
- package/src/organisms/index.ts +14 -0
|
@@ -0,0 +1,942 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
import React, { memo, useCallback, useMemo, useRef, useState } from 'react';
|
|
4
|
+
import { Pressable, Text, View } from 'react-native';
|
|
5
|
+
import { contrast } from '../theme/colors';
|
|
6
|
+
import { createStyles } from '../theme/createStyles';
|
|
7
|
+
import { useAllowFontScaling, useLabels, useTheme } from '../theme/UIProvider';
|
|
8
|
+
import { SegmentedControl } from '../molecules/SegmentedControl';
|
|
9
|
+
import { Bar, Dot, Polyline, Wedge } from './chart/marks';
|
|
10
|
+
import { AxisLabels, CartesianGrid, Legend, RadarGrid, Tooltip } from './chart/frame';
|
|
11
|
+
import { seriesPalette } from './chart/palette';
|
|
12
|
+
import { angleOf, arcAt, arcsFor, clamp, exactTicks, extent, fanStrips, formatNumber, growExtent, linearScale, lttbIndices, niceScale, polar, radarAngles, sampleY, smoothPoints, stackRow, toNumber } from './chart/math';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Un filtro de la barra superior. Chart no filtra nada por su cuenta: muestra
|
|
16
|
+
* el control y avisa. Quien tiene los datos decide qué significa "últimos 7
|
|
17
|
+
* días", y eso no se puede adivinar desde acá.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
// 'pie' y 'donut' van separados aunque compartan todo: con un solo miembro
|
|
21
|
+
// cuyo `type` fuera `'pie' | 'donut'`, TypeScript no saca ese miembro de la
|
|
22
|
+
// unión al descartarlo, y el resto del componente deja de estrecharse.
|
|
23
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
24
|
+
const DEFAULT_HEIGHT = 220;
|
|
25
|
+
const DEFAULT_TICKS = 4;
|
|
26
|
+
/** Canal de las etiquetas de valor, a la izquierda. */
|
|
27
|
+
const VALUE_GUTTER = 38;
|
|
28
|
+
/** Canal de las categorías cuando van a la izquierda (barras horizontales). */
|
|
29
|
+
const CATEGORY_GUTTER = 64;
|
|
30
|
+
/** Canal de las etiquetas de abajo. */
|
|
31
|
+
const BOTTOM_GUTTER = 16;
|
|
32
|
+
/** Fracción del ancho de banda que ocupan las barras; el resto es aire. */
|
|
33
|
+
const BAND_FILL = 0.7;
|
|
34
|
+
/** Aire alrededor del radar para que entren las etiquetas de los ejes. */
|
|
35
|
+
const RADAR_INSET = 26;
|
|
36
|
+
const DIM_OPACITY = 0.3;
|
|
37
|
+
const useStyles = createStyles(theme => ({
|
|
38
|
+
root: {
|
|
39
|
+
alignSelf: 'stretch'
|
|
40
|
+
},
|
|
41
|
+
filters: {
|
|
42
|
+
gap: theme.tokens.spacing.sm,
|
|
43
|
+
marginBottom: theme.tokens.spacing.md
|
|
44
|
+
},
|
|
45
|
+
plot: {
|
|
46
|
+
position: 'relative',
|
|
47
|
+
overflow: 'visible'
|
|
48
|
+
},
|
|
49
|
+
empty: {
|
|
50
|
+
position: 'absolute',
|
|
51
|
+
left: 0,
|
|
52
|
+
right: 0,
|
|
53
|
+
top: 0,
|
|
54
|
+
bottom: 0,
|
|
55
|
+
alignItems: 'center',
|
|
56
|
+
justifyContent: 'center'
|
|
57
|
+
},
|
|
58
|
+
emptyText: {
|
|
59
|
+
fontSize: theme.tokens.fontSize.sm,
|
|
60
|
+
color: theme.colors.textLight
|
|
61
|
+
},
|
|
62
|
+
sliceLabel: {
|
|
63
|
+
position: 'absolute',
|
|
64
|
+
fontSize: theme.tokens.fontSize.xs,
|
|
65
|
+
fontWeight: theme.tokens.fontWeight.semibold,
|
|
66
|
+
textAlign: 'center'
|
|
67
|
+
},
|
|
68
|
+
radarAxisLabel: {
|
|
69
|
+
position: 'absolute',
|
|
70
|
+
fontSize: theme.tokens.fontSize.xs,
|
|
71
|
+
color: theme.colors.textLight,
|
|
72
|
+
textAlign: 'center'
|
|
73
|
+
}
|
|
74
|
+
}));
|
|
75
|
+
|
|
76
|
+
/** Estado controlado o propio, con el mismo contrato en los dos casos. */
|
|
77
|
+
function useControlled(controlled, fallback) {
|
|
78
|
+
const [own, setOwn] = useState(fallback);
|
|
79
|
+
const isControlled = controlled !== undefined;
|
|
80
|
+
return [isControlled ? controlled : own, setOwn, isControlled];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Resuelve el dominio del eje de valores a partir de lo que pidió el
|
|
85
|
+
* consumidor. En 'sticky' la referencia vive en un ref: lo que importa no es
|
|
86
|
+
* el dominio de este render sino el más ancho que se haya visto, y guardarlo
|
|
87
|
+
* en estado dispararía un render de más por cada tick que ensancha el eje.
|
|
88
|
+
*/
|
|
89
|
+
function useDomain(measured, domain) {
|
|
90
|
+
const widest = useRef(null);
|
|
91
|
+
if (domain === 'sticky') {
|
|
92
|
+
widest.current = growExtent(widest.current, measured);
|
|
93
|
+
return widest.current;
|
|
94
|
+
}
|
|
95
|
+
widest.current = null;
|
|
96
|
+
return domain ?? measured;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Chart
|
|
101
|
+
* Barras (verticales, horizontales, agrupadas, apiladas), línea, área, torta,
|
|
102
|
+
* dona y radar en un solo componente, porque lo que cambia entre ellos es la
|
|
103
|
+
* marca que se dibuja y no el resto: datos, escala, ejes, leyenda, tooltip,
|
|
104
|
+
* selección y filtros son los mismos seis mecanismos en todos.
|
|
105
|
+
*
|
|
106
|
+
* Se dibuja con `View`: React Native no trae lienzo vectorial y la librería no
|
|
107
|
+
* puede agregar uno. Las tres primitivas están en `chart/marks.tsx` y la
|
|
108
|
+
* geometría entera, sin React, en `chart/math.ts`.
|
|
109
|
+
*
|
|
110
|
+
* Gestalt: destino común — al tocar una categoría, todo lo que no es ella baja
|
|
111
|
+
* a {@link DIM_OPACITY} a la vez, así que lo resaltado se lee como un grupo y
|
|
112
|
+
* no como una marca suelta. La leyenda usa similitud: el mismo color que la
|
|
113
|
+
* serie, y por eso puede apagarla sin que haga falta explicar qué apaga.
|
|
114
|
+
*/
|
|
115
|
+
export function Chart(props) {
|
|
116
|
+
const {
|
|
117
|
+
data,
|
|
118
|
+
series,
|
|
119
|
+
xKey = 'x',
|
|
120
|
+
selectBy,
|
|
121
|
+
window: windowSize,
|
|
122
|
+
height = DEFAULT_HEIGHT,
|
|
123
|
+
legend = 'auto',
|
|
124
|
+
hiddenKeys,
|
|
125
|
+
defaultHiddenKeys,
|
|
126
|
+
onHiddenKeysChange,
|
|
127
|
+
filters,
|
|
128
|
+
selected,
|
|
129
|
+
onSelect,
|
|
130
|
+
tooltip = true,
|
|
131
|
+
formatValue = formatNumber,
|
|
132
|
+
animate = true,
|
|
133
|
+
duration,
|
|
134
|
+
emptyText,
|
|
135
|
+
accessibilityLabel,
|
|
136
|
+
style,
|
|
137
|
+
testID
|
|
138
|
+
} = props;
|
|
139
|
+
const theme = useTheme();
|
|
140
|
+
const styles = useStyles();
|
|
141
|
+
const allowFontScaling = useAllowFontScaling();
|
|
142
|
+
const uiLabels = useLabels();
|
|
143
|
+
const [width, setWidth] = useState(0);
|
|
144
|
+
const [hidden, setHidden, hiddenIsControlled] = useControlled(hiddenKeys, defaultHiddenKeys ?? []);
|
|
145
|
+
const [pick, setPick, selectionIsControlled] = useControlled(selected, null);
|
|
146
|
+
const isPie = props.type === 'pie' || props.type === 'donut';
|
|
147
|
+
const polyline = props.type === 'line' || props.type === 'area';
|
|
148
|
+
const decimate = polyline ? props.decimate : false;
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Lo que se dibuja: la ventana de `data`, y de ahí los puntos que caben.
|
|
152
|
+
* El diezmado corre sobre la fila entera, elegida con la primera serie
|
|
153
|
+
* visible: las series comparten eje x, así que quedarse con índices
|
|
154
|
+
* distintos por serie rompería la alineación, la capa de toque y el tooltip.
|
|
155
|
+
*/
|
|
156
|
+
const rows = useMemo(() => {
|
|
157
|
+
const windowed = windowSize && windowSize > 0 ? data.slice(-windowSize) : data;
|
|
158
|
+
if (!polyline || decimate === false || width === 0) return windowed;
|
|
159
|
+
// Un punto cada dos píxeles: más no se ve y sí se paga.
|
|
160
|
+
const target = typeof decimate === 'number' ? decimate : Math.floor(width / 2);
|
|
161
|
+
if (windowed.length <= target) return windowed;
|
|
162
|
+
const lead = series.find(item => !hidden.includes(item.key))?.key ?? series[0]?.key;
|
|
163
|
+
const values = windowed.map(row => lead ? toNumber(row[lead]) : null);
|
|
164
|
+
return lttbIndices(values, target).map(index => windowed[index]);
|
|
165
|
+
}, [data, windowSize, polyline, decimate, width, series, hidden]);
|
|
166
|
+
const labels = useMemo(() => rows.map(row => String(row[xKey] ?? '')), [rows, xKey]);
|
|
167
|
+
const ids = useMemo(() => rows.map((row, index) => selectBy ? selectBy(row, index) : String(index)), [rows, selectBy]);
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* La selección se guarda por `id` y se resuelve contra las filas de ahora:
|
|
171
|
+
* si el dato se corrió, el resaltado lo sigue; si salió de la ventana,
|
|
172
|
+
* desaparece. Reusar el índice a ciegas señalaría el dato que quedó en ese
|
|
173
|
+
* lugar, que no es el que nadie eligió.
|
|
174
|
+
*/
|
|
175
|
+
const selection = useMemo(() => {
|
|
176
|
+
if (!pick) return null;
|
|
177
|
+
const index = ids.indexOf(pick.id);
|
|
178
|
+
if (index === -1) return null;
|
|
179
|
+
const row = rows[index];
|
|
180
|
+
return {
|
|
181
|
+
...pick,
|
|
182
|
+
index,
|
|
183
|
+
row,
|
|
184
|
+
label: labels[index] ?? pick.label,
|
|
185
|
+
value: toNumber(row[pick.seriesKey]) ?? pick.value
|
|
186
|
+
};
|
|
187
|
+
}, [pick, ids, rows, labels]);
|
|
188
|
+
|
|
189
|
+
// En torta las categorías son las filas; en el resto, las columnas. De eso
|
|
190
|
+
// dependen el color, la leyenda y qué apaga un toque en ella.
|
|
191
|
+
const palette = seriesPalette(theme, Math.max(1, isPie ? rows.length : series.length));
|
|
192
|
+
const entries = useMemo(() => isPie ? labels.map((label, index) => ({
|
|
193
|
+
key: label,
|
|
194
|
+
label,
|
|
195
|
+
color: series[0]?.color ?? palette[index] ?? theme.colors.primary
|
|
196
|
+
})) : series.map((item, index) => ({
|
|
197
|
+
key: item.key,
|
|
198
|
+
label: item.label ?? item.key,
|
|
199
|
+
color: item.color ?? palette[index] ?? theme.colors.primary
|
|
200
|
+
})), [isPie, labels, series, palette, theme.colors.primary]);
|
|
201
|
+
const visible = useMemo(() => entries.filter(entry => !hidden.includes(entry.key)), [entries, hidden]);
|
|
202
|
+
const duration_ = duration ?? theme.tokens.duration.slow * 2;
|
|
203
|
+
const applyHidden = useCallback(key => {
|
|
204
|
+
const next = hidden.includes(key) ? hidden.filter(k => k !== key) : [...hidden, key];
|
|
205
|
+
if (!hiddenIsControlled) setHidden(next);
|
|
206
|
+
onHiddenKeysChange?.(next);
|
|
207
|
+
}, [hidden, hiddenIsControlled, setHidden, onHiddenKeysChange]);
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Los plots avisan qué se tocó —fila y serie— y la selección entera se
|
|
211
|
+
* arma acá: es el único lugar que conoce `selectBy`, las etiquetas y las
|
|
212
|
+
* filas de verdad, y así los tres no repiten el armado.
|
|
213
|
+
*/
|
|
214
|
+
const applySelection = useCallback(hit => {
|
|
215
|
+
const row = hit ? rows[hit.index] : undefined;
|
|
216
|
+
const next = hit && row ? {
|
|
217
|
+
id: ids[hit.index] ?? String(hit.index),
|
|
218
|
+
index: hit.index,
|
|
219
|
+
row,
|
|
220
|
+
label: labels[hit.index] ?? '',
|
|
221
|
+
seriesKey: hit.seriesKey,
|
|
222
|
+
value: toNumber(row[hit.seriesKey]) ?? 0
|
|
223
|
+
} : null;
|
|
224
|
+
// Tocar lo ya elegido lo suelta: sin eso no habría forma de volver al
|
|
225
|
+
// chart entero sin tocar afuera, y "afuera" en una pantalla con scroll
|
|
226
|
+
// no siempre existe.
|
|
227
|
+
const same = next && selection && next.id === selection.id && next.seriesKey === selection.seriesKey;
|
|
228
|
+
const value = same ? null : next;
|
|
229
|
+
if (!selectionIsControlled) setPick(value);
|
|
230
|
+
onSelect?.(value);
|
|
231
|
+
}, [rows, ids, labels, selection, selectionIsControlled, setPick, onSelect]);
|
|
232
|
+
const onLayout = useCallback(event => setWidth(event.nativeEvent.layout.width), []);
|
|
233
|
+
const showLegend = legend === 'toggle' || legend === 'static' || legend === 'auto' && entries.length > 1;
|
|
234
|
+
const toggleable = legend === 'toggle' || legend === 'auto' && entries.length > 1;
|
|
235
|
+
const empty = rows.length === 0 || visible.length === 0;
|
|
236
|
+
const shared = {
|
|
237
|
+
rows,
|
|
238
|
+
labels,
|
|
239
|
+
entries,
|
|
240
|
+
visible,
|
|
241
|
+
width,
|
|
242
|
+
height,
|
|
243
|
+
formatValue,
|
|
244
|
+
animate,
|
|
245
|
+
duration: duration_,
|
|
246
|
+
selection,
|
|
247
|
+
onPick: applySelection,
|
|
248
|
+
tooltip,
|
|
249
|
+
testID
|
|
250
|
+
};
|
|
251
|
+
return /*#__PURE__*/_jsxs(View, {
|
|
252
|
+
style: [styles.root, style],
|
|
253
|
+
testID: testID,
|
|
254
|
+
accessible: false,
|
|
255
|
+
children: [filters && filters.length > 0 ? /*#__PURE__*/_jsx(View, {
|
|
256
|
+
style: styles.filters,
|
|
257
|
+
children: filters.map(filter => /*#__PURE__*/_jsx(SegmentedControl, {
|
|
258
|
+
options: filter.options,
|
|
259
|
+
value: filter.value,
|
|
260
|
+
onChange: filter.onChange,
|
|
261
|
+
testID: testID && `${testID}-filter-${filter.key}`
|
|
262
|
+
}, filter.key))
|
|
263
|
+
}) : null, /*#__PURE__*/_jsxs(View, {
|
|
264
|
+
style: [styles.plot, {
|
|
265
|
+
height
|
|
266
|
+
}],
|
|
267
|
+
onLayout: onLayout,
|
|
268
|
+
accessible: true,
|
|
269
|
+
accessibilityRole: "image",
|
|
270
|
+
accessibilityLabel: accessibilityLabel,
|
|
271
|
+
testID: testID && `${testID}-plot`,
|
|
272
|
+
children: [width === 0 || empty ? null : props.type === 'pie' || props.type === 'donut' ? /*#__PURE__*/_jsx(PiePlot, {
|
|
273
|
+
...shared,
|
|
274
|
+
donut: props.type === 'donut',
|
|
275
|
+
innerRadius: props.innerRadius,
|
|
276
|
+
sliceLabels: props.sliceLabels ?? 'percent',
|
|
277
|
+
valueKey: series[0]?.key ?? ''
|
|
278
|
+
}) : props.type === 'radar' ? /*#__PURE__*/_jsx(RadarPlot, {
|
|
279
|
+
...shared,
|
|
280
|
+
domain: props.domain,
|
|
281
|
+
levels: props.levels ?? 4,
|
|
282
|
+
fill: props.fill ?? true,
|
|
283
|
+
thickness: props.thickness ?? 2,
|
|
284
|
+
dots: props.dots ?? true
|
|
285
|
+
}) : /*#__PURE__*/_jsx(CartesianPlot, {
|
|
286
|
+
...shared,
|
|
287
|
+
type: props.type,
|
|
288
|
+
orientation: props.type === 'bar' ? props.orientation ?? 'vertical' : 'vertical',
|
|
289
|
+
stacked: props.type === 'bar' || props.type === 'area' ? props.stacked ?? false : false,
|
|
290
|
+
barRadius: props.type === 'bar' ? props.barRadius ?? theme.tokens.radius.xs : 0,
|
|
291
|
+
smooth: props.type !== 'bar' ? props.smooth ?? false : false,
|
|
292
|
+
thickness: props.type !== 'bar' ? props.thickness ?? 2 : 0,
|
|
293
|
+
dots: props.type === 'line' ? props.dots ?? true : props.type === 'area' ? props.dots ?? false : false,
|
|
294
|
+
fillOpacity: props.type === 'area' ? props.fillOpacity ?? 0.22 : 0,
|
|
295
|
+
domain: props.domain,
|
|
296
|
+
grid: props.grid ?? true,
|
|
297
|
+
ticks: props.ticks ?? DEFAULT_TICKS,
|
|
298
|
+
xAxis: props.xAxis ?? true,
|
|
299
|
+
yAxis: props.yAxis ?? true
|
|
300
|
+
}), empty ? /*#__PURE__*/_jsx(View, {
|
|
301
|
+
style: styles.empty,
|
|
302
|
+
testID: testID && `${testID}-empty`,
|
|
303
|
+
children: /*#__PURE__*/_jsx(Text, {
|
|
304
|
+
allowFontScaling: allowFontScaling,
|
|
305
|
+
style: styles.emptyText,
|
|
306
|
+
children: emptyText ?? uiLabels.noResultsTitle
|
|
307
|
+
})
|
|
308
|
+
}) : null]
|
|
309
|
+
}), showLegend ? /*#__PURE__*/_jsx(Legend, {
|
|
310
|
+
items: entries,
|
|
311
|
+
hidden: hidden,
|
|
312
|
+
onToggle: toggleable ? applyHidden : undefined,
|
|
313
|
+
testID: testID && `${testID}-legend`
|
|
314
|
+
}) : null]
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
Chart.displayName = 'Chart';
|
|
318
|
+
|
|
319
|
+
// ── lo que comparten los tres plots ────────────────────────────────────────
|
|
320
|
+
|
|
321
|
+
/** Filas del tooltip para una categoría del eje x. */
|
|
322
|
+
function tooltipRowsAt(rows, index, visible, format) {
|
|
323
|
+
const row = rows[index];
|
|
324
|
+
if (!row) return [];
|
|
325
|
+
return visible.map(entry => {
|
|
326
|
+
const value = toNumber(row[entry.key]);
|
|
327
|
+
return value === null ? null : {
|
|
328
|
+
key: entry.key,
|
|
329
|
+
label: entry.label,
|
|
330
|
+
value: format(value),
|
|
331
|
+
color: entry.color
|
|
332
|
+
};
|
|
333
|
+
}).filter(item => item !== null);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ── barras, línea y área ───────────────────────────────────────────────────
|
|
337
|
+
|
|
338
|
+
/** Ancho de una tira del relleno de área, en píxeles. Más fino = más vistas. */
|
|
339
|
+
const STRIP_WIDTH = 4;
|
|
340
|
+
const MAX_STRIPS = 140;
|
|
341
|
+
/** Aire entre dos barras del mismo grupo. */
|
|
342
|
+
const GROUP_GAP = 2;
|
|
343
|
+
const CartesianPlot = /*#__PURE__*/memo(props => {
|
|
344
|
+
const {
|
|
345
|
+
rows,
|
|
346
|
+
labels,
|
|
347
|
+
visible,
|
|
348
|
+
width,
|
|
349
|
+
height,
|
|
350
|
+
formatValue,
|
|
351
|
+
animate,
|
|
352
|
+
duration,
|
|
353
|
+
selection,
|
|
354
|
+
onPick,
|
|
355
|
+
tooltip,
|
|
356
|
+
testID,
|
|
357
|
+
type,
|
|
358
|
+
orientation,
|
|
359
|
+
stacked,
|
|
360
|
+
barRadius,
|
|
361
|
+
smooth,
|
|
362
|
+
thickness,
|
|
363
|
+
dots,
|
|
364
|
+
fillOpacity,
|
|
365
|
+
domain,
|
|
366
|
+
grid,
|
|
367
|
+
ticks: tickCount,
|
|
368
|
+
xAxis,
|
|
369
|
+
yAxis
|
|
370
|
+
} = props;
|
|
371
|
+
const theme = useTheme();
|
|
372
|
+
const horizontal = type === 'bar' && orientation === 'horizontal';
|
|
373
|
+
const keys = useMemo(() => visible.map(entry => entry.key), [visible]);
|
|
374
|
+
const leftGutter = yAxis ? horizontal ? CATEGORY_GUTTER : VALUE_GUTTER : 0;
|
|
375
|
+
const bottomGutter = xAxis ? BOTTOM_GUTTER : 0;
|
|
376
|
+
const plotW = Math.max(1, width - leftGutter);
|
|
377
|
+
const plotH = Math.max(1, height - bottomGutter);
|
|
378
|
+
|
|
379
|
+
// La línea puede no tocar el cero (una temperatura, un ranking); una barra
|
|
380
|
+
// sí tiene que hacerlo, porque su largo es el que dice el valor.
|
|
381
|
+
const measured = useMemo(() => extent(rows, keys, {
|
|
382
|
+
stacked,
|
|
383
|
+
includeZero: type !== 'line'
|
|
384
|
+
}), [rows, keys, stacked, type]);
|
|
385
|
+
const [low, high] = useDomain(measured, domain);
|
|
386
|
+
const scale = useMemo(() => {
|
|
387
|
+
// Un dominio a mano se respeta tal cual; uno medido (o 'sticky') se
|
|
388
|
+
// redondea, que es lo que hace legible el eje.
|
|
389
|
+
const nice = Array.isArray(domain) ? exactTicks(low, high, tickCount) : niceScale(low, high, tickCount);
|
|
390
|
+
const toPixel = horizontal ? linearScale([nice.min, nice.max], [0, plotW]) : linearScale([nice.min, nice.max], [plotH, 0]);
|
|
391
|
+
return {
|
|
392
|
+
nice,
|
|
393
|
+
toPixel
|
|
394
|
+
};
|
|
395
|
+
}, [domain, low, high, tickCount, horizontal, plotW, plotH]);
|
|
396
|
+
const band = (horizontal ? plotH : plotW) / Math.max(1, rows.length);
|
|
397
|
+
const centerOf = useCallback(index => (index + 0.5) * band, [band]);
|
|
398
|
+
const baseline = scale.toPixel(clamp(0, scale.nice.min, scale.nice.max));
|
|
399
|
+
const stacks = useMemo(() => stacked ? rows.map(row => stackRow(row, keys)) : null, [stacked, rows, keys]);
|
|
400
|
+
|
|
401
|
+
/** Puntos de una serie: `to` es el valor, `from` el piso desde donde se rellena. */
|
|
402
|
+
const pointsFor = useCallback((key, edge) => {
|
|
403
|
+
const out = [];
|
|
404
|
+
rows.forEach((row, index) => {
|
|
405
|
+
if (stacks) {
|
|
406
|
+
const stackBand = stacks[index].find(item => item.key === key);
|
|
407
|
+
if (!stackBand) return;
|
|
408
|
+
out.push({
|
|
409
|
+
x: centerOf(index),
|
|
410
|
+
y: scale.toPixel(stackBand[edge])
|
|
411
|
+
});
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
const value = toNumber(row[key]);
|
|
415
|
+
if (value === null) return;
|
|
416
|
+
out.push({
|
|
417
|
+
x: centerOf(index),
|
|
418
|
+
y: edge === 'to' ? scale.toPixel(value) : baseline
|
|
419
|
+
});
|
|
420
|
+
});
|
|
421
|
+
return out;
|
|
422
|
+
}, [rows, stacks, centerOf, scale, baseline]);
|
|
423
|
+
const pickAt = useCallback((index, event) => {
|
|
424
|
+
// Sobre qué serie cayó el dedo: la que tiene su marca más cerca del
|
|
425
|
+
// toque en el eje de valores. Con una sola serie es siempre esa.
|
|
426
|
+
const touch = (horizontal ? event?.nativeEvent?.locationX : event?.nativeEvent?.locationY) ?? 0;
|
|
427
|
+
let bestKey = keys[0] ?? '';
|
|
428
|
+
let bestDistance = Infinity;
|
|
429
|
+
for (const key of keys) {
|
|
430
|
+
const value = toNumber(rows[index]?.[key]);
|
|
431
|
+
if (value === null) continue;
|
|
432
|
+
const distance = Math.abs(scale.toPixel(value) - touch);
|
|
433
|
+
if (distance < bestDistance) {
|
|
434
|
+
bestDistance = distance;
|
|
435
|
+
bestKey = key;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
onPick({
|
|
439
|
+
index,
|
|
440
|
+
seriesKey: bestKey
|
|
441
|
+
});
|
|
442
|
+
}, [horizontal, keys, rows, scale, onPick]);
|
|
443
|
+
const gridLines = scale.nice.values.map(value => scale.toPixel(value));
|
|
444
|
+
const zeroIndex = scale.nice.values.findIndex(value => value === 0);
|
|
445
|
+
const rowsForTooltip = selection ? tooltipRowsAt(rows, selection.index, visible, formatValue) : [];
|
|
446
|
+
const anchor = !selection ? null : horizontal ? {
|
|
447
|
+
x: leftGutter + Math.max(...visible.map(entry => scale.toPixel(toNumber(rows[selection.index]?.[entry.key]) ?? 0))),
|
|
448
|
+
y: centerOf(selection.index)
|
|
449
|
+
} : {
|
|
450
|
+
x: leftGutter + centerOf(selection.index),
|
|
451
|
+
y: Math.min(...visible.map(entry => scale.toPixel(toNumber(rows[selection.index]?.[entry.key]) ?? 0)))
|
|
452
|
+
};
|
|
453
|
+
return /*#__PURE__*/_jsxs(_Fragment, {
|
|
454
|
+
children: [/*#__PURE__*/_jsxs(View, {
|
|
455
|
+
style: {
|
|
456
|
+
position: 'absolute',
|
|
457
|
+
left: leftGutter,
|
|
458
|
+
top: 0,
|
|
459
|
+
width: plotW,
|
|
460
|
+
height: plotH
|
|
461
|
+
},
|
|
462
|
+
children: [grid ? /*#__PURE__*/_jsx(CartesianGrid, {
|
|
463
|
+
width: plotW,
|
|
464
|
+
height: plotH,
|
|
465
|
+
lines: gridLines,
|
|
466
|
+
orientation: horizontal ? 'vertical' : 'horizontal',
|
|
467
|
+
zeroIndex: zeroIndex === -1 ? undefined : zeroIndex,
|
|
468
|
+
testID: testID && `${testID}-grid`
|
|
469
|
+
}) : null, type === 'bar' ? rows.map((row, rowIndex) => {
|
|
470
|
+
const fill = band * BAND_FILL;
|
|
471
|
+
const slot = fill / (stacked ? 1 : Math.max(1, keys.length));
|
|
472
|
+
const bands = stacks ? stacks[rowIndex].filter(item => keys.includes(item.key)) : keys.map(key => {
|
|
473
|
+
const value = toNumber(row[key]);
|
|
474
|
+
return value === null ? null : {
|
|
475
|
+
key,
|
|
476
|
+
from: 0,
|
|
477
|
+
to: value,
|
|
478
|
+
value
|
|
479
|
+
};
|
|
480
|
+
}).filter(item => item !== null);
|
|
481
|
+
return bands.map(item => {
|
|
482
|
+
const seriesIndex = keys.indexOf(item.key);
|
|
483
|
+
const color = visible[seriesIndex]?.color ?? theme.colors.primary;
|
|
484
|
+
const start = centerOf(rowIndex) - fill / 2 + (stacked ? 0 : seriesIndex * slot);
|
|
485
|
+
const thicknessOfBar = Math.max(1, slot - (stacked || keys.length === 1 ? 0 : GROUP_GAP));
|
|
486
|
+
const near = scale.toPixel(item.from);
|
|
487
|
+
const far = scale.toPixel(item.to);
|
|
488
|
+
const size = Math.abs(far - near);
|
|
489
|
+
const positive = item.value >= 0;
|
|
490
|
+
const dim = !!selection && selection.index !== rowIndex;
|
|
491
|
+
return horizontal ? /*#__PURE__*/_jsx(Bar, {
|
|
492
|
+
x: positive ? near : plotW - near,
|
|
493
|
+
y: start,
|
|
494
|
+
width: size,
|
|
495
|
+
height: thicknessOfBar,
|
|
496
|
+
from: positive ? 'left' : 'right',
|
|
497
|
+
color: color,
|
|
498
|
+
radius: barRadius,
|
|
499
|
+
dim: dim,
|
|
500
|
+
animate: animate,
|
|
501
|
+
duration: duration,
|
|
502
|
+
testID: testID && `${testID}-bar-${rowIndex}-${item.key}`
|
|
503
|
+
}, `${rowIndex}-${item.key}`) : /*#__PURE__*/_jsx(Bar, {
|
|
504
|
+
x: start,
|
|
505
|
+
y: positive ? plotH - near : near,
|
|
506
|
+
width: thicknessOfBar,
|
|
507
|
+
height: size,
|
|
508
|
+
from: positive ? 'bottom' : 'top',
|
|
509
|
+
color: color,
|
|
510
|
+
radius: barRadius,
|
|
511
|
+
dim: dim,
|
|
512
|
+
animate: animate,
|
|
513
|
+
duration: duration,
|
|
514
|
+
testID: testID && `${testID}-bar-${rowIndex}-${item.key}`
|
|
515
|
+
}, `${rowIndex}-${item.key}`);
|
|
516
|
+
});
|
|
517
|
+
}) : visible.map(entry => {
|
|
518
|
+
const raw = pointsFor(entry.key, 'to');
|
|
519
|
+
const line = smooth ? smoothPoints(raw) : raw;
|
|
520
|
+
const base = smooth ? smoothPoints(pointsFor(entry.key, 'from')) : pointsFor(entry.key, 'from');
|
|
521
|
+
const stripCount = Math.min(MAX_STRIPS, Math.max(8, Math.round(plotW / STRIP_WIDTH)));
|
|
522
|
+
const stripWidth = plotW / stripCount;
|
|
523
|
+
return /*#__PURE__*/_jsxs(View, {
|
|
524
|
+
style: {
|
|
525
|
+
position: 'absolute',
|
|
526
|
+
left: 0,
|
|
527
|
+
top: 0,
|
|
528
|
+
width: plotW,
|
|
529
|
+
height: plotH
|
|
530
|
+
},
|
|
531
|
+
children: [type === 'area' ?
|
|
532
|
+
/*#__PURE__*/
|
|
533
|
+
// La opacidad va en el grupo, no en cada tira: el medio
|
|
534
|
+
// píxel de solape que tapa la costura del antialias se
|
|
535
|
+
// compone dos veces y deja una raya vertical cada cuatro
|
|
536
|
+
// píxeles. Atenuado el grupo entero, se pinta una sola vez.
|
|
537
|
+
_jsx(View, {
|
|
538
|
+
pointerEvents: "none",
|
|
539
|
+
style: {
|
|
540
|
+
position: 'absolute',
|
|
541
|
+
left: 0,
|
|
542
|
+
top: 0,
|
|
543
|
+
width: plotW,
|
|
544
|
+
height: plotH,
|
|
545
|
+
opacity: fillOpacity
|
|
546
|
+
},
|
|
547
|
+
children: Array.from({
|
|
548
|
+
length: stripCount
|
|
549
|
+
}, (_, strip) => {
|
|
550
|
+
const x = (strip + 0.5) * stripWidth;
|
|
551
|
+
const top = sampleY(line, x);
|
|
552
|
+
const bottom = sampleY(base, x);
|
|
553
|
+
if (top === null || bottom === null) return null;
|
|
554
|
+
return /*#__PURE__*/_jsx(View, {
|
|
555
|
+
style: {
|
|
556
|
+
position: 'absolute',
|
|
557
|
+
left: strip * stripWidth,
|
|
558
|
+
width: stripWidth + 0.5,
|
|
559
|
+
top: Math.min(top, bottom),
|
|
560
|
+
height: Math.abs(bottom - top),
|
|
561
|
+
backgroundColor: entry.color
|
|
562
|
+
}
|
|
563
|
+
}, strip);
|
|
564
|
+
})
|
|
565
|
+
}) : null, /*#__PURE__*/_jsx(Polyline, {
|
|
566
|
+
points: line,
|
|
567
|
+
color: entry.color,
|
|
568
|
+
thickness: thickness,
|
|
569
|
+
testID: testID && `${testID}-line-${entry.key}`
|
|
570
|
+
}), dots ? raw.map((point, index) => /*#__PURE__*/_jsx(Dot, {
|
|
571
|
+
point: point,
|
|
572
|
+
color: entry.color,
|
|
573
|
+
ringColor: theme.colors.surface,
|
|
574
|
+
size: selection?.index === index ? 8 : 6,
|
|
575
|
+
testID: testID && `${testID}-dot-${entry.key}-${index}`
|
|
576
|
+
}, index)) : null]
|
|
577
|
+
}, entry.key);
|
|
578
|
+
}), rows.map((row, index) => /*#__PURE__*/_jsx(Pressable, {
|
|
579
|
+
onPress: event => pickAt(index, event),
|
|
580
|
+
accessibilityRole: "button",
|
|
581
|
+
accessibilityState: {
|
|
582
|
+
selected: selection?.index === index
|
|
583
|
+
},
|
|
584
|
+
accessibilityLabel: [labels[index], ...tooltipRowsAt(rows, index, visible, formatValue).map(item => `${item.label} ${item.value}`)].join(', '),
|
|
585
|
+
style: horizontal ? {
|
|
586
|
+
position: 'absolute',
|
|
587
|
+
left: 0,
|
|
588
|
+
width: plotW,
|
|
589
|
+
top: index * band,
|
|
590
|
+
height: band
|
|
591
|
+
} : {
|
|
592
|
+
position: 'absolute',
|
|
593
|
+
top: 0,
|
|
594
|
+
height: plotH,
|
|
595
|
+
left: index * band,
|
|
596
|
+
width: band
|
|
597
|
+
},
|
|
598
|
+
testID: testID && `${testID}-band-${index}`
|
|
599
|
+
}, index))]
|
|
600
|
+
}), yAxis ? /*#__PURE__*/_jsx(View, {
|
|
601
|
+
style: {
|
|
602
|
+
position: 'absolute',
|
|
603
|
+
left: 0,
|
|
604
|
+
top: 0,
|
|
605
|
+
width: leftGutter,
|
|
606
|
+
height: plotH
|
|
607
|
+
},
|
|
608
|
+
children: /*#__PURE__*/_jsx(AxisLabels, {
|
|
609
|
+
axis: "y",
|
|
610
|
+
gutter: Math.max(0, leftGutter - 6),
|
|
611
|
+
span: plotH,
|
|
612
|
+
labels: horizontal ? labels.map((text, index) => ({
|
|
613
|
+
text,
|
|
614
|
+
position: centerOf(index)
|
|
615
|
+
})) : scale.nice.values.map(value => ({
|
|
616
|
+
text: formatValue(value),
|
|
617
|
+
position: scale.toPixel(value)
|
|
618
|
+
})),
|
|
619
|
+
testID: testID && `${testID}-y-axis`
|
|
620
|
+
})
|
|
621
|
+
}) : null, xAxis ? /*#__PURE__*/_jsx(View, {
|
|
622
|
+
style: {
|
|
623
|
+
position: 'absolute',
|
|
624
|
+
left: leftGutter,
|
|
625
|
+
top: plotH + 2,
|
|
626
|
+
width: plotW,
|
|
627
|
+
height: bottomGutter
|
|
628
|
+
},
|
|
629
|
+
children: /*#__PURE__*/_jsx(AxisLabels, {
|
|
630
|
+
axis: "x",
|
|
631
|
+
gutter: 0,
|
|
632
|
+
span: plotW,
|
|
633
|
+
labels: horizontal ? scale.nice.values.map(value => ({
|
|
634
|
+
text: formatValue(value),
|
|
635
|
+
position: scale.toPixel(value)
|
|
636
|
+
})) : labels.map((text, index) => ({
|
|
637
|
+
text,
|
|
638
|
+
position: centerOf(index)
|
|
639
|
+
})),
|
|
640
|
+
testID: testID && `${testID}-x-axis`
|
|
641
|
+
})
|
|
642
|
+
}) : null, tooltip && selection && anchor && rowsForTooltip.length > 0 ? /*#__PURE__*/_jsx(Tooltip, {
|
|
643
|
+
title: selection.label,
|
|
644
|
+
rows: rowsForTooltip,
|
|
645
|
+
anchor: anchor,
|
|
646
|
+
containerWidth: width,
|
|
647
|
+
testID: testID && `${testID}-tooltip`
|
|
648
|
+
}) : null]
|
|
649
|
+
});
|
|
650
|
+
});
|
|
651
|
+
CartesianPlot.displayName = 'CartesianPlot';
|
|
652
|
+
|
|
653
|
+
// ── torta y dona ───────────────────────────────────────────────────────────
|
|
654
|
+
|
|
655
|
+
const DONUT_RADIUS = 0.6;
|
|
656
|
+
/** Cuánto sale la porción elegida, en píxeles. */
|
|
657
|
+
const EXPLODE = 8;
|
|
658
|
+
/** Dónde cae la etiqueta, como fracción entre el agujero y el borde. */
|
|
659
|
+
const LABEL_AT = 0.62;
|
|
660
|
+
|
|
661
|
+
/** Blanco o negro, el que se lea encima del color. Un color raro no rompe: cae en `text`. */
|
|
662
|
+
function inkOn(background, theme) {
|
|
663
|
+
try {
|
|
664
|
+
return contrast(background, theme.colors.white) >= contrast(background, theme.colors.black) ? theme.colors.white : theme.colors.black;
|
|
665
|
+
} catch {
|
|
666
|
+
return theme.colors.text;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
const PiePlot = /*#__PURE__*/memo(props => {
|
|
670
|
+
const {
|
|
671
|
+
rows,
|
|
672
|
+
labels,
|
|
673
|
+
entries,
|
|
674
|
+
visible,
|
|
675
|
+
width,
|
|
676
|
+
height,
|
|
677
|
+
formatValue,
|
|
678
|
+
selection,
|
|
679
|
+
onPick,
|
|
680
|
+
tooltip,
|
|
681
|
+
testID,
|
|
682
|
+
donut,
|
|
683
|
+
innerRadius,
|
|
684
|
+
sliceLabels,
|
|
685
|
+
valueKey
|
|
686
|
+
} = props;
|
|
687
|
+
const theme = useTheme();
|
|
688
|
+
const styles = useStyles();
|
|
689
|
+
const allowFontScaling = useAllowFontScaling();
|
|
690
|
+
const center = {
|
|
691
|
+
x: width / 2,
|
|
692
|
+
y: height / 2
|
|
693
|
+
};
|
|
694
|
+
const radius = Math.max(0, Math.min(width, height) / 2 - 2);
|
|
695
|
+
const hole = clamp(innerRadius ?? (donut ? DONUT_RADIUS : 0), 0, 0.9) * radius;
|
|
696
|
+
const arcs = useMemo(() => {
|
|
697
|
+
// Una categoría apagada vale 0: sale de la torta y el resto se reparte el
|
|
698
|
+
// 100%, que es lo que espera quien la apagó desde la leyenda.
|
|
699
|
+
const values = rows.map((row, index) => visible.some(entry => entry.key === labels[index]) ? toNumber(row[valueKey]) ?? 0 : 0);
|
|
700
|
+
return arcsFor(values);
|
|
701
|
+
}, [rows, labels, visible, valueKey]);
|
|
702
|
+
const pickAt = useCallback(event => {
|
|
703
|
+
const point = {
|
|
704
|
+
x: event?.nativeEvent?.locationX ?? 0,
|
|
705
|
+
y: event?.nativeEvent?.locationY ?? 0
|
|
706
|
+
};
|
|
707
|
+
const distance = Math.hypot(point.x - center.x, point.y - center.y);
|
|
708
|
+
// El agujero de una dona no es parte de ninguna porción, y afuera del
|
|
709
|
+
// círculo tampoco: los dos sueltan la selección.
|
|
710
|
+
if (distance > radius || distance < hole) return onPick(null);
|
|
711
|
+
const index = arcAt(arcs, angleOf(center, point));
|
|
712
|
+
if (index === -1) return onPick(null);
|
|
713
|
+
onPick({
|
|
714
|
+
index,
|
|
715
|
+
seriesKey: labels[index] ?? ''
|
|
716
|
+
});
|
|
717
|
+
}, [center.x, center.y, radius, hole, arcs, labels, onPick]);
|
|
718
|
+
const chosen = selection ? arcs.find(arc => arc.index === selection.index) : undefined;
|
|
719
|
+
return /*#__PURE__*/_jsxs(_Fragment, {
|
|
720
|
+
children: [arcs.map(arc => {
|
|
721
|
+
const dim = !!selection && selection.index !== arc.index;
|
|
722
|
+
return /*#__PURE__*/_jsx(Wedge, {
|
|
723
|
+
size: radius * 2,
|
|
724
|
+
center: center,
|
|
725
|
+
start: arc.start,
|
|
726
|
+
sweep: arc.sweep,
|
|
727
|
+
color: entries[arc.index]?.color ?? theme.colors.primary,
|
|
728
|
+
opacity: dim ? DIM_OPACITY : 1,
|
|
729
|
+
explode: selection?.index === arc.index ? EXPLODE : 0,
|
|
730
|
+
testID: testID && `${testID}-slice-${arc.index}`
|
|
731
|
+
}, arc.index);
|
|
732
|
+
}), hole > 0 ? /*#__PURE__*/_jsx(View, {
|
|
733
|
+
pointerEvents: "none",
|
|
734
|
+
style: {
|
|
735
|
+
position: 'absolute',
|
|
736
|
+
left: center.x - hole,
|
|
737
|
+
top: center.y - hole,
|
|
738
|
+
width: hole * 2,
|
|
739
|
+
height: hole * 2,
|
|
740
|
+
borderRadius: hole,
|
|
741
|
+
backgroundColor: theme.colors.surface
|
|
742
|
+
}
|
|
743
|
+
}) : null, sliceLabels === 'none' ? null : arcs.map(arc => {
|
|
744
|
+
// Una porción muy fina no tiene dónde escribir: la etiqueta se
|
|
745
|
+
// saldría encima de las vecinas y no se leería ninguna.
|
|
746
|
+
if (arc.sweep < 18) return null;
|
|
747
|
+
const at = polar(center, hole + (radius - hole) * LABEL_AT, arc.start + arc.sweep / 2);
|
|
748
|
+
const color = entries[arc.index]?.color ?? theme.colors.primary;
|
|
749
|
+
return /*#__PURE__*/_jsx(Text, {
|
|
750
|
+
allowFontScaling: allowFontScaling,
|
|
751
|
+
style: [styles.sliceLabel, {
|
|
752
|
+
left: at.x - 28,
|
|
753
|
+
top: at.y - 8,
|
|
754
|
+
width: 56,
|
|
755
|
+
color: inkOn(color, theme)
|
|
756
|
+
}],
|
|
757
|
+
testID: testID && `${testID}-slice-label-${arc.index}`,
|
|
758
|
+
children: sliceLabels === 'percent' ? `${Math.round(arc.ratio * 100)}%` : formatValue(arc.value)
|
|
759
|
+
}, arc.index);
|
|
760
|
+
}), /*#__PURE__*/_jsx(Pressable, {
|
|
761
|
+
onPress: pickAt,
|
|
762
|
+
accessibilityRole: "adjustable",
|
|
763
|
+
accessibilityLabel: arcs.map(arc => `${labels[arc.index]} ${Math.round(arc.ratio * 100)}%`).join(', '),
|
|
764
|
+
style: {
|
|
765
|
+
position: 'absolute',
|
|
766
|
+
left: 0,
|
|
767
|
+
top: 0,
|
|
768
|
+
width,
|
|
769
|
+
height
|
|
770
|
+
},
|
|
771
|
+
testID: testID && `${testID}-hit`
|
|
772
|
+
}), tooltip && selection && chosen ? /*#__PURE__*/_jsx(Tooltip, {
|
|
773
|
+
title: selection.label,
|
|
774
|
+
rows: [{
|
|
775
|
+
key: selection.seriesKey,
|
|
776
|
+
label: `${Math.round(chosen.ratio * 100)}%`,
|
|
777
|
+
value: formatValue(selection.value),
|
|
778
|
+
color: entries[selection.index]?.color ?? theme.colors.primary
|
|
779
|
+
}],
|
|
780
|
+
anchor: polar(center, radius * 0.9, chosen.start + chosen.sweep / 2),
|
|
781
|
+
containerWidth: width,
|
|
782
|
+
testID: testID && `${testID}-tooltip`
|
|
783
|
+
}) : null]
|
|
784
|
+
});
|
|
785
|
+
});
|
|
786
|
+
PiePlot.displayName = 'PiePlot';
|
|
787
|
+
|
|
788
|
+
// ── radar ──────────────────────────────────────────────────────────────────
|
|
789
|
+
|
|
790
|
+
/** Tiras por lado del polígono cuando se rellena. Más = borde más limpio, más vistas. */
|
|
791
|
+
const RADAR_STEPS = 5;
|
|
792
|
+
const RADAR_FILL_OPACITY = 0.18;
|
|
793
|
+
const RADAR_LABEL_WIDTH = 56;
|
|
794
|
+
const RadarPlot = /*#__PURE__*/memo(props => {
|
|
795
|
+
const {
|
|
796
|
+
rows,
|
|
797
|
+
labels,
|
|
798
|
+
visible,
|
|
799
|
+
width,
|
|
800
|
+
height,
|
|
801
|
+
formatValue,
|
|
802
|
+
selection,
|
|
803
|
+
onPick,
|
|
804
|
+
tooltip,
|
|
805
|
+
testID,
|
|
806
|
+
domain,
|
|
807
|
+
levels,
|
|
808
|
+
fill,
|
|
809
|
+
thickness,
|
|
810
|
+
dots
|
|
811
|
+
} = props;
|
|
812
|
+
const theme = useTheme();
|
|
813
|
+
const styles = useStyles();
|
|
814
|
+
const allowFontScaling = useAllowFontScaling();
|
|
815
|
+
const center = {
|
|
816
|
+
x: width / 2,
|
|
817
|
+
y: height / 2
|
|
818
|
+
};
|
|
819
|
+
const radius = Math.max(0, Math.min(width, height) / 2 - RADAR_INSET);
|
|
820
|
+
const angles = useMemo(() => radarAngles(rows.length), [rows.length]);
|
|
821
|
+
const keys = useMemo(() => visible.map(entry => entry.key), [visible]);
|
|
822
|
+
|
|
823
|
+
// El radar mide desde el centro, y el centro es 0: un mínimo distinto de
|
|
824
|
+
// cero haría que dos valores parecidos se vean lejísimos.
|
|
825
|
+
const measured = useMemo(() => [0, extent(rows, keys, {
|
|
826
|
+
includeZero: true
|
|
827
|
+
})[1]], [rows, keys]);
|
|
828
|
+
const [, top] = useDomain(measured, domain);
|
|
829
|
+
const scale = useMemo(() => {
|
|
830
|
+
const nice = Array.isArray(domain) ? exactTicks(0, top, levels) : niceScale(0, top, levels);
|
|
831
|
+
return {
|
|
832
|
+
nice,
|
|
833
|
+
toRadius: linearScale([0, nice.max], [0, radius])
|
|
834
|
+
};
|
|
835
|
+
}, [domain, top, levels, radius]);
|
|
836
|
+
const pointsOf = useCallback(key => rows.map((row, index) => polar(center, scale.toRadius(Math.max(0, toNumber(row[key]) ?? 0)), angles[index] ?? 0)), [rows, center, scale, angles]);
|
|
837
|
+
const pickAt = useCallback(event => {
|
|
838
|
+
if (rows.length === 0) return;
|
|
839
|
+
const angle = angleOf(center, {
|
|
840
|
+
x: event?.nativeEvent?.locationX ?? 0,
|
|
841
|
+
y: event?.nativeEvent?.locationY ?? 0
|
|
842
|
+
});
|
|
843
|
+
const index = Math.round(angle / (360 / rows.length)) % rows.length;
|
|
844
|
+
const key = keys[0] ?? '';
|
|
845
|
+
onPick({
|
|
846
|
+
index,
|
|
847
|
+
seriesKey: key
|
|
848
|
+
});
|
|
849
|
+
}, [rows, center, keys, onPick]);
|
|
850
|
+
return /*#__PURE__*/_jsxs(_Fragment, {
|
|
851
|
+
children: [/*#__PURE__*/_jsx(RadarGrid, {
|
|
852
|
+
center: center,
|
|
853
|
+
radius: radius,
|
|
854
|
+
angles: angles,
|
|
855
|
+
levels: levels,
|
|
856
|
+
testID: testID && `${testID}-grid`
|
|
857
|
+
}), visible.map(entry => {
|
|
858
|
+
const points = pointsOf(entry.key);
|
|
859
|
+
return /*#__PURE__*/_jsxs(View, {
|
|
860
|
+
style: {
|
|
861
|
+
position: 'absolute',
|
|
862
|
+
left: 0,
|
|
863
|
+
top: 0,
|
|
864
|
+
width,
|
|
865
|
+
height
|
|
866
|
+
},
|
|
867
|
+
children: [fill ?
|
|
868
|
+
/*#__PURE__*/
|
|
869
|
+
// La opacidad va en el grupo, no en cada tira. Por tira, el
|
|
870
|
+
// solape de 0.4° que tapa la costura del antialias se compone
|
|
871
|
+
// dos veces y deja un radio más oscuro por corte: el relleno
|
|
872
|
+
// termina pareciendo una telaraña. Aplicada al grupo, las tiras
|
|
873
|
+
// se pintan opacas entre sí y el conjunto se atenúa una vez.
|
|
874
|
+
_jsx(View, {
|
|
875
|
+
pointerEvents: "none",
|
|
876
|
+
style: {
|
|
877
|
+
position: 'absolute',
|
|
878
|
+
left: 0,
|
|
879
|
+
top: 0,
|
|
880
|
+
width,
|
|
881
|
+
height,
|
|
882
|
+
opacity: RADAR_FILL_OPACITY
|
|
883
|
+
},
|
|
884
|
+
testID: testID && `${testID}-fill-${entry.key}`,
|
|
885
|
+
children: fanStrips(center, points, RADAR_STEPS).map((strip, index) => /*#__PURE__*/_jsx(Wedge, {
|
|
886
|
+
size: strip.radius * 2,
|
|
887
|
+
center: center,
|
|
888
|
+
start: strip.start,
|
|
889
|
+
sweep: strip.sweep,
|
|
890
|
+
color: entry.color
|
|
891
|
+
}, index))
|
|
892
|
+
}) : null, /*#__PURE__*/_jsx(Polyline, {
|
|
893
|
+
points: points,
|
|
894
|
+
color: entry.color,
|
|
895
|
+
thickness: thickness,
|
|
896
|
+
closed: true,
|
|
897
|
+
testID: testID && `${testID}-outline-${entry.key}`
|
|
898
|
+
}), dots ? points.map((point, index) => /*#__PURE__*/_jsx(Dot, {
|
|
899
|
+
point: point,
|
|
900
|
+
color: entry.color,
|
|
901
|
+
ringColor: theme.colors.surface,
|
|
902
|
+
size: selection?.index === index ? 8 : 6
|
|
903
|
+
}, index)) : null]
|
|
904
|
+
}, entry.key);
|
|
905
|
+
}), labels.map((label, index) => {
|
|
906
|
+
const at = polar(center, radius + RADAR_INSET / 2, angles[index] ?? 0);
|
|
907
|
+
return /*#__PURE__*/_jsx(Text, {
|
|
908
|
+
numberOfLines: 1,
|
|
909
|
+
allowFontScaling: allowFontScaling,
|
|
910
|
+
style: [styles.radarAxisLabel, {
|
|
911
|
+
left: at.x - RADAR_LABEL_WIDTH / 2,
|
|
912
|
+
top: at.y - 7,
|
|
913
|
+
width: RADAR_LABEL_WIDTH
|
|
914
|
+
}, selection?.index === index && {
|
|
915
|
+
color: theme.colors.text
|
|
916
|
+
}],
|
|
917
|
+
testID: testID && `${testID}-axis-${index}`,
|
|
918
|
+
children: label
|
|
919
|
+
}, `${label}-${index}`);
|
|
920
|
+
}), /*#__PURE__*/_jsx(Pressable, {
|
|
921
|
+
onPress: pickAt,
|
|
922
|
+
accessibilityRole: "adjustable",
|
|
923
|
+
accessibilityLabel: labels.join(', '),
|
|
924
|
+
style: {
|
|
925
|
+
position: 'absolute',
|
|
926
|
+
left: 0,
|
|
927
|
+
top: 0,
|
|
928
|
+
width,
|
|
929
|
+
height
|
|
930
|
+
},
|
|
931
|
+
testID: testID && `${testID}-hit`
|
|
932
|
+
}), tooltip && selection ? /*#__PURE__*/_jsx(Tooltip, {
|
|
933
|
+
title: selection.label,
|
|
934
|
+
rows: tooltipRowsAt(rows, selection.index, visible, formatValue),
|
|
935
|
+
anchor: polar(center, radius, angles[selection.index] ?? 0),
|
|
936
|
+
containerWidth: width,
|
|
937
|
+
testID: testID && `${testID}-tooltip`
|
|
938
|
+
}) : null]
|
|
939
|
+
});
|
|
940
|
+
});
|
|
941
|
+
RadarPlot.displayName = 'RadarPlot';
|
|
942
|
+
//# sourceMappingURL=Chart.js.map
|