orn-ui 0.2.10 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (552) hide show
  1. package/AGENTS.md +66 -51
  2. package/README.md +3 -7
  3. package/lib/commonjs/atoms/Avatar.js +77 -5
  4. package/lib/commonjs/atoms/Avatar.js.map +1 -1
  5. package/lib/commonjs/atoms/Badge.js +17 -8
  6. package/lib/commonjs/atoms/Badge.js.map +1 -1
  7. package/lib/commonjs/atoms/Button.js +108 -39
  8. package/lib/commonjs/atoms/Button.js.map +1 -1
  9. package/lib/commonjs/atoms/Card.js +24 -2
  10. package/lib/commonjs/atoms/Card.js.map +1 -1
  11. package/lib/commonjs/atoms/Checkbox.js +41 -10
  12. package/lib/commonjs/atoms/Checkbox.js.map +1 -1
  13. package/lib/commonjs/atoms/Divider.js +30 -5
  14. package/lib/commonjs/atoms/Divider.js.map +1 -1
  15. package/lib/commonjs/atoms/EmptyState.js +58 -20
  16. package/lib/commonjs/atoms/EmptyState.js.map +1 -1
  17. package/lib/commonjs/atoms/Fab.js +32 -12
  18. package/lib/commonjs/atoms/Fab.js.map +1 -1
  19. package/lib/commonjs/atoms/Gradient.js +102 -36
  20. package/lib/commonjs/atoms/Gradient.js.map +1 -1
  21. package/lib/commonjs/atoms/IconButton.js +25 -4
  22. package/lib/commonjs/atoms/IconButton.js.map +1 -1
  23. package/lib/commonjs/atoms/Image.js +61 -25
  24. package/lib/commonjs/atoms/Image.js.map +1 -1
  25. package/lib/commonjs/atoms/Input.js +105 -17
  26. package/lib/commonjs/atoms/Input.js.map +1 -1
  27. package/lib/commonjs/atoms/KeyValueRow.js +37 -9
  28. package/lib/commonjs/atoms/KeyValueRow.js.map +1 -1
  29. package/lib/commonjs/atoms/PressableScale.js +25 -5
  30. package/lib/commonjs/atoms/PressableScale.js.map +1 -1
  31. package/lib/commonjs/atoms/ShinyText.js +29 -29
  32. package/lib/commonjs/atoms/Skeleton.js +28 -13
  33. package/lib/commonjs/atoms/Skeleton.js.map +1 -1
  34. package/lib/commonjs/atoms/Spinner.js +81 -22
  35. package/lib/commonjs/atoms/Spinner.js.map +1 -1
  36. package/lib/commonjs/atoms/Text.js +56 -5
  37. package/lib/commonjs/atoms/Text.js.map +1 -1
  38. package/lib/commonjs/atoms/Transition.js +46 -43
  39. package/lib/commonjs/atoms/Transition.js.map +1 -1
  40. package/lib/commonjs/atoms/index.js +6 -0
  41. package/lib/commonjs/atoms/index.js.map +1 -1
  42. package/lib/commonjs/atoms/inputValidation.js +56 -0
  43. package/lib/commonjs/atoms/inputValidation.js.map +1 -0
  44. package/lib/commonjs/icons/Icon.js +4 -4
  45. package/lib/commonjs/icons/defaultIcons.js +576 -24
  46. package/lib/commonjs/icons/defaultIcons.js.map +1 -1
  47. package/lib/commonjs/icons/index.js +6 -0
  48. package/lib/commonjs/icons/index.js.map +1 -1
  49. package/lib/commonjs/molecules/AvatarHeader.js +47 -14
  50. package/lib/commonjs/molecules/AvatarHeader.js.map +1 -1
  51. package/lib/commonjs/molecules/FormActions.js +25 -9
  52. package/lib/commonjs/molecules/FormActions.js.map +1 -1
  53. package/lib/commonjs/molecules/InfoRow.js +30 -14
  54. package/lib/commonjs/molecules/InfoRow.js.map +1 -1
  55. package/lib/commonjs/molecules/OptionCard.js +37 -22
  56. package/lib/commonjs/molecules/OptionCard.js.map +1 -1
  57. package/lib/commonjs/molecules/SegmentedControl.js +103 -14
  58. package/lib/commonjs/molecules/SegmentedControl.js.map +1 -1
  59. package/lib/commonjs/molecules/Stepper.js +61 -16
  60. package/lib/commonjs/molecules/Stepper.js.map +1 -1
  61. package/lib/commonjs/molecules/Steps.js +105 -41
  62. package/lib/commonjs/molecules/Steps.js.map +1 -1
  63. package/lib/commonjs/molecules/SymmetricGrid.js +26 -18
  64. package/lib/commonjs/molecules/SymmetricGrid.js.map +1 -1
  65. package/lib/commonjs/organisms/Alert.js +49 -35
  66. package/lib/commonjs/organisms/Alert.js.map +1 -1
  67. package/lib/commonjs/organisms/AlertProvider.js +10 -10
  68. package/lib/commonjs/organisms/BottomSheet.js +137 -85
  69. package/lib/commonjs/organisms/BottomSheet.js.map +1 -1
  70. package/lib/commonjs/organisms/Chart.js +174 -154
  71. package/lib/commonjs/organisms/Chart.js.map +1 -1
  72. package/lib/commonjs/organisms/DateField.js +36 -20
  73. package/lib/commonjs/organisms/DateField.js.map +1 -1
  74. package/lib/commonjs/organisms/DatePicker.js +56 -33
  75. package/lib/commonjs/organisms/DatePicker.js.map +1 -1
  76. package/lib/commonjs/organisms/List.js +62 -34
  77. package/lib/commonjs/organisms/List.js.map +1 -1
  78. package/lib/commonjs/organisms/Modal.js +120 -82
  79. package/lib/commonjs/organisms/Modal.js.map +1 -1
  80. package/lib/commonjs/organisms/NavigationBar.js +63 -24
  81. package/lib/commonjs/organisms/NavigationBar.js.map +1 -1
  82. package/lib/commonjs/organisms/OptionWheel.js +64 -64
  83. package/lib/commonjs/organisms/OptionWheel.js.map +1 -1
  84. package/lib/commonjs/organisms/ReorderableList.js +48 -27
  85. package/lib/commonjs/organisms/ReorderableList.js.map +1 -1
  86. package/lib/commonjs/organisms/Screen.js +68 -35
  87. package/lib/commonjs/organisms/Screen.js.map +1 -1
  88. package/lib/commonjs/organisms/SearchList.js +56 -26
  89. package/lib/commonjs/organisms/SearchList.js.map +1 -1
  90. package/lib/commonjs/organisms/Select.js +45 -24
  91. package/lib/commonjs/organisms/Select.js.map +1 -1
  92. package/lib/commonjs/organisms/Slides.js +36 -23
  93. package/lib/commonjs/organisms/Slides.js.map +1 -1
  94. package/lib/commonjs/organisms/ThemeToggle.js +10 -9
  95. package/lib/commonjs/organisms/ThemeToggle.js.map +1 -1
  96. package/lib/commonjs/organisms/Timeline.js +73 -56
  97. package/lib/commonjs/organisms/Timeline.js.map +1 -1
  98. package/lib/commonjs/organisms/Toast.js +46 -15
  99. package/lib/commonjs/organisms/Toast.js.map +1 -1
  100. package/lib/commonjs/organisms/ToastProvider.js +14 -10
  101. package/lib/commonjs/organisms/ToastProvider.js.map +1 -1
  102. package/lib/commonjs/organisms/Wizard.js +40 -15
  103. package/lib/commonjs/organisms/Wizard.js.map +1 -1
  104. package/lib/commonjs/organisms/chart/frame.js +25 -25
  105. package/lib/commonjs/organisms/chart/frame.js.map +1 -1
  106. package/lib/commonjs/organisms/chart/marks.js +42 -42
  107. package/lib/commonjs/organisms/chart/math.js +84 -85
  108. package/lib/commonjs/organisms/chart/math.js.map +1 -1
  109. package/lib/commonjs/organisms/chart/palette.js +28 -28
  110. package/lib/commonjs/organisms/imperativeBridge.js +5 -5
  111. package/lib/commonjs/organisms/imperativeBridge.js.map +1 -1
  112. package/lib/commonjs/organisms/useSlides.js +53 -21
  113. package/lib/commonjs/organisms/useSlides.js.map +1 -1
  114. package/lib/commonjs/safe-area/SafeAreaUIProvider.js +9 -66
  115. package/lib/commonjs/safe-area/SafeAreaUIProvider.js.map +1 -1
  116. package/lib/commonjs/safe-area/index.js.map +1 -1
  117. package/lib/commonjs/theme/KeyboardAvoidance.js +206 -0
  118. package/lib/commonjs/theme/KeyboardAvoidance.js.map +1 -0
  119. package/lib/commonjs/theme/NativeOverlay.js +263 -0
  120. package/lib/commonjs/theme/NativeOverlay.js.map +1 -0
  121. package/lib/commonjs/theme/UIProvider.js +86 -33
  122. package/lib/commonjs/theme/UIProvider.js.map +1 -1
  123. package/lib/commonjs/theme/colors.js +17 -17
  124. package/lib/commonjs/theme/colors.js.map +1 -1
  125. package/lib/commonjs/theme/createStyles.js +19 -16
  126. package/lib/commonjs/theme/createStyles.js.map +1 -1
  127. package/lib/commonjs/theme/createTheme.js +6 -6
  128. package/lib/commonjs/theme/index.js +6 -0
  129. package/lib/commonjs/theme/index.js.map +1 -1
  130. package/lib/commonjs/theme/insets.js +1 -1
  131. package/lib/commonjs/theme/labels.js +29 -1
  132. package/lib/commonjs/theme/labels.js.map +1 -1
  133. package/lib/commonjs/theme/palettes.js +25 -23
  134. package/lib/commonjs/theme/palettes.js.map +1 -1
  135. package/lib/commonjs/theme/tokens.js +28 -14
  136. package/lib/commonjs/theme/tokens.js.map +1 -1
  137. package/lib/commonjs/theme/useKeyboardField.js +36 -0
  138. package/lib/commonjs/theme/useKeyboardField.js.map +1 -0
  139. package/lib/commonjs/theme/useReduceMotion.js +31 -0
  140. package/lib/commonjs/theme/useReduceMotion.js.map +1 -0
  141. package/lib/module/atoms/Avatar.js +79 -8
  142. package/lib/module/atoms/Avatar.js.map +1 -1
  143. package/lib/module/atoms/Badge.js +17 -8
  144. package/lib/module/atoms/Badge.js.map +1 -1
  145. package/lib/module/atoms/Button.js +110 -41
  146. package/lib/module/atoms/Button.js.map +1 -1
  147. package/lib/module/atoms/Card.js +24 -2
  148. package/lib/module/atoms/Card.js.map +1 -1
  149. package/lib/module/atoms/Checkbox.js +42 -11
  150. package/lib/module/atoms/Checkbox.js.map +1 -1
  151. package/lib/module/atoms/Divider.js +31 -6
  152. package/lib/module/atoms/Divider.js.map +1 -1
  153. package/lib/module/atoms/EmptyState.js +58 -20
  154. package/lib/module/atoms/EmptyState.js.map +1 -1
  155. package/lib/module/atoms/Fab.js +33 -13
  156. package/lib/module/atoms/Fab.js.map +1 -1
  157. package/lib/module/atoms/Gradient.js +103 -37
  158. package/lib/module/atoms/Gradient.js.map +1 -1
  159. package/lib/module/atoms/IconButton.js +25 -4
  160. package/lib/module/atoms/IconButton.js.map +1 -1
  161. package/lib/module/atoms/Image.js +61 -25
  162. package/lib/module/atoms/Image.js.map +1 -1
  163. package/lib/module/atoms/Input.js +108 -20
  164. package/lib/module/atoms/Input.js.map +1 -1
  165. package/lib/module/atoms/KeyValueRow.js +37 -9
  166. package/lib/module/atoms/KeyValueRow.js.map +1 -1
  167. package/lib/module/atoms/PressableScale.js +25 -5
  168. package/lib/module/atoms/PressableScale.js.map +1 -1
  169. package/lib/module/atoms/ShinyText.js +29 -29
  170. package/lib/module/atoms/Skeleton.js +30 -13
  171. package/lib/module/atoms/Skeleton.js.map +1 -1
  172. package/lib/module/atoms/Spinner.js +82 -23
  173. package/lib/module/atoms/Spinner.js.map +1 -1
  174. package/lib/module/atoms/Text.js +56 -5
  175. package/lib/module/atoms/Text.js.map +1 -1
  176. package/lib/module/atoms/Transition.js +41 -42
  177. package/lib/module/atoms/Transition.js.map +1 -1
  178. package/lib/module/atoms/index.js +1 -1
  179. package/lib/module/atoms/index.js.map +1 -1
  180. package/lib/module/atoms/inputValidation.js +52 -0
  181. package/lib/module/atoms/inputValidation.js.map +1 -0
  182. package/lib/module/icons/Icon.js +4 -4
  183. package/lib/module/icons/defaultIcons.js +574 -24
  184. package/lib/module/icons/defaultIcons.js.map +1 -1
  185. package/lib/module/icons/index.js +1 -1
  186. package/lib/module/icons/index.js.map +1 -1
  187. package/lib/module/molecules/AvatarHeader.js +47 -14
  188. package/lib/module/molecules/AvatarHeader.js.map +1 -1
  189. package/lib/module/molecules/FormActions.js +25 -9
  190. package/lib/module/molecules/FormActions.js.map +1 -1
  191. package/lib/module/molecules/InfoRow.js +30 -14
  192. package/lib/module/molecules/InfoRow.js.map +1 -1
  193. package/lib/module/molecules/OptionCard.js +37 -22
  194. package/lib/module/molecules/OptionCard.js.map +1 -1
  195. package/lib/module/molecules/SegmentedControl.js +107 -18
  196. package/lib/module/molecules/SegmentedControl.js.map +1 -1
  197. package/lib/module/molecules/Stepper.js +63 -18
  198. package/lib/module/molecules/Stepper.js.map +1 -1
  199. package/lib/module/molecules/Steps.js +107 -43
  200. package/lib/module/molecules/Steps.js.map +1 -1
  201. package/lib/module/molecules/SymmetricGrid.js +26 -18
  202. package/lib/module/molecules/SymmetricGrid.js.map +1 -1
  203. package/lib/module/organisms/Alert.js +51 -37
  204. package/lib/module/organisms/Alert.js.map +1 -1
  205. package/lib/module/organisms/AlertProvider.js +10 -10
  206. package/lib/module/organisms/BottomSheet.js +142 -88
  207. package/lib/module/organisms/BottomSheet.js.map +1 -1
  208. package/lib/module/organisms/Chart.js +176 -156
  209. package/lib/module/organisms/Chart.js.map +1 -1
  210. package/lib/module/organisms/DateField.js +36 -20
  211. package/lib/module/organisms/DateField.js.map +1 -1
  212. package/lib/module/organisms/DatePicker.js +56 -33
  213. package/lib/module/organisms/DatePicker.js.map +1 -1
  214. package/lib/module/organisms/List.js +62 -34
  215. package/lib/module/organisms/List.js.map +1 -1
  216. package/lib/module/organisms/Modal.js +122 -84
  217. package/lib/module/organisms/Modal.js.map +1 -1
  218. package/lib/module/organisms/NavigationBar.js +64 -25
  219. package/lib/module/organisms/NavigationBar.js.map +1 -1
  220. package/lib/module/organisms/OptionWheel.js +64 -64
  221. package/lib/module/organisms/OptionWheel.js.map +1 -1
  222. package/lib/module/organisms/ReorderableList.js +48 -27
  223. package/lib/module/organisms/ReorderableList.js.map +1 -1
  224. package/lib/module/organisms/Screen.js +70 -37
  225. package/lib/module/organisms/Screen.js.map +1 -1
  226. package/lib/module/organisms/SearchList.js +56 -26
  227. package/lib/module/organisms/SearchList.js.map +1 -1
  228. package/lib/module/organisms/Select.js +46 -25
  229. package/lib/module/organisms/Select.js.map +1 -1
  230. package/lib/module/organisms/Slides.js +37 -24
  231. package/lib/module/organisms/Slides.js.map +1 -1
  232. package/lib/module/organisms/ThemeToggle.js +11 -10
  233. package/lib/module/organisms/ThemeToggle.js.map +1 -1
  234. package/lib/module/organisms/Timeline.js +73 -56
  235. package/lib/module/organisms/Timeline.js.map +1 -1
  236. package/lib/module/organisms/Toast.js +47 -16
  237. package/lib/module/organisms/Toast.js.map +1 -1
  238. package/lib/module/organisms/ToastProvider.js +15 -11
  239. package/lib/module/organisms/ToastProvider.js.map +1 -1
  240. package/lib/module/organisms/Wizard.js +41 -16
  241. package/lib/module/organisms/Wizard.js.map +1 -1
  242. package/lib/module/organisms/chart/frame.js +25 -25
  243. package/lib/module/organisms/chart/frame.js.map +1 -1
  244. package/lib/module/organisms/chart/marks.js +42 -42
  245. package/lib/module/organisms/chart/math.js +84 -85
  246. package/lib/module/organisms/chart/math.js.map +1 -1
  247. package/lib/module/organisms/chart/palette.js +28 -28
  248. package/lib/module/organisms/imperativeBridge.js +5 -5
  249. package/lib/module/organisms/imperativeBridge.js.map +1 -1
  250. package/lib/module/organisms/useSlides.js +53 -21
  251. package/lib/module/organisms/useSlides.js.map +1 -1
  252. package/lib/module/safe-area/SafeAreaUIProvider.js +10 -65
  253. package/lib/module/safe-area/SafeAreaUIProvider.js.map +1 -1
  254. package/lib/module/safe-area/index.js +1 -0
  255. package/lib/module/safe-area/index.js.map +1 -1
  256. package/lib/module/theme/KeyboardAvoidance.js +198 -0
  257. package/lib/module/theme/KeyboardAvoidance.js.map +1 -0
  258. package/lib/module/theme/NativeOverlay.js +257 -0
  259. package/lib/module/theme/NativeOverlay.js.map +1 -0
  260. package/lib/module/theme/UIProvider.js +88 -35
  261. package/lib/module/theme/UIProvider.js.map +1 -1
  262. package/lib/module/theme/colors.js +17 -17
  263. package/lib/module/theme/colors.js.map +1 -1
  264. package/lib/module/theme/createStyles.js +20 -16
  265. package/lib/module/theme/createStyles.js.map +1 -1
  266. package/lib/module/theme/createTheme.js +6 -6
  267. package/lib/module/theme/index.js +1 -1
  268. package/lib/module/theme/index.js.map +1 -1
  269. package/lib/module/theme/insets.js +1 -1
  270. package/lib/module/theme/labels.js +29 -1
  271. package/lib/module/theme/labels.js.map +1 -1
  272. package/lib/module/theme/palettes.js +25 -23
  273. package/lib/module/theme/palettes.js.map +1 -1
  274. package/lib/module/theme/tokens.js +27 -13
  275. package/lib/module/theme/tokens.js.map +1 -1
  276. package/lib/module/theme/useKeyboardField.js +33 -0
  277. package/lib/module/theme/useKeyboardField.js.map +1 -0
  278. package/lib/module/theme/useReduceMotion.js +28 -0
  279. package/lib/module/theme/useReduceMotion.js.map +1 -0
  280. package/lib/typescript/atoms/Avatar.d.ts +35 -5
  281. package/lib/typescript/atoms/Avatar.d.ts.map +1 -1
  282. package/lib/typescript/atoms/Badge.d.ts +14 -3
  283. package/lib/typescript/atoms/Badge.d.ts.map +1 -1
  284. package/lib/typescript/atoms/Button.d.ts +34 -3
  285. package/lib/typescript/atoms/Button.d.ts.map +1 -1
  286. package/lib/typescript/atoms/Card.d.ts +13 -2
  287. package/lib/typescript/atoms/Card.d.ts.map +1 -1
  288. package/lib/typescript/atoms/Checkbox.d.ts +26 -1
  289. package/lib/typescript/atoms/Checkbox.d.ts.map +1 -1
  290. package/lib/typescript/atoms/Divider.d.ts +18 -1
  291. package/lib/typescript/atoms/Divider.d.ts.map +1 -1
  292. package/lib/typescript/atoms/EmptyState.d.ts +30 -3
  293. package/lib/typescript/atoms/EmptyState.d.ts.map +1 -1
  294. package/lib/typescript/atoms/Fab.d.ts +24 -4
  295. package/lib/typescript/atoms/Fab.d.ts.map +1 -1
  296. package/lib/typescript/atoms/Gradient.d.ts +21 -15
  297. package/lib/typescript/atoms/Gradient.d.ts.map +1 -1
  298. package/lib/typescript/atoms/IconButton.d.ts +18 -4
  299. package/lib/typescript/atoms/IconButton.d.ts.map +1 -1
  300. package/lib/typescript/atoms/Image.d.ts +39 -17
  301. package/lib/typescript/atoms/Image.d.ts.map +1 -1
  302. package/lib/typescript/atoms/Input.d.ts +57 -7
  303. package/lib/typescript/atoms/Input.d.ts.map +1 -1
  304. package/lib/typescript/atoms/KeyValueRow.d.ts +16 -4
  305. package/lib/typescript/atoms/KeyValueRow.d.ts.map +1 -1
  306. package/lib/typescript/atoms/PressableScale.d.ts +12 -5
  307. package/lib/typescript/atoms/PressableScale.d.ts.map +1 -1
  308. package/lib/typescript/atoms/ShinyText.d.ts +17 -17
  309. package/lib/typescript/atoms/ShinyText.d.ts.map +1 -1
  310. package/lib/typescript/atoms/Skeleton.d.ts +12 -7
  311. package/lib/typescript/atoms/Skeleton.d.ts.map +1 -1
  312. package/lib/typescript/atoms/Spinner.d.ts +29 -1
  313. package/lib/typescript/atoms/Spinner.d.ts.map +1 -1
  314. package/lib/typescript/atoms/Text.d.ts +46 -0
  315. package/lib/typescript/atoms/Text.d.ts.map +1 -1
  316. package/lib/typescript/atoms/Transition.d.ts +19 -15
  317. package/lib/typescript/atoms/Transition.d.ts.map +1 -1
  318. package/lib/typescript/atoms/index.d.ts +3 -3
  319. package/lib/typescript/atoms/index.d.ts.map +1 -1
  320. package/lib/typescript/atoms/inputValidation.d.ts +29 -0
  321. package/lib/typescript/atoms/inputValidation.d.ts.map +1 -0
  322. package/lib/typescript/icons/Icon.d.ts +4 -4
  323. package/lib/typescript/icons/defaultIcons.d.ts +19 -2
  324. package/lib/typescript/icons/defaultIcons.d.ts.map +1 -1
  325. package/lib/typescript/icons/index.d.ts +2 -2
  326. package/lib/typescript/icons/index.d.ts.map +1 -1
  327. package/lib/typescript/icons/types.d.ts +13 -3
  328. package/lib/typescript/icons/types.d.ts.map +1 -1
  329. package/lib/typescript/molecules/AvatarHeader.d.ts +23 -4
  330. package/lib/typescript/molecules/AvatarHeader.d.ts.map +1 -1
  331. package/lib/typescript/molecules/FormActions.d.ts +21 -4
  332. package/lib/typescript/molecules/FormActions.d.ts.map +1 -1
  333. package/lib/typescript/molecules/InfoRow.d.ts +17 -3
  334. package/lib/typescript/molecules/InfoRow.d.ts.map +1 -1
  335. package/lib/typescript/molecules/OptionCard.d.ts +20 -5
  336. package/lib/typescript/molecules/OptionCard.d.ts.map +1 -1
  337. package/lib/typescript/molecules/SegmentedControl.d.ts +18 -2
  338. package/lib/typescript/molecules/SegmentedControl.d.ts.map +1 -1
  339. package/lib/typescript/molecules/Stepper.d.ts +23 -7
  340. package/lib/typescript/molecules/Stepper.d.ts.map +1 -1
  341. package/lib/typescript/molecules/Steps.d.ts +18 -4
  342. package/lib/typescript/molecules/Steps.d.ts.map +1 -1
  343. package/lib/typescript/molecules/SymmetricGrid.d.ts +15 -5
  344. package/lib/typescript/molecules/SymmetricGrid.d.ts.map +1 -1
  345. package/lib/typescript/organisms/Alert.d.ts +28 -2
  346. package/lib/typescript/organisms/Alert.d.ts.map +1 -1
  347. package/lib/typescript/organisms/AlertProvider.d.ts +10 -10
  348. package/lib/typescript/organisms/AlertProvider.d.ts.map +1 -1
  349. package/lib/typescript/organisms/BottomSheet.d.ts +20 -5
  350. package/lib/typescript/organisms/BottomSheet.d.ts.map +1 -1
  351. package/lib/typescript/organisms/Chart.d.ts +86 -75
  352. package/lib/typescript/organisms/Chart.d.ts.map +1 -1
  353. package/lib/typescript/organisms/DateField.d.ts +17 -3
  354. package/lib/typescript/organisms/DateField.d.ts.map +1 -1
  355. package/lib/typescript/organisms/DatePicker.d.ts +7 -11
  356. package/lib/typescript/organisms/DatePicker.d.ts.map +1 -1
  357. package/lib/typescript/organisms/List.d.ts +30 -3
  358. package/lib/typescript/organisms/List.d.ts.map +1 -1
  359. package/lib/typescript/organisms/Modal.d.ts +30 -3
  360. package/lib/typescript/organisms/Modal.d.ts.map +1 -1
  361. package/lib/typescript/organisms/NavigationBar.d.ts +26 -7
  362. package/lib/typescript/organisms/NavigationBar.d.ts.map +1 -1
  363. package/lib/typescript/organisms/OptionWheel.d.ts +20 -16
  364. package/lib/typescript/organisms/OptionWheel.d.ts.map +1 -1
  365. package/lib/typescript/organisms/ReorderableList.d.ts +20 -11
  366. package/lib/typescript/organisms/ReorderableList.d.ts.map +1 -1
  367. package/lib/typescript/organisms/Screen.d.ts +27 -8
  368. package/lib/typescript/organisms/Screen.d.ts.map +1 -1
  369. package/lib/typescript/organisms/SearchList.d.ts +46 -3
  370. package/lib/typescript/organisms/SearchList.d.ts.map +1 -1
  371. package/lib/typescript/organisms/Select.d.ts +26 -6
  372. package/lib/typescript/organisms/Select.d.ts.map +1 -1
  373. package/lib/typescript/organisms/Slides.d.ts +44 -26
  374. package/lib/typescript/organisms/Slides.d.ts.map +1 -1
  375. package/lib/typescript/organisms/ThemeToggle.d.ts +6 -2
  376. package/lib/typescript/organisms/ThemeToggle.d.ts.map +1 -1
  377. package/lib/typescript/organisms/Timeline.d.ts +26 -9
  378. package/lib/typescript/organisms/Timeline.d.ts.map +1 -1
  379. package/lib/typescript/organisms/Toast.d.ts +19 -4
  380. package/lib/typescript/organisms/Toast.d.ts.map +1 -1
  381. package/lib/typescript/organisms/ToastProvider.d.ts +8 -8
  382. package/lib/typescript/organisms/ToastProvider.d.ts.map +1 -1
  383. package/lib/typescript/organisms/Wizard.d.ts +24 -6
  384. package/lib/typescript/organisms/Wizard.d.ts.map +1 -1
  385. package/lib/typescript/organisms/chart/frame.d.ts +21 -21
  386. package/lib/typescript/organisms/chart/frame.d.ts.map +1 -1
  387. package/lib/typescript/organisms/chart/marks.d.ts +28 -28
  388. package/lib/typescript/organisms/chart/marks.d.ts.map +1 -1
  389. package/lib/typescript/organisms/chart/math.d.ts +69 -70
  390. package/lib/typescript/organisms/chart/math.d.ts.map +1 -1
  391. package/lib/typescript/organisms/chart/palette.d.ts +2 -2
  392. package/lib/typescript/organisms/chart/palette.d.ts.map +1 -1
  393. package/lib/typescript/organisms/imperativeBridge.d.ts +4 -4
  394. package/lib/typescript/organisms/useSlides.d.ts +8 -16
  395. package/lib/typescript/organisms/useSlides.d.ts.map +1 -1
  396. package/lib/typescript/safe-area/SafeAreaUIProvider.d.ts +9 -7
  397. package/lib/typescript/safe-area/SafeAreaUIProvider.d.ts.map +1 -1
  398. package/lib/typescript/safe-area/index.d.ts.map +1 -1
  399. package/lib/typescript/theme/KeyboardAvoidance.d.ts +62 -0
  400. package/lib/typescript/theme/KeyboardAvoidance.d.ts.map +1 -0
  401. package/lib/typescript/theme/NativeOverlay.d.ts +43 -0
  402. package/lib/typescript/theme/NativeOverlay.d.ts.map +1 -0
  403. package/lib/typescript/theme/UIProvider.d.ts +38 -20
  404. package/lib/typescript/theme/UIProvider.d.ts.map +1 -1
  405. package/lib/typescript/theme/colors.d.ts +13 -13
  406. package/lib/typescript/theme/colors.d.ts.map +1 -1
  407. package/lib/typescript/theme/createStyles.d.ts +20 -14
  408. package/lib/typescript/theme/createStyles.d.ts.map +1 -1
  409. package/lib/typescript/theme/createTheme.d.ts +5 -5
  410. package/lib/typescript/theme/index.d.ts +1 -1
  411. package/lib/typescript/theme/index.d.ts.map +1 -1
  412. package/lib/typescript/theme/insets.d.ts +1 -1
  413. package/lib/typescript/theme/insets.d.ts.map +1 -1
  414. package/lib/typescript/theme/labels.d.ts +57 -2
  415. package/lib/typescript/theme/labels.d.ts.map +1 -1
  416. package/lib/typescript/theme/palettes.d.ts +25 -19
  417. package/lib/typescript/theme/palettes.d.ts.map +1 -1
  418. package/lib/typescript/theme/tokens.d.ts +15 -8
  419. package/lib/typescript/theme/tokens.d.ts.map +1 -1
  420. package/lib/typescript/theme/types.d.ts +7 -7
  421. package/lib/typescript/theme/types.d.ts.map +1 -1
  422. package/lib/typescript/theme/useKeyboardField.d.ts +17 -0
  423. package/lib/typescript/theme/useKeyboardField.d.ts.map +1 -0
  424. package/lib/typescript/theme/useReduceMotion.d.ts +7 -0
  425. package/lib/typescript/theme/useReduceMotion.d.ts.map +1 -0
  426. package/llms.txt +4 -4
  427. package/package.json +15 -14
  428. package/registry/alert-provider.json +4 -3
  429. package/registry/alert.json +3 -2
  430. package/registry/avatar-header.json +1 -1
  431. package/registry/avatar.json +3 -2
  432. package/registry/badge.json +1 -1
  433. package/registry/bottom-sheet.json +1 -1
  434. package/registry/button.json +3 -2
  435. package/registry/card.json +1 -1
  436. package/registry/chart.json +5 -5
  437. package/registry/checkbox.json +3 -2
  438. package/registry/core.json +31 -15
  439. package/registry/date-field.json +1 -1
  440. package/registry/date-picker.json +3 -2
  441. package/registry/divider.json +1 -1
  442. package/registry/empty-state.json +4 -2
  443. package/registry/fab.json +3 -2
  444. package/registry/form-actions.json +3 -2
  445. package/registry/gradient.json +1 -1
  446. package/registry/icon-button.json +3 -2
  447. package/registry/image.json +1 -1
  448. package/registry/index.json +79 -61
  449. package/registry/info-row.json +1 -1
  450. package/registry/input.json +5 -1
  451. package/registry/key-value-row.json +1 -1
  452. package/registry/list.json +3 -1
  453. package/registry/manifest.json +3 -1
  454. package/registry/modal.json +1 -1
  455. package/registry/navigation-bar.json +1 -1
  456. package/registry/option-card.json +3 -2
  457. package/registry/option-wheel.json +1 -1
  458. package/registry/pressable-scale.json +3 -2
  459. package/registry/reorderable-list.json +1 -1
  460. package/registry/screen.json +1 -1
  461. package/registry/search-list.json +7 -1
  462. package/registry/segmented-control.json +1 -1
  463. package/registry/select.json +1 -1
  464. package/registry/shiny-text.json +1 -1
  465. package/registry/skeleton.json +3 -2
  466. package/registry/slides.json +2 -2
  467. package/registry/spinner.json +1 -1
  468. package/registry/stepper.json +1 -1
  469. package/registry/steps.json +1 -1
  470. package/registry/symmetric-grid.json +1 -1
  471. package/registry/theme-toggle.json +1 -1
  472. package/registry/timeline.json +1 -1
  473. package/registry/title.json +1 -1
  474. package/registry/toast-provider.json +2 -2
  475. package/registry/toast.json +1 -1
  476. package/registry/transition.json +1 -1
  477. package/registry/wizard.json +1 -1
  478. package/src/atoms/Avatar.tsx +117 -26
  479. package/src/atoms/Badge.tsx +20 -9
  480. package/src/atoms/Button.tsx +102 -30
  481. package/src/atoms/Card.tsx +23 -3
  482. package/src/atoms/Checkbox.tsx +49 -12
  483. package/src/atoms/Divider.tsx +34 -4
  484. package/src/atoms/EmptyState.tsx +63 -16
  485. package/src/atoms/Fab.tsx +56 -9
  486. package/src/atoms/Gradient.tsx +92 -41
  487. package/src/atoms/IconButton.tsx +27 -4
  488. package/src/atoms/Image.tsx +74 -28
  489. package/src/atoms/Input.tsx +129 -19
  490. package/src/atoms/KeyValueRow.tsx +72 -28
  491. package/src/atoms/PressableScale.tsx +31 -7
  492. package/src/atoms/ShinyText.tsx +31 -31
  493. package/src/atoms/Skeleton.tsx +37 -17
  494. package/src/atoms/Spinner.tsx +87 -24
  495. package/src/atoms/Text.tsx +66 -6
  496. package/src/atoms/Transition.tsx +40 -44
  497. package/src/atoms/index.ts +3 -3
  498. package/src/atoms/inputValidation.ts +59 -0
  499. package/src/icons/Icon.tsx +4 -4
  500. package/src/icons/defaultIcons.tsx +445 -22
  501. package/src/icons/index.ts +2 -2
  502. package/src/icons/types.ts +26 -4
  503. package/src/molecules/AvatarHeader.tsx +88 -34
  504. package/src/molecules/FormActions.tsx +36 -6
  505. package/src/molecules/InfoRow.tsx +39 -12
  506. package/src/molecules/OptionCard.tsx +55 -20
  507. package/src/molecules/SegmentedControl.tsx +135 -15
  508. package/src/molecules/Stepper.tsx +73 -19
  509. package/src/molecules/Steps.tsx +121 -39
  510. package/src/molecules/SymmetricGrid.tsx +28 -12
  511. package/src/organisms/Alert.tsx +97 -26
  512. package/src/organisms/AlertProvider.tsx +13 -13
  513. package/src/organisms/BottomSheet.tsx +115 -43
  514. package/src/organisms/Chart.tsx +193 -164
  515. package/src/organisms/DateField.tsx +40 -15
  516. package/src/organisms/DatePicker.tsx +53 -32
  517. package/src/organisms/List.tsx +64 -15
  518. package/src/organisms/Modal.tsx +122 -63
  519. package/src/organisms/NavigationBar.tsx +73 -23
  520. package/src/organisms/OptionWheel.tsx +70 -66
  521. package/src/organisms/ReorderableList.tsx +49 -27
  522. package/src/organisms/Screen.tsx +79 -42
  523. package/src/organisms/SearchList.tsx +84 -20
  524. package/src/organisms/Select.tsx +63 -29
  525. package/src/organisms/Slides.tsx +63 -44
  526. package/src/organisms/ThemeToggle.tsx +15 -6
  527. package/src/organisms/Timeline.tsx +79 -62
  528. package/src/organisms/Toast.tsx +50 -15
  529. package/src/organisms/ToastProvider.tsx +17 -13
  530. package/src/organisms/Wizard.tsx +58 -21
  531. package/src/organisms/chart/frame.tsx +35 -35
  532. package/src/organisms/chart/marks.tsx +53 -53
  533. package/src/organisms/chart/math.ts +93 -94
  534. package/src/organisms/chart/palette.ts +28 -28
  535. package/src/organisms/imperativeBridge.ts +5 -5
  536. package/src/organisms/useSlides.ts +66 -31
  537. package/src/safe-area/SafeAreaUIProvider.tsx +9 -70
  538. package/src/safe-area/index.ts +1 -0
  539. package/src/theme/KeyboardAvoidance.tsx +216 -0
  540. package/src/theme/NativeOverlay.tsx +269 -0
  541. package/src/theme/UIProvider.tsx +109 -46
  542. package/src/theme/colors.ts +21 -21
  543. package/src/theme/createStyles.ts +23 -19
  544. package/src/theme/createTheme.ts +6 -6
  545. package/src/theme/index.ts +1 -1
  546. package/src/theme/insets.ts +1 -1
  547. package/src/theme/labels.ts +85 -2
  548. package/src/theme/palettes.ts +33 -25
  549. package/src/theme/tokens.ts +27 -13
  550. package/src/theme/types.ts +7 -7
  551. package/src/theme/useKeyboardField.ts +33 -0
  552. package/src/theme/useReduceMotion.ts +27 -0
@@ -9,23 +9,23 @@
9
9
  "files": [
10
10
  {
11
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/** Piso del canal de las etiquetas de valor, a la izquierda. */\nconst VALUE_GUTTER = 38;\n/**\n * Ancho estimado de un caracter de etiqueta. React Native no mide texto sin\n * renderizarlo, y medir para decidir el layout haría aparecer el chart\n * corrido un frame; a `fontSize.xs` un dígito ronda 0.6em, y de más es mejor\n * que de menos — sobra aire, no se corta el número.\n */\nconst VALUE_CHAR = 6.5;\n/** Tope del canal: un eje ancho no puede comerse el plot. */\nconst VALUE_GUTTER_MAX = 0.3;\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 bottomGutter = xAxis ? BOTTOM_GUTTER : 0;\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 // 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 = useMemo(\n () => (Array.isArray(domain) ? exactTicks(low, high, tickCount) : niceScale(low, high, tickCount)),\n [domain, low, high, tickCount]\n );\n\n /**\n * El canal sale de la etiqueta más larga, no de una constante: con un\n * `formatValue` propio (\"$3000\", \"1.250 ms\") un ancho fijo corta el número\n * y el eje pasa a decir \"$30…\". Los ticks no dependen del ancho del plot,\n * así que se pueden medir antes de repartirlo.\n */\n const leftGutter = useMemo(() => {\n if (!yAxis) return 0;\n if (horizontal) return CATEGORY_GUTTER;\n const longest = nice.values.reduce((widest, value) => Math.max(widest, formatValue(value).length), 0);\n const wanted = Math.ceil(longest * VALUE_CHAR) + theme.tokens.spacing.sm;\n return Math.max(VALUE_GUTTER, Math.min(wanted, Math.floor(width * VALUE_GUTTER_MAX)));\n }, [yAxis, horizontal, nice, formatValue, width, theme.tokens.spacing.sm]);\n\n const plotW = Math.max(1, width - leftGutter);\n const plotH = Math.max(1, height - bottomGutter);\n\n const toPixel = useMemo(\n () =>\n horizontal\n ? linearScale([nice.min, nice.max], [0, plotW])\n : linearScale([nice.min, nice.max], [plotH, 0]),\n [horizontal, nice, plotW, plotH]\n );\n const scale = useMemo(() => ({ nice, toPixel }), [nice, toPixel]);\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"
12
+ "content": "import React, { createContext, memo, useCallback, useContext, 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 { useReduceMotion } from '../theme/useReduceMotion';\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 /** Column of `data` this series reads. */\n key: string;\n /** Text shown in the legend and tooltip. Defaults to `key`. */\n label?: string;\n /** Overrides the color derived from the theme. */\n color?: string;\n}\n\nexport interface ChartFilterOption {\n value: string;\n label: string;\n}\n\n/**\n * A filter in the top bar. Chart doesn't filter anything on its own: it shows\n * the control and reports. Whoever has the data decides what \"last 7 days\"\n * means, and that can't be guessed from here.\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 * Row identity, stable across a `window` or decimation. Comes from\n * `selectBy`; without it, it is the index, which does not survive the data\n * shifting.\n */\n id: string;\n /**\n * Index within the **drawn** rows, not `data`: with `window` or decimation\n * they are not the same list. Use `row` to get back to the datum.\n */\n index: number;\n /** The whole row, without resolving the index against anything. */\n row: ChartRow;\n /** Value of `xKey` in that row. */\n label: string;\n /** Series that was touched. In a pie it is the row, which is the category there. */\n seriesKey: string;\n value: number;\n}\n\ninterface ChartBaseProps {\n /** Rows: one per category, with the label in `xKey` and one column per series. */\n data: ChartRow[];\n /**\n * Series to draw. 'pie'/'donut' only use the first one: a pie's categories\n * are the rows, not the columns.\n */\n series: ChartSeries[];\n /** Column holding each row's label. @default 'x' */\n xKey?: string;\n /**\n * Stable identity of a row. Without it the selection is stored by index,\n * and in a sliding window the index shifts: the highlighted point ends up\n * pointing at another datum without anyone touching it. With it the\n * highlight follows the datum and disappears only when it leaves the window.\n */\n selectBy?: (row: ChartRow, index: number) => string;\n /**\n * Draws only the last N rows. It is the sliding window of a live chart: the\n * code pushing data doesn't have to trim it on every tick.\n */\n window?: number;\n /** Height of the plot area, excluding filters and legend. @default 220 */\n height?: number;\n /**\n * 'toggle' turns the legend into the cheapest filter a chart has: tapping a\n * series hides it. @default 'auto' (toggle with 2+ series, none with one)\n */\n legend?: 'auto' | 'none' | 'static' | 'toggle';\n /** Hidden series (controlled). Without it, Chart tracks them itself. */\n hiddenKeys?: string[];\n /** Series hidden at first (uncontrolled). @default [] */\n defaultHiddenKeys?: string[];\n /** Called when the legend shows or hides a series. */\n onHiddenKeysChange?: (keys: string[]) => void;\n /** Controls above the chart, one per filter. */\n filters?: ChartFilter[];\n /** Highlighted point (controlled). `null` means none. */\n selected?: ChartSelection | null;\n /** Called when a category is tapped, with `null` when it is released. */\n onSelect?: (selection: ChartSelection | null) => void;\n /** Bubble with the values of the touched category. @default true */\n tooltip?: boolean;\n /** Formats values on the axis, tooltip and slices. @default compact format: 1.2k, 3.4M */\n formatValue?: (value: number) => string;\n /** Marks grow in on mount and morph on data changes (off with Reduce Motion). @default true */\n animate?: boolean;\n /** Duration in ms of the marks' animation. @default tokens.duration.slow * 2 */\n duration?: number;\n /** Text shown when there is nothing left to draw. */\n emptyText?: string;\n /** Required: a chart has no text that describes it. */\n accessibilityLabel: string;\n /**\n * Color behind the chart. The donut hole and the ring around points are\n * painted with it, so they must match whatever the chart sits on.\n * @default colors.surfaceRaised (a Chart usually lives in a Card)\n */\n surfaceColor?: string;\n style?: StyleProp<ViewStyle>;\n testID?: string;\n}\n\n/** Background the marks paint \"holes\" with (donut center, point rings). */\nconst ChartSurfaceContext = createContext<string | undefined>(undefined);\nfunction useChartSurface(): string {\n const theme = useTheme();\n return useContext(ChartSurfaceContext) ?? theme.colors.surfaceRaised;\n}\n\ninterface CartesianProps {\n /**\n * Fixes the value axis. By default it is recomputed with every datum, which\n * is right for a static chart and worst for a live one: a higher value\n * arrives, the whole scale shifts and the line jumps even though the data\n * didn't move.\n *\n * - `[min, max]` — exact, not rounded to nice numbers.\n * - `'sticky'` — starts from the data and grows when something doesn't fit,\n * but never shrinks back.\n */\n domain?: [number, number] | 'sticky';\n /** Reference lines on the value axis. @default true */\n grid?: boolean;\n /** Target tick count on the value axis; rounding may yield one more or one fewer. @default 4 */\n ticks?: number;\n /** Labels on the horizontal axis. @default true */\n xAxis?: boolean;\n /** Labels on the vertical axis. @default true */\n yAxis?: boolean;\n}\n\ninterface BarChartProps extends CartesianProps {\n /** Kind of chart. Props marked \"Only with type\" depend on it. */\n type: 'bar';\n /** 'horizontal' puts the categories on the vertical axis. @default 'vertical' */\n orientation?: 'vertical' | 'horizontal';\n /** Stacks the series instead of placing them side by side. @default false */\n stacked?: boolean;\n /** Radius of each bar's free end. @default radius.xs */\n barRadius?: number;\n}\n\ninterface LineChartProps extends CartesianProps {\n type: 'line';\n /**\n * Maximum number of points to draw, picked with LTTB. Defaults to a value\n * derived from the measured width: more than one point every two pixels\n * isn't visible but still costs. `false` draws them all.\n */\n decimate?: number | false;\n /** Catmull-Rom curves instead of straight segments. @default false */\n smooth?: boolean;\n /** Line width in pt. @default 2 */\n thickness?: number;\n /** A dot on each datum. @default true */\n dots?: boolean;\n}\n\ninterface AreaChartProps extends CartesianProps {\n type: 'area';\n /** Same as in 'line'. */\n decimate?: number | false;\n smooth?: boolean;\n thickness?: number;\n /** @default false */\n dots?: boolean;\n /** @default false */\n stacked?: boolean;\n /** Opacity of the fill under the line. @default 0.22 */\n fillOpacity?: number;\n}\n\ninterface PieBaseProps {\n /** Hole as a fraction of the radius, 0–0.9. @default 0 for 'pie', 0.6 for 'donut' */\n innerRadius?: number;\n /** What to write on each slice. @default 'percent' */\n sliceLabels?: 'none' | 'percent' | 'value';\n}\n\n// 'pie' and 'donut' are kept separate even though they share everything: with\n// a single member whose `type` were `'pie' | 'donut'`, TypeScript doesn't drop\n// that member from the union when ruling it out, and the rest of the component\n// stops narrowing.\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 * Fixes the axes' range. A radar measures from the center, so the minimum is\n * always 0 and only `max` of `[min, max]` is used.\n */\n domain?: [number, number] | 'sticky';\n /** Rings of the web. @default 4 */\n levels?: number;\n /** Fills the polygon in addition to drawing its outline. @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/** Floor of the value-label gutter, on the left. */\nconst VALUE_GUTTER = 38;\n/**\n * Estimated width of a label character. React Native doesn't measure text\n * without rendering it, and measuring to decide the layout would make the\n * chart show up shifted for a frame; at `fontSize.xs` a digit is around\n * 0.6em, and too much is better than too little — extra room, no clipped number.\n */\nconst VALUE_CHAR = 6.5;\n/** Gutter cap: a wide axis can't eat the plot. */\nconst VALUE_GUTTER_MAX = 0.3;\n/** Category gutter when they go on the left (horizontal bars). */\nconst CATEGORY_GUTTER = 64;\n/** Gutter for the bottom labels. */\nconst BOTTOM_GUTTER = 16;\n/** Fraction of the band width taken by the bars; the rest is room. */\nconst BAND_FILL = 0.7;\n/** Room around the radar so the axis labels fit. */\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/** Controlled or own state, with the same contract in both cases. */\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 * Resolves the value axis domain from what the consumer asked for. In\n * 'sticky' the reference lives in a ref: what matters isn't this render's\n * domain but the widest one seen so far, and keeping it in state would fire\n * an extra render for every tick that widens the axis.\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 * Bars (vertical, horizontal, grouped, stacked), line, area, pie, donut and\n * radar in a single component, because what changes between them is the mark\n * being drawn and not the rest: data, scale, axes, legend, tooltip, selection\n * and filters are the same six mechanisms in all of them.\n *\n * It is drawn with `View`: React Native ships no vector canvas and the library\n * can't add one. The three primitives are in `chart/marks.tsx` and the whole\n * geometry, without React, in `chart/math.ts`.\n *\n * Gestalt: common fate — when you tap a category, everything that isn't it\n * drops to {@link DIM_OPACITY} at once, so what is highlighted reads as a group\n * and not as a loose mark. The legend uses similarity: the same color as the\n * series, which is why it can turn it off without having to explain what it\n * turns off.\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: animateProp = true,\n duration,\n emptyText,\n accessibilityLabel,\n surfaceColor,\n style,\n testID,\n } = props;\n\n const theme = useTheme();\n const styles = useStyles();\n const allowFontScaling = useAllowFontScaling();\n const uiLabels = useLabels();\n // Reduce Motion: the marks appear in their final shape instead of growing.\n const reduceMotion = useReduceMotion();\n const animate = animateProp && !reduceMotion;\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 * What gets drawn: the window of `data`, and from that the points that fit.\n * Decimation runs over the whole row, chosen with the first visible series:\n * the series share the x axis, so keeping different indexes per series would\n * break the alignment, the touch layer and the 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 // One point every two pixels: more can't be seen but still has to be paid for.\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 * The selection is stored by `id` and resolved against the current rows:\n * if the datum shifted, the highlight follows it; if it left the window, it\n * disappears. Blindly reusing the index would point at the datum that ended\n * up in that spot, which isn't the one anyone picked.\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 // In a pie the categories are the rows; in the rest, the columns. The\n // color, the legend and what a tap on it turns off depend on that.\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 * The plots report what was tapped —row and series— and the whole selection\n * is built here: it is the only place that knows `selectBy`, the labels and\n * the actual rows, so the three don't repeat the building.\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 // Tapping what is already selected releases it: without that there would\n // be no way back to the whole chart without tapping outside, and\n // \"outside\" doesn't always exist on a scrolling screen.\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 <ChartSurfaceContext.Provider value={surfaceColor ?? theme.colors.surfaceRaised}>\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 </ChartSurfaceContext.Provider>\n );\n}\n\nChart.displayName = 'Chart';\n\n// ── what the three plots share ─────────────────────────────────────────────\n\ninterface PlotProps {\n rows: ChartRow[];\n labels: string[];\n /** All categories, with their color: the color doesn't change when one is turned off. */\n entries: LegendItem[];\n /** The ones still turned on. */\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 /** Reports what was tapped; Chart builds the selection. `null` releases it. */\n onPick: (hit: { index: number; seriesKey: string } | null) => void;\n tooltip: boolean;\n testID?: string;\n}\n\n/** Tooltip rows for a category on the x axis. */\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// ── bars, line and area ────────────────────────────────────────────────────\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/** Width of one strip of the area fill, in pixels. Thinner = more views. */\nconst STRIP_WIDTH = 4;\nconst MAX_STRIPS = 140;\n/** Room between two bars of the same group. */\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 surface = useChartSurface();\n const horizontal = type === 'bar' && orientation === 'horizontal';\n const keys = useMemo(() => visible.map((entry) => entry.key), [visible]);\n\n const bottomGutter = xAxis ? BOTTOM_GUTTER : 0;\n\n // The line may not touch zero (a temperature, a ranking); a bar does have\n // to, because its length is what states the value.\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 // A hand-set domain is respected as is; a measured one (or 'sticky') is\n // rounded, which is what makes the axis legible.\n const nice = useMemo(\n () => (Array.isArray(domain) ? exactTicks(low, high, tickCount) : niceScale(low, high, tickCount)),\n [domain, low, high, tickCount]\n );\n\n /**\n * The gutter comes from the longest label, not from a constant: with a\n * custom `formatValue` (\"$3000\", \"1.250 ms\") a fixed width clips the number\n * and the axis ends up saying \"$30…\". The ticks don't depend on the plot's\n * width, so they can be measured before splitting it up.\n */\n const leftGutter = useMemo(() => {\n if (!yAxis) return 0;\n if (horizontal) return CATEGORY_GUTTER;\n const longest = nice.values.reduce((widest, value) => Math.max(widest, formatValue(value).length), 0);\n const wanted = Math.ceil(longest * VALUE_CHAR) + theme.tokens.spacing.sm;\n return Math.max(VALUE_GUTTER, Math.min(wanted, Math.floor(width * VALUE_GUTTER_MAX)));\n }, [yAxis, horizontal, nice, formatValue, width, theme.tokens.spacing.sm]);\n\n const plotW = Math.max(1, width - leftGutter);\n const plotH = Math.max(1, height - bottomGutter);\n\n const toPixel = useMemo(\n () =>\n horizontal\n ? linearScale([nice.min, nice.max], [0, plotW])\n : linearScale([nice.min, nice.max], [plotH, 0]),\n [horizontal, nice, plotW, plotH]\n );\n const scale = useMemo(() => ({ nice, toPixel }), [nice, toPixel]);\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 /** A series' points: `to` is the value, `from` the floor it is filled from. */\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 // Which series the finger landed on: the one whose mark is closest to\n // the touch on the value axis. With a single series it is always that one.\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 {/* Frame: grid and labels, beneath the marks. */}\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 {/* Area: without `path` there is no fill under a curve, so it\n is cut into vertical strips and each one asks the polyline\n for its height. At 4px per strip the edge already reads as continuous. */}\n {type === 'area' ? (\n // Opacity goes on the group, not on each strip: the half\n // pixel of overlap that hides the antialias seam is\n // composited twice and leaves a vertical stripe every four\n // pixels. Dimming the whole group, it is painted only once.\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={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 {/* Touch layer: a whole band per category. Tapping a 2px line is\n impossible; tapping its column isn't. */}\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// ── pie and donut ──────────────────────────────────────────────────────────\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/** How far the selected slice pops out, in pixels. */\nconst EXPLODE = 8;\n/** Where the label falls, as a fraction between the hole and the edge. */\nconst LABEL_AT = 0.62;\n\n/** White or black, whichever reads over the color. An odd color doesn't break: it falls back to `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 surface = useChartSurface();\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 // A turned-off category is worth 0: it leaves the pie and the rest share\n // the 100%, which is what whoever turned it off from the legend expects.\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 // A donut's hole isn't part of any slice, and neither is the area\n // outside the circle: both release the selection.\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: surface,\n }}\n />\n ) : null}\n\n {sliceLabels === 'none'\n ? null\n : arcs.map((arc) => {\n // A very thin slice has nowhere to write: the label would spill\n // over its neighbors and none would be readable.\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/** Strips per side of the polygon when it is filled. More = cleaner edge, more views. */\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 surface = useChartSurface();\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 // The radar measures from the center, and the center is 0: a minimum other\n // than zero would make two similar values look miles apart.\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 {/* Fill: the same sector a pie draws, one per strip of the\n polygon, with the radius that strip gets. */}\n {fill ? (\n // Opacity goes on the group, not on each strip. Per strip, the\n // 0.4° overlap that hides the antialias seam is composited\n // twice and leaves a darker radius at each cut: the fill ends\n // up looking like a spider web. Applied to the group, the strips\n // paint opaque against each other and the whole is dimmed once.\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={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
13
  },
14
14
  {
15
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"
16
+ "content": "/**\n * Chart's marks: the only thing the component knows how to draw.\n *\n * React Native ships no vector canvas and the library can't add one (zero\n * dependencies), so the three primitives here are composed from `View` and\n * are enough for the six chart types:\n *\n * | primitive | used by |\n * |------------|-----------------------------------------------------|\n * | `Bar` | vertical and horizontal bars, grouped or stacked |\n * | `Polyline` | line, radar outline, radar grid, axes |\n * | `Wedge` | pie, donut and the radar fill |\n *\n * They are pure: they don't listen to touches or know the scale. What handles\n * touches is Chart.tsx's hit-test layer, which covers whole columns instead of\n * the mark — a 2px line is not a touch target.\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 * A value that chases `target` instead of jumping. A filter that changes the\n * data moves the bar from where it was, and that movement is what says \"it's\n * the same bar with another number\" instead of \"it's another 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 // Geometry, not transform: `height`/`top` are layout props and the\n // native driver doesn't touch them. It is the price of letting the bar\n // grow from its base with negative values without recomputing the origin.\n useNativeDriver: false,\n });\n animation.start();\n return () => animation.stop();\n }, [target, animate, duration, value]);\n\n return value;\n}\n\n// ── bar ────────────────────────────────────────────────────────────────────\n\nexport interface BarProps {\n /** The bar's final box inside the plot, already in pixels. */\n x: number;\n y: number;\n width: number;\n height: number;\n color: string;\n /** Side it grows from: the bar grows from its base, not from the center. */\n from: 'bottom' | 'top' | 'left' | 'right';\n /** Radius of the two corners at the free end. */\n radius?: number;\n /** Dims the bars that aren't the selected one. */\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 // The end that moves is the one opposite the base: a bar growing from\n // the bottom keeps its lower edge fixed and raises the upper one.\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// ── polyline ───────────────────────────────────────────────────────────────\n\nexport interface PolylineProps {\n points: Point[];\n color: string;\n thickness?: number;\n /** Joins the last point with the first (radar, polygonal grid). */\n closed?: boolean;\n opacity?: number;\n testID?: string;\n}\n\n/**\n * A line is a thin rectangle rotated around its center, one per segment. With\n * `smoothPoints` there are so many segments that the polyline reads as a curve.\n *\n * The ends are butt, not rounded: a `borderRadius` per segment would multiply\n * the views and at 2px thick nobody sees the difference.\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 /** Ring in the background color: separates the dot from the line crossing it. */\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// ── slice ──────────────────────────────────────────────────────────────────\n\ninterface HalfProps {\n size: number;\n /** Opening of this half, 0–180. */\n sweep: number;\n color: string;\n}\n\n/**\n * Sector of up to 180°, without curves or masks. Three boxes:\n *\n * 1. a fixed clip to the right half (`overflow: 'hidden'`), which is the\n * 0°–180° sector;\n * 2. inside, a `size` × `size` square centered on the circle's center, which\n * is what rotates — rotating around its own center, it rotates around the\n * circle's center without needing `transformOrigin`;\n * 3. inside the square, a **half disc**, not a whole disc: a half-box\n * rectangle with its two outer corners rounded to the radius.\n *\n * The half disc is what makes the rotation mean something. With a full disc\n * the clip always leaves the same half moon —rotating a solid circle doesn't\n * change it—, and every slice, whether 5° or 180°, comes out equally wide:\n * 180°.\n *\n * Rotated `sweep - 180`, the half disc covers `sweep - 180` to `sweep`, and\n * what survives the clip is exactly 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 /** Diameter: the slice is drawn centered in a `size` × `size` box. */\n size: number;\n /** Angle where it starts, 0° at the top and increasing clockwise. */\n start: number;\n /** Apertura, 0–360. */\n sweep: number;\n color: string;\n opacity?: number;\n /** Pushes the slice outward along its bisector (the selected one). */\n explode?: number;\n /** Center within the container. Without it the slice rests on (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 // Exactly 360° leaves both halves at 180°, and the top one covers the bottom\n // one with an antialias slit. A hair less isn't visible and doesn't happen.\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
17
  },
18
18
  {
19
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"
20
+ "content": "/**\n * Chart geometry and scales: everything that can be tested without mounting a\n * tree lives here, without React and without dependencies. marks.tsx only\n * translates these numbers into styles, and Chart.tsx only decides which mark to draw.\n *\n * Conventions:\n * - React Native coordinates: origin at the top-left, `y` grows downward.\n * - Angles in degrees, 0° at 12 o'clock, increasing clockwise —\n * which is how a pie is read and how `transform: rotate` turns in RN.\n */\n\nexport interface Point {\n x: number;\n y: number;\n}\n\n/** Data row: an x-axis category and one number per series. */\nexport type ChartRow = Record<string, string | number | null | undefined>;\n\n/** Reads a cell as a number, or null if it isn't one (a gap in the series). */\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/** Kills the floating point noise left by adding `step` in a loop. */\nfunction tidy(value: number): number {\n return Number(value.toFixed(10));\n}\n\n// ── scales ─────────────────────────────────────────────────────────────────\n\n/**\n * The 1, 2, 5 or 10 (times its power of ten) closest above `rough`.\n * It is what makes ticks land on numbers someone would say out loud —\n * 0, 250, 500 — and not on 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 /** Lower end, rounded outward. */\n min: number;\n /** Upper end, rounded outward. */\n max: number;\n step: number;\n values: number[];\n}\n\n/**\n * Domain rounded to legible values plus the list of ticks. `count` is an\n * intent, not a promise: rounding outward may give one more or one fewer,\n * and that is preferable to an axis with arbitrary numbers.\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 // A single value doesn't define a range. A symmetric one is made up so\n // the mark neither sticks to the edge nor fills the whole height.\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` instead of `end`: adding step accumulates error and the\n // last tick would be lost precisely in the most used domains (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 for a domain the consumer set by hand. It rounds nothing: if someone\n * asks for 0–100, the axis says 0–100. It is the opposite of `niceScale`, and\n * it is what a live chart needs — an axis that rearranges itself on every tick\n * makes the line jump even though the data hasn't moved.\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 * Merges two domains keeping the widest. It is the 'sticky' mode: the axis\n * grows when a value arrives that didn't fit and never shrinks back, so what\n * you see moving is the data and not the scale.\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/** Linear domain → range interpolator. A degenerate domain falls to the middle of the range. */\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 /** The series are summed instead of compared (stacked bars/areas). */\n stacked?: boolean;\n /** Forces 0 into the domain: without it, a short bar lies. */\n includeZero?: boolean;\n}\n\n/** Minimum and maximum of the requested series. With no data it returns [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 // Positives and negatives stack toward opposite sides of zero, so each\n // side has its own total and neither cancels the other.\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// ── stacking ───────────────────────────────────────────────────────────────\n\nexport interface StackBand {\n key: string;\n /** Edge of the band closest to zero. */\n from: number;\n /** Edge furthest from zero. */\n to: number;\n value: number;\n}\n\n/**\n * Stacked bands of a row. Positives grow from 0 upward and negatives from 0\n * downward: a negative series doesn't eat the positives' height, which is\n * what happens if everything is stacked in a single accumulator.\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/** Point at `radius` from the center at the given angle (0° up, clockwise). */\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/** Angle of a point relative to the center, normalized to [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 /** Index of the row it represents. */\n index: number;\n start: number;\n sweep: number;\n value: number;\n /** Share of the total, 0–1. */\n ratio: number;\n}\n\n/**\n * Arcs of a pie. Negatives don't exist in a pie (there is no \"less slice\"):\n * they are dropped instead of inverted. Total 0 ⇒ nothing to draw.\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/** The arc containing that angle, or -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/** Angles of a radar's axes: the first at the top, the rest evenly spread. */\nexport function radarAngles(count: number): number[] {\n if (count <= 0) return [];\n return Array.from({ length: count }, (_, index) => (index * 360) / count);\n}\n\n// ── segments and curves ────────────────────────────────────────────────────\n\nexport interface SegmentBox {\n left: number;\n top: number;\n width: number;\n height: number;\n /** Degrees for `transform: [{ rotate }]`. */\n rotate: number;\n}\n\n/**\n * Box of a segment a→b: without SVG, a line is a thin rectangle rotated\n * around its center. Width = distance, height = thickness, rotation = the slope.\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 * Smooths a polyline with Catmull-Rom sampled in `steps` segments per span.\n * The curve passes through all the original points (unlike a free-control\n * Bézier), so the data is still the data; only path is added in between, and\n * the result is drawn with the same straight segments.\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 // The endpoints are duplicated so the first and last spans have the\n // four references the spline asks for.\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 * The polyline's `y` at that `x`, or null if it falls outside. It is what\n * turns a line into an area: each vertical strip asks for its height here.\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// ── formatting ─────────────────────────────────────────────────────────────\n\nconst UNITS = ['', 'k', 'M', 'B'];\n\n/**\n * Default format for axes and tooltip: compact, because the axis of a 320px\n * chart has no room for \"1250000\". The consumer replaces it entirely with\n * `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 * Breaks a polygon around a center into angular slices: it is what lets a\n * radar be filled with the same primitive that draws a pie.\n *\n * The cut goes along the polygon's side (parameter `t`), not by angle: the\n * radius of a chord isn't linear in the angle, and cutting by angle would\n * round off the vertices exactly where the data says there should be a point.\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 // The jump from 360 to 0 falls somewhere along the path: without this,\n // that slice would be drawn turning backwards and cover everything else.\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 // The slices overlap a hair on purpose: without the overlap, the\n // antialias leaves a light seam between strips.\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// ── decimation ─────────────────────────────────────────────────────────────\n\n/**\n * LTTB (Largest-Triangle-Three-Buckets): picks `threshold` points from\n * `values` while keeping the shape of the curve. Returns indexes, not points,\n * so the caller can keep the whole row —all its series— and not only the one\n * used to decide.\n *\n * Why not sample every N: every N eats the peaks, which are exactly what\n * someone looks at in a time series. LTTB splits the path into buckets and\n * from each one keeps the point that forms the largest triangle with the one\n * already picked and with the average of the next bucket — that is, the one\n * that changes the silhouette the most. First and last are always kept.\n *\n * `x` is the index: a chart's categories are spread evenly along the axis, so\n * the horizontal distance between two points is their index distance.\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 // One bucket per intermediate point: the first and the last aren't drawn.\n // With `threshold < total` a bucket spans more than one point, so none is\n // empty and exactly `threshold` indexes come out, with no repeats.\n const every = (total - 2) / (threshold - 2);\n let previous = 0;\n\n for (let bucket = 0; bucket < threshold - 2; bucket += 1) {\n // Average of the *next* bucket: the triangle's third vertex. It is\n // offset one bucket from the candidates, and that offset is the\n // algorithm — comparing against the bucket itself doesn't look ahead.\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
21
  },
22
22
  {
23
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"
24
+ "content": "/**\n * Chart's frame: everything around the marks — grid, axes, legend and\n * tooltip. Nothing here knows the chart type; it receives numbers and texts\n * already resolved.\n *\n * Gestalt: the grid uses `border`, two steps below the series' color, so the\n * data stays as figure and the reference as ground. The legend repeats each\n * series' exact color (similarity), which is why it can act as a filter\n * control without explaining what it turns off: whatever has that color\n * turns off.\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.surfaceRaised,\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// ── grid and cartesian axes ────────────────────────────────────────────────\n\nexport interface CartesianGridProps {\n width: number;\n height: number;\n /** Pixel position of each line, along the axis perpendicular to it. */\n lines: number[];\n /** 'horizontal' draws lines running across the width (y axis with ticks). */\n orientation: 'horizontal' | 'vertical';\n /** Index of the zero line, more marked than the rest. */\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 // The zero line is a different reference from the others: it\n // doesn't divide the space, it separates what adds from what subtracts.\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' spreads them across the width below the plot; 'y' stacks them on the left. */\n axis: 'x' | 'y';\n /** Width of the gutter reserved for the labels. */\n gutter: number;\n /** Size of the plot along the labels' axis. */\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 // An x label is centered under its category and can overlap its neighbor;\n // it gets the plot's even share as its width and is truncated to one line.\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// ── radar grid ─────────────────────────────────────────────────────────────\n\nexport interface RadarGridProps {\n center: Point;\n radius: number;\n angles: number[];\n /** Concentric rings, including the edge. */\n levels: number;\n testID?: string;\n}\n\n/**\n * The radar's rings and spokes. The rings are polygons, not circles: a circle\n * suggests there are values between one axis and the next, and there aren't —\n * there are only the axes that were passed in.\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// ── legend ─────────────────────────────────────────────────────────────────\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 /** Without this the legend is only a reference; with it, it is the filter. */\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 /** The point it describes, in container coordinates. */\n anchor: Point;\n /** Available width: the bubble shifts so it doesn't spill out. */\n containerWidth: number;\n style?: StyleProp<ViewStyle>;\n testID?: string;\n}\n\nconst TOOLTIP_WIDTH = 148;\nconst TOOLTIP_GAP = 10;\n/** Estimated bubble height: the real one isn't known until it's measured, and\n * measuring it to decide where to put it would make it show up shifted for a 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 instead of flip: moving it to the other side of the finger makes it\n // jump sides while dragging, and the jump reads as a bug.\n const left = Math.max(0, Math.min(containerWidth - TOOLTIP_WIDTH, anchor.x - TOOLTIP_WIDTH / 2));\n // It goes above the point, unless there is no room: then it goes below,\n // because getting cut off against the top edge makes it unreadable.\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
25
  },
26
26
  {
27
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"
28
+ "content": "/**\n * Chart's categorical palette, derived from the theme.\n *\n * There is deliberately no list of colors here: if there were, a custom\n * `brand` would produce a chart that doesn't look like the rest of the app,\n * and dark mode would have to be maintained by hand. The first series reuse\n * the theme's accents (which already come with contrast resolved for their\n * scheme) and, when they run out, the primary's hue keeps rotating by the\n * golden angle: the 137.5° sequence is the one that takes longest to repeat\n * a similar hue, which is exactly what you ask of a categorical palette.\n *\n * Each color is checked against `surface` at 3:1 — the WCAG 1.4.11 minimum\n * for graphical objects, which is what a bar is — and lightened or darkened\n * until it meets it.\n */\nimport { contrast, toHex, toHsl } from '../../theme/colors';\nimport type { Theme } from '../../theme/types';\n\n/** Minimum contrast of a graphical object against its background (WCAG 1.4.11). */\nexport const GRAPHIC_CONTRAST = 3;\n\nconst GOLDEN_ANGLE = 137.508;\n\n/** Theme accents in order of appearance: hues well apart from one another. */\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 * Pushes the lightness until it stands off the background. The step is small\n * and the cap low because the goal is to correct a color, not invent another:\n * if it didn't get there in 12 tries, the best achieved is returned.\n */\nfunction readable(hex: string, surface: string): string {\n if (contrast(hex, surface) >= GRAPHIC_CONTRAST) return hex;\n\n const hsl = toHsl(hex);\n // Over a light background you have to darken, over a dark one lighten.\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 // Once past the accents, the primary's hue is rotated. `l` alternates one\n // step per turn so two neighboring hues aren't confused if the rotation\n // brings them close.\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 * The color helpers speak hex. A theme with an accent in `rgba()` is legal\n * for React Native and makes `toHsl` throw: in that case it falls back to the\n * accents as they came, cycling. A chart with repeated colors is worse than\n * one with derived colors, but far better than one that doesn't render.\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// One palette per theme, and per theme the longest array requested so far:\n// the cache is shared among all mounted charts, which is the normal case\n// (a dashboard with six charts on the same theme).\nconst cache = new WeakMap<Theme, string[]>();\n\n/** `count` stable colors for the series: the same index always gives the same 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
29
  }
30
30
  ],
31
31
  "registryDependencies": [
@@ -9,10 +9,11 @@
9
9
  "files": [
10
10
  {
11
11
  "path": "atoms/Checkbox.tsx",
12
- "content": "import React, { memo, useEffect, useRef } from 'react';\nimport { Animated, Text, TouchableOpacity } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors } from '../theme/UIProvider';\nimport { Icon } from '../icons/Icon';\n\nexport interface CheckboxProps {\n value: boolean;\n onValueChange: (value: boolean) => void;\n label?: string;\n color?: string;\n disabled?: boolean;\n testID?: string;\n}\n\nconst useStyles = createStyles((theme) => ({\n container: { flexDirection: 'row', alignItems: 'center', marginBottom: theme.tokens.spacing.sm },\n box: {\n width: 24,\n height: 24,\n borderRadius: theme.tokens.radius.sm,\n borderWidth: 2,\n justifyContent: 'center',\n alignItems: 'center',\n marginRight: theme.tokens.spacing.sm + 2,\n },\n label: { fontSize: theme.tokens.fontSize.md, color: theme.colors.text },\n}));\n\nexport const Checkbox = memo(({ value, onValueChange, label, color, disabled, testID }: CheckboxProps) => {\n const colors = useColors();\n const styles = useStyles();\n const allowFontScaling = useAllowFontScaling();\n const activeColor = color || colors.primary;\n // El check va encima del relleno: con `color` propio no sabemos qué tono\n // contrasta, así que ahí seguimos con blanco (decisión del consumidor).\n const checkColor = color ? colors.white : colors.onPrimary;\n const progress = useRef(new Animated.Value(value ? 1 : 0)).current;\n const checkScale = useRef(new Animated.Value(value ? 1 : 0)).current;\n\n useEffect(() => {\n Animated.timing(progress, { toValue: value ? 1 : 0, duration: 150, useNativeDriver: false }).start();\n Animated.spring(checkScale, {\n toValue: value ? 1 : 0,\n useNativeDriver: true,\n speed: 40,\n bounciness: 10,\n }).start();\n }, [value]);\n\n const backgroundColor = progress.interpolate({ inputRange: [0, 1], outputRange: [colors.surface, activeColor] });\n const borderColor = progress.interpolate({ inputRange: [0, 1], outputRange: [colors.border, activeColor] });\n\n return (\n <TouchableOpacity\n style={styles.container}\n activeOpacity={0.7}\n disabled={disabled}\n onPress={() => onValueChange(!value)}\n accessibilityRole=\"checkbox\"\n accessibilityState={{ checked: value, disabled: !!disabled }}\n accessibilityLabel={label}\n testID={testID}\n >\n <Animated.View style={[styles.box, { backgroundColor, borderColor, opacity: disabled ? 0.5 : 1 }]}>\n <Animated.View style={{ transform: [{ scale: checkScale }] }}>\n <Icon name=\"check\" size={16} color={checkColor} />\n </Animated.View>\n </Animated.View>\n {label && (\n <Text allowFontScaling={allowFontScaling} style={styles.label}>\n {label}\n </Text>\n )}\n </TouchableOpacity>\n );\n});\nCheckbox.displayName = 'Checkbox';\n"
12
+ "content": "import React, { memo, useEffect, useRef } from 'react';\nimport { Animated, Text, TouchableOpacity } from 'react-native';\nimport { createStyles } from '../theme/createStyles';\nimport { useAllowFontScaling, useColors, useTheme } from '../theme/UIProvider';\nimport { Icon } from '../icons/Icon';\nimport { onColor } from '../icons/defaultIcons';\nimport { useReduceMotion } from './Transition';\n\nexport interface CheckboxProps {\n /** Whether the box is checked (controlled). */\n value: boolean;\n /** Called with the new value when the row is pressed. */\n onValueChange: (value: boolean) => void;\n /** Text next to the box; the whole row is touchable. Also the accessible name. */\n label?: string;\n /**\n * Fill and border color when checked. The check turns black or white for contrast. @default\n * colors.primary\n */\n color?: string;\n /** Dims the box and the label and ignores presses. */\n disabled?: boolean;\n /** Effectively required without `label`: it is all a screen reader announces. */\n accessibilityLabel?: string;\n testID?: string;\n}\n\nconst useStyles = createStyles((theme) => ({\n // 44pt tall: the whole row is the touch area (Fitts). Replaces the\n // marginBottom that separated stacked checkboxes; now the row provides the room.\n container: { flexDirection: 'row', alignItems: 'center', minHeight: 44 },\n box: {\n width: 24,\n height: 24,\n borderRadius: theme.tokens.radius.sm,\n borderWidth: 2,\n justifyContent: 'center',\n alignItems: 'center',\n marginRight: theme.tokens.spacing.md,\n },\n label: { fontSize: theme.tokens.fontSize.md, color: theme.colors.text },\n labelDisabled: { color: theme.colors.textLight },\n}));\n\n/**\n * Checkbox\n * Box + optional label; the whole row is touchable.\n *\n * Gestalt:\n * - Proximity: the box and its label sit `spacing.md` apart, less than the\n * spacing between rows, so each label reads with its box.\n * - Common region: the whole row is the touch area, not just the box, and it\n * is at least 44pt tall (Fitts).\n * - Figure and ground: when checked, the box fills with the accent and the\n * check turns black or white depending on that fill (also with a custom `color`).\n *\n * `disabled` dims the box and the label (not just the box). The check\n * animation is skipped with Reduce Motion.\n */\nexport const Checkbox = memo(({ value, onValueChange, label, color, disabled, accessibilityLabel, testID }: CheckboxProps) => {\n const colors = useColors();\n const styles = useStyles();\n const allowFontScaling = useAllowFontScaling();\n const activeColor = color || colors.primary;\n // The check sits on top of the fill: with a custom `color`, black or white is\n // picked by luminance (a fixed white disappeared over light colors).\n const checkColor = color ? onColor(color) : colors.onPrimary;\n const theme = useTheme();\n const reduceMotion = useReduceMotion();\n const progress = useRef(new Animated.Value(value ? 1 : 0)).current;\n const checkScale = useRef(new Animated.Value(value ? 1 : 0)).current;\n\n useEffect(() => {\n if (reduceMotion) {\n progress.setValue(value ? 1 : 0);\n checkScale.setValue(value ? 1 : 0);\n return;\n }\n Animated.timing(progress, { toValue: value ? 1 : 0, duration: theme.tokens.duration.fast, useNativeDriver: false }).start();\n Animated.spring(checkScale, {\n toValue: value ? 1 : 0,\n useNativeDriver: true,\n speed: 40,\n bounciness: 10,\n }).start();\n }, [value, reduceMotion]);\n\n const backgroundColor = progress.interpolate({ inputRange: [0, 1], outputRange: [colors.surface, activeColor] });\n const borderColor = progress.interpolate({ inputRange: [0, 1], outputRange: [colors.border, activeColor] });\n\n return (\n <TouchableOpacity\n style={styles.container}\n activeOpacity={0.7}\n disabled={disabled}\n onPress={() => onValueChange(!value)}\n accessibilityRole=\"checkbox\"\n accessibilityState={{ checked: value, disabled: !!disabled }}\n accessibilityLabel={accessibilityLabel ?? label}\n testID={testID}\n >\n <Animated.View style={[styles.box, { backgroundColor, borderColor, opacity: disabled ? 0.5 : 1 }]}>\n <Animated.View style={{ transform: [{ scale: checkScale }] }}>\n <Icon name=\"check\" size={16} color={checkColor} />\n </Animated.View>\n </Animated.View>\n {!!label && (\n <Text allowFontScaling={allowFontScaling} style={[styles.label, disabled && styles.labelDisabled]}>\n {label}\n </Text>\n )}\n </TouchableOpacity>\n );\n});\nCheckbox.displayName = 'Checkbox';\n"
13
13
  }
14
14
  ],
15
15
  "registryDependencies": [
16
- "core"
16
+ "core",
17
+ "transition"
17
18
  ]
18
19
  }