orn-ui 0.2.7 → 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 +25 -3
- package/README.md +9 -8
- package/lib/commonjs/atoms/Gradient.js +111 -0
- package/lib/commonjs/atoms/Gradient.js.map +1 -0
- package/lib/commonjs/atoms/ShinyText.js +188 -0
- package/lib/commonjs/atoms/ShinyText.js.map +1 -0
- package/lib/commonjs/atoms/index.js +14 -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/Slides.js +406 -0
- package/lib/commonjs/organisms/Slides.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 +28 -0
- package/lib/commonjs/organisms/index.js.map +1 -1
- package/lib/commonjs/organisms/useSlides.js +186 -0
- package/lib/commonjs/organisms/useSlides.js.map +1 -0
- package/lib/module/atoms/Gradient.js +106 -0
- package/lib/module/atoms/Gradient.js.map +1 -0
- package/lib/module/atoms/ShinyText.js +183 -0
- package/lib/module/atoms/ShinyText.js.map +1 -0
- package/lib/module/atoms/index.js +2 -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/Slides.js +401 -0
- package/lib/module/organisms/Slides.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 +4 -0
- package/lib/module/organisms/index.js.map +1 -1
- package/lib/module/organisms/useSlides.js +182 -0
- package/lib/module/organisms/useSlides.js.map +1 -0
- package/lib/typescript/atoms/Gradient.d.ts +37 -0
- package/lib/typescript/atoms/Gradient.d.ts.map +1 -0
- 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 +4 -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/Slides.d.ts +69 -0
- package/lib/typescript/organisms/Slides.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 +8 -0
- package/lib/typescript/organisms/index.d.ts.map +1 -1
- package/lib/typescript/organisms/useSlides.d.ts +52 -0
- package/lib/typescript/organisms/useSlides.d.ts.map +1 -0
- package/llms.txt +6 -5
- package/package.json +33 -2
- package/registry/chart.json +35 -0
- package/registry/gradient.json +18 -0
- package/registry/index.json +79 -0
- package/registry/manifest.json +42 -0
- package/registry/option-wheel.json +19 -0
- package/registry/shiny-text.json +19 -0
- package/registry/slides.json +24 -0
- package/registry/timeline.json +20 -0
- package/src/atoms/Gradient.tsx +99 -0
- package/src/atoms/ShinyText.tsx +226 -0
- package/src/atoms/index.ts +4 -0
- package/src/organisms/Chart.tsx +1223 -0
- package/src/organisms/OptionWheel.tsx +476 -0
- package/src/organisms/Slides.tsx +413 -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 +22 -0
- package/src/organisms/useSlides.ts +220 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "orn-ui",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "React Native / Expo component library:
|
|
3
|
+
"version": "0.2.9",
|
|
4
|
+
"description": "React Native / Expo component library: 50 typed components, light/dark theming, zero runtime dependencies. Verified on Expo SDK 54-57. Agent-friendly: one component instead of hundreds of lines of hand-written UI.",
|
|
5
5
|
"author": "David Trujillo <davot098@gmail.com>",
|
|
6
6
|
"main": "lib/commonjs/index.js",
|
|
7
7
|
"module": "lib/module/index.js",
|
|
@@ -83,6 +83,11 @@
|
|
|
83
83
|
"react-native": "./src/atoms/Divider.tsx",
|
|
84
84
|
"default": "./lib/module/atoms/Divider.js"
|
|
85
85
|
},
|
|
86
|
+
"./gradient": {
|
|
87
|
+
"types": "./lib/typescript/atoms/Gradient.d.ts",
|
|
88
|
+
"react-native": "./src/atoms/Gradient.tsx",
|
|
89
|
+
"default": "./lib/module/atoms/Gradient.js"
|
|
90
|
+
},
|
|
86
91
|
"./avatar": {
|
|
87
92
|
"types": "./lib/typescript/atoms/Avatar.d.ts",
|
|
88
93
|
"react-native": "./src/atoms/Avatar.tsx",
|
|
@@ -118,6 +123,11 @@
|
|
|
118
123
|
"react-native": "./src/atoms/Transition.tsx",
|
|
119
124
|
"default": "./lib/module/atoms/Transition.js"
|
|
120
125
|
},
|
|
126
|
+
"./shiny-text": {
|
|
127
|
+
"types": "./lib/typescript/atoms/ShinyText.d.ts",
|
|
128
|
+
"react-native": "./src/atoms/ShinyText.tsx",
|
|
129
|
+
"default": "./lib/module/atoms/ShinyText.js"
|
|
130
|
+
},
|
|
121
131
|
"./empty-state": {
|
|
122
132
|
"types": "./lib/typescript/atoms/EmptyState.d.ts",
|
|
123
133
|
"react-native": "./src/atoms/EmptyState.tsx",
|
|
@@ -183,6 +193,11 @@
|
|
|
183
193
|
"react-native": "./src/molecules/SymmetricGrid.tsx",
|
|
184
194
|
"default": "./lib/module/molecules/SymmetricGrid.js"
|
|
185
195
|
},
|
|
196
|
+
"./timeline": {
|
|
197
|
+
"types": "./lib/typescript/organisms/Timeline.d.ts",
|
|
198
|
+
"react-native": "./src/organisms/Timeline.tsx",
|
|
199
|
+
"default": "./lib/module/organisms/Timeline.js"
|
|
200
|
+
},
|
|
186
201
|
"./modal": {
|
|
187
202
|
"types": "./lib/typescript/organisms/Modal.d.ts",
|
|
188
203
|
"react-native": "./src/organisms/Modal.tsx",
|
|
@@ -193,6 +208,11 @@
|
|
|
193
208
|
"react-native": "./src/organisms/BottomSheet.tsx",
|
|
194
209
|
"default": "./lib/module/organisms/BottomSheet.js"
|
|
195
210
|
},
|
|
211
|
+
"./option-wheel": {
|
|
212
|
+
"types": "./lib/typescript/organisms/OptionWheel.d.ts",
|
|
213
|
+
"react-native": "./src/organisms/OptionWheel.tsx",
|
|
214
|
+
"default": "./lib/module/organisms/OptionWheel.js"
|
|
215
|
+
},
|
|
196
216
|
"./select": {
|
|
197
217
|
"types": "./lib/typescript/organisms/Select.d.ts",
|
|
198
218
|
"react-native": "./src/organisms/Select.tsx",
|
|
@@ -318,6 +338,16 @@
|
|
|
318
338
|
"react-native": "./src/organisms/ReorderableList.tsx",
|
|
319
339
|
"default": "./lib/module/organisms/ReorderableList.js"
|
|
320
340
|
},
|
|
341
|
+
"./slides": {
|
|
342
|
+
"types": "./lib/typescript/organisms/Slides.d.ts",
|
|
343
|
+
"react-native": "./src/organisms/Slides.tsx",
|
|
344
|
+
"default": "./lib/module/organisms/Slides.js"
|
|
345
|
+
},
|
|
346
|
+
"./chart": {
|
|
347
|
+
"types": "./lib/typescript/organisms/Chart.d.ts",
|
|
348
|
+
"react-native": "./src/organisms/Chart.tsx",
|
|
349
|
+
"default": "./lib/module/organisms/Chart.js"
|
|
350
|
+
},
|
|
321
351
|
"./theme": {
|
|
322
352
|
"types": "./lib/typescript/theme/index.d.ts",
|
|
323
353
|
"react-native": "./src/theme/index.ts",
|
|
@@ -359,6 +389,7 @@
|
|
|
359
389
|
}
|
|
360
390
|
},
|
|
361
391
|
"devDependencies": {
|
|
392
|
+
"@react-native/babel-preset": "^0.86.2",
|
|
362
393
|
"@react-native/jest-preset": "^0.86.2",
|
|
363
394
|
"@testing-library/react-native": "^13.3.3",
|
|
364
395
|
"@types/jest": "^29.5.14",
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "Chart",
|
|
3
|
+
"slug": "chart",
|
|
4
|
+
"type": "component",
|
|
5
|
+
"category": "organisms",
|
|
6
|
+
"exportedNames": [
|
|
7
|
+
"Chart"
|
|
8
|
+
],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "organisms/Chart.tsx",
|
|
12
|
+
"content": "import React, { memo, useCallback, useMemo, useRef, useState } from 'react';\nimport {\n Pressable,\n Text,\n View,\n type GestureResponderEvent,\n type LayoutChangeEvent,\n type StyleProp,\n type ViewStyle,\n} from 'react-native';\nimport { contrast } from '../theme/colors';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useLabels, useTheme } from '../theme/UIProvider';\nimport { SegmentedControl } from '../molecules/SegmentedControl';\nimport { Bar, Dot, Polyline, Wedge } from './chart/marks';\nimport { AxisLabels, CartesianGrid, Legend, RadarGrid, Tooltip, type LegendItem, type TooltipRow } from './chart/frame';\nimport { seriesPalette } from './chart/palette';\nimport {\n angleOf,\n arcAt,\n arcsFor,\n clamp,\n exactTicks,\n extent,\n fanStrips,\n formatNumber,\n growExtent,\n linearScale,\n lttbIndices,\n niceScale,\n polar,\n radarAngles,\n sampleY,\n smoothPoints,\n stackRow,\n toNumber,\n type ChartRow,\n type Point,\n} from './chart/math';\n\nexport type { ChartRow } from './chart/math';\n\nexport type ChartType = 'bar' | 'line' | 'area' | 'pie' | 'donut' | 'radar';\n\nexport interface ChartSeries {\n /** Columna de `data` que lee esta serie. */\n key: string;\n /** Lo que dicen la leyenda y el tooltip. Por defecto, `key`. */\n label?: string;\n /** Pisa el color derivado del theme. */\n color?: string;\n}\n\nexport interface ChartFilterOption {\n value: string;\n label: string;\n}\n\n/**\n * Un filtro de la barra superior. Chart no filtra nada por su cuenta: muestra\n * el control y avisa. Quien tiene los datos decide qué significa \"últimos 7\n * días\", y eso no se puede adivinar desde acá.\n */\nexport interface ChartFilter {\n key: string;\n options: ChartFilterOption[];\n value: string;\n onChange: (value: string) => void;\n}\n\nexport interface ChartSelection {\n /**\n * Identidad de la fila, estable a través de un `window` o un diezmado.\n * Sale de `selectBy`; sin él es el índice, y entonces no sobrevive a que\n * los datos corran.\n */\n id: string;\n /**\n * Índice adentro de las filas **dibujadas**, no de `data`: con `window` o\n * con diezmado no son la misma lista. Para volver al dato está `row`.\n */\n index: number;\n /** La fila entera, sin tener que resolver el índice contra nada. */\n row: ChartRow;\n /** Valor de `xKey` en esa fila. */\n label: string;\n /** Serie tocada. En torta es la fila, que ahí es la categoría. */\n seriesKey: string;\n value: number;\n}\n\ninterface ChartBaseProps {\n data: ChartRow[];\n /**\n * Series a dibujar. En 'pie'/'donut' sólo se usa la primera: las categorías\n * de una torta son las filas, no las columnas.\n */\n series: ChartSeries[];\n /** Columna con la etiqueta de cada fila. @default 'x' */\n xKey?: string;\n /**\n * Identidad estable de una fila. Sin esto la selección se guarda por índice,\n * y en una ventana deslizante el índice corre: el punto resaltado pasa a\n * señalar otro dato sin que nadie lo toque. Con esto el resaltado sigue al\n * dato, y desaparece solo cuando el dato sale de la ventana.\n */\n selectBy?: (row: ChartRow, index: number) => string;\n /**\n * Dibuja sólo las últimas N filas. Es la ventana deslizante de un chart en\n * vivo: quien empuja los datos no tiene que recortarlos en cada tick.\n */\n window?: number;\n /** Alto del área de dibujo, sin contar filtros ni leyenda. @default 220 */\n height?: number;\n /**\n * 'toggle' convierte la leyenda en el filtro más barato que tiene un chart:\n * tocar una serie la apaga. @default 'auto' (toggle con 2+ series, none con una)\n */\n legend?: 'auto' | 'none' | 'static' | 'toggle';\n /** Series apagadas, controlado. Sin esto, Chart se las acuerda solo. */\n hiddenKeys?: string[];\n /** @default [] */\n defaultHiddenKeys?: string[];\n onHiddenKeysChange?: (keys: string[]) => void;\n /** Controles arriba del chart, uno por filtro. */\n filters?: ChartFilter[];\n /** Punto resaltado, controlado. `null` es \"ninguno\". */\n selected?: ChartSelection | null;\n onSelect?: (selection: ChartSelection | null) => void;\n /** Globo con los valores de la categoría tocada. @default true */\n tooltip?: boolean;\n /** @default formato compacto: 1.2k, 3.4M */\n formatValue?: (value: number) => string;\n /** @default true */\n animate?: boolean;\n /** ms de la animación de las marcas. @default tokens.duration.slow * 2 */\n duration?: number;\n /** Texto cuando no queda nada que dibujar. */\n emptyText?: string;\n /** Obligatorio: un chart no tiene texto que lo describa. */\n accessibilityLabel: string;\n style?: StyleProp<ViewStyle>;\n testID?: string;\n}\n\ninterface CartesianProps {\n /**\n * Fija el eje de valores. Por defecto se recalcula con cada dato, que es lo\n * correcto para un chart estático y lo peor para uno en vivo: llega un valor\n * más alto, la escala entera se reacomoda y la línea salta aunque el dato no\n * se haya movido.\n *\n * - `[min, max]` — exacto, sin redondear a números lindos.\n * - `'sticky'` — arranca del dato y crece cuando algo no entra, pero no se\n * vuelve a achicar.\n */\n domain?: [number, number] | 'sticky';\n /** Líneas de referencia del eje de valores. @default true */\n grid?: boolean;\n /** Ticks buscados en el eje de valores; el redondeo puede dar uno más o uno menos. @default 4 */\n ticks?: number;\n /** Etiquetas del eje horizontal. @default true */\n xAxis?: boolean;\n /** Etiquetas del eje vertical. @default true */\n yAxis?: boolean;\n}\n\ninterface BarChartProps extends CartesianProps {\n type: 'bar';\n /** 'horizontal' pone las categorías en el eje vertical. @default 'vertical' */\n orientation?: 'vertical' | 'horizontal';\n /** Apila las series en vez de ponerlas lado a lado. @default false */\n stacked?: boolean;\n /** Radio del extremo libre de cada barra. @default radius.xs */\n barRadius?: number;\n}\n\ninterface LineChartProps extends CartesianProps {\n type: 'line';\n /**\n * Cuántos puntos dibujar como mucho, eligiéndolos con LTTB. Por defecto sale\n * del ancho medido: más de un punto cada dos píxeles no se ve y sí se paga.\n * `false` los dibuja todos.\n */\n decimate?: number | false;\n /** Catmull-Rom en vez de tramos rectos. @default false */\n smooth?: boolean;\n /** @default 2 */\n thickness?: number;\n /** Punto en cada dato. @default true */\n dots?: boolean;\n}\n\ninterface AreaChartProps extends CartesianProps {\n type: 'area';\n /** Igual que en 'line'. */\n decimate?: number | false;\n smooth?: boolean;\n thickness?: number;\n /** @default false */\n dots?: boolean;\n /** @default false */\n stacked?: boolean;\n /** Opacidad del relleno bajo la línea. @default 0.22 */\n fillOpacity?: number;\n}\n\ninterface PieBaseProps {\n /** Agujero como fracción del radio, 0–0.9. @default 0 en 'pie', 0.6 en 'donut' */\n innerRadius?: number;\n /** Qué escribir sobre cada porción. @default 'percent' */\n sliceLabels?: 'none' | 'percent' | 'value';\n}\n\n// 'pie' y 'donut' van separados aunque compartan todo: con un solo miembro\n// cuyo `type` fuera `'pie' | 'donut'`, TypeScript no saca ese miembro de la\n// unión al descartarlo, y el resto del componente deja de estrecharse.\ninterface PieChartProps extends PieBaseProps {\n type: 'pie';\n}\n\ninterface DonutChartProps extends PieBaseProps {\n type: 'donut';\n}\n\ninterface RadarChartProps {\n type: 'radar';\n /**\n * Fija el alcance de los ejes. El radar mide desde el centro, así que el\n * mínimo es siempre 0 y de `[min, max]` sólo se usa `max`.\n */\n domain?: [number, number] | 'sticky';\n /** Anillos de la telaraña. @default 4 */\n levels?: number;\n /** Rellena el polígono además de dibujar su contorno. @default true */\n fill?: boolean;\n thickness?: number;\n /** @default true */\n dots?: boolean;\n}\n\nexport type ChartProps = ChartBaseProps &\n (BarChartProps | LineChartProps | AreaChartProps | PieChartProps | DonutChartProps | RadarChartProps);\n\nconst DEFAULT_HEIGHT = 220;\nconst DEFAULT_TICKS = 4;\n/** Canal de las etiquetas de valor, a la izquierda. */\nconst VALUE_GUTTER = 38;\n/** Canal de las categorías cuando van a la izquierda (barras horizontales). */\nconst CATEGORY_GUTTER = 64;\n/** Canal de las etiquetas de abajo. */\nconst BOTTOM_GUTTER = 16;\n/** Fracción del ancho de banda que ocupan las barras; el resto es aire. */\nconst BAND_FILL = 0.7;\n/** Aire alrededor del radar para que entren las etiquetas de los ejes. */\nconst RADAR_INSET = 26;\nconst DIM_OPACITY = 0.3;\n\nconst useStyles = createStyles((theme) => ({\n root: { alignSelf: 'stretch' },\n filters: { gap: theme.tokens.spacing.sm, marginBottom: theme.tokens.spacing.md },\n plot: { position: 'relative', overflow: 'visible' },\n empty: {\n position: 'absolute',\n left: 0,\n right: 0,\n top: 0,\n bottom: 0,\n alignItems: 'center',\n justifyContent: 'center',\n },\n emptyText: { fontSize: theme.tokens.fontSize.sm, color: theme.colors.textLight },\n sliceLabel: {\n position: 'absolute',\n fontSize: theme.tokens.fontSize.xs,\n fontWeight: theme.tokens.fontWeight.semibold,\n textAlign: 'center',\n },\n radarAxisLabel: {\n position: 'absolute',\n fontSize: theme.tokens.fontSize.xs,\n color: theme.colors.textLight,\n textAlign: 'center',\n },\n}));\n\n/** Estado controlado o propio, con el mismo contrato en los dos casos. */\nfunction useControlled<T>(controlled: T | undefined, fallback: T): [T, (next: T) => void, boolean] {\n const [own, setOwn] = useState(fallback);\n const isControlled = controlled !== undefined;\n return [isControlled ? (controlled as T) : own, setOwn, isControlled];\n}\n\n/**\n * Resuelve el dominio del eje de valores a partir de lo que pidió el\n * consumidor. En 'sticky' la referencia vive en un ref: lo que importa no es\n * el dominio de este render sino el más ancho que se haya visto, y guardarlo\n * en estado dispararía un render de más por cada tick que ensancha el eje.\n */\nfunction useDomain(measured: [number, number], domain: [number, number] | 'sticky' | undefined): [number, number] {\n const widest = useRef<[number, number] | null>(null);\n if (domain === 'sticky') {\n widest.current = growExtent(widest.current, measured);\n return widest.current;\n }\n widest.current = null;\n return domain ?? measured;\n}\n\n/**\n * Chart\n * Barras (verticales, horizontales, agrupadas, apiladas), línea, área, torta,\n * dona y radar en un solo componente, porque lo que cambia entre ellos es la\n * marca que se dibuja y no el resto: datos, escala, ejes, leyenda, tooltip,\n * selección y filtros son los mismos seis mecanismos en todos.\n *\n * Se dibuja con `View`: React Native no trae lienzo vectorial y la librería no\n * puede agregar uno. Las tres primitivas están en `chart/marks.tsx` y la\n * geometría entera, sin React, en `chart/math.ts`.\n *\n * Gestalt: destino común — al tocar una categoría, todo lo que no es ella baja\n * a {@link DIM_OPACITY} a la vez, así que lo resaltado se lee como un grupo y\n * no como una marca suelta. La leyenda usa similitud: el mismo color que la\n * serie, y por eso puede apagarla sin que haga falta explicar qué apaga.\n */\nexport function Chart(props: ChartProps) {\n const {\n data,\n series,\n xKey = 'x',\n selectBy,\n window: windowSize,\n height = DEFAULT_HEIGHT,\n legend = 'auto',\n hiddenKeys,\n defaultHiddenKeys,\n onHiddenKeysChange,\n filters,\n selected,\n onSelect,\n tooltip = true,\n formatValue = formatNumber,\n animate = true,\n duration,\n emptyText,\n accessibilityLabel,\n style,\n testID,\n } = props;\n\n const theme = useTheme();\n const styles = useStyles();\n const allowFontScaling = useAllowFontScaling();\n const uiLabels = useLabels();\n const [width, setWidth] = useState(0);\n\n const [hidden, setHidden, hiddenIsControlled] = useControlled(hiddenKeys, defaultHiddenKeys ?? []);\n const [pick, setPick, selectionIsControlled] = useControlled<ChartSelection | null>(selected, null);\n\n const isPie = props.type === 'pie' || props.type === 'donut';\n const polyline = props.type === 'line' || props.type === 'area';\n const decimate = polyline ? props.decimate : false;\n\n /**\n * Lo que se dibuja: la ventana de `data`, y de ahí los puntos que caben.\n * El diezmado corre sobre la fila entera, elegida con la primera serie\n * visible: las series comparten eje x, así que quedarse con índices\n * distintos por serie rompería la alineación, la capa de toque y el tooltip.\n */\n const rows = useMemo(() => {\n const windowed = windowSize && windowSize > 0 ? data.slice(-windowSize) : data;\n if (!polyline || decimate === false || width === 0) return windowed;\n // Un punto cada dos píxeles: más no se ve y sí se paga.\n const target = typeof decimate === 'number' ? decimate : Math.floor(width / 2);\n if (windowed.length <= target) return windowed;\n const lead = series.find((item) => !hidden.includes(item.key))?.key ?? series[0]?.key;\n const values = windowed.map((row) => (lead ? toNumber(row[lead]) : null));\n return lttbIndices(values, target).map((index) => windowed[index]!);\n }, [data, windowSize, polyline, decimate, width, series, hidden]);\n\n const labels = useMemo(() => rows.map((row) => String(row[xKey] ?? '')), [rows, xKey]);\n const ids = useMemo(\n () => rows.map((row, index) => (selectBy ? selectBy(row, index) : String(index))),\n [rows, selectBy]\n );\n\n /**\n * La selección se guarda por `id` y se resuelve contra las filas de ahora:\n * si el dato se corrió, el resaltado lo sigue; si salió de la ventana,\n * desaparece. Reusar el índice a ciegas señalaría el dato que quedó en ese\n * lugar, que no es el que nadie eligió.\n */\n const selection = useMemo(() => {\n if (!pick) return null;\n const index = ids.indexOf(pick.id);\n if (index === -1) return null;\n const row = rows[index]!;\n return {\n ...pick,\n index,\n row,\n label: labels[index] ?? pick.label,\n value: toNumber(row[pick.seriesKey]) ?? pick.value,\n };\n }, [pick, ids, rows, labels]);\n\n // En torta las categorías son las filas; en el resto, las columnas. De eso\n // dependen el color, la leyenda y qué apaga un toque en ella.\n const palette = seriesPalette(theme, Math.max(1, isPie ? rows.length : series.length));\n const entries: LegendItem[] = useMemo(\n () =>\n isPie\n ? labels.map((label, index) => ({ key: label, label, color: series[0]?.color ?? palette[index] ?? theme.colors.primary }))\n : series.map((item, index) => ({\n key: item.key,\n label: item.label ?? item.key,\n color: item.color ?? palette[index] ?? theme.colors.primary,\n })),\n [isPie, labels, series, palette, theme.colors.primary]\n );\n\n const visible = useMemo(() => entries.filter((entry) => !hidden.includes(entry.key)), [entries, hidden]);\n const duration_ = duration ?? theme.tokens.duration.slow * 2;\n\n const applyHidden = useCallback(\n (key: string) => {\n const next = hidden.includes(key) ? hidden.filter((k) => k !== key) : [...hidden, key];\n if (!hiddenIsControlled) setHidden(next);\n onHiddenKeysChange?.(next);\n },\n [hidden, hiddenIsControlled, setHidden, onHiddenKeysChange]\n );\n\n /**\n * Los plots avisan qué se tocó —fila y serie— y la selección entera se\n * arma acá: es el único lugar que conoce `selectBy`, las etiquetas y las\n * filas de verdad, y así los tres no repiten el armado.\n */\n const applySelection = useCallback(\n (hit: { index: number; seriesKey: string } | null) => {\n const row = hit ? rows[hit.index] : undefined;\n const next: ChartSelection | null =\n hit && row\n ? {\n id: ids[hit.index] ?? String(hit.index),\n index: hit.index,\n row,\n label: labels[hit.index] ?? '',\n seriesKey: hit.seriesKey,\n value: toNumber(row[hit.seriesKey]) ?? 0,\n }\n : null;\n // Tocar lo ya elegido lo suelta: sin eso no habría forma de volver al\n // chart entero sin tocar afuera, y \"afuera\" en una pantalla con scroll\n // no siempre existe.\n const same = next && selection && next.id === selection.id && next.seriesKey === selection.seriesKey;\n const value = same ? null : next;\n if (!selectionIsControlled) setPick(value);\n onSelect?.(value);\n },\n [rows, ids, labels, selection, selectionIsControlled, setPick, onSelect]\n );\n\n const onLayout = useCallback((event: LayoutChangeEvent) => setWidth(event.nativeEvent.layout.width), []);\n\n const showLegend = legend === 'toggle' || legend === 'static' || (legend === 'auto' && entries.length > 1);\n const toggleable = legend === 'toggle' || (legend === 'auto' && entries.length > 1);\n const empty = rows.length === 0 || visible.length === 0;\n\n const shared = {\n rows,\n labels,\n entries,\n visible,\n width,\n height,\n formatValue,\n animate,\n duration: duration_,\n selection,\n onPick: applySelection,\n tooltip,\n testID,\n };\n\n return (\n <View style={[styles.root, style]} testID={testID} accessible={false}>\n {filters && filters.length > 0 ? (\n <View style={styles.filters}>\n {filters.map((filter) => (\n <SegmentedControl\n key={filter.key}\n options={filter.options}\n value={filter.value}\n onChange={filter.onChange}\n testID={testID && `${testID}-filter-${filter.key}`}\n />\n ))}\n </View>\n ) : null}\n\n <View\n style={[styles.plot, { height }]}\n onLayout={onLayout}\n accessible\n accessibilityRole=\"image\"\n accessibilityLabel={accessibilityLabel}\n testID={testID && `${testID}-plot`}\n >\n {width === 0 || empty ? null : props.type === 'pie' || props.type === 'donut' ? (\n <PiePlot\n {...shared}\n donut={props.type === 'donut'}\n innerRadius={props.innerRadius}\n sliceLabels={props.sliceLabels ?? 'percent'}\n valueKey={series[0]?.key ?? ''}\n />\n ) : props.type === 'radar' ? (\n <RadarPlot\n {...shared}\n domain={props.domain}\n levels={props.levels ?? 4}\n fill={props.fill ?? true}\n thickness={props.thickness ?? 2}\n dots={props.dots ?? true}\n />\n ) : (\n <CartesianPlot\n {...shared}\n type={props.type}\n orientation={props.type === 'bar' ? (props.orientation ?? 'vertical') : 'vertical'}\n stacked={props.type === 'bar' || props.type === 'area' ? (props.stacked ?? false) : false}\n barRadius={props.type === 'bar' ? (props.barRadius ?? theme.tokens.radius.xs) : 0}\n smooth={props.type !== 'bar' ? (props.smooth ?? false) : false}\n thickness={props.type !== 'bar' ? (props.thickness ?? 2) : 0}\n dots={props.type === 'line' ? (props.dots ?? true) : props.type === 'area' ? (props.dots ?? false) : false}\n fillOpacity={props.type === 'area' ? (props.fillOpacity ?? 0.22) : 0}\n domain={props.domain}\n grid={props.grid ?? true}\n ticks={props.ticks ?? DEFAULT_TICKS}\n xAxis={props.xAxis ?? true}\n yAxis={props.yAxis ?? true}\n />\n )}\n\n {empty ? (\n <View style={styles.empty} testID={testID && `${testID}-empty`}>\n <Text allowFontScaling={allowFontScaling} style={styles.emptyText}>\n {emptyText ?? uiLabels.noResultsTitle}\n </Text>\n </View>\n ) : null}\n </View>\n\n {showLegend ? (\n <Legend items={entries} hidden={hidden} onToggle={toggleable ? applyHidden : undefined} testID={testID && `${testID}-legend`} />\n ) : null}\n </View>\n );\n}\n\nChart.displayName = 'Chart';\n\n// ── lo que comparten los tres plots ────────────────────────────────────────\n\ninterface PlotProps {\n rows: ChartRow[];\n labels: string[];\n /** Todas las categorías, con su color: el color no cambia al apagar una. */\n entries: LegendItem[];\n /** Las que quedaron prendidas. */\n visible: LegendItem[];\n width: number;\n height: number;\n formatValue: (value: number) => string;\n animate: boolean;\n duration: number;\n selection: ChartSelection | null;\n /** Avisa qué se tocó; Chart arma la selección. `null` la suelta. */\n onPick: (hit: { index: number; seriesKey: string } | null) => void;\n tooltip: boolean;\n testID?: string;\n}\n\n/** Filas del tooltip para una categoría del eje x. */\nfunction tooltipRowsAt(rows: ChartRow[], index: number, visible: LegendItem[], format: (value: number) => string): TooltipRow[] {\n const row = rows[index];\n if (!row) return [];\n return visible\n .map((entry) => {\n const value = toNumber(row[entry.key]);\n return value === null ? null : { key: entry.key, label: entry.label, value: format(value), color: entry.color };\n })\n .filter((item): item is TooltipRow => item !== null);\n}\n\n// ── barras, línea y área ───────────────────────────────────────────────────\n\ninterface CartesianPlotProps extends PlotProps {\n type: 'bar' | 'line' | 'area';\n orientation: 'vertical' | 'horizontal';\n stacked: boolean;\n barRadius: number;\n smooth: boolean;\n thickness: number;\n dots: boolean;\n fillOpacity: number;\n domain?: [number, number] | 'sticky';\n grid: boolean;\n ticks: number;\n xAxis: boolean;\n yAxis: boolean;\n}\n\n/** Ancho de una tira del relleno de área, en píxeles. Más fino = más vistas. */\nconst STRIP_WIDTH = 4;\nconst MAX_STRIPS = 140;\n/** Aire entre dos barras del mismo grupo. */\nconst GROUP_GAP = 2;\n\nconst CartesianPlot = memo((props: CartesianPlotProps) => {\n const {\n rows,\n labels,\n visible,\n width,\n height,\n formatValue,\n animate,\n duration,\n selection,\n onPick,\n tooltip,\n testID,\n type,\n orientation,\n stacked,\n barRadius,\n smooth,\n thickness,\n dots,\n fillOpacity,\n domain,\n grid,\n ticks: tickCount,\n xAxis,\n yAxis,\n } = props;\n\n const theme = useTheme();\n const horizontal = type === 'bar' && orientation === 'horizontal';\n const keys = useMemo(() => visible.map((entry) => entry.key), [visible]);\n\n const leftGutter = yAxis ? (horizontal ? CATEGORY_GUTTER : VALUE_GUTTER) : 0;\n const bottomGutter = xAxis ? BOTTOM_GUTTER : 0;\n const plotW = Math.max(1, width - leftGutter);\n const plotH = Math.max(1, height - bottomGutter);\n\n // La línea puede no tocar el cero (una temperatura, un ranking); una barra\n // sí tiene que hacerlo, porque su largo es el que dice el valor.\n const measured = useMemo(\n () => extent(rows, keys, { stacked, includeZero: type !== 'line' }),\n [rows, keys, stacked, type]\n );\n const [low, high] = useDomain(measured, domain);\n\n const scale = useMemo(() => {\n // Un dominio a mano se respeta tal cual; uno medido (o 'sticky') se\n // redondea, que es lo que hace legible el eje.\n const nice = Array.isArray(domain) ? exactTicks(low, high, tickCount) : niceScale(low, high, tickCount);\n const toPixel = horizontal\n ? linearScale([nice.min, nice.max], [0, plotW])\n : linearScale([nice.min, nice.max], [plotH, 0]);\n return { nice, toPixel };\n }, [domain, low, high, tickCount, horizontal, plotW, plotH]);\n\n const band = (horizontal ? plotH : plotW) / Math.max(1, rows.length);\n const centerOf = useCallback((index: number) => (index + 0.5) * band, [band]);\n const baseline = scale.toPixel(clamp(0, scale.nice.min, scale.nice.max));\n\n const stacks = useMemo(() => (stacked ? rows.map((row) => stackRow(row, keys)) : null), [stacked, rows, keys]);\n\n /** Puntos de una serie: `to` es el valor, `from` el piso desde donde se rellena. */\n const pointsFor = useCallback(\n (key: string, edge: 'to' | 'from'): Point[] => {\n const out: Point[] = [];\n rows.forEach((row, index) => {\n if (stacks) {\n const stackBand = stacks[index]!.find((item) => item.key === key);\n if (!stackBand) return;\n out.push({ x: centerOf(index), y: scale.toPixel(stackBand[edge]) });\n return;\n }\n const value = toNumber(row[key]);\n if (value === null) return;\n out.push({ x: centerOf(index), y: edge === 'to' ? scale.toPixel(value) : baseline });\n });\n return out;\n },\n [rows, stacks, centerOf, scale, baseline]\n );\n\n const pickAt = useCallback(\n (index: number, event: GestureResponderEvent) => {\n // Sobre qué serie cayó el dedo: la que tiene su marca más cerca del\n // toque en el eje de valores. Con una sola serie es siempre esa.\n const touch = (horizontal ? event?.nativeEvent?.locationX : event?.nativeEvent?.locationY) ?? 0;\n let bestKey = keys[0] ?? '';\n let bestDistance = Infinity;\n for (const key of keys) {\n const value = toNumber(rows[index]?.[key]);\n if (value === null) continue;\n const distance = Math.abs(scale.toPixel(value) - touch);\n if (distance < bestDistance) {\n bestDistance = distance;\n bestKey = key;\n }\n }\n onPick({ index, seriesKey: bestKey });\n },\n [horizontal, keys, rows, scale, onPick]\n );\n\n const gridLines = scale.nice.values.map((value) => scale.toPixel(value));\n const zeroIndex = scale.nice.values.findIndex((value) => value === 0);\n\n const rowsForTooltip = selection ? tooltipRowsAt(rows, selection.index, visible, formatValue) : [];\n const anchor: Point | null = !selection\n ? null\n : horizontal\n ? {\n x: leftGutter + Math.max(...visible.map((entry) => scale.toPixel(toNumber(rows[selection.index]?.[entry.key]) ?? 0))),\n y: centerOf(selection.index),\n }\n : {\n x: leftGutter + centerOf(selection.index),\n y: Math.min(...visible.map((entry) => scale.toPixel(toNumber(rows[selection.index]?.[entry.key]) ?? 0))),\n };\n\n return (\n <>\n {/* Marco: grilla y etiquetas, por debajo de las marcas. */}\n <View style={{ position: 'absolute', left: leftGutter, top: 0, width: plotW, height: plotH }}>\n {grid ? (\n <CartesianGrid\n width={plotW}\n height={plotH}\n lines={gridLines}\n orientation={horizontal ? 'vertical' : 'horizontal'}\n zeroIndex={zeroIndex === -1 ? undefined : zeroIndex}\n testID={testID && `${testID}-grid`}\n />\n ) : null}\n\n {type === 'bar'\n ? rows.map((row, rowIndex) => {\n const fill = band * BAND_FILL;\n const slot = fill / (stacked ? 1 : Math.max(1, keys.length));\n const bands = stacks\n ? stacks[rowIndex]!.filter((item) => keys.includes(item.key))\n : keys\n .map((key) => {\n const value = toNumber(row[key]);\n return value === null ? null : { key, from: 0, to: value, value };\n })\n .filter((item): item is { key: string; from: number; to: number; value: number } => item !== null);\n\n return bands.map((item) => {\n const seriesIndex = keys.indexOf(item.key);\n const color = visible[seriesIndex]?.color ?? theme.colors.primary;\n const start = centerOf(rowIndex) - fill / 2 + (stacked ? 0 : seriesIndex * slot);\n const thicknessOfBar = Math.max(1, slot - (stacked || keys.length === 1 ? 0 : GROUP_GAP));\n const near = scale.toPixel(item.from);\n const far = scale.toPixel(item.to);\n const size = Math.abs(far - near);\n const positive = item.value >= 0;\n const dim = !!selection && selection.index !== rowIndex;\n\n return horizontal ? (\n <Bar\n key={`${rowIndex}-${item.key}`}\n x={positive ? near : plotW - near}\n y={start}\n width={size}\n height={thicknessOfBar}\n from={positive ? 'left' : 'right'}\n color={color}\n radius={barRadius}\n dim={dim}\n animate={animate}\n duration={duration}\n testID={testID && `${testID}-bar-${rowIndex}-${item.key}`}\n />\n ) : (\n <Bar\n key={`${rowIndex}-${item.key}`}\n x={start}\n y={positive ? plotH - near : near}\n width={thicknessOfBar}\n height={size}\n from={positive ? 'bottom' : 'top'}\n color={color}\n radius={barRadius}\n dim={dim}\n animate={animate}\n duration={duration}\n testID={testID && `${testID}-bar-${rowIndex}-${item.key}`}\n />\n );\n });\n })\n : visible.map((entry) => {\n const raw = pointsFor(entry.key, 'to');\n const line = smooth ? smoothPoints(raw) : raw;\n const base = smooth ? smoothPoints(pointsFor(entry.key, 'from')) : pointsFor(entry.key, 'from');\n const stripCount = Math.min(MAX_STRIPS, Math.max(8, Math.round(plotW / STRIP_WIDTH)));\n const stripWidth = plotW / stripCount;\n\n return (\n <View key={entry.key} style={{ position: 'absolute', left: 0, top: 0, width: plotW, height: plotH }}>\n {/* Área: sin `path` no hay relleno bajo una curva, así que se\n corta en tiras verticales y cada una pregunta su alto a la\n polilínea. A 4px por tira el borde ya se lee como continuo. */}\n {type === 'area' ? (\n // La opacidad va en el grupo, no en cada tira: el medio\n // píxel de solape que tapa la costura del antialias se\n // compone dos veces y deja una raya vertical cada cuatro\n // píxeles. Atenuado el grupo entero, se pinta una sola vez.\n <View\n pointerEvents=\"none\"\n style={{ position: 'absolute', left: 0, top: 0, width: plotW, height: plotH, opacity: fillOpacity }}\n >\n {Array.from({ length: stripCount }, (_, strip) => {\n const x = (strip + 0.5) * stripWidth;\n const top = sampleY(line, x);\n const bottom = sampleY(base, x);\n if (top === null || bottom === null) return null;\n return (\n <View\n key={strip}\n style={{\n position: 'absolute',\n left: strip * stripWidth,\n width: stripWidth + 0.5,\n top: Math.min(top, bottom),\n height: Math.abs(bottom - top),\n backgroundColor: entry.color,\n }}\n />\n );\n })}\n </View>\n ) : null}\n\n <Polyline points={line} color={entry.color} thickness={thickness} testID={testID && `${testID}-line-${entry.key}`} />\n\n {dots\n ? raw.map((point, index) => (\n <Dot\n key={index}\n point={point}\n color={entry.color}\n ringColor={theme.colors.surface}\n size={selection?.index === index ? 8 : 6}\n testID={testID && `${testID}-dot-${entry.key}-${index}`}\n />\n ))\n : null}\n </View>\n );\n })}\n\n {/* Capa de toque: una banda entera por categoría. Tocar una línea de\n 2px es imposible; tocar su columna, no. */}\n {rows.map((row, index) => (\n <Pressable\n key={index}\n onPress={(event) => pickAt(index, event)}\n accessibilityRole=\"button\"\n accessibilityState={{ selected: selection?.index === index }}\n accessibilityLabel={[labels[index], ...tooltipRowsAt(rows, index, visible, formatValue).map((item) => `${item.label} ${item.value}`)].join(', ')}\n style={\n horizontal\n ? { position: 'absolute', left: 0, width: plotW, top: index * band, height: band }\n : { position: 'absolute', top: 0, height: plotH, left: index * band, width: band }\n }\n testID={testID && `${testID}-band-${index}`}\n />\n ))}\n </View>\n\n {yAxis ? (\n <View style={{ position: 'absolute', left: 0, top: 0, width: leftGutter, height: plotH }}>\n <AxisLabels\n axis=\"y\"\n gutter={Math.max(0, leftGutter - 6)}\n span={plotH}\n labels={\n horizontal\n ? labels.map((text, index) => ({ text, position: centerOf(index) }))\n : scale.nice.values.map((value) => ({ text: formatValue(value), position: scale.toPixel(value) }))\n }\n testID={testID && `${testID}-y-axis`}\n />\n </View>\n ) : null}\n\n {xAxis ? (\n <View style={{ position: 'absolute', left: leftGutter, top: plotH + 2, width: plotW, height: bottomGutter }}>\n <AxisLabels\n axis=\"x\"\n gutter={0}\n span={plotW}\n labels={\n horizontal\n ? scale.nice.values.map((value) => ({ text: formatValue(value), position: scale.toPixel(value) }))\n : labels.map((text, index) => ({ text, position: centerOf(index) }))\n }\n testID={testID && `${testID}-x-axis`}\n />\n </View>\n ) : null}\n\n {tooltip && selection && anchor && rowsForTooltip.length > 0 ? (\n <Tooltip\n title={selection.label}\n rows={rowsForTooltip}\n anchor={anchor}\n containerWidth={width}\n testID={testID && `${testID}-tooltip`}\n />\n ) : null}\n </>\n );\n});\nCartesianPlot.displayName = 'CartesianPlot';\n\n// ── torta y dona ───────────────────────────────────────────────────────────\n\ninterface PiePlotProps extends PlotProps {\n donut: boolean;\n innerRadius?: number;\n sliceLabels: 'none' | 'percent' | 'value';\n valueKey: string;\n}\n\nconst DONUT_RADIUS = 0.6;\n/** Cuánto sale la porción elegida, en píxeles. */\nconst EXPLODE = 8;\n/** Dónde cae la etiqueta, como fracción entre el agujero y el borde. */\nconst LABEL_AT = 0.62;\n\n/** Blanco o negro, el que se lea encima del color. Un color raro no rompe: cae en `text`. */\nfunction inkOn(background: string, theme: { colors: { white: string; black: string; text: string } }): string {\n try {\n return contrast(background, theme.colors.white) >= contrast(background, theme.colors.black)\n ? theme.colors.white\n : theme.colors.black;\n } catch {\n return theme.colors.text;\n }\n}\n\nconst PiePlot = memo((props: PiePlotProps) => {\n const { rows, labels, entries, visible, width, height, formatValue, selection, onPick, tooltip, testID, donut, innerRadius, sliceLabels, valueKey } = props;\n const theme = useTheme();\n const styles = useStyles();\n const allowFontScaling = useAllowFontScaling();\n\n const center: Point = { x: width / 2, y: height / 2 };\n const radius = Math.max(0, Math.min(width, height) / 2 - 2);\n const hole = clamp(innerRadius ?? (donut ? DONUT_RADIUS : 0), 0, 0.9) * radius;\n\n const arcs = useMemo(() => {\n // Una categoría apagada vale 0: sale de la torta y el resto se reparte el\n // 100%, que es lo que espera quien la apagó desde la leyenda.\n const values = rows.map((row, index) =>\n visible.some((entry) => entry.key === labels[index]) ? (toNumber(row[valueKey]) ?? 0) : 0\n );\n return arcsFor(values);\n }, [rows, labels, visible, valueKey]);\n\n const pickAt = useCallback(\n (event: GestureResponderEvent) => {\n const point = { x: event?.nativeEvent?.locationX ?? 0, y: event?.nativeEvent?.locationY ?? 0 };\n const distance = Math.hypot(point.x - center.x, point.y - center.y);\n // El agujero de una dona no es parte de ninguna porción, y afuera del\n // círculo tampoco: los dos sueltan la selección.\n if (distance > radius || distance < hole) return onPick(null);\n const index = arcAt(arcs, angleOf(center, point));\n if (index === -1) return onPick(null);\n onPick({ index, seriesKey: labels[index] ?? '' });\n },\n [center.x, center.y, radius, hole, arcs, labels, onPick]\n );\n\n const chosen = selection ? arcs.find((arc) => arc.index === selection.index) : undefined;\n\n return (\n <>\n {arcs.map((arc) => {\n const dim = !!selection && selection.index !== arc.index;\n return (\n <Wedge\n key={arc.index}\n size={radius * 2}\n center={center}\n start={arc.start}\n sweep={arc.sweep}\n color={entries[arc.index]?.color ?? theme.colors.primary}\n opacity={dim ? DIM_OPACITY : 1}\n explode={selection?.index === arc.index ? EXPLODE : 0}\n testID={testID && `${testID}-slice-${arc.index}`}\n />\n );\n })}\n\n {hole > 0 ? (\n <View\n pointerEvents=\"none\"\n style={{\n position: 'absolute',\n left: center.x - hole,\n top: center.y - hole,\n width: hole * 2,\n height: hole * 2,\n borderRadius: hole,\n backgroundColor: theme.colors.surface,\n }}\n />\n ) : null}\n\n {sliceLabels === 'none'\n ? null\n : arcs.map((arc) => {\n // Una porción muy fina no tiene dónde escribir: la etiqueta se\n // saldría encima de las vecinas y no se leería ninguna.\n if (arc.sweep < 18) return null;\n const at = polar(center, hole + (radius - hole) * LABEL_AT, arc.start + arc.sweep / 2);\n const color = entries[arc.index]?.color ?? theme.colors.primary;\n return (\n <Text\n key={arc.index}\n allowFontScaling={allowFontScaling}\n style={[styles.sliceLabel, { left: at.x - 28, top: at.y - 8, width: 56, color: inkOn(color, theme) }]}\n testID={testID && `${testID}-slice-label-${arc.index}`}\n >\n {sliceLabels === 'percent' ? `${Math.round(arc.ratio * 100)}%` : formatValue(arc.value)}\n </Text>\n );\n })}\n\n <Pressable\n onPress={pickAt}\n accessibilityRole=\"adjustable\"\n accessibilityLabel={arcs.map((arc) => `${labels[arc.index]} ${Math.round(arc.ratio * 100)}%`).join(', ')}\n style={{ position: 'absolute', left: 0, top: 0, width, height }}\n testID={testID && `${testID}-hit`}\n />\n\n {tooltip && selection && chosen ? (\n <Tooltip\n title={selection.label}\n rows={[\n {\n key: selection.seriesKey,\n label: `${Math.round(chosen.ratio * 100)}%`,\n value: formatValue(selection.value),\n color: entries[selection.index]?.color ?? theme.colors.primary,\n },\n ]}\n anchor={polar(center, radius * 0.9, chosen.start + chosen.sweep / 2)}\n containerWidth={width}\n testID={testID && `${testID}-tooltip`}\n />\n ) : null}\n </>\n );\n});\nPiePlot.displayName = 'PiePlot';\n\n// ── radar ──────────────────────────────────────────────────────────────────\n\ninterface RadarPlotProps extends PlotProps {\n domain?: [number, number] | 'sticky';\n levels: number;\n fill: boolean;\n thickness: number;\n dots: boolean;\n}\n\n/** Tiras por lado del polígono cuando se rellena. Más = borde más limpio, más vistas. */\nconst RADAR_STEPS = 5;\nconst RADAR_FILL_OPACITY = 0.18;\nconst RADAR_LABEL_WIDTH = 56;\n\nconst RadarPlot = memo((props: RadarPlotProps) => {\n const { rows, labels, visible, width, height, formatValue, selection, onPick, tooltip, testID, domain, levels, fill, thickness, dots } = props;\n const theme = useTheme();\n const styles = useStyles();\n const allowFontScaling = useAllowFontScaling();\n\n const center: Point = { x: width / 2, y: height / 2 };\n const radius = Math.max(0, Math.min(width, height) / 2 - RADAR_INSET);\n const angles = useMemo(() => radarAngles(rows.length), [rows.length]);\n const keys = useMemo(() => visible.map((entry) => entry.key), [visible]);\n\n // El radar mide desde el centro, y el centro es 0: un mínimo distinto de\n // cero haría que dos valores parecidos se vean lejísimos.\n const measured = useMemo(() => [0, extent(rows, keys, { includeZero: true })[1]] as [number, number], [rows, keys]);\n const [, top] = useDomain(measured, domain);\n\n const scale = useMemo(() => {\n const nice = Array.isArray(domain) ? exactTicks(0, top, levels) : niceScale(0, top, levels);\n return { nice, toRadius: linearScale([0, nice.max], [0, radius]) };\n }, [domain, top, levels, radius]);\n\n const pointsOf = useCallback(\n (key: string): Point[] =>\n rows.map((row, index) => polar(center, scale.toRadius(Math.max(0, toNumber(row[key]) ?? 0)), angles[index] ?? 0)),\n [rows, center, scale, angles]\n );\n\n const pickAt = useCallback(\n (event: GestureResponderEvent) => {\n if (rows.length === 0) return;\n const angle = angleOf(center, { x: event?.nativeEvent?.locationX ?? 0, y: event?.nativeEvent?.locationY ?? 0 });\n const index = Math.round(angle / (360 / rows.length)) % rows.length;\n const key = keys[0] ?? '';\n onPick({ index, seriesKey: key });\n },\n [rows, center, keys, onPick]\n );\n\n return (\n <>\n <RadarGrid center={center} radius={radius} angles={angles} levels={levels} testID={testID && `${testID}-grid`} />\n\n {visible.map((entry) => {\n const points = pointsOf(entry.key);\n return (\n <View key={entry.key} style={{ position: 'absolute', left: 0, top: 0, width, height }}>\n {/* Relleno: el mismo sector que dibuja una torta, uno por tira del\n polígono, con el radio que le toca a esa tira. */}\n {fill ? (\n // La opacidad va en el grupo, no en cada tira. Por tira, el\n // solape de 0.4° que tapa la costura del antialias se compone\n // dos veces y deja un radio más oscuro por corte: el relleno\n // termina pareciendo una telaraña. Aplicada al grupo, las tiras\n // se pintan opacas entre sí y el conjunto se atenúa una vez.\n <View\n pointerEvents=\"none\"\n style={{ position: 'absolute', left: 0, top: 0, width, height, opacity: RADAR_FILL_OPACITY }}\n testID={testID && `${testID}-fill-${entry.key}`}\n >\n {fanStrips(center, points, RADAR_STEPS).map((strip, index) => (\n <Wedge\n key={index}\n size={strip.radius * 2}\n center={center}\n start={strip.start}\n sweep={strip.sweep}\n color={entry.color}\n />\n ))}\n </View>\n ) : null}\n <Polyline points={points} color={entry.color} thickness={thickness} closed testID={testID && `${testID}-outline-${entry.key}`} />\n {dots\n ? points.map((point, index) => (\n <Dot\n key={index}\n point={point}\n color={entry.color}\n ringColor={theme.colors.surface}\n size={selection?.index === index ? 8 : 6}\n />\n ))\n : null}\n </View>\n );\n })}\n\n {labels.map((label, index) => {\n const at = polar(center, radius + RADAR_INSET / 2, angles[index] ?? 0);\n return (\n <Text\n key={`${label}-${index}`}\n numberOfLines={1}\n allowFontScaling={allowFontScaling}\n style={[\n styles.radarAxisLabel,\n { left: at.x - RADAR_LABEL_WIDTH / 2, top: at.y - 7, width: RADAR_LABEL_WIDTH },\n selection?.index === index && { color: theme.colors.text },\n ]}\n testID={testID && `${testID}-axis-${index}`}\n >\n {label}\n </Text>\n );\n })}\n\n <Pressable\n onPress={pickAt}\n accessibilityRole=\"adjustable\"\n accessibilityLabel={labels.join(', ')}\n style={{ position: 'absolute', left: 0, top: 0, width, height }}\n testID={testID && `${testID}-hit`}\n />\n\n {tooltip && selection ? (\n <Tooltip\n title={selection.label}\n rows={tooltipRowsAt(rows, selection.index, visible, formatValue)}\n anchor={polar(center, radius, angles[selection.index] ?? 0)}\n containerWidth={width}\n testID={testID && `${testID}-tooltip`}\n />\n ) : null}\n </>\n );\n});\nRadarPlot.displayName = 'RadarPlot';\n"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"path": "organisms/chart/marks.tsx",
|
|
16
|
+
"content": "/**\n * Las marcas de Chart: lo único que sabe dibujar el componente.\n *\n * React Native no trae un lienzo vectorial y la librería no puede agregar uno\n * (cero dependencias), así que las tres primitivas de acá se componen con\n * `View` y bastan para los seis tipos de chart:\n *\n * | primitiva | la usan |\n * |------------|-----------------------------------------------------|\n * | `Bar` | barras verticales y horizontales, agrupadas o apiladas |\n * | `Polyline` | línea, contorno del radar, grilla del radar, ejes |\n * | `Wedge` | torta, dona y el relleno del radar |\n *\n * Son puras: no escuchan toques ni conocen la escala. Quien toca es la capa de\n * hit-test de Chart.tsx, que cubre columnas enteras en vez de la marca —\n * una línea de 2px no es un blanco táctil.\n */\nimport React, { memo, useEffect, useRef } from 'react';\nimport { Animated, Easing, View, type ViewStyle } from 'react-native';\nimport { segmentBox, type Point } from './math';\n\n/**\n * Valor que persigue a `target` en vez de saltar. Un filtro que cambia los\n * datos mueve la barra desde donde estaba, y ese movimiento es lo que dice\n * \"es la misma barra con otro número\" en lugar de \"es otro chart\".\n */\nexport function useAnimatedNumber(target: number, animate: boolean, duration: number): Animated.Value {\n const value = useRef(new Animated.Value(animate ? 0 : target)).current;\n\n useEffect(() => {\n if (!animate) {\n value.setValue(target);\n return;\n }\n const animation = Animated.timing(value, {\n toValue: target,\n duration,\n easing: Easing.out(Easing.cubic),\n // Geometría, no transform: `height`/`top` son props de layout y el\n // driver nativo no las toca. Es el precio de que la barra pueda crecer\n // desde su base con valores negativos sin recalcular el origen.\n useNativeDriver: false,\n });\n animation.start();\n return () => animation.stop();\n }, [target, animate, duration, value]);\n\n return value;\n}\n\n// ── barra ──────────────────────────────────────────────────────────────────\n\nexport interface BarProps {\n /** Caja final de la barra dentro del plot, ya en píxeles. */\n x: number;\n y: number;\n width: number;\n height: number;\n color: string;\n /** Lado por donde nace: la barra crece desde su base, no desde el centro. */\n from: 'bottom' | 'top' | 'left' | 'right';\n /** Radio de las dos esquinas del extremo libre. */\n radius?: number;\n /** Atenúa las barras que no son la seleccionada. */\n dim?: boolean;\n animate?: boolean;\n duration?: number;\n testID?: string;\n}\n\nconst DIM_OPACITY = 0.32;\n\nexport const Bar = memo(\n ({ x, y, width, height, color, from, radius = 0, dim = false, animate = true, duration = 400, testID }: BarProps) => {\n const vertical = from === 'bottom' || from === 'top';\n const grown = useAnimatedNumber(vertical ? height : width, animate, duration);\n\n // El extremo que se mueve es el opuesto a la base: una barra que nace\n // abajo mantiene fijo su borde inferior y sube el superior.\n const box = vertical\n ? { left: x, width, height: grown, ...(from === 'bottom' ? { bottom: y } : { top: y }) }\n : { top: y, height, width: grown, ...(from === 'left' ? { left: x } : { right: x }) };\n\n const corners: ViewStyle =\n radius <= 0\n ? {}\n : from === 'bottom'\n ? { borderTopLeftRadius: radius, borderTopRightRadius: radius }\n : from === 'top'\n ? { borderBottomLeftRadius: radius, borderBottomRightRadius: radius }\n : from === 'left'\n ? { borderTopRightRadius: radius, borderBottomRightRadius: radius }\n : { borderTopLeftRadius: radius, borderBottomLeftRadius: radius };\n\n return (\n <Animated.View\n testID={testID}\n pointerEvents=\"none\"\n style={[{ position: 'absolute', backgroundColor: color, opacity: dim ? DIM_OPACITY : 1 }, corners, box]}\n />\n );\n }\n);\nBar.displayName = 'Bar';\n\n// ── polilínea ──────────────────────────────────────────────────────────────\n\nexport interface PolylineProps {\n points: Point[];\n color: string;\n thickness?: number;\n /** Une el último punto con el primero (radar, grilla poligonal). */\n closed?: boolean;\n opacity?: number;\n testID?: string;\n}\n\n/**\n * Una línea es un rectángulo fino rotado sobre su centro, uno por tramo. Con\n * `smoothPoints` los tramos son tantos que la poligonal se lee como curva.\n *\n * Los extremos quedan a tope, no redondeados: un `borderRadius` por tramo\n * multiplicaría las vistas y a 2px de grosor nadie ve la diferencia.\n */\nexport const Polyline = memo(({ points, color, thickness = 2, closed = false, opacity = 1, testID }: PolylineProps) => {\n if (points.length < 2) return null;\n const path = closed ? [...points, points[0]!] : points;\n\n return (\n <View testID={testID} pointerEvents=\"none\" style={{ position: 'absolute', left: 0, top: 0, right: 0, bottom: 0, opacity }}>\n {path.slice(0, -1).map((point, index) => {\n const box = segmentBox(point, path[index + 1]!, thickness);\n return (\n <View\n key={index}\n style={{\n position: 'absolute',\n left: box.left,\n top: box.top,\n width: box.width,\n height: box.height,\n backgroundColor: color,\n transform: [{ rotate: `${box.rotate}deg` }],\n }}\n />\n );\n })}\n </View>\n );\n});\nPolyline.displayName = 'Polyline';\n\nexport interface DotProps {\n point: Point;\n color: string;\n size?: number;\n /** Aro del color del fondo: despega el punto de la línea que lo cruza. */\n ringColor?: string;\n ringWidth?: number;\n testID?: string;\n}\n\nexport const Dot = memo(({ point, color, size = 7, ringColor, ringWidth = 2, testID }: DotProps) => {\n const outer = size + (ringColor ? ringWidth * 2 : 0);\n return (\n <View\n testID={testID}\n pointerEvents=\"none\"\n style={{\n position: 'absolute',\n left: point.x - outer / 2,\n top: point.y - outer / 2,\n width: outer,\n height: outer,\n borderRadius: outer / 2,\n backgroundColor: color,\n borderWidth: ringColor ? ringWidth : 0,\n borderColor: ringColor,\n }}\n />\n );\n});\nDot.displayName = 'Dot';\n\n// ── porción ────────────────────────────────────────────────────────────────\n\ninterface HalfProps {\n size: number;\n /** Apertura de esta mitad, 0–180. */\n sweep: number;\n color: string;\n}\n\n/**\n * Sector de hasta 180°, sin curvas ni máscaras. Tres cajas:\n *\n * 1. un recorte fijo a la mitad derecha (`overflow: 'hidden'`), que es el\n * sector 0°–180°;\n * 2. adentro, un cuadrado `size` × `size` centrado en el centro del círculo,\n * que es lo que gira — al rotar sobre su propio centro, rota sobre el\n * centro del círculo sin necesidad de `transformOrigin`;\n * 3. adentro del cuadrado, **medio disco**, no un disco entero: un rectángulo\n * de media caja con las dos esquinas de afuera redondeadas al radio.\n *\n * El medio disco es lo que hace que la rotación signifique algo. Con un disco\n * completo el recorte deja siempre la misma media luna —rotar un círculo\n * sólido no lo cambia—, y toda porción, sea de 5° o de 180°, sale igual de\n * ancha: 180°.\n *\n * Girado `sweep - 180`, el medio disco cubre de `sweep - 180` a `sweep`, y lo\n * que sobrevive al recorte es exactamente 0°–`sweep`.\n */\nconst Half = ({ size, sweep, color }: HalfProps) => (\n <View style={{ position: 'absolute', left: size / 2, top: 0, width: size / 2, height: size, overflow: 'hidden' }}>\n <View\n style={{\n position: 'absolute',\n left: -size / 2,\n top: 0,\n width: size,\n height: size,\n transform: [{ rotate: `${sweep - 180}deg` }],\n }}\n >\n <View\n style={{\n position: 'absolute',\n left: size / 2,\n top: 0,\n width: size / 2,\n height: size,\n backgroundColor: color,\n borderTopRightRadius: size / 2,\n borderBottomRightRadius: size / 2,\n }}\n />\n </View>\n </View>\n);\n\nexport interface WedgeProps {\n /** Diámetro: la porción se dibuja centrada en una caja `size` × `size`. */\n size: number;\n /** Ángulo donde empieza, 0° arriba y creciendo en horario. */\n start: number;\n /** Apertura, 0–360. */\n sweep: number;\n color: string;\n opacity?: number;\n /** Desplaza la porción hacia afuera por su bisectriz (la seleccionada). */\n explode?: number;\n /** Centro dentro del contenedor. Sin él la porción se apoya en (0, 0). */\n center?: Point;\n testID?: string;\n}\n\nconst FULL_CIRCLE = 359.9;\n\nexport const Wedge = memo(({ size, start, sweep, color, opacity = 1, explode = 0, center, testID }: WedgeProps) => {\n if (sweep <= 0 || size <= 0) return null;\n // 360° exactos dejan las dos mitades a 180°, y la de arriba tapa a la de\n // abajo por una rendija de antialias. Un pelo menos no se ve y no pasa.\n const total = Math.min(sweep, FULL_CIRCLE);\n const first = Math.min(total, 180);\n const second = total - first;\n const bisector = (start + total / 2) * (Math.PI / 180);\n\n return (\n <View\n testID={testID}\n pointerEvents=\"none\"\n style={{\n position: 'absolute',\n left: (center?.x ?? size / 2) - size / 2,\n top: (center?.y ?? size / 2) - size / 2,\n width: size,\n height: size,\n opacity,\n transform: [\n { translateX: explode * Math.sin(bisector) },\n { translateY: -explode * Math.cos(bisector) },\n { rotate: `${start}deg` },\n ],\n }}\n >\n <Half size={size} sweep={first} color={color} />\n {second > 0 ? (\n <View style={{ position: 'absolute', width: size, height: size, transform: [{ rotate: '180deg' }] }}>\n <Half size={size} sweep={second} color={color} />\n </View>\n ) : null}\n </View>\n );\n});\nWedge.displayName = 'Wedge';\n"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"path": "organisms/chart/math.ts",
|
|
20
|
+
"content": "/**\n * Geometría y escalas de Chart: todo lo que se puede probar sin montar un\n * árbol vive acá, sin React y sin dependencias. marks.tsx sólo traduce estos\n * números a estilos, y Chart.tsx sólo decide qué marca dibujar.\n *\n * Convenciones:\n * - Coordenadas de React Native: origen arriba-izquierda, `y` crece hacia abajo.\n * - Ángulos en grados, 0° a las 12 en punto, creciendo en sentido horario —\n * que es como se lee una torta y como gira `transform: rotate` en RN.\n */\n\nexport interface Point {\n x: number;\n y: number;\n}\n\n/** Fila de datos: una categoría del eje x y un número por serie. */\nexport type ChartRow = Record<string, string | number | null | undefined>;\n\n/** Lee una celda como número, o null si no lo es (hueco en la serie). */\nexport function toNumber(value: unknown): number | null {\n return typeof value === 'number' && Number.isFinite(value) ? value : null;\n}\n\nexport function clamp(value: number, min: number, max: number): number {\n return Math.min(max, Math.max(min, value));\n}\n\n/** Mata el ruido de punto flotante que deja sumar `step` en un bucle. */\nfunction tidy(value: number): number {\n return Number(value.toFixed(10));\n}\n\n// ── escalas ────────────────────────────────────────────────────────────────\n\n/**\n * El 1, 2, 5 o 10 (por su potencia de diez) más cercano por encima de `rough`.\n * Es lo que hace que los ticks caigan en números que alguien diría en voz\n * alta — 0, 250, 500 — y no en 0, 237, 474.\n */\nexport function niceStep(rough: number): number {\n if (!(rough > 0)) return 1;\n const magnitude = Math.pow(10, Math.floor(Math.log10(rough)));\n const fraction = rough / magnitude;\n const nice = fraction <= 1 ? 1 : fraction <= 2 ? 2 : fraction <= 5 ? 5 : 10;\n return nice * magnitude;\n}\n\nexport interface Ticks {\n /** Extremo inferior redondeado hacia afuera. */\n min: number;\n /** Extremo superior redondeado hacia afuera. */\n max: number;\n step: number;\n values: number[];\n}\n\n/**\n * Dominio redondeado a valores legibles más la lista de ticks. `count` es una\n * intención, no una promesa: redondear hacia afuera puede dar uno más o uno\n * menos, y eso es preferible a un eje con números arbitrarios.\n */\nexport function niceScale(min: number, max: number, count = 5): Ticks {\n let lo = Math.min(min, max);\n let hi = Math.max(min, max);\n if (!Number.isFinite(lo) || !Number.isFinite(hi)) {\n lo = 0;\n hi = 1;\n }\n if (lo === hi) {\n // Un solo valor no define un rango. Se le inventa uno simétrico para que\n // la marca no quede ni pegada al borde ni ocupando el alto entero.\n const pad = Math.abs(lo) || 1;\n lo -= pad;\n hi += pad;\n }\n const step = niceStep((hi - lo) / Math.max(1, count));\n const start = Math.floor(lo / step) * step;\n const end = Math.ceil(hi / step) * step;\n const values: number[] = [];\n // `end + step / 2` en vez de `end`: sumar step acumula error y el último\n // tick se perdería justo en los dominios que más se usan (0–100, 0–1).\n for (let value = start; value <= end + step / 2; value += step) values.push(tidy(value));\n return { min: start, max: end, step, values };\n}\n\n/**\n * Ticks de un dominio que el consumidor fijó a mano. No redondea nada: si\n * alguien pide 0–100, el eje dice 0–100. Es lo contrario de `niceScale`, y es\n * lo que necesita un chart en vivo — un eje que se reacomoda en cada tick hace\n * saltar la línea aunque el dato no se haya movido.\n */\nexport function exactTicks(min: number, max: number, count = 5): Ticks {\n const lo = Math.min(min, max);\n const hi = Math.max(min, max);\n const steps = Math.max(1, Math.floor(count));\n if (lo === hi) return { min: lo, max: hi, step: 0, values: [lo] };\n const step = (hi - lo) / steps;\n return {\n min: lo,\n max: hi,\n step,\n values: Array.from({ length: steps + 1 }, (_, index) => tidy(lo + index * step)),\n };\n}\n\n/**\n * Une dos dominios quedándose con el más ancho. Es el modo 'sticky': el eje\n * crece cuando llega un valor que no entraba y no se vuelve a achicar, así\n * que lo que se ve moverse es el dato y no la escala.\n */\nexport function growExtent(previous: [number, number] | null, next: [number, number]): [number, number] {\n if (!previous) return next;\n return [Math.min(previous[0], next[0]), Math.max(previous[1], next[1])];\n}\n\n/** Interpolador lineal dominio → rango. Dominio degenerado cae al medio del rango. */\nexport function linearScale(domain: [number, number], range: [number, number]): (value: number) => number {\n const [d0, d1] = domain;\n const [r0, r1] = range;\n const span = d1 - d0;\n if (span === 0) return () => (r0 + r1) / 2;\n return (value: number) => r0 + ((value - d0) / span) * (r1 - r0);\n}\n\nexport interface ExtentOptions {\n /** Las series se suman en vez de compararse (barras/áreas apiladas). */\n stacked?: boolean;\n /** Fuerza el 0 adentro del dominio: sin él, una barra corta miente. */\n includeZero?: boolean;\n}\n\n/** Mínimo y máximo de las series pedidas. Sin datos devuelve [0, 1]. */\nexport function extent(rows: ChartRow[], keys: string[], options: ExtentOptions = {}): [number, number] {\n const { stacked = false, includeZero = false } = options;\n let min = Infinity;\n let max = -Infinity;\n\n for (const row of rows) {\n if (stacked) {\n // Positivos y negativos se apilan hacia lados opuestos del cero, así que\n // cada lado tiene su propio total y ninguno cancela al otro.\n let up = 0;\n let down = 0;\n for (const key of keys) {\n const value = toNumber(row[key]);\n if (value === null) continue;\n if (value >= 0) up += value;\n else down += value;\n }\n min = Math.min(min, down);\n max = Math.max(max, up);\n } else {\n for (const key of keys) {\n const value = toNumber(row[key]);\n if (value === null) continue;\n min = Math.min(min, value);\n max = Math.max(max, value);\n }\n }\n }\n\n if (min === Infinity) return [0, 1];\n if (includeZero) {\n min = Math.min(min, 0);\n max = Math.max(max, 0);\n }\n return [min, max];\n}\n\n// ── apilado ────────────────────────────────────────────────────────────────\n\nexport interface StackBand {\n key: string;\n /** Borde de la banda más cerca del cero. */\n from: number;\n /** Borde más lejos del cero. */\n to: number;\n value: number;\n}\n\n/**\n * Bandas apiladas de una fila. Los positivos crecen desde 0 hacia arriba y\n * los negativos desde 0 hacia abajo: una serie negativa no le come el alto a\n * las positivas, que es lo que pasa si se apila todo en un solo acumulador.\n */\nexport function stackRow(row: ChartRow, keys: string[]): StackBand[] {\n const bands: StackBand[] = [];\n let up = 0;\n let down = 0;\n for (const key of keys) {\n const value = toNumber(row[key]);\n if (value === null) continue;\n if (value >= 0) {\n bands.push({ key, from: up, to: up + value, value });\n up += value;\n } else {\n bands.push({ key, from: down, to: down + value, value });\n down += value;\n }\n }\n return bands;\n}\n\n// ── polar ──────────────────────────────────────────────────────────────────\n\nconst RAD = Math.PI / 180;\n\n/** Punto a `radius` del centro en el ángulo dado (0° arriba, horario). */\nexport function polar(center: Point, radius: number, angle: number): Point {\n return {\n x: center.x + radius * Math.sin(angle * RAD),\n y: center.y - radius * Math.cos(angle * RAD),\n };\n}\n\n/** Ángulo de un punto respecto del centro, normalizado a [0, 360). */\nexport function angleOf(center: Point, point: Point): number {\n const degrees = Math.atan2(point.x - center.x, center.y - point.y) / RAD;\n return (degrees + 360) % 360;\n}\n\nexport interface Arc {\n /** Índice de la fila que representa. */\n index: number;\n start: number;\n sweep: number;\n value: number;\n /** Proporción sobre el total, 0–1. */\n ratio: number;\n}\n\n/**\n * Arcos de una torta. Los negativos no existen en una torta (no hay \"menos\n * porción\"): se descartan en vez de invertirse. Total 0 ⇒ nada que dibujar.\n */\nexport function arcsFor(values: number[]): Arc[] {\n const safe = values.map((value) => (Number.isFinite(value) && value > 0 ? value : 0));\n const total = safe.reduce((sum, value) => sum + value, 0);\n if (total <= 0) return [];\n\n const arcs: Arc[] = [];\n let cursor = 0;\n safe.forEach((value, index) => {\n if (value <= 0) return;\n const sweep = (value / total) * 360;\n arcs.push({ index, start: cursor, sweep, value, ratio: value / total });\n cursor += sweep;\n });\n return arcs;\n}\n\n/** El arco que contiene ese ángulo, o -1. */\nexport function arcAt(arcs: Arc[], angle: number): number {\n const normalized = (angle + 360) % 360;\n for (const arc of arcs) {\n if (normalized >= arc.start && normalized < arc.start + arc.sweep) return arc.index;\n }\n return -1;\n}\n\n/** Ángulos de los ejes de un radar: el primero arriba, el resto en reparto parejo. */\nexport function radarAngles(count: number): number[] {\n if (count <= 0) return [];\n return Array.from({ length: count }, (_, index) => (index * 360) / count);\n}\n\n// ── segmentos y curvas ─────────────────────────────────────────────────────\n\nexport interface SegmentBox {\n left: number;\n top: number;\n width: number;\n height: number;\n /** Grados para `transform: [{ rotate }]`. */\n rotate: number;\n}\n\n/**\n * Caja de un segmento a→b: sin SVG, una línea es un rectángulo fino rotado\n * sobre su centro. Ancho = distancia, alto = grosor, rotación = la pendiente.\n */\nexport function segmentBox(a: Point, b: Point, thickness: number): SegmentBox {\n const dx = b.x - a.x;\n const dy = b.y - a.y;\n const length = Math.hypot(dx, dy);\n return {\n left: (a.x + b.x) / 2 - length / 2,\n top: (a.y + b.y) / 2 - thickness / 2,\n width: length,\n height: thickness,\n rotate: Math.atan2(dy, dx) / RAD,\n };\n}\n\n/**\n * Suaviza una polilínea con Catmull-Rom muestreada en `steps` tramos por vano.\n * La curva pasa por todos los puntos originales (a diferencia de un Bézier de\n * control libre), así que el dato sigue siendo el dato; sólo se agrega camino\n * entre medio, y el resultado se dibuja con los mismos segmentos rectos.\n */\nexport function smoothPoints(points: Point[], steps = 8): Point[] {\n if (points.length < 3 || steps < 2) return points;\n const out: Point[] = [points[0]!];\n for (let i = 0; i < points.length - 1; i += 1) {\n // Los extremos se duplican para que el primer y el último vano tengan\n // las cuatro referencias que pide la spline.\n const p0 = points[i - 1] ?? points[i]!;\n const p1 = points[i]!;\n const p2 = points[i + 1]!;\n const p3 = points[i + 2] ?? p2;\n for (let s = 1; s <= steps; s += 1) {\n const t = s / steps;\n const t2 = t * t;\n const t3 = t2 * t;\n out.push({\n x: 0.5 * (2 * p1.x + (-p0.x + p2.x) * t + (2 * p0.x - 5 * p1.x + 4 * p2.x - p3.x) * t2 + (-p0.x + 3 * p1.x - 3 * p2.x + p3.x) * t3),\n y: 0.5 * (2 * p1.y + (-p0.y + p2.y) * t + (2 * p0.y - 5 * p1.y + 4 * p2.y - p3.y) * t2 + (-p0.y + 3 * p1.y - 3 * p2.y + p3.y) * t3),\n });\n }\n }\n return out;\n}\n\n/**\n * `y` de la polilínea en esa `x`, o null si cae fuera. Es lo que convierte una\n * línea en área: cada tira vertical pregunta su alto acá.\n */\nexport function sampleY(points: Point[], x: number): number | null {\n if (points.length === 0) return null;\n if (points.length === 1) return points[0]!.y;\n for (let i = 0; i < points.length - 1; i += 1) {\n const a = points[i]!;\n const b = points[i + 1]!;\n const [lo, hi] = a.x <= b.x ? [a, b] : [b, a];\n if (x < lo.x || x > hi.x) continue;\n if (hi.x === lo.x) return lo.y;\n return lo.y + ((x - lo.x) / (hi.x - lo.x)) * (hi.y - lo.y);\n }\n return null;\n}\n\n// ── formato ────────────────────────────────────────────────────────────────\n\nconst UNITS = ['', 'k', 'M', 'B'];\n\n/**\n * Formato por defecto de ejes y tooltip: compacto, porque un eje de un chart\n * de 320px no tiene lugar para \"1250000\". El consumidor lo reemplaza entero\n * con `formatValue`.\n */\nexport function formatNumber(value: number): string {\n if (!Number.isFinite(value)) return '';\n const sign = value < 0 ? '-' : '';\n let magnitude = Math.abs(value);\n let unit = 0;\n while (magnitude >= 1000 && unit < UNITS.length - 1) {\n magnitude /= 1000;\n unit += 1;\n }\n const decimals = magnitude < 10 && magnitude % 1 !== 0 ? 1 : 0;\n return `${sign}${magnitude.toFixed(decimals)}${UNITS[unit]}`;\n}\n\nexport interface FanStrip {\n start: number;\n sweep: number;\n radius: number;\n}\n\n/**\n * Descompone un polígono alrededor de un centro en porciones angulares: es lo\n * que permite rellenar un radar con la misma primitiva que dibuja una torta.\n *\n * El corte va por el lado del polígono (parámetro `t`), no por el ángulo: el\n * radio de una cuerda no es lineal en el ángulo, y cortando por ángulo los\n * vértices quedarían redondeados justo donde el dato manda que haya una punta.\n */\nexport function fanStrips(center: Point, points: Point[], steps = 6): FanStrip[] {\n if (points.length < 3 || steps < 1) return [];\n const strips: FanStrip[] = [];\n\n for (let i = 0; i < points.length; i += 1) {\n const a = points[i]!;\n const b = points[(i + 1) % points.length]!;\n\n let previous = a;\n let previousAngle = angleOf(center, a);\n for (let s = 1; s <= steps; s += 1) {\n const t = s / steps;\n const point = { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };\n const angle = angleOf(center, point);\n // El salto de 360 a 0 cae en algún lado del recorrido: sin esto, esa\n // porción se dibujaría girando al revés y taparía todo el resto.\n let sweep = angle - previousAngle;\n if (sweep < 0) sweep += 360;\n if (sweep > 0) {\n const radius = (Math.hypot(previous.x - center.x, previous.y - center.y) + Math.hypot(point.x - center.x, point.y - center.y)) / 2;\n // Las porciones se solapan un pelo a propósito: sin el solape, el\n // antialias deja una costura clara entre tira y tira.\n strips.push({ start: previousAngle, sweep: sweep + 0.4, radius });\n }\n previous = point;\n previousAngle = angle;\n }\n }\n return strips;\n}\n\n// ── diezmado ───────────────────────────────────────────────────────────────\n\n/**\n * LTTB (Largest-Triangle-Three-Buckets): elige `threshold` puntos de `values`\n * quedándose con la forma de la curva. Devuelve índices, no puntos, para que\n * quien llama pueda conservar la fila entera —todas sus series— y no sólo la\n * que se usó para decidir.\n *\n * Por qué no un muestreo cada N: cada N se come los picos, que son justo lo\n * que alguien mira en una serie temporal. LTTB parte el recorrido en baldes y\n * de cada uno se queda con el punto que forma el triángulo más grande con el\n * ya elegido y con el promedio del balde siguiente — o sea, con el que más\n * cambia la silueta. Primero y último se conservan siempre.\n *\n * `x` es el índice: las categorías de un chart están repartidas parejo sobre\n * el eje, así que la distancia horizontal entre dos puntos es su distancia de\n * índice.\n */\nexport function lttbIndices(values: (number | null)[], threshold: number): number[] {\n const total = values.length;\n if (threshold >= total || threshold < 3) return values.map((_, index) => index);\n\n const at = (index: number) => values[index] ?? 0;\n const selected = [0];\n // Un balde por punto intermedio: el primero y el último no se sortean. Con\n // `threshold < total` el balde mide más de un punto, así que ninguno queda\n // vacío y salen exactamente `threshold` índices, sin repetidos.\n const every = (total - 2) / (threshold - 2);\n let previous = 0;\n\n for (let bucket = 0; bucket < threshold - 2; bucket += 1) {\n // Promedio del balde *siguiente*: el tercer vértice del triángulo. Va\n // corrido un balde respecto de los candidatos, y ese corrimiento es el\n // algoritmo — comparar contra el propio balde no mira hacia adelante.\n const avgStart = Math.floor((bucket + 1) * every) + 1;\n const avgEnd = Math.min(Math.floor((bucket + 2) * every) + 1, total);\n let avgX = 0;\n let avgY = 0;\n for (let i = avgStart; i < avgEnd; i += 1) {\n avgX += i;\n avgY += at(i);\n }\n const span = Math.max(1, avgEnd - avgStart);\n avgX /= span;\n avgY /= span;\n\n const start = Math.floor(bucket * every) + 1;\n const end = Math.floor((bucket + 1) * every) + 1;\n let best = start;\n let bestArea = -1;\n for (let i = start; i < end; i += 1) {\n const area = Math.abs(\n (previous - avgX) * (at(i) - at(previous)) - (previous - i) * (avgY - at(previous))\n );\n if (area > bestArea) {\n bestArea = area;\n best = i;\n }\n }\n selected.push(best);\n previous = best;\n }\n\n selected.push(total - 1);\n return selected;\n}\n"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"path": "organisms/chart/frame.tsx",
|
|
24
|
+
"content": "/**\n * El marco de Chart: todo lo que rodea a las marcas — grilla, ejes, leyenda y\n * tooltip. Nada de acá conoce el tipo de chart; recibe números y textos ya\n * resueltos.\n *\n * Gestalt: la grilla usa `border`, dos escalones por debajo del color de las\n * series, para que el dato quede como figura y la referencia como fondo. La\n * leyenda repite el color exacto de cada serie (similitud) y por eso puede\n * hacer de control de filtro sin explicar qué apaga: se apaga lo que tiene ese\n * color.\n */\nimport React, { memo } from 'react';\nimport { Text, TouchableOpacity, View, type StyleProp, type ViewStyle } from 'react-native';\nimport { createStyles } from '../../theme/createStyles';\nimport { useAllowFontScaling, useColors } from '../../theme/UIProvider';\nimport { polar, type Point } from './math';\n\nconst useStyles = createStyles((theme) => ({\n gridLine: { position: 'absolute', backgroundColor: theme.colors.border },\n axisLabel: {\n position: 'absolute',\n fontSize: theme.tokens.fontSize.xs,\n color: theme.colors.textLight,\n },\n legend: {\n flexDirection: 'row',\n flexWrap: 'wrap',\n justifyContent: 'center',\n gap: theme.tokens.spacing.sm,\n marginTop: theme.tokens.spacing.sm,\n },\n legendItem: {\n flexDirection: 'row',\n alignItems: 'center',\n gap: theme.tokens.spacing.xs,\n paddingVertical: theme.tokens.spacing.xxs,\n paddingHorizontal: theme.tokens.spacing.sm,\n borderRadius: theme.tokens.radius.full,\n backgroundColor: theme.colors.inputBackground,\n },\n legendItemOff: { backgroundColor: 'transparent', borderWidth: 1, borderColor: theme.colors.border },\n swatch: { width: 10, height: 10, borderRadius: theme.tokens.radius.xs },\n legendLabel: { fontSize: theme.tokens.fontSize.xs, color: theme.colors.text },\n legendLabelOff: { color: theme.colors.textLight },\n tooltip: {\n position: 'absolute',\n paddingVertical: theme.tokens.spacing.sm,\n paddingHorizontal: theme.tokens.spacing.md,\n borderRadius: theme.tokens.radius.md,\n backgroundColor: theme.colors.surface,\n borderWidth: 1,\n borderColor: theme.colors.border,\n gap: theme.tokens.spacing.xxs,\n ...theme.tokens.shadow.md,\n },\n tooltipTitle: {\n fontSize: theme.tokens.fontSize.xs,\n fontWeight: theme.tokens.fontWeight.semibold,\n color: theme.colors.text,\n },\n tooltipRow: { flexDirection: 'row', alignItems: 'center', gap: theme.tokens.spacing.xs },\n tooltipLabel: { fontSize: theme.tokens.fontSize.xs, color: theme.colors.textLight },\n tooltipValue: {\n fontSize: theme.tokens.fontSize.xs,\n fontWeight: theme.tokens.fontWeight.semibold,\n color: theme.colors.text,\n marginLeft: 'auto',\n },\n}));\n\n// ── grilla y ejes cartesianos ──────────────────────────────────────────────\n\nexport interface CartesianGridProps {\n width: number;\n height: number;\n /** Posición en píxeles de cada línea, sobre el eje perpendicular a ella. */\n lines: number[];\n /** 'horizontal' dibuja líneas que cruzan a lo ancho (eje y con ticks). */\n orientation: 'horizontal' | 'vertical';\n /** Índice de la línea del cero, más marcada que el resto. */\n zeroIndex?: number;\n testID?: string;\n}\n\nexport const CartesianGrid = memo(({ width, height, lines, orientation, zeroIndex, testID }: CartesianGridProps) => {\n const styles = useStyles();\n const colors = useColors();\n\n return (\n <View testID={testID} pointerEvents=\"none\" style={{ position: 'absolute', left: 0, top: 0, width, height }}>\n {lines.map((position, index) => {\n const isZero = index === zeroIndex;\n return (\n <View\n key={index}\n style={[\n styles.gridLine,\n orientation === 'horizontal'\n ? { left: 0, width, top: position, height: 1 }\n : { top: 0, height, left: position, width: 1 },\n // La línea del cero es una referencia distinta de las demás: no\n // divide el espacio, separa lo que suma de lo que resta.\n isZero && { backgroundColor: colors.textLight },\n ]}\n />\n );\n })}\n </View>\n );\n});\nCartesianGrid.displayName = 'CartesianGrid';\n\nexport interface AxisLabelsProps {\n labels: { text: string; position: number }[];\n /** 'x' las reparte a lo ancho debajo del plot; 'y' las apila a la izquierda. */\n axis: 'x' | 'y';\n /** Ancho del canal reservado para las etiquetas. */\n gutter: number;\n /** Tamaño del plot sobre el eje de las etiquetas. */\n span: number;\n testID?: string;\n}\n\nconst LABEL_HEIGHT = 14;\n\nexport const AxisLabels = memo(({ labels, axis, gutter, span, testID }: AxisLabelsProps) => {\n const styles = useStyles();\n const allowFontScaling = useAllowFontScaling();\n // Una etiqueta de x se centra bajo su categoría y puede pisar a la vecina;\n // se le da como ancho el reparto parejo del plot y se trunca a una línea.\n const slot = axis === 'x' ? Math.max(24, span / Math.max(1, labels.length)) : gutter;\n\n return (\n <View testID={testID} pointerEvents=\"none\" style={{ position: 'absolute', left: 0, top: 0, right: 0, bottom: 0 }}>\n {labels.map((label, index) => (\n <Text\n key={`${label.text}-${index}`}\n numberOfLines={1}\n allowFontScaling={allowFontScaling}\n style={[\n styles.axisLabel,\n axis === 'x'\n ? { left: label.position - slot / 2, width: slot, top: 0, textAlign: 'center' }\n : { left: 0, width: gutter, top: label.position - LABEL_HEIGHT / 2, height: LABEL_HEIGHT, textAlign: 'right' },\n ]}\n >\n {label.text}\n </Text>\n ))}\n </View>\n );\n});\nAxisLabels.displayName = 'AxisLabels';\n\n// ── grilla del radar ───────────────────────────────────────────────────────\n\nexport interface RadarGridProps {\n center: Point;\n radius: number;\n angles: number[];\n /** Anillos concéntricos, incluido el borde. */\n levels: number;\n testID?: string;\n}\n\n/**\n * Anillos y radios del radar. Los anillos son polígonos, no círculos: un\n * círculo sugiere que entre eje y eje hay valores, y no los hay — sólo hay\n * los ejes que se pasaron.\n */\nexport const RadarGrid = memo(({ center, radius, angles, levels, testID }: RadarGridProps) => {\n const colors = useColors();\n if (angles.length < 3 || radius <= 0) return null;\n\n const rings = Array.from({ length: levels }, (_, index) => (radius * (index + 1)) / levels);\n\n return (\n <View testID={testID} pointerEvents=\"none\" style={{ position: 'absolute', left: 0, top: 0, right: 0, bottom: 0 }}>\n {rings.map((ringRadius, ringIndex) => {\n const points = angles.map((angle) => polar(center, ringRadius, angle));\n return points.map((point, index) => {\n const next = points[(index + 1) % points.length]!;\n const dx = next.x - point.x;\n const dy = next.y - point.y;\n const length = Math.hypot(dx, dy);\n return (\n <View\n key={`ring-${ringIndex}-${index}`}\n style={{\n position: 'absolute',\n left: (point.x + next.x) / 2 - length / 2,\n top: (point.y + next.y) / 2 - 0.5,\n width: length,\n height: 1,\n backgroundColor: colors.border,\n transform: [{ rotate: `${(Math.atan2(dy, dx) * 180) / Math.PI}deg` }],\n }}\n />\n );\n });\n })}\n {angles.map((angle, index) => {\n const tip = polar(center, radius, angle);\n const dx = tip.x - center.x;\n const dy = tip.y - center.y;\n return (\n <View\n key={`spoke-${index}`}\n style={{\n position: 'absolute',\n left: (center.x + tip.x) / 2 - radius / 2,\n top: (center.y + tip.y) / 2 - 0.5,\n width: radius,\n height: 1,\n backgroundColor: colors.border,\n transform: [{ rotate: `${(Math.atan2(dy, dx) * 180) / Math.PI}deg` }],\n }}\n />\n );\n })}\n </View>\n );\n});\nRadarGrid.displayName = 'RadarGrid';\n\n// ── leyenda ────────────────────────────────────────────────────────────────\n\nexport interface LegendItem {\n key: string;\n label: string;\n color: string;\n}\n\nexport interface LegendProps {\n items: LegendItem[];\n hidden: string[];\n /** Sin esto la leyenda es sólo referencia; con esto es el filtro. */\n onToggle?: (key: string) => void;\n testID?: string;\n}\n\nexport const Legend = memo(({ items, hidden, onToggle, testID }: LegendProps) => {\n const styles = useStyles();\n const colors = useColors();\n const allowFontScaling = useAllowFontScaling();\n\n return (\n <View style={styles.legend} testID={testID}>\n {items.map((item) => {\n const off = hidden.includes(item.key);\n const content = (\n <>\n <View style={[styles.swatch, { backgroundColor: off ? colors.border : item.color }]} />\n <Text allowFontScaling={allowFontScaling} style={[styles.legendLabel, off && styles.legendLabelOff]}>\n {item.label}\n </Text>\n </>\n );\n\n if (!onToggle) {\n return (\n <View key={item.key} style={styles.legendItem} testID={testID && `${testID}-${item.key}`}>\n {content}\n </View>\n );\n }\n return (\n <TouchableOpacity\n key={item.key}\n style={[styles.legendItem, off && styles.legendItemOff]}\n onPress={() => onToggle(item.key)}\n accessibilityRole=\"switch\"\n accessibilityState={{ checked: !off }}\n accessibilityLabel={item.label}\n testID={testID && `${testID}-${item.key}`}\n >\n {content}\n </TouchableOpacity>\n );\n })}\n </View>\n );\n});\nLegend.displayName = 'Legend';\n\n// ── tooltip ────────────────────────────────────────────────────────────────\n\nexport interface TooltipRow {\n key: string;\n label: string;\n value: string;\n color: string;\n}\n\nexport interface TooltipProps {\n title: string;\n rows: TooltipRow[];\n /** Punto que describe, en coordenadas del contenedor. */\n anchor: Point;\n /** Ancho disponible: el globo se corre para no salirse. */\n containerWidth: number;\n style?: StyleProp<ViewStyle>;\n testID?: string;\n}\n\nconst TOOLTIP_WIDTH = 148;\nconst TOOLTIP_GAP = 10;\n/** Alto estimado del globo: el real no se sabe hasta después de medirlo, y\n * medirlo para decidir dónde ponerlo lo haría aparecer corrido un frame. */\nconst TOOLTIP_TITLE = 22;\nconst TOOLTIP_ROW = 17;\n\nexport const Tooltip = memo(({ title, rows, anchor, containerWidth, style, testID }: TooltipProps) => {\n const styles = useStyles();\n const allowFontScaling = useAllowFontScaling();\n // Clamp en vez de flip: moverlo al otro lado del dedo hace que salte de lado\n // mientras se arrastra, y el salto se lee como un error.\n const left = Math.max(0, Math.min(containerWidth - TOOLTIP_WIDTH, anchor.x - TOOLTIP_WIDTH / 2));\n // Va arriba del punto, salvo que no haya lugar: ahí baja, porque taparse\n // contra el borde superior lo deja ilegible.\n const estimated = TOOLTIP_TITLE + rows.length * TOOLTIP_ROW;\n const above = anchor.y - TOOLTIP_GAP - estimated;\n const top = above >= 0 ? above : anchor.y + TOOLTIP_GAP;\n\n return (\n <View\n testID={testID}\n pointerEvents=\"none\"\n accessibilityRole=\"alert\"\n style={[styles.tooltip, { left, width: TOOLTIP_WIDTH, top }, style]}\n >\n <Text allowFontScaling={allowFontScaling} style={styles.tooltipTitle}>\n {title}\n </Text>\n {rows.map((row) => (\n <View key={row.key} style={styles.tooltipRow}>\n <View style={[styles.swatch, { backgroundColor: row.color }]} />\n <Text allowFontScaling={allowFontScaling} numberOfLines={1} style={styles.tooltipLabel}>\n {row.label}\n </Text>\n <Text allowFontScaling={allowFontScaling} style={styles.tooltipValue}>\n {row.value}\n </Text>\n </View>\n ))}\n </View>\n );\n});\nTooltip.displayName = 'Tooltip';\n"
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"path": "organisms/chart/palette.ts",
|
|
28
|
+
"content": "/**\n * Paleta categórica de Chart, derivada del theme.\n *\n * No hay una lista de colores acá a propósito: si la hubiera, un `brand`\n * personalizado daría un chart que no se parece al resto de la app, y el modo\n * oscuro tendría que mantenerse a mano. Las primeras series reusan los acentos\n * del theme (que ya vienen con el contraste resuelto para su scheme) y, cuando\n * se acaban, se sigue rotando el tono del primario por el ángulo áureo: la\n * secuencia 137.5° es la que más tarda en repetir un tono parecido, que es\n * exactamente lo que se le pide a una paleta categórica.\n *\n * Cada color se verifica contra `surface` a 3:1 — el mínimo de WCAG 1.4.11\n * para objetos gráficos, que es lo que es una barra — y se aclara u oscurece\n * hasta cumplirlo.\n */\nimport { contrast, toHex, toHsl } from '../../theme/colors';\nimport type { Theme } from '../../theme/types';\n\n/** Contraste mínimo de un objeto gráfico contra su fondo (WCAG 1.4.11). */\nexport const GRAPHIC_CONTRAST = 3;\n\nconst GOLDEN_ANGLE = 137.508;\n\n/** Acentos del theme en orden de aparición: tonos bien separados entre sí. */\nfunction seeds(theme: Theme): string[] {\n const { primary, secondary, warning, success, error } = theme.colors;\n return [primary, secondary, warning, success, error];\n}\n\n/**\n * Empuja la luminosidad hasta despegar del fondo. El paso es chico y el tope\n * bajo porque el objetivo es corregir un color, no inventar otro: si en 12\n * intentos no llegó, se devuelve lo mejor que se consiguió.\n */\nfunction readable(hex: string, surface: string): string {\n if (contrast(hex, surface) >= GRAPHIC_CONTRAST) return hex;\n\n const hsl = toHsl(hex);\n // Sobre un fondo claro hay que oscurecer, sobre uno oscuro aclarar.\n const direction = toHsl(surface).l > 0.5 ? -1 : 1;\n let best = hex;\n let bestContrast = contrast(hex, surface);\n\n for (let step = 1; step <= 12; step += 1) {\n const candidate = toHex({ ...hsl, l: Math.min(0.95, Math.max(0.08, hsl.l + direction * step * 0.05)) });\n const score = contrast(candidate, surface);\n if (score > bestContrast) {\n best = candidate;\n bestContrast = score;\n }\n if (score >= GRAPHIC_CONTRAST) return candidate;\n }\n return best;\n}\n\nfunction buildUnsafe(theme: Theme, count: number): string[] {\n const base = seeds(theme);\n const surface = theme.colors.surface;\n const out: string[] = [];\n\n for (let index = 0; index < count; index += 1) {\n if (index < base.length) {\n out.push(readable(base[index]!, surface));\n continue;\n }\n // Pasados los acentos, se gira el tono del primario. `l` alterna un\n // escalón por vuelta para que dos tonos vecinos no se confundan si la\n // rotación los acerca.\n const seed = toHsl(base[0]!);\n const turn = index - base.length + 1;\n out.push(\n readable(\n toHex({\n h: (seed.h + turn * GOLDEN_ANGLE) % 360,\n s: Math.max(0.35, seed.s),\n l: Math.min(0.78, Math.max(0.28, seed.l + (turn % 2 === 0 ? 0.08 : -0.04))),\n }),\n surface\n )\n );\n }\n return out;\n}\n\n/**\n * Los helpers de color hablan hex. Un theme con un acento en `rgba()` es\n * legal para React Native y hace que `toHsl` tire: en ese caso se cae a los\n * acentos tal cual vinieron, ciclando. Un chart con colores repetidos es peor\n * que uno con colores derivados, pero muchísimo mejor que uno que no renderiza.\n */\nfunction build(theme: Theme, count: number): string[] {\n try {\n return buildUnsafe(theme, count);\n } catch {\n const base = seeds(theme);\n return Array.from({ length: count }, (_, index) => base[index % base.length]!);\n }\n}\n\n// Una paleta por theme, y por theme el arreglo más largo pedido hasta ahora:\n// la cache se comparte entre todos los charts montados, que es el caso normal\n// (un dashboard con seis charts sobre el mismo theme).\nconst cache = new WeakMap<Theme, string[]>();\n\n/** `count` colores estables para las series: el mismo índice da siempre el mismo color. */\nexport function seriesPalette(theme: Theme, count: number): string[] {\n const cached = cache.get(theme);\n if (cached && cached.length >= count) return cached.slice(0, count);\n const built = build(theme, Math.max(count, 8));\n cache.set(theme, built);\n return built.slice(0, count);\n}\n"
|
|
29
|
+
}
|
|
30
|
+
],
|
|
31
|
+
"registryDependencies": [
|
|
32
|
+
"core",
|
|
33
|
+
"segmented-control"
|
|
34
|
+
]
|
|
35
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "Gradient",
|
|
3
|
+
"slug": "gradient",
|
|
4
|
+
"type": "component",
|
|
5
|
+
"category": "atoms",
|
|
6
|
+
"exportedNames": [
|
|
7
|
+
"Gradient"
|
|
8
|
+
],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "atoms/Gradient.tsx",
|
|
12
|
+
"content": "import React, { memo, useMemo } from 'react';\nimport { StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';\n\nexport type GradientDirection = 'vertical' | 'horizontal' | 'diagonal' | 'diagonal-reverse';\n\nexport interface GradientProps {\n /** Paradas del degradado. Uno solo pinta plano; ninguno no pinta nada. */\n colors: string[];\n /**\n * 'vertical' va de arriba a abajo, 'horizontal' de izquierda a derecha,\n * 'diagonal' de arriba-izquierda a abajo-derecha y 'diagonal-reverse' de\n * arriba-derecha a abajo-izquierda.\n * @default 'vertical'\n */\n direction?: GradientDirection;\n /**\n * Bandas por tramo (un tramo = un par de colores consecutivos). Más bandas\n * = transición más suave y más vistas en el árbol.\n * @default 24\n */\n steps?: number;\n /** Contenido por encima del degradado. */\n children?: React.ReactNode;\n style?: StyleProp<ViewStyle>;\n testID?: string;\n}\n\nconst DEFAULT_STEPS = 24;\n\nconst styles = StyleSheet.create({\n // Recorta el plano rotado de las diagonales y el degradado al borderRadius.\n root: { overflow: 'hidden' },\n segment: { flex: 1 },\n band: { flex: 1 },\n // Plano √2 centrado: rotado 45° cubre las esquinas sin medir el contenedor.\n diagonalPlane: { position: 'absolute', top: '-21%', left: '-21%', width: '142%', height: '142%' },\n});\n\n/**\n * Gradient\n * Degradado lineal sin dependencias: React Native no trae uno en el rango\n * soportado (Expo SDK 54–57) y `experimental_backgroundImage` no está en toda\n * esa ventana, así que se compone con bandas.\n *\n * Cada tramo pinta el color inicial y encima N bandas del final con opacidad\n * de 0 a 1. El compositor mezcla alpha linealmente: el resultado es la\n * interpolación sRGB entre paradas sin parsear el color, y por eso vale\n * cualquier formato que acepte RN (hex, `rgba()`, nombre).\n */\nexport const Gradient = memo(\n ({ colors, direction = 'vertical', steps = DEFAULT_STEPS, children, style, testID }: GradientProps) => {\n const isRow = direction === 'horizontal';\n const isDiagonal = direction === 'diagonal' || direction === 'diagonal-reverse';\n // rotate positivo gira en horario (Y crece hacia abajo): -45° manda el eje\n // del degradado hacia abajo-derecha.\n const rotate = direction === 'diagonal' ? '-45deg' : '45deg';\n const bandCount = Math.max(2, Math.floor(steps));\n\n const painted = useMemo(() => {\n if (colors.length === 0) return null;\n if (colors.length === 1) {\n return <View style={[StyleSheet.absoluteFill, { backgroundColor: colors[0] }]} />;\n }\n\n const segments = colors.slice(0, -1).map((from, index) => {\n const to = colors[index + 1] as string;\n return (\n <View\n key={`${from}-${to}-${index}`}\n style={[styles.segment, { backgroundColor: from, flexDirection: isRow ? 'row' : 'column' }]}\n >\n {Array.from({ length: bandCount }, (_, band) => (\n <View key={band} style={[styles.band, { backgroundColor: to, opacity: band / (bandCount - 1) }]} />\n ))}\n </View>\n );\n });\n\n const stack = (\n <View style={[StyleSheet.absoluteFill, { flexDirection: isRow ? 'row' : 'column' }]}>{segments}</View>\n );\n\n if (!isDiagonal) return stack;\n return <View style={[styles.diagonalPlane, { transform: [{ rotate }] }]}>{segments}</View>;\n }, [colors, isRow, isDiagonal, rotate, bandCount]);\n\n return (\n <View testID={testID} style={[styles.root, style]}>\n {/* pointerEvents va en el pintado, no en la raíz: el degradado no debe\n robarle los toques a lo que el consumidor ponga encima. */}\n <View style={StyleSheet.absoluteFill} pointerEvents=\"none\">\n {painted}\n </View>\n {children}\n </View>\n );\n }\n);\nGradient.displayName = 'Gradient';\n"
|
|
13
|
+
}
|
|
14
|
+
],
|
|
15
|
+
"registryDependencies": [
|
|
16
|
+
"core"
|
|
17
|
+
]
|
|
18
|
+
}
|
package/registry/index.json
CHANGED
|
@@ -103,6 +103,18 @@
|
|
|
103
103
|
],
|
|
104
104
|
"bytes": 489
|
|
105
105
|
},
|
|
106
|
+
{
|
|
107
|
+
"name": "Gradient",
|
|
108
|
+
"slug": "gradient",
|
|
109
|
+
"category": "atoms",
|
|
110
|
+
"exportedNames": [
|
|
111
|
+
"Gradient"
|
|
112
|
+
],
|
|
113
|
+
"registryDependencies": [
|
|
114
|
+
"core"
|
|
115
|
+
],
|
|
116
|
+
"bytes": 3852
|
|
117
|
+
},
|
|
106
118
|
{
|
|
107
119
|
"name": "Avatar",
|
|
108
120
|
"slug": "avatar",
|
|
@@ -166,6 +178,19 @@
|
|
|
166
178
|
],
|
|
167
179
|
"bytes": 6569
|
|
168
180
|
},
|
|
181
|
+
{
|
|
182
|
+
"name": "ShinyText",
|
|
183
|
+
"slug": "shiny-text",
|
|
184
|
+
"category": "atoms",
|
|
185
|
+
"exportedNames": [
|
|
186
|
+
"ShinyText"
|
|
187
|
+
],
|
|
188
|
+
"registryDependencies": [
|
|
189
|
+
"core",
|
|
190
|
+
"transition"
|
|
191
|
+
],
|
|
192
|
+
"bytes": 7848
|
|
193
|
+
},
|
|
169
194
|
{
|
|
170
195
|
"name": "EmptyState",
|
|
171
196
|
"slug": "empty-state",
|
|
@@ -315,6 +340,20 @@
|
|
|
315
340
|
],
|
|
316
341
|
"bytes": 2773
|
|
317
342
|
},
|
|
343
|
+
{
|
|
344
|
+
"name": "Timeline",
|
|
345
|
+
"slug": "timeline",
|
|
346
|
+
"category": "organisms",
|
|
347
|
+
"exportedNames": [
|
|
348
|
+
"Timeline"
|
|
349
|
+
],
|
|
350
|
+
"registryDependencies": [
|
|
351
|
+
"core",
|
|
352
|
+
"pressable-scale",
|
|
353
|
+
"transition"
|
|
354
|
+
],
|
|
355
|
+
"bytes": 17449
|
|
356
|
+
},
|
|
318
357
|
{
|
|
319
358
|
"name": "Modal",
|
|
320
359
|
"slug": "modal",
|
|
@@ -347,6 +386,19 @@
|
|
|
347
386
|
],
|
|
348
387
|
"bytes": 9678
|
|
349
388
|
},
|
|
389
|
+
{
|
|
390
|
+
"name": "OptionWheel",
|
|
391
|
+
"slug": "option-wheel",
|
|
392
|
+
"category": "organisms",
|
|
393
|
+
"exportedNames": [
|
|
394
|
+
"OptionWheel"
|
|
395
|
+
],
|
|
396
|
+
"registryDependencies": [
|
|
397
|
+
"core",
|
|
398
|
+
"transition"
|
|
399
|
+
],
|
|
400
|
+
"bytes": 18339
|
|
401
|
+
},
|
|
350
402
|
{
|
|
351
403
|
"name": "Select",
|
|
352
404
|
"slug": "select",
|
|
@@ -553,6 +605,33 @@
|
|
|
553
605
|
"core"
|
|
554
606
|
],
|
|
555
607
|
"bytes": 7324
|
|
608
|
+
},
|
|
609
|
+
{
|
|
610
|
+
"name": "Slides",
|
|
611
|
+
"slug": "slides",
|
|
612
|
+
"category": "organisms",
|
|
613
|
+
"exportedNames": [
|
|
614
|
+
"Slides"
|
|
615
|
+
],
|
|
616
|
+
"registryDependencies": [
|
|
617
|
+
"core",
|
|
618
|
+
"transition",
|
|
619
|
+
"gradient"
|
|
620
|
+
],
|
|
621
|
+
"bytes": 21434
|
|
622
|
+
},
|
|
623
|
+
{
|
|
624
|
+
"name": "Chart",
|
|
625
|
+
"slug": "chart",
|
|
626
|
+
"category": "organisms",
|
|
627
|
+
"exportedNames": [
|
|
628
|
+
"Chart"
|
|
629
|
+
],
|
|
630
|
+
"registryDependencies": [
|
|
631
|
+
"core",
|
|
632
|
+
"segmented-control"
|
|
633
|
+
],
|
|
634
|
+
"bytes": 90641
|
|
556
635
|
}
|
|
557
636
|
]
|
|
558
637
|
}
|
package/registry/manifest.json
CHANGED
|
@@ -60,6 +60,13 @@
|
|
|
60
60
|
"file": "atoms/Divider.tsx",
|
|
61
61
|
"slug": "divider"
|
|
62
62
|
},
|
|
63
|
+
{
|
|
64
|
+
"name": "Gradient",
|
|
65
|
+
"siblingNames": [],
|
|
66
|
+
"category": "atoms",
|
|
67
|
+
"file": "atoms/Gradient.tsx",
|
|
68
|
+
"slug": "gradient"
|
|
69
|
+
},
|
|
63
70
|
{
|
|
64
71
|
"name": "Avatar",
|
|
65
72
|
"siblingNames": [],
|
|
@@ -99,6 +106,13 @@
|
|
|
99
106
|
"file": "atoms/Transition.tsx",
|
|
100
107
|
"slug": "transition"
|
|
101
108
|
},
|
|
109
|
+
{
|
|
110
|
+
"name": "ShinyText",
|
|
111
|
+
"siblingNames": [],
|
|
112
|
+
"category": "atoms",
|
|
113
|
+
"file": "atoms/ShinyText.tsx",
|
|
114
|
+
"slug": "shiny-text"
|
|
115
|
+
},
|
|
102
116
|
{
|
|
103
117
|
"name": "EmptyState",
|
|
104
118
|
"siblingNames": [],
|
|
@@ -185,6 +199,13 @@
|
|
|
185
199
|
"file": "molecules/SymmetricGrid.tsx",
|
|
186
200
|
"slug": "symmetric-grid"
|
|
187
201
|
},
|
|
202
|
+
{
|
|
203
|
+
"name": "Timeline",
|
|
204
|
+
"siblingNames": [],
|
|
205
|
+
"category": "organisms",
|
|
206
|
+
"file": "organisms/Timeline.tsx",
|
|
207
|
+
"slug": "timeline"
|
|
208
|
+
},
|
|
188
209
|
{
|
|
189
210
|
"name": "Modal",
|
|
190
211
|
"siblingNames": [],
|
|
@@ -199,6 +220,13 @@
|
|
|
199
220
|
"file": "organisms/BottomSheet.tsx",
|
|
200
221
|
"slug": "bottom-sheet"
|
|
201
222
|
},
|
|
223
|
+
{
|
|
224
|
+
"name": "OptionWheel",
|
|
225
|
+
"siblingNames": [],
|
|
226
|
+
"category": "organisms",
|
|
227
|
+
"file": "organisms/OptionWheel.tsx",
|
|
228
|
+
"slug": "option-wheel"
|
|
229
|
+
},
|
|
202
230
|
{
|
|
203
231
|
"name": "Select",
|
|
204
232
|
"siblingNames": [],
|
|
@@ -310,6 +338,20 @@
|
|
|
310
338
|
"category": "organisms",
|
|
311
339
|
"file": "organisms/ReorderableList.tsx",
|
|
312
340
|
"slug": "reorderable-list"
|
|
341
|
+
},
|
|
342
|
+
{
|
|
343
|
+
"name": "Slides",
|
|
344
|
+
"siblingNames": [],
|
|
345
|
+
"category": "organisms",
|
|
346
|
+
"file": "organisms/Slides.tsx",
|
|
347
|
+
"slug": "slides"
|
|
348
|
+
},
|
|
349
|
+
{
|
|
350
|
+
"name": "Chart",
|
|
351
|
+
"siblingNames": [],
|
|
352
|
+
"category": "organisms",
|
|
353
|
+
"file": "organisms/Chart.tsx",
|
|
354
|
+
"slug": "chart"
|
|
313
355
|
}
|
|
314
356
|
]
|
|
315
357
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "OptionWheel",
|
|
3
|
+
"slug": "option-wheel",
|
|
4
|
+
"type": "component",
|
|
5
|
+
"category": "organisms",
|
|
6
|
+
"exportedNames": [
|
|
7
|
+
"OptionWheel"
|
|
8
|
+
],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "organisms/OptionWheel.tsx",
|
|
12
|
+
"content": "import React, { useCallback, useEffect, useMemo, useRef } from 'react';\nimport {\n Animated,\n ScrollView,\n Text,\n View,\n type LayoutChangeEvent,\n type NativeScrollEvent,\n type NativeSyntheticEvent,\n type StyleProp,\n type ViewStyle,\n} from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useTheme } from '../theme/UIProvider';\nimport { useReduceMotion } from '../atoms/Transition';\n\nexport interface OptionWheelOption<T> {\n label: string;\n value: T;\n /** The wheel refuses to settle here and slides to the nearest enabled row. */\n disabled?: boolean;\n}\n\nexport type OptionWheelVariant = 'window' | 'spotlight';\n\nexport interface OptionWheelProps<T> {\n options: OptionWheelOption<T>[];\n selectedValue: T | undefined;\n onSelect: (value: T) => void;\n /** Title above the wheel. */\n label?: string;\n /** Required when there's no `label`: the wheel as a whole needs a name. */\n accessibilityLabel?: string;\n /**\n * Rows on screen. Forced to the next odd number so one of them is the\n * centre. @default 5\n */\n visibleCount?: number;\n /** Height of each row. @default 44 */\n itemHeight?: number;\n /** Fixed text to the right of the centre row ('kg', 'min'). */\n unit?: string;\n /** Tilts the far rows to suggest a cylinder. @default true */\n perspective?: boolean;\n /**\n * 'window' marks the pick with a band behind the centre row. 'spotlight'\n * drops the band and lets the far rows fall away and blur instead, which\n * needs a quiet surface under it to read.\n * @default 'window'\n */\n variant?: OptionWheelVariant;\n /**\n * Colour of the option labels. The surface the wheel sits on is whoever\n * composes it to paint — this is how the text keeps contrast against it.\n * @default theme.colors.text\n */\n textColor?: string;\n /**\n * Radius, in pixels, of the wheel the rows sit on. Its centre is off to the\n * side, so the rows bow inwards as they get further from the middle and tilt\n * with the tangent — the wheel reads as a rim seen edge-on instead of a flat\n * list. Smaller radius, rounder arc. Undefined leaves the rows flat.\n */\n curveRadius?: number;\n /** Which side the wheel's centre is on. @default 'left' */\n curveFrom?: 'left' | 'right';\n disabled?: boolean;\n style?: StyleProp<ViewStyle>;\n testID?: string;\n}\n\nconst DEFAULT_VISIBLE_COUNT = 5;\n/** 44pt: el mínimo táctil de iOS, y alcanza para una línea `lg` con aire. */\nconst DEFAULT_ITEM_HEIGHT = 44;\n/**\n * Filas hacia cada lado que la interpolación distingue; más lejos, todas\n * iguales (`extrapolate: 'clamp'`). Los tres rangos son simétricos respecto\n * del centro: la fila de arriba y la de abajo se ven igual, sólo que la de\n * arriba se inclina hacia atrás y la de abajo hacia adelante.\n */\nconst FALLOFF = 2;\n\n/**\n * Cada variante es un juego de rangos. 'spotlight' cae mucho más rápido y se\n * inclina más porque no tiene banda que señale el centro: todo el trabajo de\n * decir cuál está elegida lo hacen el contraste y la nitidez.\n */\nconst RANGES: Record<OptionWheelVariant, { opacity: number[]; scale: number[]; rotation: string[] }> = {\n window: {\n opacity: [0.22, 0.55, 1, 0.55, 0.22],\n scale: [0.8, 0.9, 1, 0.9, 0.8],\n rotation: ['60deg', '32deg', '0deg', '-32deg', '-60deg'],\n },\n spotlight: {\n opacity: [0.16, 0.42, 1, 0.42, 0.16],\n scale: [0.72, 0.87, 1, 0.87, 0.72],\n rotation: ['72deg', '40deg', '0deg', '-40deg', '-72deg'],\n },\n};\n\n/**\n * React Native no tiene desenfoque sin dependencia nativa, así que en\n * 'spotlight' cada fila que no está en el centro se dibuja además dos veces\n * corrida y a media tinta. La superposición emborrona el trazo: no es un blur\n * gaussiano, pero a ese tamaño y con esa opacidad lee igual, y sigue siendo\n * `opacity` y `transform` sobre el mismo scroll, o sea el hilo nativo.\n */\n/**\n * Dónde cae cada punto del `inputRange` sobre el arco. `Animated` no sabe\n * calcular raíces ni arcosenos, pero no hace falta: las cinco distancias al\n * centro son fijas por fila, así que la flecha del arco y el ángulo de la\n * tangente se resuelven una vez acá y entran como `outputRange`.\n */\nfunction arcOffsets(radius: number, itemHeight: number, sign: number) {\n const shift: number[] = [];\n const angle: string[] = [];\n for (let step = -FALLOFF; step <= FALLOFF; step += 1) {\n // Más allá del radio la fila ya dio la vuelta: se corta ahí en vez de\n // sacarle la raíz a un negativo.\n const dy = Math.min(Math.abs(step * itemHeight), radius);\n // Flecha del arco: cuánto se mete la fila hacia el centro de la rueda.\n shift.push(sign * (radius - Math.sqrt(radius * radius - dy * dy)));\n // La tangente del arco, para que la fila siga la curva en vez de cruzarla.\n const degrees = Math.asin(dy / radius) * (180 / Math.PI);\n angle.push(`${-sign * Math.sign(step) * degrees}deg`);\n }\n return { shift, angle };\n}\n\nconst GHOST_SHIFTS = [-1, 1];\nconst GHOST_SPREAD = 3;\nconst GHOST_OPACITY_RANGE = [0.55, 0.3, 0, 0.3, 0.55];\n\nconst useStyles = createStyles((theme) => ({\n root: { gap: theme.tokens.spacing.sm },\n label: {\n fontWeight: theme.tokens.fontWeight.bold,\n fontSize: theme.tokens.fontSize.md,\n color: theme.colors.text,\n },\n viewport: { overflow: 'hidden' },\n disabled: { opacity: 0.5 },\n // La ventana central: figura contra el fondo de opciones que pasan.\n window: {\n position: 'absolute',\n left: 0,\n right: 0,\n borderTopWidth: 1,\n borderBottomWidth: 1,\n borderColor: theme.colors.border,\n backgroundColor: theme.colors.primarySoft,\n },\n row: { justifyContent: 'center', alignItems: 'center' },\n ghost: {\n position: 'absolute',\n top: 0,\n left: 0,\n right: 0,\n bottom: 0,\n justifyContent: 'center',\n alignItems: 'center',\n },\n rowContent: { flexDirection: 'row', alignItems: 'center', gap: theme.tokens.spacing.xs },\n text: { fontSize: theme.tokens.fontSize.lg, color: theme.colors.text },\n textDisabled: { color: theme.colors.textLight },\n unit: { fontSize: theme.tokens.fontSize.sm, color: theme.colors.textLight },\n}));\n\n/** El índice habilitado más cercano a `index`, o -1 si no hay ninguno. */\nfunction nearestEnabled<T>(options: OptionWheelOption<T>[], index: number): number {\n if (options.length === 0) return -1;\n for (let step = 0; step < options.length; step += 1) {\n const before = index - step;\n const after = index + step;\n if (after < options.length && !options[after]?.disabled) return after;\n if (before >= 0 && !options[before]?.disabled) return before;\n }\n return -1;\n}\n\n/**\n * OptionWheel\n * Selector de rueda: las opciones pasan por una ventana fija y la que queda en\n * el centro es la elegida. El scroll hace snap a la altura de fila, así que\n * soltar el dedo en cualquier lado siempre deja una opción centrada y no media.\n *\n * Gestalt, figura y fondo: lo que marca la selección no es un estilo sobre la\n * fila —que viajaría con ella— sino una ventana quieta encima de la lista. La\n * figura se queda, el fondo se mueve, y por eso la rueda se lee sin leer: el\n * lugar señala, no el color. Y destino común: todas las filas se achican y se\n * inclinan con la misma ley respecto del centro, de modo que se perciben como\n * una superficie rígida que gira y no como N filas animándose por su cuenta.\n *\n * La escala, la opacidad y la inclinación salen de interpolar el\n * `contentOffset` del scroll, no del estado de React: se calculan en el hilo\n * nativo (`useNativeDriver`) y el hilo de JS sólo participa cuando la rueda ya\n * frenó, para avisar qué quedó elegido. Con \"reducir movimiento\" activo se\n * apaga la inclinación y queda el tamaño, que es información, no decoración.\n *\n * `disabled` en una opción no puede impedir que el snap la centre, así que la\n * rueda la centra y se corre sola a la habilitada más cercana.\n */\nexport function OptionWheel<T extends string | number>({\n options,\n selectedValue,\n onSelect,\n label,\n accessibilityLabel,\n visibleCount = DEFAULT_VISIBLE_COUNT,\n itemHeight = DEFAULT_ITEM_HEIGHT,\n unit,\n perspective = true,\n variant = 'window',\n textColor,\n curveRadius,\n curveFrom = 'left',\n disabled = false,\n style,\n testID,\n}: OptionWheelProps<T>) {\n const theme = useTheme();\n const styles = useStyles();\n const allowFontScaling = useAllowFontScaling();\n const reduceMotion = useReduceMotion();\n\n const scrollRef = useRef<ScrollView>(null);\n const scrollY = useRef(new Animated.Value(0)).current;\n // El primer posicionamiento no se anima: la rueda tiene que aparecer ya\n // puesta en su valor, no viajando hasta él.\n const settled = useRef(false);\n\n // Impar siempre: con un número par no hay fila central que alinear con la\n // ventana, y el snap dejaría la selección entre dos filas.\n const rows = visibleCount % 2 === 0 ? visibleCount + 1 : visibleCount;\n const padding = ((rows - 1) / 2) * itemHeight;\n const height = rows * itemHeight;\n\n const selectedIndex = useMemo(\n () => options.findIndex((option) => option.value === selectedValue),\n [options, selectedValue]\n );\n\n const scrollToIndex = useCallback(\n (index: number, animated: boolean) => {\n const y = index * itemHeight;\n scrollRef.current?.scrollTo({ y, animated });\n // `scrollY` sólo lo alimenta `onScroll`, y un salto sin animar no\n // siempre lo emite: sin esto el valor se queda en 0, la interpolación\n // cree que la fila centrada es la primera y apaga la que de verdad está\n // en la ventana. El scroll animado sí emite en todo el recorrido, así\n // que ahí forzarlo pelearía con la animación.\n if (!animated) scrollY.setValue(y);\n },\n [itemHeight, scrollY]\n );\n\n useEffect(() => {\n if (selectedIndex < 0) return;\n scrollToIndex(selectedIndex, settled.current && !reduceMotion);\n settled.current = true;\n }, [selectedIndex, scrollToIndex, reduceMotion]);\n\n /**\n * Corre tanto al final del arrastre como al final de la inercia: en Android\n * un arrastre corto sin impulso no dispara `onMomentumScrollEnd`, y la rueda\n * se quedaría con la selección vieja. Reentrar con el mismo índice es inocuo\n * porque sólo avisa cuando cambia.\n */\n const commit = useCallback(\n (event: NativeSyntheticEvent<NativeScrollEvent>) => {\n const raw = Math.round(event.nativeEvent.contentOffset.y / itemHeight);\n const clamped = Math.min(Math.max(raw, 0), options.length - 1);\n const landed = options[clamped];\n if (!landed) return;\n const target = landed.disabled ? nearestEnabled(options, clamped) : clamped;\n const option = options[target];\n // Sin opciones habilitadas no hay dónde parar: la rueda se queda donde\n // la dejaron y no avisa nada.\n if (!option) return;\n if (target !== clamped) scrollToIndex(target, true);\n if (option.value !== selectedValue) onSelect(option.value);\n },\n [options, itemHeight, selectedValue, onSelect, scrollToIndex]\n );\n\n /** VoiceOver/TalkBack recorren la rueda con increment/decrement, no con el dedo. */\n const step = useCallback(\n (delta: number) => {\n if (disabled || selectedIndex < 0) return;\n const next = nearestEnabled(options, selectedIndex + delta);\n const option = options[next];\n // En el borde, `nearestEnabled` devuelve la fila donde ya estamos.\n if (!option || next === selectedIndex) return;\n onSelect(option.value);\n },\n [disabled, options, selectedIndex, onSelect]\n );\n\n const onLayout = useCallback(\n (_: LayoutChangeEvent) => {\n if (selectedIndex >= 0) scrollToIndex(selectedIndex, false);\n },\n [selectedIndex, scrollToIndex]\n );\n\n const selected = selectedIndex >= 0 ? options[selectedIndex] : undefined;\n const ranges = RANGES[variant];\n // El desenfoque simulado triplica los nodos de texto: sólo va donde aporta,\n // y con \"reducir movimiento\" no va, porque ahí la nitidez es lo único que\n // queda para distinguir la fila elegida.\n const smeared = variant === 'spotlight' && !reduceMotion;\n const arc = useMemo(\n () =>\n curveRadius && curveRadius > 0 && !reduceMotion\n ? arcOffsets(curveRadius, itemHeight, curveFrom === 'left' ? -1 : 1)\n : undefined,\n [curveRadius, itemHeight, curveFrom, reduceMotion]\n );\n\n return (\n <View style={[styles.root, disabled && styles.disabled, style]} testID={testID}>\n {!!label && (\n <Text allowFontScaling={allowFontScaling} style={styles.label}>\n {label}\n </Text>\n )}\n\n <View\n accessible\n accessibilityRole=\"adjustable\"\n accessibilityLabel={label ?? accessibilityLabel}\n accessibilityValue={{ text: selected?.label }}\n accessibilityState={{ disabled }}\n accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]}\n onAccessibilityAction={(event) => step(event.nativeEvent.actionName === 'increment' ? 1 : -1)}\n style={[styles.viewport, { height }]}\n testID={testID && `${testID}-viewport`}\n >\n {variant === 'window' && (\n <View\n pointerEvents=\"none\"\n style={[styles.window, { top: padding, height: itemHeight, borderRadius: theme.tokens.radius.sm }]}\n />\n )}\n\n <Animated.ScrollView\n ref={scrollRef as never}\n onLayout={onLayout}\n scrollEnabled={!disabled}\n showsVerticalScrollIndicator={false}\n snapToInterval={itemHeight}\n decelerationRate=\"fast\"\n scrollEventThrottle={16}\n contentContainerStyle={{ paddingVertical: padding }}\n onScroll={Animated.event([{ nativeEvent: { contentOffset: { y: scrollY } } }], {\n useNativeDriver: true,\n })}\n onScrollEndDrag={commit}\n onMomentumScrollEnd={commit}\n testID={testID && `${testID}-scroll`}\n >\n {options.map((option, index) => {\n const centre = index * itemHeight;\n const inputRange = [\n centre - FALLOFF * itemHeight,\n centre - itemHeight,\n centre,\n centre + itemHeight,\n centre + FALLOFF * itemHeight,\n ];\n type Interpolated = Animated.AnimatedInterpolation<string | number>;\n const transform: Array<\n | { perspective: number }\n | { scale: Interpolated }\n | { rotateX: Interpolated }\n | { rotateZ: Interpolated }\n | { translateX: Interpolated }\n > = [\n { scale: scrollY.interpolate({ inputRange, outputRange: ranges.scale, extrapolate: 'clamp' }) },\n ];\n if (perspective && !reduceMotion) {\n // El perspective va primero: aplicado después de la rotación no\n // deforma nada y la fila se ve plana aunque esté rotada.\n transform.unshift({ perspective: height * 2 });\n transform.push({\n rotateX: scrollY.interpolate({ inputRange, outputRange: ranges.rotation, extrapolate: 'clamp' }),\n });\n }\n\n if (arc) {\n transform.push(\n { translateX: scrollY.interpolate({ inputRange, outputRange: arc.shift, extrapolate: 'clamp' }) },\n { rotateZ: scrollY.interpolate({ inputRange, outputRange: arc.angle, extrapolate: 'clamp' }) }\n );\n }\n\n const label = (\n <Text\n allowFontScaling={allowFontScaling}\n style={[styles.text, option.disabled && styles.textDisabled, !!textColor && { color: textColor }]}\n >\n {option.label}\n </Text>\n );\n\n return (\n <Animated.View\n key={String(option.value)}\n testID={testID && `${testID}-item-${index}`}\n style={[\n styles.row,\n {\n height: itemHeight,\n opacity: scrollY.interpolate({\n inputRange,\n outputRange: ranges.opacity,\n extrapolate: 'clamp',\n }),\n transform,\n },\n ]}\n >\n {smeared &&\n GHOST_SHIFTS.map((direction) => (\n <Animated.View\n key={direction}\n pointerEvents=\"none\"\n // `accessibilityElementsHidden` no alcanza: el lector\n // leería la fila tres veces igual, porque la rueda entera\n // ya es un solo elemento y esto vive adentro.\n style={[\n styles.ghost,\n {\n opacity: scrollY.interpolate({\n inputRange,\n outputRange: GHOST_OPACITY_RANGE,\n extrapolate: 'clamp',\n }),\n transform: [\n {\n translateX: scrollY.interpolate({\n inputRange,\n outputRange: [\n direction * GHOST_SPREAD,\n direction * (GHOST_SPREAD / 2),\n 0,\n direction * (GHOST_SPREAD / 2),\n direction * GHOST_SPREAD,\n ],\n extrapolate: 'clamp',\n }),\n },\n ],\n },\n ]}\n >\n {label}\n </Animated.View>\n ))}\n\n <View style={styles.rowContent}>\n {label}\n {!!unit && index === selectedIndex && (\n <Text allowFontScaling={allowFontScaling} style={styles.unit}>\n {unit}\n </Text>\n )}\n </View>\n </Animated.View>\n );\n })}\n </Animated.ScrollView>\n </View>\n </View>\n );\n}\n"
|
|
13
|
+
}
|
|
14
|
+
],
|
|
15
|
+
"registryDependencies": [
|
|
16
|
+
"core",
|
|
17
|
+
"transition"
|
|
18
|
+
]
|
|
19
|
+
}
|