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.
Files changed (80) hide show
  1. package/AGENTS.md +6 -2
  2. package/README.md +6 -6
  3. package/lib/commonjs/atoms/ShinyText.js +188 -0
  4. package/lib/commonjs/atoms/ShinyText.js.map +1 -0
  5. package/lib/commonjs/atoms/index.js +7 -0
  6. package/lib/commonjs/atoms/index.js.map +1 -1
  7. package/lib/commonjs/organisms/Chart.js +947 -0
  8. package/lib/commonjs/organisms/Chart.js.map +1 -0
  9. package/lib/commonjs/organisms/OptionWheel.js +408 -0
  10. package/lib/commonjs/organisms/OptionWheel.js.map +1 -0
  11. package/lib/commonjs/organisms/Timeline.js +420 -0
  12. package/lib/commonjs/organisms/Timeline.js.map +1 -0
  13. package/lib/commonjs/organisms/chart/frame.js +373 -0
  14. package/lib/commonjs/organisms/chart/frame.js.map +1 -0
  15. package/lib/commonjs/organisms/chart/marks.js +316 -0
  16. package/lib/commonjs/organisms/chart/marks.js.map +1 -0
  17. package/lib/commonjs/organisms/chart/math.js +468 -0
  18. package/lib/commonjs/organisms/chart/math.js.map +1 -0
  19. package/lib/commonjs/organisms/chart/palette.js +120 -0
  20. package/lib/commonjs/organisms/chart/palette.js.map +1 -0
  21. package/lib/commonjs/organisms/index.js +21 -0
  22. package/lib/commonjs/organisms/index.js.map +1 -1
  23. package/lib/module/atoms/ShinyText.js +183 -0
  24. package/lib/module/atoms/ShinyText.js.map +1 -0
  25. package/lib/module/atoms/index.js +1 -0
  26. package/lib/module/atoms/index.js.map +1 -1
  27. package/lib/module/organisms/Chart.js +942 -0
  28. package/lib/module/organisms/Chart.js.map +1 -0
  29. package/lib/module/organisms/OptionWheel.js +403 -0
  30. package/lib/module/organisms/OptionWheel.js.map +1 -0
  31. package/lib/module/organisms/Timeline.js +415 -0
  32. package/lib/module/organisms/Timeline.js.map +1 -0
  33. package/lib/module/organisms/chart/frame.js +367 -0
  34. package/lib/module/organisms/chart/frame.js.map +1 -0
  35. package/lib/module/organisms/chart/marks.js +310 -0
  36. package/lib/module/organisms/chart/marks.js.map +1 -0
  37. package/lib/module/organisms/chart/math.js +445 -0
  38. package/lib/module/organisms/chart/math.js.map +1 -0
  39. package/lib/module/organisms/chart/palette.js +114 -0
  40. package/lib/module/organisms/chart/palette.js.map +1 -0
  41. package/lib/module/organisms/index.js +3 -0
  42. package/lib/module/organisms/index.js.map +1 -1
  43. package/lib/typescript/atoms/ShinyText.d.ts +48 -0
  44. package/lib/typescript/atoms/ShinyText.d.ts.map +1 -0
  45. package/lib/typescript/atoms/index.d.ts +2 -0
  46. package/lib/typescript/atoms/index.d.ts.map +1 -1
  47. package/lib/typescript/organisms/Chart.d.ts +208 -0
  48. package/lib/typescript/organisms/Chart.d.ts.map +1 -0
  49. package/lib/typescript/organisms/OptionWheel.d.ts +78 -0
  50. package/lib/typescript/organisms/OptionWheel.d.ts.map +1 -0
  51. package/lib/typescript/organisms/Timeline.d.ts +61 -0
  52. package/lib/typescript/organisms/Timeline.d.ts.map +1 -0
  53. package/lib/typescript/organisms/chart/frame.d.ts +85 -0
  54. package/lib/typescript/organisms/chart/frame.d.ts.map +1 -0
  55. package/lib/typescript/organisms/chart/marks.d.ts +88 -0
  56. package/lib/typescript/organisms/chart/marks.d.ts.map +1 -0
  57. package/lib/typescript/organisms/chart/math.d.ts +161 -0
  58. package/lib/typescript/organisms/chart/math.d.ts.map +1 -0
  59. package/lib/typescript/organisms/chart/palette.d.ts +6 -0
  60. package/lib/typescript/organisms/chart/palette.d.ts.map +1 -0
  61. package/lib/typescript/organisms/index.d.ts +6 -0
  62. package/lib/typescript/organisms/index.d.ts.map +1 -1
  63. package/llms.txt +3 -3
  64. package/package.json +23 -2
  65. package/registry/chart.json +35 -0
  66. package/registry/index.json +53 -0
  67. package/registry/manifest.json +28 -0
  68. package/registry/option-wheel.json +19 -0
  69. package/registry/shiny-text.json +19 -0
  70. package/registry/timeline.json +20 -0
  71. package/src/atoms/ShinyText.tsx +226 -0
  72. package/src/atoms/index.ts +2 -0
  73. package/src/organisms/Chart.tsx +1223 -0
  74. package/src/organisms/OptionWheel.tsx +476 -0
  75. package/src/organisms/Timeline.tsx +467 -0
  76. package/src/organisms/chart/frame.tsx +349 -0
  77. package/src/organisms/chart/marks.tsx +295 -0
  78. package/src/organisms/chart/math.ts +475 -0
  79. package/src/organisms/chart/palette.ts +112 -0
  80. package/src/organisms/index.ts +14 -0
@@ -0,0 +1,1223 @@
1
+ import React, { memo, useCallback, useMemo, useRef, useState } from 'react';
2
+ import {
3
+ Pressable,
4
+ Text,
5
+ View,
6
+ type GestureResponderEvent,
7
+ type LayoutChangeEvent,
8
+ type StyleProp,
9
+ type ViewStyle,
10
+ } from 'react-native';
11
+ import { contrast } from '../theme/colors';
12
+ import { createStyles } from '../theme/createStyles';
13
+ import { useAllowFontScaling, useLabels, useTheme } from '../theme/UIProvider';
14
+ import { SegmentedControl } from '../molecules/SegmentedControl';
15
+ import { Bar, Dot, Polyline, Wedge } from './chart/marks';
16
+ import { AxisLabels, CartesianGrid, Legend, RadarGrid, Tooltip, type LegendItem, type TooltipRow } from './chart/frame';
17
+ import { seriesPalette } from './chart/palette';
18
+ import {
19
+ angleOf,
20
+ arcAt,
21
+ arcsFor,
22
+ clamp,
23
+ exactTicks,
24
+ extent,
25
+ fanStrips,
26
+ formatNumber,
27
+ growExtent,
28
+ linearScale,
29
+ lttbIndices,
30
+ niceScale,
31
+ polar,
32
+ radarAngles,
33
+ sampleY,
34
+ smoothPoints,
35
+ stackRow,
36
+ toNumber,
37
+ type ChartRow,
38
+ type Point,
39
+ } from './chart/math';
40
+
41
+ export type { ChartRow } from './chart/math';
42
+
43
+ export type ChartType = 'bar' | 'line' | 'area' | 'pie' | 'donut' | 'radar';
44
+
45
+ export interface ChartSeries {
46
+ /** Columna de `data` que lee esta serie. */
47
+ key: string;
48
+ /** Lo que dicen la leyenda y el tooltip. Por defecto, `key`. */
49
+ label?: string;
50
+ /** Pisa el color derivado del theme. */
51
+ color?: string;
52
+ }
53
+
54
+ export interface ChartFilterOption {
55
+ value: string;
56
+ label: string;
57
+ }
58
+
59
+ /**
60
+ * Un filtro de la barra superior. Chart no filtra nada por su cuenta: muestra
61
+ * el control y avisa. Quien tiene los datos decide qué significa "últimos 7
62
+ * días", y eso no se puede adivinar desde acá.
63
+ */
64
+ export interface ChartFilter {
65
+ key: string;
66
+ options: ChartFilterOption[];
67
+ value: string;
68
+ onChange: (value: string) => void;
69
+ }
70
+
71
+ export interface ChartSelection {
72
+ /**
73
+ * Identidad de la fila, estable a través de un `window` o un diezmado.
74
+ * Sale de `selectBy`; sin él es el índice, y entonces no sobrevive a que
75
+ * los datos corran.
76
+ */
77
+ id: string;
78
+ /**
79
+ * Índice adentro de las filas **dibujadas**, no de `data`: con `window` o
80
+ * con diezmado no son la misma lista. Para volver al dato está `row`.
81
+ */
82
+ index: number;
83
+ /** La fila entera, sin tener que resolver el índice contra nada. */
84
+ row: ChartRow;
85
+ /** Valor de `xKey` en esa fila. */
86
+ label: string;
87
+ /** Serie tocada. En torta es la fila, que ahí es la categoría. */
88
+ seriesKey: string;
89
+ value: number;
90
+ }
91
+
92
+ interface ChartBaseProps {
93
+ data: ChartRow[];
94
+ /**
95
+ * Series a dibujar. En 'pie'/'donut' sólo se usa la primera: las categorías
96
+ * de una torta son las filas, no las columnas.
97
+ */
98
+ series: ChartSeries[];
99
+ /** Columna con la etiqueta de cada fila. @default 'x' */
100
+ xKey?: string;
101
+ /**
102
+ * Identidad estable de una fila. Sin esto la selección se guarda por índice,
103
+ * y en una ventana deslizante el índice corre: el punto resaltado pasa a
104
+ * señalar otro dato sin que nadie lo toque. Con esto el resaltado sigue al
105
+ * dato, y desaparece solo cuando el dato sale de la ventana.
106
+ */
107
+ selectBy?: (row: ChartRow, index: number) => string;
108
+ /**
109
+ * Dibuja sólo las últimas N filas. Es la ventana deslizante de un chart en
110
+ * vivo: quien empuja los datos no tiene que recortarlos en cada tick.
111
+ */
112
+ window?: number;
113
+ /** Alto del área de dibujo, sin contar filtros ni leyenda. @default 220 */
114
+ height?: number;
115
+ /**
116
+ * 'toggle' convierte la leyenda en el filtro más barato que tiene un chart:
117
+ * tocar una serie la apaga. @default 'auto' (toggle con 2+ series, none con una)
118
+ */
119
+ legend?: 'auto' | 'none' | 'static' | 'toggle';
120
+ /** Series apagadas, controlado. Sin esto, Chart se las acuerda solo. */
121
+ hiddenKeys?: string[];
122
+ /** @default [] */
123
+ defaultHiddenKeys?: string[];
124
+ onHiddenKeysChange?: (keys: string[]) => void;
125
+ /** Controles arriba del chart, uno por filtro. */
126
+ filters?: ChartFilter[];
127
+ /** Punto resaltado, controlado. `null` es "ninguno". */
128
+ selected?: ChartSelection | null;
129
+ onSelect?: (selection: ChartSelection | null) => void;
130
+ /** Globo con los valores de la categoría tocada. @default true */
131
+ tooltip?: boolean;
132
+ /** @default formato compacto: 1.2k, 3.4M */
133
+ formatValue?: (value: number) => string;
134
+ /** @default true */
135
+ animate?: boolean;
136
+ /** ms de la animación de las marcas. @default tokens.duration.slow * 2 */
137
+ duration?: number;
138
+ /** Texto cuando no queda nada que dibujar. */
139
+ emptyText?: string;
140
+ /** Obligatorio: un chart no tiene texto que lo describa. */
141
+ accessibilityLabel: string;
142
+ style?: StyleProp<ViewStyle>;
143
+ testID?: string;
144
+ }
145
+
146
+ interface CartesianProps {
147
+ /**
148
+ * Fija el eje de valores. Por defecto se recalcula con cada dato, que es lo
149
+ * correcto para un chart estático y lo peor para uno en vivo: llega un valor
150
+ * más alto, la escala entera se reacomoda y la línea salta aunque el dato no
151
+ * se haya movido.
152
+ *
153
+ * - `[min, max]` — exacto, sin redondear a números lindos.
154
+ * - `'sticky'` — arranca del dato y crece cuando algo no entra, pero no se
155
+ * vuelve a achicar.
156
+ */
157
+ domain?: [number, number] | 'sticky';
158
+ /** Líneas de referencia del eje de valores. @default true */
159
+ grid?: boolean;
160
+ /** Ticks buscados en el eje de valores; el redondeo puede dar uno más o uno menos. @default 4 */
161
+ ticks?: number;
162
+ /** Etiquetas del eje horizontal. @default true */
163
+ xAxis?: boolean;
164
+ /** Etiquetas del eje vertical. @default true */
165
+ yAxis?: boolean;
166
+ }
167
+
168
+ interface BarChartProps extends CartesianProps {
169
+ type: 'bar';
170
+ /** 'horizontal' pone las categorías en el eje vertical. @default 'vertical' */
171
+ orientation?: 'vertical' | 'horizontal';
172
+ /** Apila las series en vez de ponerlas lado a lado. @default false */
173
+ stacked?: boolean;
174
+ /** Radio del extremo libre de cada barra. @default radius.xs */
175
+ barRadius?: number;
176
+ }
177
+
178
+ interface LineChartProps extends CartesianProps {
179
+ type: 'line';
180
+ /**
181
+ * Cuántos puntos dibujar como mucho, eligiéndolos con LTTB. Por defecto sale
182
+ * del ancho medido: más de un punto cada dos píxeles no se ve y sí se paga.
183
+ * `false` los dibuja todos.
184
+ */
185
+ decimate?: number | false;
186
+ /** Catmull-Rom en vez de tramos rectos. @default false */
187
+ smooth?: boolean;
188
+ /** @default 2 */
189
+ thickness?: number;
190
+ /** Punto en cada dato. @default true */
191
+ dots?: boolean;
192
+ }
193
+
194
+ interface AreaChartProps extends CartesianProps {
195
+ type: 'area';
196
+ /** Igual que en 'line'. */
197
+ decimate?: number | false;
198
+ smooth?: boolean;
199
+ thickness?: number;
200
+ /** @default false */
201
+ dots?: boolean;
202
+ /** @default false */
203
+ stacked?: boolean;
204
+ /** Opacidad del relleno bajo la línea. @default 0.22 */
205
+ fillOpacity?: number;
206
+ }
207
+
208
+ interface PieBaseProps {
209
+ /** Agujero como fracción del radio, 0–0.9. @default 0 en 'pie', 0.6 en 'donut' */
210
+ innerRadius?: number;
211
+ /** Qué escribir sobre cada porción. @default 'percent' */
212
+ sliceLabels?: 'none' | 'percent' | 'value';
213
+ }
214
+
215
+ // 'pie' y 'donut' van separados aunque compartan todo: con un solo miembro
216
+ // cuyo `type` fuera `'pie' | 'donut'`, TypeScript no saca ese miembro de la
217
+ // unión al descartarlo, y el resto del componente deja de estrecharse.
218
+ interface PieChartProps extends PieBaseProps {
219
+ type: 'pie';
220
+ }
221
+
222
+ interface DonutChartProps extends PieBaseProps {
223
+ type: 'donut';
224
+ }
225
+
226
+ interface RadarChartProps {
227
+ type: 'radar';
228
+ /**
229
+ * Fija el alcance de los ejes. El radar mide desde el centro, así que el
230
+ * mínimo es siempre 0 y de `[min, max]` sólo se usa `max`.
231
+ */
232
+ domain?: [number, number] | 'sticky';
233
+ /** Anillos de la telaraña. @default 4 */
234
+ levels?: number;
235
+ /** Rellena el polígono además de dibujar su contorno. @default true */
236
+ fill?: boolean;
237
+ thickness?: number;
238
+ /** @default true */
239
+ dots?: boolean;
240
+ }
241
+
242
+ export type ChartProps = ChartBaseProps &
243
+ (BarChartProps | LineChartProps | AreaChartProps | PieChartProps | DonutChartProps | RadarChartProps);
244
+
245
+ const DEFAULT_HEIGHT = 220;
246
+ const DEFAULT_TICKS = 4;
247
+ /** Canal de las etiquetas de valor, a la izquierda. */
248
+ const VALUE_GUTTER = 38;
249
+ /** Canal de las categorías cuando van a la izquierda (barras horizontales). */
250
+ const CATEGORY_GUTTER = 64;
251
+ /** Canal de las etiquetas de abajo. */
252
+ const BOTTOM_GUTTER = 16;
253
+ /** Fracción del ancho de banda que ocupan las barras; el resto es aire. */
254
+ const BAND_FILL = 0.7;
255
+ /** Aire alrededor del radar para que entren las etiquetas de los ejes. */
256
+ const RADAR_INSET = 26;
257
+ const DIM_OPACITY = 0.3;
258
+
259
+ const useStyles = createStyles((theme) => ({
260
+ root: { alignSelf: 'stretch' },
261
+ filters: { gap: theme.tokens.spacing.sm, marginBottom: theme.tokens.spacing.md },
262
+ plot: { position: 'relative', overflow: 'visible' },
263
+ empty: {
264
+ position: 'absolute',
265
+ left: 0,
266
+ right: 0,
267
+ top: 0,
268
+ bottom: 0,
269
+ alignItems: 'center',
270
+ justifyContent: 'center',
271
+ },
272
+ emptyText: { fontSize: theme.tokens.fontSize.sm, color: theme.colors.textLight },
273
+ sliceLabel: {
274
+ position: 'absolute',
275
+ fontSize: theme.tokens.fontSize.xs,
276
+ fontWeight: theme.tokens.fontWeight.semibold,
277
+ textAlign: 'center',
278
+ },
279
+ radarAxisLabel: {
280
+ position: 'absolute',
281
+ fontSize: theme.tokens.fontSize.xs,
282
+ color: theme.colors.textLight,
283
+ textAlign: 'center',
284
+ },
285
+ }));
286
+
287
+ /** Estado controlado o propio, con el mismo contrato en los dos casos. */
288
+ function useControlled<T>(controlled: T | undefined, fallback: T): [T, (next: T) => void, boolean] {
289
+ const [own, setOwn] = useState(fallback);
290
+ const isControlled = controlled !== undefined;
291
+ return [isControlled ? (controlled as T) : own, setOwn, isControlled];
292
+ }
293
+
294
+ /**
295
+ * Resuelve el dominio del eje de valores a partir de lo que pidió el
296
+ * consumidor. En 'sticky' la referencia vive en un ref: lo que importa no es
297
+ * el dominio de este render sino el más ancho que se haya visto, y guardarlo
298
+ * en estado dispararía un render de más por cada tick que ensancha el eje.
299
+ */
300
+ function useDomain(measured: [number, number], domain: [number, number] | 'sticky' | undefined): [number, number] {
301
+ const widest = useRef<[number, number] | null>(null);
302
+ if (domain === 'sticky') {
303
+ widest.current = growExtent(widest.current, measured);
304
+ return widest.current;
305
+ }
306
+ widest.current = null;
307
+ return domain ?? measured;
308
+ }
309
+
310
+ /**
311
+ * Chart
312
+ * Barras (verticales, horizontales, agrupadas, apiladas), línea, área, torta,
313
+ * dona y radar en un solo componente, porque lo que cambia entre ellos es la
314
+ * marca que se dibuja y no el resto: datos, escala, ejes, leyenda, tooltip,
315
+ * selección y filtros son los mismos seis mecanismos en todos.
316
+ *
317
+ * Se dibuja con `View`: React Native no trae lienzo vectorial y la librería no
318
+ * puede agregar uno. Las tres primitivas están en `chart/marks.tsx` y la
319
+ * geometría entera, sin React, en `chart/math.ts`.
320
+ *
321
+ * Gestalt: destino común — al tocar una categoría, todo lo que no es ella baja
322
+ * a {@link DIM_OPACITY} a la vez, así que lo resaltado se lee como un grupo y
323
+ * no como una marca suelta. La leyenda usa similitud: el mismo color que la
324
+ * serie, y por eso puede apagarla sin que haga falta explicar qué apaga.
325
+ */
326
+ export function Chart(props: ChartProps) {
327
+ const {
328
+ data,
329
+ series,
330
+ xKey = 'x',
331
+ selectBy,
332
+ window: windowSize,
333
+ height = DEFAULT_HEIGHT,
334
+ legend = 'auto',
335
+ hiddenKeys,
336
+ defaultHiddenKeys,
337
+ onHiddenKeysChange,
338
+ filters,
339
+ selected,
340
+ onSelect,
341
+ tooltip = true,
342
+ formatValue = formatNumber,
343
+ animate = true,
344
+ duration,
345
+ emptyText,
346
+ accessibilityLabel,
347
+ style,
348
+ testID,
349
+ } = props;
350
+
351
+ const theme = useTheme();
352
+ const styles = useStyles();
353
+ const allowFontScaling = useAllowFontScaling();
354
+ const uiLabels = useLabels();
355
+ const [width, setWidth] = useState(0);
356
+
357
+ const [hidden, setHidden, hiddenIsControlled] = useControlled(hiddenKeys, defaultHiddenKeys ?? []);
358
+ const [pick, setPick, selectionIsControlled] = useControlled<ChartSelection | null>(selected, null);
359
+
360
+ const isPie = props.type === 'pie' || props.type === 'donut';
361
+ const polyline = props.type === 'line' || props.type === 'area';
362
+ const decimate = polyline ? props.decimate : false;
363
+
364
+ /**
365
+ * Lo que se dibuja: la ventana de `data`, y de ahí los puntos que caben.
366
+ * El diezmado corre sobre la fila entera, elegida con la primera serie
367
+ * visible: las series comparten eje x, así que quedarse con índices
368
+ * distintos por serie rompería la alineación, la capa de toque y el tooltip.
369
+ */
370
+ const rows = useMemo(() => {
371
+ const windowed = windowSize && windowSize > 0 ? data.slice(-windowSize) : data;
372
+ if (!polyline || decimate === false || width === 0) return windowed;
373
+ // Un punto cada dos píxeles: más no se ve y sí se paga.
374
+ const target = typeof decimate === 'number' ? decimate : Math.floor(width / 2);
375
+ if (windowed.length <= target) return windowed;
376
+ const lead = series.find((item) => !hidden.includes(item.key))?.key ?? series[0]?.key;
377
+ const values = windowed.map((row) => (lead ? toNumber(row[lead]) : null));
378
+ return lttbIndices(values, target).map((index) => windowed[index]!);
379
+ }, [data, windowSize, polyline, decimate, width, series, hidden]);
380
+
381
+ const labels = useMemo(() => rows.map((row) => String(row[xKey] ?? '')), [rows, xKey]);
382
+ const ids = useMemo(
383
+ () => rows.map((row, index) => (selectBy ? selectBy(row, index) : String(index))),
384
+ [rows, selectBy]
385
+ );
386
+
387
+ /**
388
+ * La selección se guarda por `id` y se resuelve contra las filas de ahora:
389
+ * si el dato se corrió, el resaltado lo sigue; si salió de la ventana,
390
+ * desaparece. Reusar el índice a ciegas señalaría el dato que quedó en ese
391
+ * lugar, que no es el que nadie eligió.
392
+ */
393
+ const selection = useMemo(() => {
394
+ if (!pick) return null;
395
+ const index = ids.indexOf(pick.id);
396
+ if (index === -1) return null;
397
+ const row = rows[index]!;
398
+ return {
399
+ ...pick,
400
+ index,
401
+ row,
402
+ label: labels[index] ?? pick.label,
403
+ value: toNumber(row[pick.seriesKey]) ?? pick.value,
404
+ };
405
+ }, [pick, ids, rows, labels]);
406
+
407
+ // En torta las categorías son las filas; en el resto, las columnas. De eso
408
+ // dependen el color, la leyenda y qué apaga un toque en ella.
409
+ const palette = seriesPalette(theme, Math.max(1, isPie ? rows.length : series.length));
410
+ const entries: LegendItem[] = useMemo(
411
+ () =>
412
+ isPie
413
+ ? labels.map((label, index) => ({ key: label, label, color: series[0]?.color ?? palette[index] ?? theme.colors.primary }))
414
+ : series.map((item, index) => ({
415
+ key: item.key,
416
+ label: item.label ?? item.key,
417
+ color: item.color ?? palette[index] ?? theme.colors.primary,
418
+ })),
419
+ [isPie, labels, series, palette, theme.colors.primary]
420
+ );
421
+
422
+ const visible = useMemo(() => entries.filter((entry) => !hidden.includes(entry.key)), [entries, hidden]);
423
+ const duration_ = duration ?? theme.tokens.duration.slow * 2;
424
+
425
+ const applyHidden = useCallback(
426
+ (key: string) => {
427
+ const next = hidden.includes(key) ? hidden.filter((k) => k !== key) : [...hidden, key];
428
+ if (!hiddenIsControlled) setHidden(next);
429
+ onHiddenKeysChange?.(next);
430
+ },
431
+ [hidden, hiddenIsControlled, setHidden, onHiddenKeysChange]
432
+ );
433
+
434
+ /**
435
+ * Los plots avisan qué se tocó —fila y serie— y la selección entera se
436
+ * arma acá: es el único lugar que conoce `selectBy`, las etiquetas y las
437
+ * filas de verdad, y así los tres no repiten el armado.
438
+ */
439
+ const applySelection = useCallback(
440
+ (hit: { index: number; seriesKey: string } | null) => {
441
+ const row = hit ? rows[hit.index] : undefined;
442
+ const next: ChartSelection | null =
443
+ hit && row
444
+ ? {
445
+ id: ids[hit.index] ?? String(hit.index),
446
+ index: hit.index,
447
+ row,
448
+ label: labels[hit.index] ?? '',
449
+ seriesKey: hit.seriesKey,
450
+ value: toNumber(row[hit.seriesKey]) ?? 0,
451
+ }
452
+ : null;
453
+ // Tocar lo ya elegido lo suelta: sin eso no habría forma de volver al
454
+ // chart entero sin tocar afuera, y "afuera" en una pantalla con scroll
455
+ // no siempre existe.
456
+ const same = next && selection && next.id === selection.id && next.seriesKey === selection.seriesKey;
457
+ const value = same ? null : next;
458
+ if (!selectionIsControlled) setPick(value);
459
+ onSelect?.(value);
460
+ },
461
+ [rows, ids, labels, selection, selectionIsControlled, setPick, onSelect]
462
+ );
463
+
464
+ const onLayout = useCallback((event: LayoutChangeEvent) => setWidth(event.nativeEvent.layout.width), []);
465
+
466
+ const showLegend = legend === 'toggle' || legend === 'static' || (legend === 'auto' && entries.length > 1);
467
+ const toggleable = legend === 'toggle' || (legend === 'auto' && entries.length > 1);
468
+ const empty = rows.length === 0 || visible.length === 0;
469
+
470
+ const shared = {
471
+ rows,
472
+ labels,
473
+ entries,
474
+ visible,
475
+ width,
476
+ height,
477
+ formatValue,
478
+ animate,
479
+ duration: duration_,
480
+ selection,
481
+ onPick: applySelection,
482
+ tooltip,
483
+ testID,
484
+ };
485
+
486
+ return (
487
+ <View style={[styles.root, style]} testID={testID} accessible={false}>
488
+ {filters && filters.length > 0 ? (
489
+ <View style={styles.filters}>
490
+ {filters.map((filter) => (
491
+ <SegmentedControl
492
+ key={filter.key}
493
+ options={filter.options}
494
+ value={filter.value}
495
+ onChange={filter.onChange}
496
+ testID={testID && `${testID}-filter-${filter.key}`}
497
+ />
498
+ ))}
499
+ </View>
500
+ ) : null}
501
+
502
+ <View
503
+ style={[styles.plot, { height }]}
504
+ onLayout={onLayout}
505
+ accessible
506
+ accessibilityRole="image"
507
+ accessibilityLabel={accessibilityLabel}
508
+ testID={testID && `${testID}-plot`}
509
+ >
510
+ {width === 0 || empty ? null : props.type === 'pie' || props.type === 'donut' ? (
511
+ <PiePlot
512
+ {...shared}
513
+ donut={props.type === 'donut'}
514
+ innerRadius={props.innerRadius}
515
+ sliceLabels={props.sliceLabels ?? 'percent'}
516
+ valueKey={series[0]?.key ?? ''}
517
+ />
518
+ ) : props.type === 'radar' ? (
519
+ <RadarPlot
520
+ {...shared}
521
+ domain={props.domain}
522
+ levels={props.levels ?? 4}
523
+ fill={props.fill ?? true}
524
+ thickness={props.thickness ?? 2}
525
+ dots={props.dots ?? true}
526
+ />
527
+ ) : (
528
+ <CartesianPlot
529
+ {...shared}
530
+ type={props.type}
531
+ orientation={props.type === 'bar' ? (props.orientation ?? 'vertical') : 'vertical'}
532
+ stacked={props.type === 'bar' || props.type === 'area' ? (props.stacked ?? false) : false}
533
+ barRadius={props.type === 'bar' ? (props.barRadius ?? theme.tokens.radius.xs) : 0}
534
+ smooth={props.type !== 'bar' ? (props.smooth ?? false) : false}
535
+ thickness={props.type !== 'bar' ? (props.thickness ?? 2) : 0}
536
+ dots={props.type === 'line' ? (props.dots ?? true) : props.type === 'area' ? (props.dots ?? false) : false}
537
+ fillOpacity={props.type === 'area' ? (props.fillOpacity ?? 0.22) : 0}
538
+ domain={props.domain}
539
+ grid={props.grid ?? true}
540
+ ticks={props.ticks ?? DEFAULT_TICKS}
541
+ xAxis={props.xAxis ?? true}
542
+ yAxis={props.yAxis ?? true}
543
+ />
544
+ )}
545
+
546
+ {empty ? (
547
+ <View style={styles.empty} testID={testID && `${testID}-empty`}>
548
+ <Text allowFontScaling={allowFontScaling} style={styles.emptyText}>
549
+ {emptyText ?? uiLabels.noResultsTitle}
550
+ </Text>
551
+ </View>
552
+ ) : null}
553
+ </View>
554
+
555
+ {showLegend ? (
556
+ <Legend items={entries} hidden={hidden} onToggle={toggleable ? applyHidden : undefined} testID={testID && `${testID}-legend`} />
557
+ ) : null}
558
+ </View>
559
+ );
560
+ }
561
+
562
+ Chart.displayName = 'Chart';
563
+
564
+ // ── lo que comparten los tres plots ────────────────────────────────────────
565
+
566
+ interface PlotProps {
567
+ rows: ChartRow[];
568
+ labels: string[];
569
+ /** Todas las categorías, con su color: el color no cambia al apagar una. */
570
+ entries: LegendItem[];
571
+ /** Las que quedaron prendidas. */
572
+ visible: LegendItem[];
573
+ width: number;
574
+ height: number;
575
+ formatValue: (value: number) => string;
576
+ animate: boolean;
577
+ duration: number;
578
+ selection: ChartSelection | null;
579
+ /** Avisa qué se tocó; Chart arma la selección. `null` la suelta. */
580
+ onPick: (hit: { index: number; seriesKey: string } | null) => void;
581
+ tooltip: boolean;
582
+ testID?: string;
583
+ }
584
+
585
+ /** Filas del tooltip para una categoría del eje x. */
586
+ function tooltipRowsAt(rows: ChartRow[], index: number, visible: LegendItem[], format: (value: number) => string): TooltipRow[] {
587
+ const row = rows[index];
588
+ if (!row) return [];
589
+ return visible
590
+ .map((entry) => {
591
+ const value = toNumber(row[entry.key]);
592
+ return value === null ? null : { key: entry.key, label: entry.label, value: format(value), color: entry.color };
593
+ })
594
+ .filter((item): item is TooltipRow => item !== null);
595
+ }
596
+
597
+ // ── barras, línea y área ───────────────────────────────────────────────────
598
+
599
+ interface CartesianPlotProps extends PlotProps {
600
+ type: 'bar' | 'line' | 'area';
601
+ orientation: 'vertical' | 'horizontal';
602
+ stacked: boolean;
603
+ barRadius: number;
604
+ smooth: boolean;
605
+ thickness: number;
606
+ dots: boolean;
607
+ fillOpacity: number;
608
+ domain?: [number, number] | 'sticky';
609
+ grid: boolean;
610
+ ticks: number;
611
+ xAxis: boolean;
612
+ yAxis: boolean;
613
+ }
614
+
615
+ /** Ancho de una tira del relleno de área, en píxeles. Más fino = más vistas. */
616
+ const STRIP_WIDTH = 4;
617
+ const MAX_STRIPS = 140;
618
+ /** Aire entre dos barras del mismo grupo. */
619
+ const GROUP_GAP = 2;
620
+
621
+ const CartesianPlot = memo((props: CartesianPlotProps) => {
622
+ const {
623
+ rows,
624
+ labels,
625
+ visible,
626
+ width,
627
+ height,
628
+ formatValue,
629
+ animate,
630
+ duration,
631
+ selection,
632
+ onPick,
633
+ tooltip,
634
+ testID,
635
+ type,
636
+ orientation,
637
+ stacked,
638
+ barRadius,
639
+ smooth,
640
+ thickness,
641
+ dots,
642
+ fillOpacity,
643
+ domain,
644
+ grid,
645
+ ticks: tickCount,
646
+ xAxis,
647
+ yAxis,
648
+ } = props;
649
+
650
+ const theme = useTheme();
651
+ const horizontal = type === 'bar' && orientation === 'horizontal';
652
+ const keys = useMemo(() => visible.map((entry) => entry.key), [visible]);
653
+
654
+ const leftGutter = yAxis ? (horizontal ? CATEGORY_GUTTER : VALUE_GUTTER) : 0;
655
+ const bottomGutter = xAxis ? BOTTOM_GUTTER : 0;
656
+ const plotW = Math.max(1, width - leftGutter);
657
+ const plotH = Math.max(1, height - bottomGutter);
658
+
659
+ // La línea puede no tocar el cero (una temperatura, un ranking); una barra
660
+ // sí tiene que hacerlo, porque su largo es el que dice el valor.
661
+ const measured = useMemo(
662
+ () => extent(rows, keys, { stacked, includeZero: type !== 'line' }),
663
+ [rows, keys, stacked, type]
664
+ );
665
+ const [low, high] = useDomain(measured, domain);
666
+
667
+ const scale = useMemo(() => {
668
+ // Un dominio a mano se respeta tal cual; uno medido (o 'sticky') se
669
+ // redondea, que es lo que hace legible el eje.
670
+ const nice = Array.isArray(domain) ? exactTicks(low, high, tickCount) : niceScale(low, high, tickCount);
671
+ const toPixel = horizontal
672
+ ? linearScale([nice.min, nice.max], [0, plotW])
673
+ : linearScale([nice.min, nice.max], [plotH, 0]);
674
+ return { nice, toPixel };
675
+ }, [domain, low, high, tickCount, horizontal, plotW, plotH]);
676
+
677
+ const band = (horizontal ? plotH : plotW) / Math.max(1, rows.length);
678
+ const centerOf = useCallback((index: number) => (index + 0.5) * band, [band]);
679
+ const baseline = scale.toPixel(clamp(0, scale.nice.min, scale.nice.max));
680
+
681
+ const stacks = useMemo(() => (stacked ? rows.map((row) => stackRow(row, keys)) : null), [stacked, rows, keys]);
682
+
683
+ /** Puntos de una serie: `to` es el valor, `from` el piso desde donde se rellena. */
684
+ const pointsFor = useCallback(
685
+ (key: string, edge: 'to' | 'from'): Point[] => {
686
+ const out: Point[] = [];
687
+ rows.forEach((row, index) => {
688
+ if (stacks) {
689
+ const stackBand = stacks[index]!.find((item) => item.key === key);
690
+ if (!stackBand) return;
691
+ out.push({ x: centerOf(index), y: scale.toPixel(stackBand[edge]) });
692
+ return;
693
+ }
694
+ const value = toNumber(row[key]);
695
+ if (value === null) return;
696
+ out.push({ x: centerOf(index), y: edge === 'to' ? scale.toPixel(value) : baseline });
697
+ });
698
+ return out;
699
+ },
700
+ [rows, stacks, centerOf, scale, baseline]
701
+ );
702
+
703
+ const pickAt = useCallback(
704
+ (index: number, event: GestureResponderEvent) => {
705
+ // Sobre qué serie cayó el dedo: la que tiene su marca más cerca del
706
+ // toque en el eje de valores. Con una sola serie es siempre esa.
707
+ const touch = (horizontal ? event?.nativeEvent?.locationX : event?.nativeEvent?.locationY) ?? 0;
708
+ let bestKey = keys[0] ?? '';
709
+ let bestDistance = Infinity;
710
+ for (const key of keys) {
711
+ const value = toNumber(rows[index]?.[key]);
712
+ if (value === null) continue;
713
+ const distance = Math.abs(scale.toPixel(value) - touch);
714
+ if (distance < bestDistance) {
715
+ bestDistance = distance;
716
+ bestKey = key;
717
+ }
718
+ }
719
+ onPick({ index, seriesKey: bestKey });
720
+ },
721
+ [horizontal, keys, rows, scale, onPick]
722
+ );
723
+
724
+ const gridLines = scale.nice.values.map((value) => scale.toPixel(value));
725
+ const zeroIndex = scale.nice.values.findIndex((value) => value === 0);
726
+
727
+ const rowsForTooltip = selection ? tooltipRowsAt(rows, selection.index, visible, formatValue) : [];
728
+ const anchor: Point | null = !selection
729
+ ? null
730
+ : horizontal
731
+ ? {
732
+ x: leftGutter + Math.max(...visible.map((entry) => scale.toPixel(toNumber(rows[selection.index]?.[entry.key]) ?? 0))),
733
+ y: centerOf(selection.index),
734
+ }
735
+ : {
736
+ x: leftGutter + centerOf(selection.index),
737
+ y: Math.min(...visible.map((entry) => scale.toPixel(toNumber(rows[selection.index]?.[entry.key]) ?? 0))),
738
+ };
739
+
740
+ return (
741
+ <>
742
+ {/* Marco: grilla y etiquetas, por debajo de las marcas. */}
743
+ <View style={{ position: 'absolute', left: leftGutter, top: 0, width: plotW, height: plotH }}>
744
+ {grid ? (
745
+ <CartesianGrid
746
+ width={plotW}
747
+ height={plotH}
748
+ lines={gridLines}
749
+ orientation={horizontal ? 'vertical' : 'horizontal'}
750
+ zeroIndex={zeroIndex === -1 ? undefined : zeroIndex}
751
+ testID={testID && `${testID}-grid`}
752
+ />
753
+ ) : null}
754
+
755
+ {type === 'bar'
756
+ ? rows.map((row, rowIndex) => {
757
+ const fill = band * BAND_FILL;
758
+ const slot = fill / (stacked ? 1 : Math.max(1, keys.length));
759
+ const bands = stacks
760
+ ? stacks[rowIndex]!.filter((item) => keys.includes(item.key))
761
+ : keys
762
+ .map((key) => {
763
+ const value = toNumber(row[key]);
764
+ return value === null ? null : { key, from: 0, to: value, value };
765
+ })
766
+ .filter((item): item is { key: string; from: number; to: number; value: number } => item !== null);
767
+
768
+ return bands.map((item) => {
769
+ const seriesIndex = keys.indexOf(item.key);
770
+ const color = visible[seriesIndex]?.color ?? theme.colors.primary;
771
+ const start = centerOf(rowIndex) - fill / 2 + (stacked ? 0 : seriesIndex * slot);
772
+ const thicknessOfBar = Math.max(1, slot - (stacked || keys.length === 1 ? 0 : GROUP_GAP));
773
+ const near = scale.toPixel(item.from);
774
+ const far = scale.toPixel(item.to);
775
+ const size = Math.abs(far - near);
776
+ const positive = item.value >= 0;
777
+ const dim = !!selection && selection.index !== rowIndex;
778
+
779
+ return horizontal ? (
780
+ <Bar
781
+ key={`${rowIndex}-${item.key}`}
782
+ x={positive ? near : plotW - near}
783
+ y={start}
784
+ width={size}
785
+ height={thicknessOfBar}
786
+ from={positive ? 'left' : 'right'}
787
+ color={color}
788
+ radius={barRadius}
789
+ dim={dim}
790
+ animate={animate}
791
+ duration={duration}
792
+ testID={testID && `${testID}-bar-${rowIndex}-${item.key}`}
793
+ />
794
+ ) : (
795
+ <Bar
796
+ key={`${rowIndex}-${item.key}`}
797
+ x={start}
798
+ y={positive ? plotH - near : near}
799
+ width={thicknessOfBar}
800
+ height={size}
801
+ from={positive ? 'bottom' : 'top'}
802
+ color={color}
803
+ radius={barRadius}
804
+ dim={dim}
805
+ animate={animate}
806
+ duration={duration}
807
+ testID={testID && `${testID}-bar-${rowIndex}-${item.key}`}
808
+ />
809
+ );
810
+ });
811
+ })
812
+ : visible.map((entry) => {
813
+ const raw = pointsFor(entry.key, 'to');
814
+ const line = smooth ? smoothPoints(raw) : raw;
815
+ const base = smooth ? smoothPoints(pointsFor(entry.key, 'from')) : pointsFor(entry.key, 'from');
816
+ const stripCount = Math.min(MAX_STRIPS, Math.max(8, Math.round(plotW / STRIP_WIDTH)));
817
+ const stripWidth = plotW / stripCount;
818
+
819
+ return (
820
+ <View key={entry.key} style={{ position: 'absolute', left: 0, top: 0, width: plotW, height: plotH }}>
821
+ {/* Área: sin `path` no hay relleno bajo una curva, así que se
822
+ corta en tiras verticales y cada una pregunta su alto a la
823
+ polilínea. A 4px por tira el borde ya se lee como continuo. */}
824
+ {type === 'area' ? (
825
+ // La opacidad va en el grupo, no en cada tira: el medio
826
+ // píxel de solape que tapa la costura del antialias se
827
+ // compone dos veces y deja una raya vertical cada cuatro
828
+ // píxeles. Atenuado el grupo entero, se pinta una sola vez.
829
+ <View
830
+ pointerEvents="none"
831
+ style={{ position: 'absolute', left: 0, top: 0, width: plotW, height: plotH, opacity: fillOpacity }}
832
+ >
833
+ {Array.from({ length: stripCount }, (_, strip) => {
834
+ const x = (strip + 0.5) * stripWidth;
835
+ const top = sampleY(line, x);
836
+ const bottom = sampleY(base, x);
837
+ if (top === null || bottom === null) return null;
838
+ return (
839
+ <View
840
+ key={strip}
841
+ style={{
842
+ position: 'absolute',
843
+ left: strip * stripWidth,
844
+ width: stripWidth + 0.5,
845
+ top: Math.min(top, bottom),
846
+ height: Math.abs(bottom - top),
847
+ backgroundColor: entry.color,
848
+ }}
849
+ />
850
+ );
851
+ })}
852
+ </View>
853
+ ) : null}
854
+
855
+ <Polyline points={line} color={entry.color} thickness={thickness} testID={testID && `${testID}-line-${entry.key}`} />
856
+
857
+ {dots
858
+ ? raw.map((point, index) => (
859
+ <Dot
860
+ key={index}
861
+ point={point}
862
+ color={entry.color}
863
+ ringColor={theme.colors.surface}
864
+ size={selection?.index === index ? 8 : 6}
865
+ testID={testID && `${testID}-dot-${entry.key}-${index}`}
866
+ />
867
+ ))
868
+ : null}
869
+ </View>
870
+ );
871
+ })}
872
+
873
+ {/* Capa de toque: una banda entera por categoría. Tocar una línea de
874
+ 2px es imposible; tocar su columna, no. */}
875
+ {rows.map((row, index) => (
876
+ <Pressable
877
+ key={index}
878
+ onPress={(event) => pickAt(index, event)}
879
+ accessibilityRole="button"
880
+ accessibilityState={{ selected: selection?.index === index }}
881
+ accessibilityLabel={[labels[index], ...tooltipRowsAt(rows, index, visible, formatValue).map((item) => `${item.label} ${item.value}`)].join(', ')}
882
+ style={
883
+ horizontal
884
+ ? { position: 'absolute', left: 0, width: plotW, top: index * band, height: band }
885
+ : { position: 'absolute', top: 0, height: plotH, left: index * band, width: band }
886
+ }
887
+ testID={testID && `${testID}-band-${index}`}
888
+ />
889
+ ))}
890
+ </View>
891
+
892
+ {yAxis ? (
893
+ <View style={{ position: 'absolute', left: 0, top: 0, width: leftGutter, height: plotH }}>
894
+ <AxisLabels
895
+ axis="y"
896
+ gutter={Math.max(0, leftGutter - 6)}
897
+ span={plotH}
898
+ labels={
899
+ horizontal
900
+ ? labels.map((text, index) => ({ text, position: centerOf(index) }))
901
+ : scale.nice.values.map((value) => ({ text: formatValue(value), position: scale.toPixel(value) }))
902
+ }
903
+ testID={testID && `${testID}-y-axis`}
904
+ />
905
+ </View>
906
+ ) : null}
907
+
908
+ {xAxis ? (
909
+ <View style={{ position: 'absolute', left: leftGutter, top: plotH + 2, width: plotW, height: bottomGutter }}>
910
+ <AxisLabels
911
+ axis="x"
912
+ gutter={0}
913
+ span={plotW}
914
+ labels={
915
+ horizontal
916
+ ? scale.nice.values.map((value) => ({ text: formatValue(value), position: scale.toPixel(value) }))
917
+ : labels.map((text, index) => ({ text, position: centerOf(index) }))
918
+ }
919
+ testID={testID && `${testID}-x-axis`}
920
+ />
921
+ </View>
922
+ ) : null}
923
+
924
+ {tooltip && selection && anchor && rowsForTooltip.length > 0 ? (
925
+ <Tooltip
926
+ title={selection.label}
927
+ rows={rowsForTooltip}
928
+ anchor={anchor}
929
+ containerWidth={width}
930
+ testID={testID && `${testID}-tooltip`}
931
+ />
932
+ ) : null}
933
+ </>
934
+ );
935
+ });
936
+ CartesianPlot.displayName = 'CartesianPlot';
937
+
938
+ // ── torta y dona ───────────────────────────────────────────────────────────
939
+
940
+ interface PiePlotProps extends PlotProps {
941
+ donut: boolean;
942
+ innerRadius?: number;
943
+ sliceLabels: 'none' | 'percent' | 'value';
944
+ valueKey: string;
945
+ }
946
+
947
+ const DONUT_RADIUS = 0.6;
948
+ /** Cuánto sale la porción elegida, en píxeles. */
949
+ const EXPLODE = 8;
950
+ /** Dónde cae la etiqueta, como fracción entre el agujero y el borde. */
951
+ const LABEL_AT = 0.62;
952
+
953
+ /** Blanco o negro, el que se lea encima del color. Un color raro no rompe: cae en `text`. */
954
+ function inkOn(background: string, theme: { colors: { white: string; black: string; text: string } }): string {
955
+ try {
956
+ return contrast(background, theme.colors.white) >= contrast(background, theme.colors.black)
957
+ ? theme.colors.white
958
+ : theme.colors.black;
959
+ } catch {
960
+ return theme.colors.text;
961
+ }
962
+ }
963
+
964
+ const PiePlot = memo((props: PiePlotProps) => {
965
+ const { rows, labels, entries, visible, width, height, formatValue, selection, onPick, tooltip, testID, donut, innerRadius, sliceLabels, valueKey } = props;
966
+ const theme = useTheme();
967
+ const styles = useStyles();
968
+ const allowFontScaling = useAllowFontScaling();
969
+
970
+ const center: Point = { x: width / 2, y: height / 2 };
971
+ const radius = Math.max(0, Math.min(width, height) / 2 - 2);
972
+ const hole = clamp(innerRadius ?? (donut ? DONUT_RADIUS : 0), 0, 0.9) * radius;
973
+
974
+ const arcs = useMemo(() => {
975
+ // Una categoría apagada vale 0: sale de la torta y el resto se reparte el
976
+ // 100%, que es lo que espera quien la apagó desde la leyenda.
977
+ const values = rows.map((row, index) =>
978
+ visible.some((entry) => entry.key === labels[index]) ? (toNumber(row[valueKey]) ?? 0) : 0
979
+ );
980
+ return arcsFor(values);
981
+ }, [rows, labels, visible, valueKey]);
982
+
983
+ const pickAt = useCallback(
984
+ (event: GestureResponderEvent) => {
985
+ const point = { x: event?.nativeEvent?.locationX ?? 0, y: event?.nativeEvent?.locationY ?? 0 };
986
+ const distance = Math.hypot(point.x - center.x, point.y - center.y);
987
+ // El agujero de una dona no es parte de ninguna porción, y afuera del
988
+ // círculo tampoco: los dos sueltan la selección.
989
+ if (distance > radius || distance < hole) return onPick(null);
990
+ const index = arcAt(arcs, angleOf(center, point));
991
+ if (index === -1) return onPick(null);
992
+ onPick({ index, seriesKey: labels[index] ?? '' });
993
+ },
994
+ [center.x, center.y, radius, hole, arcs, labels, onPick]
995
+ );
996
+
997
+ const chosen = selection ? arcs.find((arc) => arc.index === selection.index) : undefined;
998
+
999
+ return (
1000
+ <>
1001
+ {arcs.map((arc) => {
1002
+ const dim = !!selection && selection.index !== arc.index;
1003
+ return (
1004
+ <Wedge
1005
+ key={arc.index}
1006
+ size={radius * 2}
1007
+ center={center}
1008
+ start={arc.start}
1009
+ sweep={arc.sweep}
1010
+ color={entries[arc.index]?.color ?? theme.colors.primary}
1011
+ opacity={dim ? DIM_OPACITY : 1}
1012
+ explode={selection?.index === arc.index ? EXPLODE : 0}
1013
+ testID={testID && `${testID}-slice-${arc.index}`}
1014
+ />
1015
+ );
1016
+ })}
1017
+
1018
+ {hole > 0 ? (
1019
+ <View
1020
+ pointerEvents="none"
1021
+ style={{
1022
+ position: 'absolute',
1023
+ left: center.x - hole,
1024
+ top: center.y - hole,
1025
+ width: hole * 2,
1026
+ height: hole * 2,
1027
+ borderRadius: hole,
1028
+ backgroundColor: theme.colors.surface,
1029
+ }}
1030
+ />
1031
+ ) : null}
1032
+
1033
+ {sliceLabels === 'none'
1034
+ ? null
1035
+ : arcs.map((arc) => {
1036
+ // Una porción muy fina no tiene dónde escribir: la etiqueta se
1037
+ // saldría encima de las vecinas y no se leería ninguna.
1038
+ if (arc.sweep < 18) return null;
1039
+ const at = polar(center, hole + (radius - hole) * LABEL_AT, arc.start + arc.sweep / 2);
1040
+ const color = entries[arc.index]?.color ?? theme.colors.primary;
1041
+ return (
1042
+ <Text
1043
+ key={arc.index}
1044
+ allowFontScaling={allowFontScaling}
1045
+ style={[styles.sliceLabel, { left: at.x - 28, top: at.y - 8, width: 56, color: inkOn(color, theme) }]}
1046
+ testID={testID && `${testID}-slice-label-${arc.index}`}
1047
+ >
1048
+ {sliceLabels === 'percent' ? `${Math.round(arc.ratio * 100)}%` : formatValue(arc.value)}
1049
+ </Text>
1050
+ );
1051
+ })}
1052
+
1053
+ <Pressable
1054
+ onPress={pickAt}
1055
+ accessibilityRole="adjustable"
1056
+ accessibilityLabel={arcs.map((arc) => `${labels[arc.index]} ${Math.round(arc.ratio * 100)}%`).join(', ')}
1057
+ style={{ position: 'absolute', left: 0, top: 0, width, height }}
1058
+ testID={testID && `${testID}-hit`}
1059
+ />
1060
+
1061
+ {tooltip && selection && chosen ? (
1062
+ <Tooltip
1063
+ title={selection.label}
1064
+ rows={[
1065
+ {
1066
+ key: selection.seriesKey,
1067
+ label: `${Math.round(chosen.ratio * 100)}%`,
1068
+ value: formatValue(selection.value),
1069
+ color: entries[selection.index]?.color ?? theme.colors.primary,
1070
+ },
1071
+ ]}
1072
+ anchor={polar(center, radius * 0.9, chosen.start + chosen.sweep / 2)}
1073
+ containerWidth={width}
1074
+ testID={testID && `${testID}-tooltip`}
1075
+ />
1076
+ ) : null}
1077
+ </>
1078
+ );
1079
+ });
1080
+ PiePlot.displayName = 'PiePlot';
1081
+
1082
+ // ── radar ──────────────────────────────────────────────────────────────────
1083
+
1084
+ interface RadarPlotProps extends PlotProps {
1085
+ domain?: [number, number] | 'sticky';
1086
+ levels: number;
1087
+ fill: boolean;
1088
+ thickness: number;
1089
+ dots: boolean;
1090
+ }
1091
+
1092
+ /** Tiras por lado del polígono cuando se rellena. Más = borde más limpio, más vistas. */
1093
+ const RADAR_STEPS = 5;
1094
+ const RADAR_FILL_OPACITY = 0.18;
1095
+ const RADAR_LABEL_WIDTH = 56;
1096
+
1097
+ const RadarPlot = memo((props: RadarPlotProps) => {
1098
+ const { rows, labels, visible, width, height, formatValue, selection, onPick, tooltip, testID, domain, levels, fill, thickness, dots } = props;
1099
+ const theme = useTheme();
1100
+ const styles = useStyles();
1101
+ const allowFontScaling = useAllowFontScaling();
1102
+
1103
+ const center: Point = { x: width / 2, y: height / 2 };
1104
+ const radius = Math.max(0, Math.min(width, height) / 2 - RADAR_INSET);
1105
+ const angles = useMemo(() => radarAngles(rows.length), [rows.length]);
1106
+ const keys = useMemo(() => visible.map((entry) => entry.key), [visible]);
1107
+
1108
+ // El radar mide desde el centro, y el centro es 0: un mínimo distinto de
1109
+ // cero haría que dos valores parecidos se vean lejísimos.
1110
+ const measured = useMemo(() => [0, extent(rows, keys, { includeZero: true })[1]] as [number, number], [rows, keys]);
1111
+ const [, top] = useDomain(measured, domain);
1112
+
1113
+ const scale = useMemo(() => {
1114
+ const nice = Array.isArray(domain) ? exactTicks(0, top, levels) : niceScale(0, top, levels);
1115
+ return { nice, toRadius: linearScale([0, nice.max], [0, radius]) };
1116
+ }, [domain, top, levels, radius]);
1117
+
1118
+ const pointsOf = useCallback(
1119
+ (key: string): Point[] =>
1120
+ rows.map((row, index) => polar(center, scale.toRadius(Math.max(0, toNumber(row[key]) ?? 0)), angles[index] ?? 0)),
1121
+ [rows, center, scale, angles]
1122
+ );
1123
+
1124
+ const pickAt = useCallback(
1125
+ (event: GestureResponderEvent) => {
1126
+ if (rows.length === 0) return;
1127
+ const angle = angleOf(center, { x: event?.nativeEvent?.locationX ?? 0, y: event?.nativeEvent?.locationY ?? 0 });
1128
+ const index = Math.round(angle / (360 / rows.length)) % rows.length;
1129
+ const key = keys[0] ?? '';
1130
+ onPick({ index, seriesKey: key });
1131
+ },
1132
+ [rows, center, keys, onPick]
1133
+ );
1134
+
1135
+ return (
1136
+ <>
1137
+ <RadarGrid center={center} radius={radius} angles={angles} levels={levels} testID={testID && `${testID}-grid`} />
1138
+
1139
+ {visible.map((entry) => {
1140
+ const points = pointsOf(entry.key);
1141
+ return (
1142
+ <View key={entry.key} style={{ position: 'absolute', left: 0, top: 0, width, height }}>
1143
+ {/* Relleno: el mismo sector que dibuja una torta, uno por tira del
1144
+ polígono, con el radio que le toca a esa tira. */}
1145
+ {fill ? (
1146
+ // La opacidad va en el grupo, no en cada tira. Por tira, el
1147
+ // solape de 0.4° que tapa la costura del antialias se compone
1148
+ // dos veces y deja un radio más oscuro por corte: el relleno
1149
+ // termina pareciendo una telaraña. Aplicada al grupo, las tiras
1150
+ // se pintan opacas entre sí y el conjunto se atenúa una vez.
1151
+ <View
1152
+ pointerEvents="none"
1153
+ style={{ position: 'absolute', left: 0, top: 0, width, height, opacity: RADAR_FILL_OPACITY }}
1154
+ testID={testID && `${testID}-fill-${entry.key}`}
1155
+ >
1156
+ {fanStrips(center, points, RADAR_STEPS).map((strip, index) => (
1157
+ <Wedge
1158
+ key={index}
1159
+ size={strip.radius * 2}
1160
+ center={center}
1161
+ start={strip.start}
1162
+ sweep={strip.sweep}
1163
+ color={entry.color}
1164
+ />
1165
+ ))}
1166
+ </View>
1167
+ ) : null}
1168
+ <Polyline points={points} color={entry.color} thickness={thickness} closed testID={testID && `${testID}-outline-${entry.key}`} />
1169
+ {dots
1170
+ ? points.map((point, index) => (
1171
+ <Dot
1172
+ key={index}
1173
+ point={point}
1174
+ color={entry.color}
1175
+ ringColor={theme.colors.surface}
1176
+ size={selection?.index === index ? 8 : 6}
1177
+ />
1178
+ ))
1179
+ : null}
1180
+ </View>
1181
+ );
1182
+ })}
1183
+
1184
+ {labels.map((label, index) => {
1185
+ const at = polar(center, radius + RADAR_INSET / 2, angles[index] ?? 0);
1186
+ return (
1187
+ <Text
1188
+ key={`${label}-${index}`}
1189
+ numberOfLines={1}
1190
+ allowFontScaling={allowFontScaling}
1191
+ style={[
1192
+ styles.radarAxisLabel,
1193
+ { left: at.x - RADAR_LABEL_WIDTH / 2, top: at.y - 7, width: RADAR_LABEL_WIDTH },
1194
+ selection?.index === index && { color: theme.colors.text },
1195
+ ]}
1196
+ testID={testID && `${testID}-axis-${index}`}
1197
+ >
1198
+ {label}
1199
+ </Text>
1200
+ );
1201
+ })}
1202
+
1203
+ <Pressable
1204
+ onPress={pickAt}
1205
+ accessibilityRole="adjustable"
1206
+ accessibilityLabel={labels.join(', ')}
1207
+ style={{ position: 'absolute', left: 0, top: 0, width, height }}
1208
+ testID={testID && `${testID}-hit`}
1209
+ />
1210
+
1211
+ {tooltip && selection ? (
1212
+ <Tooltip
1213
+ title={selection.label}
1214
+ rows={tooltipRowsAt(rows, selection.index, visible, formatValue)}
1215
+ anchor={polar(center, radius, angles[selection.index] ?? 0)}
1216
+ containerWidth={width}
1217
+ testID={testID && `${testID}-tooltip`}
1218
+ />
1219
+ ) : null}
1220
+ </>
1221
+ );
1222
+ });
1223
+ RadarPlot.displayName = 'RadarPlot';