orn-ui 0.2.8 → 0.2.10
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 +968 -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 +963 -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 +1252 -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,349 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* El marco de Chart: todo lo que rodea a las marcas — grilla, ejes, leyenda y
|
|
3
|
+
* tooltip. Nada de acá conoce el tipo de chart; recibe números y textos ya
|
|
4
|
+
* resueltos.
|
|
5
|
+
*
|
|
6
|
+
* Gestalt: la grilla usa `border`, dos escalones por debajo del color de las
|
|
7
|
+
* series, para que el dato quede como figura y la referencia como fondo. La
|
|
8
|
+
* leyenda repite el color exacto de cada serie (similitud) y por eso puede
|
|
9
|
+
* hacer de control de filtro sin explicar qué apaga: se apaga lo que tiene ese
|
|
10
|
+
* color.
|
|
11
|
+
*/
|
|
12
|
+
import React, { memo } from 'react';
|
|
13
|
+
import { Text, TouchableOpacity, View, type StyleProp, type ViewStyle } from 'react-native';
|
|
14
|
+
import { createStyles } from '../../theme/createStyles';
|
|
15
|
+
import { useAllowFontScaling, useColors } from '../../theme/UIProvider';
|
|
16
|
+
import { polar, type Point } from './math';
|
|
17
|
+
|
|
18
|
+
const useStyles = createStyles((theme) => ({
|
|
19
|
+
gridLine: { position: 'absolute', backgroundColor: theme.colors.border },
|
|
20
|
+
axisLabel: {
|
|
21
|
+
position: 'absolute',
|
|
22
|
+
fontSize: theme.tokens.fontSize.xs,
|
|
23
|
+
color: theme.colors.textLight,
|
|
24
|
+
},
|
|
25
|
+
legend: {
|
|
26
|
+
flexDirection: 'row',
|
|
27
|
+
flexWrap: 'wrap',
|
|
28
|
+
justifyContent: 'center',
|
|
29
|
+
gap: theme.tokens.spacing.sm,
|
|
30
|
+
marginTop: theme.tokens.spacing.sm,
|
|
31
|
+
},
|
|
32
|
+
legendItem: {
|
|
33
|
+
flexDirection: 'row',
|
|
34
|
+
alignItems: 'center',
|
|
35
|
+
gap: theme.tokens.spacing.xs,
|
|
36
|
+
paddingVertical: theme.tokens.spacing.xxs,
|
|
37
|
+
paddingHorizontal: theme.tokens.spacing.sm,
|
|
38
|
+
borderRadius: theme.tokens.radius.full,
|
|
39
|
+
backgroundColor: theme.colors.inputBackground,
|
|
40
|
+
},
|
|
41
|
+
legendItemOff: { backgroundColor: 'transparent', borderWidth: 1, borderColor: theme.colors.border },
|
|
42
|
+
swatch: { width: 10, height: 10, borderRadius: theme.tokens.radius.xs },
|
|
43
|
+
legendLabel: { fontSize: theme.tokens.fontSize.xs, color: theme.colors.text },
|
|
44
|
+
legendLabelOff: { color: theme.colors.textLight },
|
|
45
|
+
tooltip: {
|
|
46
|
+
position: 'absolute',
|
|
47
|
+
paddingVertical: theme.tokens.spacing.sm,
|
|
48
|
+
paddingHorizontal: theme.tokens.spacing.md,
|
|
49
|
+
borderRadius: theme.tokens.radius.md,
|
|
50
|
+
backgroundColor: theme.colors.surface,
|
|
51
|
+
borderWidth: 1,
|
|
52
|
+
borderColor: theme.colors.border,
|
|
53
|
+
gap: theme.tokens.spacing.xxs,
|
|
54
|
+
...theme.tokens.shadow.md,
|
|
55
|
+
},
|
|
56
|
+
tooltipTitle: {
|
|
57
|
+
fontSize: theme.tokens.fontSize.xs,
|
|
58
|
+
fontWeight: theme.tokens.fontWeight.semibold,
|
|
59
|
+
color: theme.colors.text,
|
|
60
|
+
},
|
|
61
|
+
tooltipRow: { flexDirection: 'row', alignItems: 'center', gap: theme.tokens.spacing.xs },
|
|
62
|
+
tooltipLabel: { fontSize: theme.tokens.fontSize.xs, color: theme.colors.textLight },
|
|
63
|
+
tooltipValue: {
|
|
64
|
+
fontSize: theme.tokens.fontSize.xs,
|
|
65
|
+
fontWeight: theme.tokens.fontWeight.semibold,
|
|
66
|
+
color: theme.colors.text,
|
|
67
|
+
marginLeft: 'auto',
|
|
68
|
+
},
|
|
69
|
+
}));
|
|
70
|
+
|
|
71
|
+
// ── grilla y ejes cartesianos ──────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
export interface CartesianGridProps {
|
|
74
|
+
width: number;
|
|
75
|
+
height: number;
|
|
76
|
+
/** Posición en píxeles de cada línea, sobre el eje perpendicular a ella. */
|
|
77
|
+
lines: number[];
|
|
78
|
+
/** 'horizontal' dibuja líneas que cruzan a lo ancho (eje y con ticks). */
|
|
79
|
+
orientation: 'horizontal' | 'vertical';
|
|
80
|
+
/** Índice de la línea del cero, más marcada que el resto. */
|
|
81
|
+
zeroIndex?: number;
|
|
82
|
+
testID?: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const CartesianGrid = memo(({ width, height, lines, orientation, zeroIndex, testID }: CartesianGridProps) => {
|
|
86
|
+
const styles = useStyles();
|
|
87
|
+
const colors = useColors();
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<View testID={testID} pointerEvents="none" style={{ position: 'absolute', left: 0, top: 0, width, height }}>
|
|
91
|
+
{lines.map((position, index) => {
|
|
92
|
+
const isZero = index === zeroIndex;
|
|
93
|
+
return (
|
|
94
|
+
<View
|
|
95
|
+
key={index}
|
|
96
|
+
style={[
|
|
97
|
+
styles.gridLine,
|
|
98
|
+
orientation === 'horizontal'
|
|
99
|
+
? { left: 0, width, top: position, height: 1 }
|
|
100
|
+
: { top: 0, height, left: position, width: 1 },
|
|
101
|
+
// La línea del cero es una referencia distinta de las demás: no
|
|
102
|
+
// divide el espacio, separa lo que suma de lo que resta.
|
|
103
|
+
isZero && { backgroundColor: colors.textLight },
|
|
104
|
+
]}
|
|
105
|
+
/>
|
|
106
|
+
);
|
|
107
|
+
})}
|
|
108
|
+
</View>
|
|
109
|
+
);
|
|
110
|
+
});
|
|
111
|
+
CartesianGrid.displayName = 'CartesianGrid';
|
|
112
|
+
|
|
113
|
+
export interface AxisLabelsProps {
|
|
114
|
+
labels: { text: string; position: number }[];
|
|
115
|
+
/** 'x' las reparte a lo ancho debajo del plot; 'y' las apila a la izquierda. */
|
|
116
|
+
axis: 'x' | 'y';
|
|
117
|
+
/** Ancho del canal reservado para las etiquetas. */
|
|
118
|
+
gutter: number;
|
|
119
|
+
/** Tamaño del plot sobre el eje de las etiquetas. */
|
|
120
|
+
span: number;
|
|
121
|
+
testID?: string;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const LABEL_HEIGHT = 14;
|
|
125
|
+
|
|
126
|
+
export const AxisLabels = memo(({ labels, axis, gutter, span, testID }: AxisLabelsProps) => {
|
|
127
|
+
const styles = useStyles();
|
|
128
|
+
const allowFontScaling = useAllowFontScaling();
|
|
129
|
+
// Una etiqueta de x se centra bajo su categoría y puede pisar a la vecina;
|
|
130
|
+
// se le da como ancho el reparto parejo del plot y se trunca a una línea.
|
|
131
|
+
const slot = axis === 'x' ? Math.max(24, span / Math.max(1, labels.length)) : gutter;
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
<View testID={testID} pointerEvents="none" style={{ position: 'absolute', left: 0, top: 0, right: 0, bottom: 0 }}>
|
|
135
|
+
{labels.map((label, index) => (
|
|
136
|
+
<Text
|
|
137
|
+
key={`${label.text}-${index}`}
|
|
138
|
+
numberOfLines={1}
|
|
139
|
+
allowFontScaling={allowFontScaling}
|
|
140
|
+
style={[
|
|
141
|
+
styles.axisLabel,
|
|
142
|
+
axis === 'x'
|
|
143
|
+
? { left: label.position - slot / 2, width: slot, top: 0, textAlign: 'center' }
|
|
144
|
+
: { left: 0, width: gutter, top: label.position - LABEL_HEIGHT / 2, height: LABEL_HEIGHT, textAlign: 'right' },
|
|
145
|
+
]}
|
|
146
|
+
>
|
|
147
|
+
{label.text}
|
|
148
|
+
</Text>
|
|
149
|
+
))}
|
|
150
|
+
</View>
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
AxisLabels.displayName = 'AxisLabels';
|
|
154
|
+
|
|
155
|
+
// ── grilla del radar ───────────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
export interface RadarGridProps {
|
|
158
|
+
center: Point;
|
|
159
|
+
radius: number;
|
|
160
|
+
angles: number[];
|
|
161
|
+
/** Anillos concéntricos, incluido el borde. */
|
|
162
|
+
levels: number;
|
|
163
|
+
testID?: string;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Anillos y radios del radar. Los anillos son polígonos, no círculos: un
|
|
168
|
+
* círculo sugiere que entre eje y eje hay valores, y no los hay — sólo hay
|
|
169
|
+
* los ejes que se pasaron.
|
|
170
|
+
*/
|
|
171
|
+
export const RadarGrid = memo(({ center, radius, angles, levels, testID }: RadarGridProps) => {
|
|
172
|
+
const colors = useColors();
|
|
173
|
+
if (angles.length < 3 || radius <= 0) return null;
|
|
174
|
+
|
|
175
|
+
const rings = Array.from({ length: levels }, (_, index) => (radius * (index + 1)) / levels);
|
|
176
|
+
|
|
177
|
+
return (
|
|
178
|
+
<View testID={testID} pointerEvents="none" style={{ position: 'absolute', left: 0, top: 0, right: 0, bottom: 0 }}>
|
|
179
|
+
{rings.map((ringRadius, ringIndex) => {
|
|
180
|
+
const points = angles.map((angle) => polar(center, ringRadius, angle));
|
|
181
|
+
return points.map((point, index) => {
|
|
182
|
+
const next = points[(index + 1) % points.length]!;
|
|
183
|
+
const dx = next.x - point.x;
|
|
184
|
+
const dy = next.y - point.y;
|
|
185
|
+
const length = Math.hypot(dx, dy);
|
|
186
|
+
return (
|
|
187
|
+
<View
|
|
188
|
+
key={`ring-${ringIndex}-${index}`}
|
|
189
|
+
style={{
|
|
190
|
+
position: 'absolute',
|
|
191
|
+
left: (point.x + next.x) / 2 - length / 2,
|
|
192
|
+
top: (point.y + next.y) / 2 - 0.5,
|
|
193
|
+
width: length,
|
|
194
|
+
height: 1,
|
|
195
|
+
backgroundColor: colors.border,
|
|
196
|
+
transform: [{ rotate: `${(Math.atan2(dy, dx) * 180) / Math.PI}deg` }],
|
|
197
|
+
}}
|
|
198
|
+
/>
|
|
199
|
+
);
|
|
200
|
+
});
|
|
201
|
+
})}
|
|
202
|
+
{angles.map((angle, index) => {
|
|
203
|
+
const tip = polar(center, radius, angle);
|
|
204
|
+
const dx = tip.x - center.x;
|
|
205
|
+
const dy = tip.y - center.y;
|
|
206
|
+
return (
|
|
207
|
+
<View
|
|
208
|
+
key={`spoke-${index}`}
|
|
209
|
+
style={{
|
|
210
|
+
position: 'absolute',
|
|
211
|
+
left: (center.x + tip.x) / 2 - radius / 2,
|
|
212
|
+
top: (center.y + tip.y) / 2 - 0.5,
|
|
213
|
+
width: radius,
|
|
214
|
+
height: 1,
|
|
215
|
+
backgroundColor: colors.border,
|
|
216
|
+
transform: [{ rotate: `${(Math.atan2(dy, dx) * 180) / Math.PI}deg` }],
|
|
217
|
+
}}
|
|
218
|
+
/>
|
|
219
|
+
);
|
|
220
|
+
})}
|
|
221
|
+
</View>
|
|
222
|
+
);
|
|
223
|
+
});
|
|
224
|
+
RadarGrid.displayName = 'RadarGrid';
|
|
225
|
+
|
|
226
|
+
// ── leyenda ────────────────────────────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
export interface LegendItem {
|
|
229
|
+
key: string;
|
|
230
|
+
label: string;
|
|
231
|
+
color: string;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export interface LegendProps {
|
|
235
|
+
items: LegendItem[];
|
|
236
|
+
hidden: string[];
|
|
237
|
+
/** Sin esto la leyenda es sólo referencia; con esto es el filtro. */
|
|
238
|
+
onToggle?: (key: string) => void;
|
|
239
|
+
testID?: string;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export const Legend = memo(({ items, hidden, onToggle, testID }: LegendProps) => {
|
|
243
|
+
const styles = useStyles();
|
|
244
|
+
const colors = useColors();
|
|
245
|
+
const allowFontScaling = useAllowFontScaling();
|
|
246
|
+
|
|
247
|
+
return (
|
|
248
|
+
<View style={styles.legend} testID={testID}>
|
|
249
|
+
{items.map((item) => {
|
|
250
|
+
const off = hidden.includes(item.key);
|
|
251
|
+
const content = (
|
|
252
|
+
<>
|
|
253
|
+
<View style={[styles.swatch, { backgroundColor: off ? colors.border : item.color }]} />
|
|
254
|
+
<Text allowFontScaling={allowFontScaling} style={[styles.legendLabel, off && styles.legendLabelOff]}>
|
|
255
|
+
{item.label}
|
|
256
|
+
</Text>
|
|
257
|
+
</>
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
if (!onToggle) {
|
|
261
|
+
return (
|
|
262
|
+
<View key={item.key} style={styles.legendItem} testID={testID && `${testID}-${item.key}`}>
|
|
263
|
+
{content}
|
|
264
|
+
</View>
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
return (
|
|
268
|
+
<TouchableOpacity
|
|
269
|
+
key={item.key}
|
|
270
|
+
style={[styles.legendItem, off && styles.legendItemOff]}
|
|
271
|
+
onPress={() => onToggle(item.key)}
|
|
272
|
+
accessibilityRole="switch"
|
|
273
|
+
accessibilityState={{ checked: !off }}
|
|
274
|
+
accessibilityLabel={item.label}
|
|
275
|
+
testID={testID && `${testID}-${item.key}`}
|
|
276
|
+
>
|
|
277
|
+
{content}
|
|
278
|
+
</TouchableOpacity>
|
|
279
|
+
);
|
|
280
|
+
})}
|
|
281
|
+
</View>
|
|
282
|
+
);
|
|
283
|
+
});
|
|
284
|
+
Legend.displayName = 'Legend';
|
|
285
|
+
|
|
286
|
+
// ── tooltip ────────────────────────────────────────────────────────────────
|
|
287
|
+
|
|
288
|
+
export interface TooltipRow {
|
|
289
|
+
key: string;
|
|
290
|
+
label: string;
|
|
291
|
+
value: string;
|
|
292
|
+
color: string;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export interface TooltipProps {
|
|
296
|
+
title: string;
|
|
297
|
+
rows: TooltipRow[];
|
|
298
|
+
/** Punto que describe, en coordenadas del contenedor. */
|
|
299
|
+
anchor: Point;
|
|
300
|
+
/** Ancho disponible: el globo se corre para no salirse. */
|
|
301
|
+
containerWidth: number;
|
|
302
|
+
style?: StyleProp<ViewStyle>;
|
|
303
|
+
testID?: string;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const TOOLTIP_WIDTH = 148;
|
|
307
|
+
const TOOLTIP_GAP = 10;
|
|
308
|
+
/** Alto estimado del globo: el real no se sabe hasta después de medirlo, y
|
|
309
|
+
* medirlo para decidir dónde ponerlo lo haría aparecer corrido un frame. */
|
|
310
|
+
const TOOLTIP_TITLE = 22;
|
|
311
|
+
const TOOLTIP_ROW = 17;
|
|
312
|
+
|
|
313
|
+
export const Tooltip = memo(({ title, rows, anchor, containerWidth, style, testID }: TooltipProps) => {
|
|
314
|
+
const styles = useStyles();
|
|
315
|
+
const allowFontScaling = useAllowFontScaling();
|
|
316
|
+
// Clamp en vez de flip: moverlo al otro lado del dedo hace que salte de lado
|
|
317
|
+
// mientras se arrastra, y el salto se lee como un error.
|
|
318
|
+
const left = Math.max(0, Math.min(containerWidth - TOOLTIP_WIDTH, anchor.x - TOOLTIP_WIDTH / 2));
|
|
319
|
+
// Va arriba del punto, salvo que no haya lugar: ahí baja, porque taparse
|
|
320
|
+
// contra el borde superior lo deja ilegible.
|
|
321
|
+
const estimated = TOOLTIP_TITLE + rows.length * TOOLTIP_ROW;
|
|
322
|
+
const above = anchor.y - TOOLTIP_GAP - estimated;
|
|
323
|
+
const top = above >= 0 ? above : anchor.y + TOOLTIP_GAP;
|
|
324
|
+
|
|
325
|
+
return (
|
|
326
|
+
<View
|
|
327
|
+
testID={testID}
|
|
328
|
+
pointerEvents="none"
|
|
329
|
+
accessibilityRole="alert"
|
|
330
|
+
style={[styles.tooltip, { left, width: TOOLTIP_WIDTH, top }, style]}
|
|
331
|
+
>
|
|
332
|
+
<Text allowFontScaling={allowFontScaling} style={styles.tooltipTitle}>
|
|
333
|
+
{title}
|
|
334
|
+
</Text>
|
|
335
|
+
{rows.map((row) => (
|
|
336
|
+
<View key={row.key} style={styles.tooltipRow}>
|
|
337
|
+
<View style={[styles.swatch, { backgroundColor: row.color }]} />
|
|
338
|
+
<Text allowFontScaling={allowFontScaling} numberOfLines={1} style={styles.tooltipLabel}>
|
|
339
|
+
{row.label}
|
|
340
|
+
</Text>
|
|
341
|
+
<Text allowFontScaling={allowFontScaling} style={styles.tooltipValue}>
|
|
342
|
+
{row.value}
|
|
343
|
+
</Text>
|
|
344
|
+
</View>
|
|
345
|
+
))}
|
|
346
|
+
</View>
|
|
347
|
+
);
|
|
348
|
+
});
|
|
349
|
+
Tooltip.displayName = 'Tooltip';
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Las marcas de Chart: lo único que sabe dibujar el componente.
|
|
3
|
+
*
|
|
4
|
+
* React Native no trae un lienzo vectorial y la librería no puede agregar uno
|
|
5
|
+
* (cero dependencias), así que las tres primitivas de acá se componen con
|
|
6
|
+
* `View` y bastan para los seis tipos de chart:
|
|
7
|
+
*
|
|
8
|
+
* | primitiva | la usan |
|
|
9
|
+
* |------------|-----------------------------------------------------|
|
|
10
|
+
* | `Bar` | barras verticales y horizontales, agrupadas o apiladas |
|
|
11
|
+
* | `Polyline` | línea, contorno del radar, grilla del radar, ejes |
|
|
12
|
+
* | `Wedge` | torta, dona y el relleno del radar |
|
|
13
|
+
*
|
|
14
|
+
* Son puras: no escuchan toques ni conocen la escala. Quien toca es la capa de
|
|
15
|
+
* hit-test de Chart.tsx, que cubre columnas enteras en vez de la marca —
|
|
16
|
+
* una línea de 2px no es un blanco táctil.
|
|
17
|
+
*/
|
|
18
|
+
import React, { memo, useEffect, useRef } from 'react';
|
|
19
|
+
import { Animated, Easing, View, type ViewStyle } from 'react-native';
|
|
20
|
+
import { segmentBox, type Point } from './math';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Valor que persigue a `target` en vez de saltar. Un filtro que cambia los
|
|
24
|
+
* datos mueve la barra desde donde estaba, y ese movimiento es lo que dice
|
|
25
|
+
* "es la misma barra con otro número" en lugar de "es otro chart".
|
|
26
|
+
*/
|
|
27
|
+
export function useAnimatedNumber(target: number, animate: boolean, duration: number): Animated.Value {
|
|
28
|
+
const value = useRef(new Animated.Value(animate ? 0 : target)).current;
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (!animate) {
|
|
32
|
+
value.setValue(target);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const animation = Animated.timing(value, {
|
|
36
|
+
toValue: target,
|
|
37
|
+
duration,
|
|
38
|
+
easing: Easing.out(Easing.cubic),
|
|
39
|
+
// Geometría, no transform: `height`/`top` son props de layout y el
|
|
40
|
+
// driver nativo no las toca. Es el precio de que la barra pueda crecer
|
|
41
|
+
// desde su base con valores negativos sin recalcular el origen.
|
|
42
|
+
useNativeDriver: false,
|
|
43
|
+
});
|
|
44
|
+
animation.start();
|
|
45
|
+
return () => animation.stop();
|
|
46
|
+
}, [target, animate, duration, value]);
|
|
47
|
+
|
|
48
|
+
return value;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── barra ──────────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
export interface BarProps {
|
|
54
|
+
/** Caja final de la barra dentro del plot, ya en píxeles. */
|
|
55
|
+
x: number;
|
|
56
|
+
y: number;
|
|
57
|
+
width: number;
|
|
58
|
+
height: number;
|
|
59
|
+
color: string;
|
|
60
|
+
/** Lado por donde nace: la barra crece desde su base, no desde el centro. */
|
|
61
|
+
from: 'bottom' | 'top' | 'left' | 'right';
|
|
62
|
+
/** Radio de las dos esquinas del extremo libre. */
|
|
63
|
+
radius?: number;
|
|
64
|
+
/** Atenúa las barras que no son la seleccionada. */
|
|
65
|
+
dim?: boolean;
|
|
66
|
+
animate?: boolean;
|
|
67
|
+
duration?: number;
|
|
68
|
+
testID?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const DIM_OPACITY = 0.32;
|
|
72
|
+
|
|
73
|
+
export const Bar = memo(
|
|
74
|
+
({ x, y, width, height, color, from, radius = 0, dim = false, animate = true, duration = 400, testID }: BarProps) => {
|
|
75
|
+
const vertical = from === 'bottom' || from === 'top';
|
|
76
|
+
const grown = useAnimatedNumber(vertical ? height : width, animate, duration);
|
|
77
|
+
|
|
78
|
+
// El extremo que se mueve es el opuesto a la base: una barra que nace
|
|
79
|
+
// abajo mantiene fijo su borde inferior y sube el superior.
|
|
80
|
+
const box = vertical
|
|
81
|
+
? { left: x, width, height: grown, ...(from === 'bottom' ? { bottom: y } : { top: y }) }
|
|
82
|
+
: { top: y, height, width: grown, ...(from === 'left' ? { left: x } : { right: x }) };
|
|
83
|
+
|
|
84
|
+
const corners: ViewStyle =
|
|
85
|
+
radius <= 0
|
|
86
|
+
? {}
|
|
87
|
+
: from === 'bottom'
|
|
88
|
+
? { borderTopLeftRadius: radius, borderTopRightRadius: radius }
|
|
89
|
+
: from === 'top'
|
|
90
|
+
? { borderBottomLeftRadius: radius, borderBottomRightRadius: radius }
|
|
91
|
+
: from === 'left'
|
|
92
|
+
? { borderTopRightRadius: radius, borderBottomRightRadius: radius }
|
|
93
|
+
: { borderTopLeftRadius: radius, borderBottomLeftRadius: radius };
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<Animated.View
|
|
97
|
+
testID={testID}
|
|
98
|
+
pointerEvents="none"
|
|
99
|
+
style={[{ position: 'absolute', backgroundColor: color, opacity: dim ? DIM_OPACITY : 1 }, corners, box]}
|
|
100
|
+
/>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
);
|
|
104
|
+
Bar.displayName = 'Bar';
|
|
105
|
+
|
|
106
|
+
// ── polilínea ──────────────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
export interface PolylineProps {
|
|
109
|
+
points: Point[];
|
|
110
|
+
color: string;
|
|
111
|
+
thickness?: number;
|
|
112
|
+
/** Une el último punto con el primero (radar, grilla poligonal). */
|
|
113
|
+
closed?: boolean;
|
|
114
|
+
opacity?: number;
|
|
115
|
+
testID?: string;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Una línea es un rectángulo fino rotado sobre su centro, uno por tramo. Con
|
|
120
|
+
* `smoothPoints` los tramos son tantos que la poligonal se lee como curva.
|
|
121
|
+
*
|
|
122
|
+
* Los extremos quedan a tope, no redondeados: un `borderRadius` por tramo
|
|
123
|
+
* multiplicaría las vistas y a 2px de grosor nadie ve la diferencia.
|
|
124
|
+
*/
|
|
125
|
+
export const Polyline = memo(({ points, color, thickness = 2, closed = false, opacity = 1, testID }: PolylineProps) => {
|
|
126
|
+
if (points.length < 2) return null;
|
|
127
|
+
const path = closed ? [...points, points[0]!] : points;
|
|
128
|
+
|
|
129
|
+
return (
|
|
130
|
+
<View testID={testID} pointerEvents="none" style={{ position: 'absolute', left: 0, top: 0, right: 0, bottom: 0, opacity }}>
|
|
131
|
+
{path.slice(0, -1).map((point, index) => {
|
|
132
|
+
const box = segmentBox(point, path[index + 1]!, thickness);
|
|
133
|
+
return (
|
|
134
|
+
<View
|
|
135
|
+
key={index}
|
|
136
|
+
style={{
|
|
137
|
+
position: 'absolute',
|
|
138
|
+
left: box.left,
|
|
139
|
+
top: box.top,
|
|
140
|
+
width: box.width,
|
|
141
|
+
height: box.height,
|
|
142
|
+
backgroundColor: color,
|
|
143
|
+
transform: [{ rotate: `${box.rotate}deg` }],
|
|
144
|
+
}}
|
|
145
|
+
/>
|
|
146
|
+
);
|
|
147
|
+
})}
|
|
148
|
+
</View>
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
Polyline.displayName = 'Polyline';
|
|
152
|
+
|
|
153
|
+
export interface DotProps {
|
|
154
|
+
point: Point;
|
|
155
|
+
color: string;
|
|
156
|
+
size?: number;
|
|
157
|
+
/** Aro del color del fondo: despega el punto de la línea que lo cruza. */
|
|
158
|
+
ringColor?: string;
|
|
159
|
+
ringWidth?: number;
|
|
160
|
+
testID?: string;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export const Dot = memo(({ point, color, size = 7, ringColor, ringWidth = 2, testID }: DotProps) => {
|
|
164
|
+
const outer = size + (ringColor ? ringWidth * 2 : 0);
|
|
165
|
+
return (
|
|
166
|
+
<View
|
|
167
|
+
testID={testID}
|
|
168
|
+
pointerEvents="none"
|
|
169
|
+
style={{
|
|
170
|
+
position: 'absolute',
|
|
171
|
+
left: point.x - outer / 2,
|
|
172
|
+
top: point.y - outer / 2,
|
|
173
|
+
width: outer,
|
|
174
|
+
height: outer,
|
|
175
|
+
borderRadius: outer / 2,
|
|
176
|
+
backgroundColor: color,
|
|
177
|
+
borderWidth: ringColor ? ringWidth : 0,
|
|
178
|
+
borderColor: ringColor,
|
|
179
|
+
}}
|
|
180
|
+
/>
|
|
181
|
+
);
|
|
182
|
+
});
|
|
183
|
+
Dot.displayName = 'Dot';
|
|
184
|
+
|
|
185
|
+
// ── porción ────────────────────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
interface HalfProps {
|
|
188
|
+
size: number;
|
|
189
|
+
/** Apertura de esta mitad, 0–180. */
|
|
190
|
+
sweep: number;
|
|
191
|
+
color: string;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Sector de hasta 180°, sin curvas ni máscaras. Tres cajas:
|
|
196
|
+
*
|
|
197
|
+
* 1. un recorte fijo a la mitad derecha (`overflow: 'hidden'`), que es el
|
|
198
|
+
* sector 0°–180°;
|
|
199
|
+
* 2. adentro, un cuadrado `size` × `size` centrado en el centro del círculo,
|
|
200
|
+
* que es lo que gira — al rotar sobre su propio centro, rota sobre el
|
|
201
|
+
* centro del círculo sin necesidad de `transformOrigin`;
|
|
202
|
+
* 3. adentro del cuadrado, **medio disco**, no un disco entero: un rectángulo
|
|
203
|
+
* de media caja con las dos esquinas de afuera redondeadas al radio.
|
|
204
|
+
*
|
|
205
|
+
* El medio disco es lo que hace que la rotación signifique algo. Con un disco
|
|
206
|
+
* completo el recorte deja siempre la misma media luna —rotar un círculo
|
|
207
|
+
* sólido no lo cambia—, y toda porción, sea de 5° o de 180°, sale igual de
|
|
208
|
+
* ancha: 180°.
|
|
209
|
+
*
|
|
210
|
+
* Girado `sweep - 180`, el medio disco cubre de `sweep - 180` a `sweep`, y lo
|
|
211
|
+
* que sobrevive al recorte es exactamente 0°–`sweep`.
|
|
212
|
+
*/
|
|
213
|
+
const Half = ({ size, sweep, color }: HalfProps) => (
|
|
214
|
+
<View style={{ position: 'absolute', left: size / 2, top: 0, width: size / 2, height: size, overflow: 'hidden' }}>
|
|
215
|
+
<View
|
|
216
|
+
style={{
|
|
217
|
+
position: 'absolute',
|
|
218
|
+
left: -size / 2,
|
|
219
|
+
top: 0,
|
|
220
|
+
width: size,
|
|
221
|
+
height: size,
|
|
222
|
+
transform: [{ rotate: `${sweep - 180}deg` }],
|
|
223
|
+
}}
|
|
224
|
+
>
|
|
225
|
+
<View
|
|
226
|
+
style={{
|
|
227
|
+
position: 'absolute',
|
|
228
|
+
left: size / 2,
|
|
229
|
+
top: 0,
|
|
230
|
+
width: size / 2,
|
|
231
|
+
height: size,
|
|
232
|
+
backgroundColor: color,
|
|
233
|
+
borderTopRightRadius: size / 2,
|
|
234
|
+
borderBottomRightRadius: size / 2,
|
|
235
|
+
}}
|
|
236
|
+
/>
|
|
237
|
+
</View>
|
|
238
|
+
</View>
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
export interface WedgeProps {
|
|
242
|
+
/** Diámetro: la porción se dibuja centrada en una caja `size` × `size`. */
|
|
243
|
+
size: number;
|
|
244
|
+
/** Ángulo donde empieza, 0° arriba y creciendo en horario. */
|
|
245
|
+
start: number;
|
|
246
|
+
/** Apertura, 0–360. */
|
|
247
|
+
sweep: number;
|
|
248
|
+
color: string;
|
|
249
|
+
opacity?: number;
|
|
250
|
+
/** Desplaza la porción hacia afuera por su bisectriz (la seleccionada). */
|
|
251
|
+
explode?: number;
|
|
252
|
+
/** Centro dentro del contenedor. Sin él la porción se apoya en (0, 0). */
|
|
253
|
+
center?: Point;
|
|
254
|
+
testID?: string;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const FULL_CIRCLE = 359.9;
|
|
258
|
+
|
|
259
|
+
export const Wedge = memo(({ size, start, sweep, color, opacity = 1, explode = 0, center, testID }: WedgeProps) => {
|
|
260
|
+
if (sweep <= 0 || size <= 0) return null;
|
|
261
|
+
// 360° exactos dejan las dos mitades a 180°, y la de arriba tapa a la de
|
|
262
|
+
// abajo por una rendija de antialias. Un pelo menos no se ve y no pasa.
|
|
263
|
+
const total = Math.min(sweep, FULL_CIRCLE);
|
|
264
|
+
const first = Math.min(total, 180);
|
|
265
|
+
const second = total - first;
|
|
266
|
+
const bisector = (start + total / 2) * (Math.PI / 180);
|
|
267
|
+
|
|
268
|
+
return (
|
|
269
|
+
<View
|
|
270
|
+
testID={testID}
|
|
271
|
+
pointerEvents="none"
|
|
272
|
+
style={{
|
|
273
|
+
position: 'absolute',
|
|
274
|
+
left: (center?.x ?? size / 2) - size / 2,
|
|
275
|
+
top: (center?.y ?? size / 2) - size / 2,
|
|
276
|
+
width: size,
|
|
277
|
+
height: size,
|
|
278
|
+
opacity,
|
|
279
|
+
transform: [
|
|
280
|
+
{ translateX: explode * Math.sin(bisector) },
|
|
281
|
+
{ translateY: -explode * Math.cos(bisector) },
|
|
282
|
+
{ rotate: `${start}deg` },
|
|
283
|
+
],
|
|
284
|
+
}}
|
|
285
|
+
>
|
|
286
|
+
<Half size={size} sweep={first} color={color} />
|
|
287
|
+
{second > 0 ? (
|
|
288
|
+
<View style={{ position: 'absolute', width: size, height: size, transform: [{ rotate: '180deg' }] }}>
|
|
289
|
+
<Half size={size} sweep={second} color={color} />
|
|
290
|
+
</View>
|
|
291
|
+
) : null}
|
|
292
|
+
</View>
|
|
293
|
+
);
|
|
294
|
+
});
|
|
295
|
+
Wedge.displayName = 'Wedge';
|