number-flow-react-native 0.1.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 (245) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +44 -0
  3. package/lib/module/core/constants.js +21 -0
  4. package/lib/module/core/constants.js.map +1 -0
  5. package/lib/module/core/intlHelpers.js +310 -0
  6. package/lib/module/core/intlHelpers.js.map +1 -0
  7. package/lib/module/core/layout.js +71 -0
  8. package/lib/module/core/layout.js.map +1 -0
  9. package/lib/module/core/mask.js +50 -0
  10. package/lib/module/core/mask.js.map +1 -0
  11. package/lib/module/core/numerals/detection.js +105 -0
  12. package/lib/module/core/numerals/detection.js.map +1 -0
  13. package/lib/module/core/numerals/digits.js +128 -0
  14. package/lib/module/core/numerals/digits.js.map +1 -0
  15. package/lib/module/core/numerals/index.js +5 -0
  16. package/lib/module/core/numerals/index.js.map +1 -0
  17. package/lib/module/core/numerals/tables.js +114 -0
  18. package/lib/module/core/numerals/tables.js.map +1 -0
  19. package/lib/module/core/superscript.js +31 -0
  20. package/lib/module/core/superscript.js.map +1 -0
  21. package/lib/module/core/timeLayout.js +98 -0
  22. package/lib/module/core/timeLayout.js.map +1 -0
  23. package/lib/module/core/timeTypes.js +4 -0
  24. package/lib/module/core/timeTypes.js.map +1 -0
  25. package/lib/module/core/timing.js +45 -0
  26. package/lib/module/core/timing.js.map +1 -0
  27. package/lib/module/core/types.js +58 -0
  28. package/lib/module/core/types.js.map +1 -0
  29. package/lib/module/core/useAccessibilityAnnouncement.js +27 -0
  30. package/lib/module/core/useAccessibilityAnnouncement.js.map +1 -0
  31. package/lib/module/core/useAnimatedX.js +25 -0
  32. package/lib/module/core/useAnimatedX.js.map +1 -0
  33. package/lib/module/core/useAnimationLifecycle.js +37 -0
  34. package/lib/module/core/useAnimationLifecycle.js.map +1 -0
  35. package/lib/module/core/useCanAnimate.js +22 -0
  36. package/lib/module/core/useCanAnimate.js.map +1 -0
  37. package/lib/module/core/useContinuousSpin.js +89 -0
  38. package/lib/module/core/useContinuousSpin.js.map +1 -0
  39. package/lib/module/core/useDebouncedWidths.js +74 -0
  40. package/lib/module/core/useDebouncedWidths.js.map +1 -0
  41. package/lib/module/core/useDigitAnimation.js +138 -0
  42. package/lib/module/core/useDigitAnimation.js.map +1 -0
  43. package/lib/module/core/useFlowPipeline.js +85 -0
  44. package/lib/module/core/useFlowPipeline.js.map +1 -0
  45. package/lib/module/core/useFormattedValue.js +28 -0
  46. package/lib/module/core/useFormattedValue.js.map +1 -0
  47. package/lib/module/core/useLayoutDiff.js +59 -0
  48. package/lib/module/core/useLayoutDiff.js.map +1 -0
  49. package/lib/module/core/useNumberFormatting.js +158 -0
  50. package/lib/module/core/useNumberFormatting.js.map +1 -0
  51. package/lib/module/core/useSlotOpacity.js +53 -0
  52. package/lib/module/core/useSlotOpacity.js.map +1 -0
  53. package/lib/module/core/useTimeFormatting.js +74 -0
  54. package/lib/module/core/useTimeFormatting.js.map +1 -0
  55. package/lib/module/core/useTimingResolution.js +21 -0
  56. package/lib/module/core/useTimingResolution.js.map +1 -0
  57. package/lib/module/core/useWorkletFormatting.js +49 -0
  58. package/lib/module/core/useWorkletFormatting.js.map +1 -0
  59. package/lib/module/core/utils.js +132 -0
  60. package/lib/module/core/utils.js.map +1 -0
  61. package/lib/module/core/warnings.js +10 -0
  62. package/lib/module/core/warnings.js.map +1 -0
  63. package/lib/module/index.js +7 -0
  64. package/lib/module/index.js.map +1 -0
  65. package/lib/module/native/DigitSlot.js +163 -0
  66. package/lib/module/native/DigitSlot.js.map +1 -0
  67. package/lib/module/native/NumberFlow.js +244 -0
  68. package/lib/module/native/NumberFlow.js.map +1 -0
  69. package/lib/module/native/SymbolSlot.js +52 -0
  70. package/lib/module/native/SymbolSlot.js.map +1 -0
  71. package/lib/module/native/TimeFlow.js +270 -0
  72. package/lib/module/native/TimeFlow.js.map +1 -0
  73. package/lib/module/native/index.js +5 -0
  74. package/lib/module/native/index.js.map +1 -0
  75. package/lib/module/native/renderSlots.js +108 -0
  76. package/lib/module/native/renderSlots.js.map +1 -0
  77. package/lib/module/native/types.js +4 -0
  78. package/lib/module/native/types.js.map +1 -0
  79. package/lib/module/native/useMeasuredGlyphMetrics.js +156 -0
  80. package/lib/module/native/useMeasuredGlyphMetrics.js.map +1 -0
  81. package/lib/module/package.json +1 -0
  82. package/lib/module/skia/DigitSlot.js +171 -0
  83. package/lib/module/skia/DigitSlot.js.map +1 -0
  84. package/lib/module/skia/SkiaNumberFlow.js +430 -0
  85. package/lib/module/skia/SkiaNumberFlow.js.map +1 -0
  86. package/lib/module/skia/SkiaTimeFlow.js +226 -0
  87. package/lib/module/skia/SkiaTimeFlow.js.map +1 -0
  88. package/lib/module/skia/SymbolSlot.js +92 -0
  89. package/lib/module/skia/SymbolSlot.js.map +1 -0
  90. package/lib/module/skia/index.js +6 -0
  91. package/lib/module/skia/index.js.map +1 -0
  92. package/lib/module/skia/renderSlots.js +131 -0
  93. package/lib/module/skia/renderSlots.js.map +1 -0
  94. package/lib/module/skia/useGlyphMetrics.js +72 -0
  95. package/lib/module/skia/useGlyphMetrics.js.map +1 -0
  96. package/lib/module/skia/useScrubbing.js +165 -0
  97. package/lib/module/skia/useScrubbing.js.map +1 -0
  98. package/lib/module/skia/useSkiaFont.js +23 -0
  99. package/lib/module/skia/useSkiaFont.js.map +1 -0
  100. package/lib/typescript/core/constants.d.ts +15 -0
  101. package/lib/typescript/core/constants.d.ts.map +1 -0
  102. package/lib/typescript/core/intlHelpers.d.ts +10 -0
  103. package/lib/typescript/core/intlHelpers.d.ts.map +1 -0
  104. package/lib/typescript/core/layout.d.ts +22 -0
  105. package/lib/typescript/core/layout.d.ts.map +1 -0
  106. package/lib/typescript/core/mask.d.ts +18 -0
  107. package/lib/typescript/core/mask.d.ts.map +1 -0
  108. package/lib/typescript/core/numerals/detection.d.ts +17 -0
  109. package/lib/typescript/core/numerals/detection.d.ts.map +1 -0
  110. package/lib/typescript/core/numerals/digits.d.ts +43 -0
  111. package/lib/typescript/core/numerals/digits.d.ts.map +1 -0
  112. package/lib/typescript/core/numerals/index.d.ts +3 -0
  113. package/lib/typescript/core/numerals/index.d.ts.map +1 -0
  114. package/lib/typescript/core/numerals/tables.d.ts +32 -0
  115. package/lib/typescript/core/numerals/tables.d.ts.map +1 -0
  116. package/lib/typescript/core/superscript.d.ts +16 -0
  117. package/lib/typescript/core/superscript.d.ts.map +1 -0
  118. package/lib/typescript/core/timeLayout.d.ts +19 -0
  119. package/lib/typescript/core/timeLayout.d.ts.map +1 -0
  120. package/lib/typescript/core/timeTypes.d.ts +80 -0
  121. package/lib/typescript/core/timeTypes.d.ts.map +1 -0
  122. package/lib/typescript/core/timing.d.ts +6 -0
  123. package/lib/typescript/core/timing.d.ts.map +1 -0
  124. package/lib/typescript/core/types.d.ts +165 -0
  125. package/lib/typescript/core/types.d.ts.map +1 -0
  126. package/lib/typescript/core/useAccessibilityAnnouncement.d.ts +10 -0
  127. package/lib/typescript/core/useAccessibilityAnnouncement.d.ts.map +1 -0
  128. package/lib/typescript/core/useAnimatedX.d.ts +9 -0
  129. package/lib/typescript/core/useAnimatedX.d.ts.map +1 -0
  130. package/lib/typescript/core/useAnimationLifecycle.d.ts +14 -0
  131. package/lib/typescript/core/useAnimationLifecycle.d.ts.map +1 -0
  132. package/lib/typescript/core/useCanAnimate.d.ts +14 -0
  133. package/lib/typescript/core/useCanAnimate.d.ts.map +1 -0
  134. package/lib/typescript/core/useContinuousSpin.d.ts +23 -0
  135. package/lib/typescript/core/useContinuousSpin.d.ts.map +1 -0
  136. package/lib/typescript/core/useDebouncedWidths.d.ts +17 -0
  137. package/lib/typescript/core/useDebouncedWidths.d.ts.map +1 -0
  138. package/lib/typescript/core/useDigitAnimation.d.ts +38 -0
  139. package/lib/typescript/core/useDigitAnimation.d.ts.map +1 -0
  140. package/lib/typescript/core/useFlowPipeline.d.ts +46 -0
  141. package/lib/typescript/core/useFlowPipeline.d.ts.map +1 -0
  142. package/lib/typescript/core/useFormattedValue.d.ts +14 -0
  143. package/lib/typescript/core/useFormattedValue.d.ts.map +1 -0
  144. package/lib/typescript/core/useLayoutDiff.d.ts +18 -0
  145. package/lib/typescript/core/useLayoutDiff.d.ts.map +1 -0
  146. package/lib/typescript/core/useNumberFormatting.d.ts +18 -0
  147. package/lib/typescript/core/useNumberFormatting.d.ts.map +1 -0
  148. package/lib/typescript/core/useSlotOpacity.d.ts +18 -0
  149. package/lib/typescript/core/useSlotOpacity.d.ts.map +1 -0
  150. package/lib/typescript/core/useTimeFormatting.d.ts +22 -0
  151. package/lib/typescript/core/useTimeFormatting.d.ts.map +1 -0
  152. package/lib/typescript/core/useTimingResolution.d.ts +13 -0
  153. package/lib/typescript/core/useTimingResolution.d.ts.map +1 -0
  154. package/lib/typescript/core/useWorkletFormatting.d.ts +14 -0
  155. package/lib/typescript/core/useWorkletFormatting.d.ts.map +1 -0
  156. package/lib/typescript/core/utils.d.ts +44 -0
  157. package/lib/typescript/core/utils.d.ts.map +1 -0
  158. package/lib/typescript/core/warnings.d.ts +2 -0
  159. package/lib/typescript/core/warnings.d.ts.map +1 -0
  160. package/lib/typescript/index.d.ts +8 -0
  161. package/lib/typescript/index.d.ts.map +1 -0
  162. package/lib/typescript/native/DigitSlot.d.ts +27 -0
  163. package/lib/typescript/native/DigitSlot.d.ts.map +1 -0
  164. package/lib/typescript/native/NumberFlow.d.ts +3 -0
  165. package/lib/typescript/native/NumberFlow.d.ts.map +1 -0
  166. package/lib/typescript/native/SymbolSlot.d.ts +19 -0
  167. package/lib/typescript/native/SymbolSlot.d.ts.map +1 -0
  168. package/lib/typescript/native/TimeFlow.d.ts +3 -0
  169. package/lib/typescript/native/TimeFlow.d.ts.map +1 -0
  170. package/lib/typescript/native/index.d.ts +3 -0
  171. package/lib/typescript/native/index.d.ts.map +1 -0
  172. package/lib/typescript/native/renderSlots.d.ts +31 -0
  173. package/lib/typescript/native/renderSlots.d.ts.map +1 -0
  174. package/lib/typescript/native/types.d.ts +36 -0
  175. package/lib/typescript/native/types.d.ts.map +1 -0
  176. package/lib/typescript/native/useMeasuredGlyphMetrics.d.ts +8 -0
  177. package/lib/typescript/native/useMeasuredGlyphMetrics.d.ts.map +1 -0
  178. package/lib/typescript/package.json +1 -0
  179. package/lib/typescript/skia/DigitSlot.d.ts +35 -0
  180. package/lib/typescript/skia/DigitSlot.d.ts.map +1 -0
  181. package/lib/typescript/skia/SkiaNumberFlow.d.ts +3 -0
  182. package/lib/typescript/skia/SkiaNumberFlow.d.ts.map +1 -0
  183. package/lib/typescript/skia/SkiaTimeFlow.d.ts +3 -0
  184. package/lib/typescript/skia/SkiaTimeFlow.d.ts.map +1 -0
  185. package/lib/typescript/skia/SymbolSlot.d.ts +26 -0
  186. package/lib/typescript/skia/SymbolSlot.d.ts.map +1 -0
  187. package/lib/typescript/skia/index.d.ts +6 -0
  188. package/lib/typescript/skia/index.d.ts.map +1 -0
  189. package/lib/typescript/skia/renderSlots.d.ts +40 -0
  190. package/lib/typescript/skia/renderSlots.d.ts.map +1 -0
  191. package/lib/typescript/skia/useGlyphMetrics.d.ts +16 -0
  192. package/lib/typescript/skia/useGlyphMetrics.d.ts.map +1 -0
  193. package/lib/typescript/skia/useScrubbing.d.ts +59 -0
  194. package/lib/typescript/skia/useScrubbing.d.ts.map +1 -0
  195. package/lib/typescript/skia/useSkiaFont.d.ts +13 -0
  196. package/lib/typescript/skia/useSkiaFont.d.ts.map +1 -0
  197. package/package.json +104 -0
  198. package/src/core/constants.ts +20 -0
  199. package/src/core/intlHelpers.ts +351 -0
  200. package/src/core/layout.ts +108 -0
  201. package/src/core/mask.ts +72 -0
  202. package/src/core/numerals/detection.ts +112 -0
  203. package/src/core/numerals/digits.ts +102 -0
  204. package/src/core/numerals/index.ts +9 -0
  205. package/src/core/numerals/tables.ts +112 -0
  206. package/src/core/superscript.ts +27 -0
  207. package/src/core/timeLayout.ts +119 -0
  208. package/src/core/timeTypes.ts +88 -0
  209. package/src/core/timing.ts +60 -0
  210. package/src/core/types.ts +189 -0
  211. package/src/core/useAccessibilityAnnouncement.ts +27 -0
  212. package/src/core/useAnimatedX.ts +30 -0
  213. package/src/core/useAnimationLifecycle.ts +54 -0
  214. package/src/core/useCanAnimate.ts +21 -0
  215. package/src/core/useContinuousSpin.ts +112 -0
  216. package/src/core/useDebouncedWidths.ts +93 -0
  217. package/src/core/useDigitAnimation.ts +192 -0
  218. package/src/core/useFlowPipeline.ts +126 -0
  219. package/src/core/useFormattedValue.ts +32 -0
  220. package/src/core/useLayoutDiff.ts +71 -0
  221. package/src/core/useNumberFormatting.ts +164 -0
  222. package/src/core/useSlotOpacity.ts +66 -0
  223. package/src/core/useTimeFormatting.ts +95 -0
  224. package/src/core/useTimingResolution.ts +47 -0
  225. package/src/core/useWorkletFormatting.ts +59 -0
  226. package/src/core/utils.ts +149 -0
  227. package/src/core/warnings.ts +8 -0
  228. package/src/index.ts +15 -0
  229. package/src/native/DigitSlot.tsx +203 -0
  230. package/src/native/NumberFlow.tsx +287 -0
  231. package/src/native/SymbolSlot.tsx +68 -0
  232. package/src/native/TimeFlow.tsx +287 -0
  233. package/src/native/index.ts +2 -0
  234. package/src/native/renderSlots.tsx +150 -0
  235. package/src/native/types.ts +40 -0
  236. package/src/native/useMeasuredGlyphMetrics.tsx +205 -0
  237. package/src/skia/DigitSlot.tsx +221 -0
  238. package/src/skia/SkiaNumberFlow.tsx +506 -0
  239. package/src/skia/SkiaTimeFlow.tsx +257 -0
  240. package/src/skia/SymbolSlot.tsx +120 -0
  241. package/src/skia/index.ts +5 -0
  242. package/src/skia/renderSlots.tsx +180 -0
  243. package/src/skia/useGlyphMetrics.ts +79 -0
  244. package/src/skia/useScrubbing.ts +223 -0
  245. package/src/skia/useSkiaFont.ts +25 -0
@@ -0,0 +1,351 @@
1
+ import { MEASURABLE_CHARS } from "./constants";
2
+ import {
3
+ detectNumberingSystem,
4
+ detectOutputZeroCodePoint,
5
+ getDigitStrings,
6
+ getZeroCodePoint,
7
+ isLocaleDigit,
8
+ } from "./numerals";
9
+
10
+ /**
11
+ * Avoids expensive new Intl.NumberFormat() on every render when callers pass
12
+ * inline format objects (which create new references each render).
13
+ */
14
+ const formatterCache = new Map<string, Intl.NumberFormat>();
15
+
16
+ export function getOrCreateFormatter(
17
+ locales?: Intl.LocalesArgument,
18
+ format?: Intl.NumberFormatOptions,
19
+ ): Intl.NumberFormat {
20
+ const key = JSON.stringify([locales, format]);
21
+ let cached = formatterCache.get(key);
22
+ if (!cached) {
23
+ cached = new Intl.NumberFormat(locales, format);
24
+ formatterCache.set(key, cached);
25
+ }
26
+ return cached;
27
+ }
28
+
29
+ /**
30
+ * Probes the formatter with a sample number that exercises group separators,
31
+ * decimal separator, and currency/percent symbols. Returns only characters NOT
32
+ * already in MEASURABLE_CHARS — this keeps the result stable across different
33
+ * prefix/suffix combinations and prevents native measurement cache invalidation.
34
+ */
35
+ const formatCharsCache = new Map<string, string>();
36
+
37
+ export function getFormatCharacters(
38
+ locales?: Intl.LocalesArgument,
39
+ format?: Intl.NumberFormatOptions,
40
+ prefix = "",
41
+ suffix = "",
42
+ ): string {
43
+ const key = JSON.stringify([locales, format, prefix, suffix]);
44
+ const cached = formatCharsCache.get(key);
45
+ if (cached !== undefined) return cached;
46
+
47
+ const formatter = getOrCreateFormatter(locales, format);
48
+ const numberingSystem = detectNumberingSystem(locales, format);
49
+ const zeroCP = getZeroCodePoint(numberingSystem);
50
+
51
+ // Sample that exercises: group separators, decimal, sign, large integers
52
+ const probes = [1234567.89, -1234567.89];
53
+ const chars = new Set<string>();
54
+
55
+ for (const probe of probes) {
56
+ for (const ch of formatter.format(probe)) {
57
+ const code = ch.charCodeAt(0);
58
+ // Skip digits in both Latin and the locale's numbering system
59
+ const isLatinDigit = code >= 48 && code <= 57;
60
+ const isLocale = isLocaleDigit(code, zeroCP);
61
+ if (!isLatinDigit && !isLocale) chars.add(ch);
62
+ }
63
+ }
64
+ // Scientific/engineering notation replaces E with ×10 in our display
65
+ if (format?.notation === "scientific" || format?.notation === "engineering") {
66
+ chars.add("\u00D7"); // × (multiplication sign)
67
+ }
68
+
69
+ // Add locale digit strings so they get measured
70
+ const digitStrings = getDigitStrings(numberingSystem);
71
+ for (const ds of digitStrings) chars.add(ds);
72
+
73
+ // Also include prefix/suffix chars.
74
+ for (const ch of prefix) chars.add(ch);
75
+ for (const ch of suffix) chars.add(ch);
76
+
77
+ /**
78
+ * Only return chars NOT already in MEASURABLE_CHARS — this keeps the result
79
+ * stable across prefix/suffix changes and avoids native measurement cache misses.
80
+ */
81
+ const result = Array.from(chars)
82
+ .filter((c) => !MEASURABLE_CHARS.includes(c))
83
+ .join("");
84
+ formatCharsCache.set(key, result);
85
+ return result;
86
+ }
87
+
88
+ const decimalSepCache = new Map<string, string>();
89
+
90
+ function detectDecimalSeparator(locales?: Intl.LocalesArgument): string {
91
+ const key = JSON.stringify(locales);
92
+ const cached = decimalSepCache.get(key);
93
+ if (cached) return cached;
94
+
95
+ let sep = ".";
96
+ try {
97
+ const fmt = new Intl.NumberFormat(locales, {
98
+ minimumFractionDigits: 1,
99
+ maximumFractionDigits: 1,
100
+ useGrouping: false,
101
+ });
102
+ const str = fmt.format(1.5);
103
+ const zeroCP = detectOutputZeroCodePoint(str);
104
+ for (const ch of str) {
105
+ const code = ch.charCodeAt(0);
106
+ if (!isLocaleDigit(code, zeroCP) && !(code >= 48 && code <= 57)) {
107
+ sep = ch;
108
+ break;
109
+ }
110
+ }
111
+ } catch {}
112
+
113
+ decimalSepCache.set(key, sep);
114
+ return sep;
115
+ }
116
+
117
+ /**
118
+ * Parses the mantissa portion of a formatted number string into typed parts.
119
+ * Handles integer digits, fraction digits, decimal separators, signs, and group separators.
120
+ */
121
+ function parseMantissa(
122
+ mantissa: string,
123
+ decimalSep: string,
124
+ parts: Intl.NumberFormatPart[],
125
+ zeroCodePoint = 48,
126
+ ): void {
127
+ /**
128
+ * Search from right — in some locales the decimal sep char is also
129
+ * used as a group separator, so leftmost match could be wrong.
130
+ */
131
+ let decimalPos = -1;
132
+ for (let i = mantissa.length - 1; i >= 0; i--) {
133
+ if (mantissa[i] === decimalSep) {
134
+ let hasDigitAfter = false;
135
+ for (let j = i + 1; j < mantissa.length; j++) {
136
+ if (isLocaleDigit(mantissa[j].charCodeAt(0), zeroCodePoint)) {
137
+ hasDigitAfter = true;
138
+ break;
139
+ }
140
+ }
141
+ if (hasDigitAfter) {
142
+ decimalPos = i;
143
+ break;
144
+ }
145
+ }
146
+ }
147
+
148
+ let buf = "";
149
+ let inFraction = false;
150
+
151
+ const flush = () => {
152
+ if (buf) {
153
+ parts.push({ type: inFraction ? "fraction" : "integer", value: buf });
154
+ buf = "";
155
+ }
156
+ };
157
+
158
+ for (let i = 0; i < mantissa.length; i++) {
159
+ const ch = mantissa[i];
160
+
161
+ if (i === decimalPos) {
162
+ flush();
163
+ parts.push({ type: "decimal", value: ch });
164
+ inFraction = true;
165
+ continue;
166
+ }
167
+
168
+ if (isLocaleDigit(ch.charCodeAt(0), zeroCodePoint)) {
169
+ buf += ch;
170
+ } else if (ch === "-") {
171
+ flush();
172
+ parts.push({ type: "minusSign", value: ch });
173
+ } else if (ch === "+") {
174
+ flush();
175
+ parts.push({ type: "plusSign", value: ch });
176
+ } else if (!inFraction) {
177
+ flush();
178
+ const prevDigit = i > 0 && isLocaleDigit(mantissa[i - 1].charCodeAt(0), zeroCodePoint);
179
+ const nextDigit =
180
+ i < mantissa.length - 1 && isLocaleDigit(mantissa[i + 1].charCodeAt(0), zeroCodePoint);
181
+ parts.push({
182
+ type: prevDigit && nextDigit ? "group" : "literal",
183
+ value: ch,
184
+ });
185
+ } else {
186
+ flush();
187
+ parts.push({ type: "literal", value: ch });
188
+ }
189
+ }
190
+
191
+ flush();
192
+ }
193
+
194
+ /**
195
+ * Parses a formatted number string (with optional E exponent) into typed parts.
196
+ * Separated from fallbackFormatToParts so it can be reused for polyfill strings.
197
+ */
198
+ function parseNumberString(
199
+ formatted: string,
200
+ decimalSep: string,
201
+ zeroCodePoint = 48,
202
+ ): Intl.NumberFormatPart[] {
203
+ // Detect exponent separator (E or e) — split into mantissa + exponent
204
+ let exponentPos = -1;
205
+ for (let i = 0; i < formatted.length; i++) {
206
+ if (formatted[i] === "E" || formatted[i] === "e") {
207
+ exponentPos = i;
208
+ break;
209
+ }
210
+ }
211
+
212
+ const mantissa = exponentPos >= 0 ? formatted.slice(0, exponentPos) : formatted;
213
+ const parts: Intl.NumberFormatPart[] = [];
214
+
215
+ parseMantissa(mantissa, decimalSep, parts, zeroCodePoint);
216
+
217
+ if (exponentPos >= 0) {
218
+ parts.push({
219
+ type: "exponentSeparator" as string,
220
+ value: formatted[exponentPos],
221
+ } as Intl.NumberFormatPart);
222
+
223
+ let expBuf = "";
224
+ for (let i = exponentPos + 1; i < formatted.length; i++) {
225
+ const ch = formatted[i];
226
+
227
+ if (ch === "-") {
228
+ parts.push({ type: "exponentMinusSign" as string, value: ch } as Intl.NumberFormatPart);
229
+ } else if (ch === "+") {
230
+ parts.push({ type: "exponentPlusSign" as string, value: ch } as Intl.NumberFormatPart);
231
+ } else if (isLocaleDigit(ch.charCodeAt(0), zeroCodePoint)) {
232
+ expBuf += ch;
233
+ }
234
+ }
235
+
236
+ if (expBuf) {
237
+ parts.push({ type: "exponentInteger" as string, value: expBuf } as Intl.NumberFormatPart);
238
+ }
239
+ }
240
+
241
+ return parts;
242
+ }
243
+
244
+ /**
245
+ * Hermes has Intl.NumberFormat but may lack formatToParts(). This fallback
246
+ * uses format() and parses the resulting string into typed parts.
247
+ */
248
+ function fallbackFormatToParts(
249
+ formatter: Intl.NumberFormat,
250
+ value: number,
251
+ locales?: Intl.LocalesArgument,
252
+ ): Intl.NumberFormatPart[] {
253
+ const formatted = formatter.format(value);
254
+ const zeroCp = detectOutputZeroCodePoint(formatted);
255
+ return parseNumberString(formatted, detectDecimalSeparator(locales), zeroCp);
256
+ }
257
+
258
+ /**
259
+ * Manually computes an engineering notation string for values where the
260
+ * platform's Intl.NumberFormat doesn't support notation: "engineering"
261
+ * (notably iOS Hermes, which uses NSNumberFormatter under the hood).
262
+ *
263
+ * Engineering notation: exponent is always a multiple of 3, mantissa has 1-3 integer digits.
264
+ */
265
+ function computeEngineeringString(
266
+ value: number,
267
+ resolved: Intl.ResolvedNumberFormatOptions,
268
+ ): string {
269
+ if (value === 0) return "0E0";
270
+
271
+ const negative = value < 0;
272
+ const abs = Math.abs(value);
273
+ const logFloor = Math.floor(Math.log10(abs));
274
+ const exp = 3 * Math.floor(logFloor / 3);
275
+ const mantissa = abs / 10 ** exp;
276
+
277
+ const maxFrac = resolved.maximumFractionDigits ?? 0;
278
+ const minFrac = resolved.minimumFractionDigits ?? 0;
279
+
280
+ let mantissaStr = mantissa.toFixed(maxFrac);
281
+
282
+ // Trim trailing zeros beyond minFrac
283
+ const dotIdx = mantissaStr.indexOf(".");
284
+ if (dotIdx >= 0) {
285
+ let end = mantissaStr.length;
286
+ const minEnd = dotIdx + 1 + minFrac;
287
+
288
+ while (end > minEnd && mantissaStr[end - 1] === "0") {
289
+ end--;
290
+ }
291
+
292
+ // Remove the dot if no fraction digits remain
293
+ if (end <= dotIdx + 1 && minFrac === 0) {
294
+ end = dotIdx;
295
+ }
296
+
297
+ mantissaStr = mantissaStr.slice(0, end);
298
+ }
299
+
300
+ const sign = negative ? "-" : "";
301
+ return `${sign}${mantissaStr}E${exp}`;
302
+ }
303
+
304
+ /**
305
+ * Safe wrapper around formatToParts that handles:
306
+ * 1. Missing formatToParts (Hermes fallback)
307
+ * 2. Broken formatToParts that returns all "literal" parts (non-Latin locales on Hermes)
308
+ * 3. Missing engineering notation support (iOS Hermes)
309
+ */
310
+ export function safeFormatToParts(
311
+ formatter: Intl.NumberFormat,
312
+ value: number,
313
+ locales?: Intl.LocalesArgument,
314
+ ): Intl.NumberFormatPart[] {
315
+ let parts: Intl.NumberFormatPart[];
316
+
317
+ if (typeof formatter.formatToParts === "function") {
318
+ parts = formatter.formatToParts(value);
319
+
320
+ /**
321
+ * Hermes may return formatToParts with all parts as "literal" for
322
+ * non-Latin locales, or misclassify digit characters. Validate that
323
+ * at least one "integer" or "fraction" part exists for non-zero values.
324
+ * If not, fall back to our manual parser which detects digits by codepoint.
325
+ */
326
+ if (value !== 0) {
327
+ const hasDigitParts = parts.some((p) => p.type === "integer" || p.type === "fraction");
328
+ if (!hasDigitParts) {
329
+ parts = fallbackFormatToParts(formatter, value, locales);
330
+ }
331
+ }
332
+ } else {
333
+ parts = fallbackFormatToParts(formatter, value, locales);
334
+ }
335
+
336
+ // iOS Hermes uses NSNumberFormatter which doesn't support engineering notation.
337
+ // It silently falls back to decimal formatting, producing no exponent parts.
338
+ // Detect this and manually compute the engineering representation.
339
+ const hasExponent = parts.some((p) => (p.type as string) === "exponentSeparator");
340
+
341
+ if (!hasExponent && Number.isFinite(value)) {
342
+ const notation = (formatter.resolvedOptions() as unknown as Record<string, unknown>).notation;
343
+
344
+ if (notation === "engineering") {
345
+ const str = computeEngineeringString(value, formatter.resolvedOptions());
346
+ return parseNumberString(str, ".");
347
+ }
348
+ }
349
+
350
+ return parts;
351
+ }
@@ -0,0 +1,108 @@
1
+ import { SUPERSCRIPT_SCALE } from "./constants";
2
+ import type { GlyphMetrics, KeyedPart, TextAlign } from "./types";
3
+ import { isDigitChar, localeDigitValue } from "./numerals";
4
+
5
+ /**
6
+ * Assigns x positions to each entry based on text alignment.
7
+ * Mutates the `x` field of each entry in place.
8
+ * Worklet-safe. When `precomputedContentWidth` is provided, skips the sum loop.
9
+ */
10
+ export function assignXPositions(
11
+ chars: { x: number; width: number }[],
12
+ totalWidth: number,
13
+ textAlign: TextAlign,
14
+ precomputedContentWidth?: number,
15
+ ): void {
16
+ "worklet";
17
+ let contentWidth = precomputedContentWidth ?? 0;
18
+ if (precomputedContentWidth === undefined) {
19
+ for (const entry of chars) contentWidth += entry.width;
20
+ }
21
+
22
+ let startX = 0;
23
+ if (textAlign === "right") startX = totalWidth - contentWidth;
24
+ else if (textAlign === "center") startX = (totalWidth - contentWidth) / 2;
25
+
26
+ let currentX = startX;
27
+ for (const entry of chars) {
28
+ entry.x = currentX;
29
+ currentX += entry.width;
30
+ }
31
+ }
32
+
33
+ export interface CharLayout {
34
+ key: string;
35
+ char: string;
36
+ isDigit: boolean;
37
+ digitValue: number;
38
+ x: number;
39
+ width: number;
40
+ superscript?: boolean;
41
+ }
42
+
43
+ export function computeKeyedLayout(
44
+ parts: KeyedPart[],
45
+ metrics: GlyphMetrics,
46
+ totalWidth: number,
47
+ textAlign: TextAlign,
48
+ localeDigitStrings?: string[],
49
+ ): CharLayout[] {
50
+ const chars: CharLayout[] = [];
51
+
52
+ for (const part of parts) {
53
+ const isSuperscript =
54
+ part.key.startsWith("exponentInteger:") || part.key.startsWith("exponentSign:");
55
+
56
+ // For digit parts, look up the width of the locale digit character
57
+ // (what DigitSlot actually renders) rather than the format output character.
58
+ // On Hermes, format() may output Latin "4" but DigitSlot renders e.g. "四".
59
+ let displayChar = part.char;
60
+ if (part.type === "digit" && localeDigitStrings && part.digitValue >= 0) {
61
+ displayChar = localeDigitStrings[part.digitValue];
62
+ }
63
+
64
+ const rawWidth = metrics.charWidths[displayChar] ?? metrics.maxDigitWidth;
65
+ const width = isSuperscript ? rawWidth * SUPERSCRIPT_SCALE : rawWidth;
66
+
67
+ chars.push({
68
+ key: part.key,
69
+ char: part.char,
70
+ isDigit: part.type === "digit",
71
+ digitValue: part.digitValue,
72
+ x: 0,
73
+ width,
74
+ superscript: isSuperscript,
75
+ });
76
+ }
77
+
78
+ assignXPositions(chars, totalWidth, textAlign);
79
+ return chars;
80
+ }
81
+
82
+ export function computeStringLayout(
83
+ text: string,
84
+ metrics: GlyphMetrics,
85
+ totalWidth: number,
86
+ textAlign: TextAlign,
87
+ zeroCodePoint = 48,
88
+ ): CharLayout[] {
89
+ const chars: CharLayout[] = [];
90
+
91
+ for (let i = 0; i < text.length; i++) {
92
+ const char = text[i];
93
+ const digit = isDigitChar(char, zeroCodePoint);
94
+ const width = metrics.charWidths[char] ?? metrics.maxDigitWidth;
95
+
96
+ chars.push({
97
+ key: `pos:${i}`,
98
+ char,
99
+ isDigit: digit,
100
+ digitValue: digit ? localeDigitValue(char.charCodeAt(0), zeroCodePoint) : -1,
101
+ x: 0,
102
+ width,
103
+ });
104
+ }
105
+
106
+ assignXPositions(chars, totalWidth, textAlign);
107
+ return chars;
108
+ }
@@ -0,0 +1,72 @@
1
+ import type { CharLayout } from "./layout";
2
+ import type { GlyphMetrics } from "./types";
3
+
4
+ // Target gradient size as a ratio of lineHeight.
5
+ // The gradient will be at least this tall, expanding the container if needed.
6
+ const TARGET_GRADIENT_RATIO = 0.2;
7
+
8
+ // Small inset so the gradient doesn't start right at the glyph boundary.
9
+ const PADDING_PX = 2;
10
+
11
+ export interface MaskHeights {
12
+ // Total gradient height at the top edge
13
+ top: number;
14
+ // Total gradient height at the bottom edge
15
+ bottom: number;
16
+ // How much the container must expand beyond lineHeight at the top
17
+ expansionTop: number;
18
+ // How much the container must expand beyond lineHeight at the bottom
19
+ expansionBottom: number;
20
+ }
21
+
22
+ /**
23
+ * Computes adaptive vertical mask heights based on the actual characters
24
+ * currently displayed.
25
+ *
26
+ * The gradient starts at the edge of the visible glyph content and extends
27
+ * outward. If the "dead zone" inside lineHeight is smaller than the target
28
+ * gradient size, the container expands beyond lineHeight.
29
+ */
30
+ export function computeAdaptiveMaskHeights(
31
+ layout: CharLayout[],
32
+ exitingEntries: Map<string, CharLayout>,
33
+ metrics: GlyphMetrics,
34
+ ): MaskHeights {
35
+ let maxAscent = 0;
36
+ let maxDescent = 0;
37
+
38
+ const processChar = (char: string) => {
39
+ const bounds = metrics.charBounds[char];
40
+ if (!bounds) return;
41
+
42
+ if (bounds.top < maxAscent) maxAscent = bounds.top;
43
+ if (bounds.bottom > maxDescent) maxDescent = bounds.bottom;
44
+ };
45
+
46
+ for (const entry of layout) {
47
+ if (entry.superscript) continue;
48
+ processChar(entry.char);
49
+ }
50
+
51
+ for (const [, entry] of exitingEntries) {
52
+ if (entry.superscript) continue;
53
+ processChar(entry.char);
54
+ }
55
+
56
+ const targetGradient = TARGET_GRADIENT_RATIO * metrics.lineHeight;
57
+
58
+ // Dead zone: space within lineHeight not occupied by glyph content.
59
+ const deadZoneTop = Math.max(0, -metrics.ascent - -maxAscent - PADDING_PX);
60
+ const deadZoneBottom = Math.max(0, metrics.descent - maxDescent - PADDING_PX);
61
+
62
+ // If the dead zone is smaller than the target, expand the container.
63
+ const expansionTop = Math.max(0, targetGradient - deadZoneTop);
64
+ const expansionBottom = Math.max(0, targetGradient - deadZoneBottom);
65
+
66
+ return {
67
+ top: deadZoneTop + expansionTop,
68
+ bottom: deadZoneBottom + expansionBottom,
69
+ expansionTop,
70
+ expansionBottom,
71
+ };
72
+ }
@@ -0,0 +1,112 @@
1
+ import { hanidecDigitValue } from "./digits";
2
+ import { CLDR_DEFAULT_NUMBERING, HANIDEC_ZERO, LATIN_ZERO, ZERO_CODEPOINTS } from "./tables";
3
+
4
+ const numberingSystemCache = new Map<string, string>();
5
+
6
+ /**
7
+ * Looks up the CLDR-expected numbering system for a locale.
8
+ * Checks explicit -u-nu- extension, then exact match (e.g. "ar-EG"),
9
+ * then language subtag (e.g. "ar").
10
+ * Returns undefined if the locale defaults to "latn".
11
+ */
12
+ function getExpectedNumberingSystem(locales?: Intl.LocalesArgument): string | undefined {
13
+ if (!locales) return undefined;
14
+
15
+ const tag = String(locales);
16
+
17
+ // Parse explicit Unicode extension: "th-TH-u-nu-thai" → "thai"
18
+ const nuMatch = tag.match(/-u-nu-([a-z]+)/);
19
+ if (nuMatch) {
20
+ return nuMatch[1] === "latn" ? undefined : nuMatch[1];
21
+ }
22
+
23
+ const exact = CLDR_DEFAULT_NUMBERING[tag];
24
+ if (exact) return exact;
25
+
26
+ // Try language subtag only (strip region): "ar-EG" → "ar"
27
+ const dashIdx = tag.indexOf("-");
28
+ if (dashIdx > 0) {
29
+ return CLDR_DEFAULT_NUMBERING[tag.slice(0, dashIdx)];
30
+ }
31
+
32
+ return undefined;
33
+ }
34
+
35
+ /**
36
+ * Scans a formatted number string and detects which numbering system's
37
+ * digits are actually present. Returns the zero codepoint of the detected
38
+ * system, or LATIN_ZERO if only Latin digits (or no digits) are found.
39
+ *
40
+ * Handles hanidec (non-contiguous ideographs) separately from contiguous systems.
41
+ */
42
+ export function detectOutputZeroCodePoint(formattedStr: string): number {
43
+ for (let i = 0; i < formattedStr.length; i++) {
44
+ const code = formattedStr.charCodeAt(i);
45
+
46
+ // Skip Latin digits — we want to detect non-Latin systems
47
+ if (code >= LATIN_ZERO && code <= LATIN_ZERO + 9) continue;
48
+
49
+ // Check hanidec (non-contiguous codepoints)
50
+ if (hanidecDigitValue(code) >= 0) return HANIDEC_ZERO;
51
+
52
+ // Check all contiguous systems (skip latn and hanidec)
53
+ for (const system in ZERO_CODEPOINTS) {
54
+ if (system === "latn" || system === "hanidec") continue;
55
+ const zeroCp = ZERO_CODEPOINTS[system];
56
+ if (code >= zeroCp && code <= zeroCp + 9) return zeroCp;
57
+ }
58
+ }
59
+
60
+ return LATIN_ZERO;
61
+ }
62
+
63
+ /**
64
+ * Detects the numbering system for a locale/format combination.
65
+ *
66
+ * Strategy: query the platform first (resolvedOptions), then verify by
67
+ * formatting a probe. If the platform reports "latn" but the CLDR table
68
+ * says otherwise (common on Hermes), use the CLDR value.
69
+ */
70
+ export function detectNumberingSystem(
71
+ locales?: Intl.LocalesArgument,
72
+ format?: Intl.NumberFormatOptions,
73
+ ): string {
74
+ const key = JSON.stringify([locales, format]);
75
+ const cached = numberingSystemCache.get(key);
76
+ if (cached) return cached;
77
+
78
+ const formatter = new Intl.NumberFormat(locales ?? undefined, format);
79
+ const platformSystem = formatter.resolvedOptions().numberingSystem;
80
+
81
+ // If the platform reports a known non-latn system, trust it
82
+ if (platformSystem && platformSystem !== "latn") {
83
+ numberingSystemCache.set(key, platformSystem);
84
+ return platformSystem;
85
+ }
86
+
87
+ // Platform says "latn" or undefined — check if the locale expects something else
88
+ const expected = getExpectedNumberingSystem(locales);
89
+ if (!expected) {
90
+ numberingSystemCache.set(key, "latn");
91
+ return "latn";
92
+ }
93
+
94
+ // Verify by checking the actual formatted output
95
+ const probe = formatter.format(1234567890);
96
+ const outputZeroCp = detectOutputZeroCodePoint(probe);
97
+
98
+ if (outputZeroCp !== LATIN_ZERO) {
99
+ // Platform does output non-Latin digits; find the matching system name
100
+ for (const system in ZERO_CODEPOINTS) {
101
+ if (ZERO_CODEPOINTS[system] === outputZeroCp) {
102
+ numberingSystemCache.set(key, system);
103
+ return system;
104
+ }
105
+ }
106
+ }
107
+
108
+ // Platform truly outputs Latin digits — use the CLDR expected system
109
+ // so digitStrings render the correct locale characters on the wheel
110
+ numberingSystemCache.set(key, expected);
111
+ return expected;
112
+ }