jfs-components 0.0.79 → 0.0.85

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 (138) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/lib/commonjs/components/AppBar/AppBar.js +70 -6
  3. package/lib/commonjs/components/AreaLineChart/AreaLineChart.js +866 -0
  4. package/lib/commonjs/components/AreaLineChart/chartMath.js +252 -0
  5. package/lib/commonjs/components/Attached/Attached.js +76 -7
  6. package/lib/commonjs/components/BubbleChart/BubbleChart.js +191 -0
  7. package/lib/commonjs/components/BubbleChart/bubblePacking.js +378 -0
  8. package/lib/commonjs/components/Checkbox/Checkbox.js +18 -2
  9. package/lib/commonjs/components/ClusterBubble/ClusterBubble.js +272 -0
  10. package/lib/commonjs/components/Drawer/Drawer.js +6 -1
  11. package/lib/commonjs/components/DropdownInput/DropdownInput.js +30 -6
  12. package/lib/commonjs/components/ExpandableCheckbox/ExpandableCheckbox.js +17 -11
  13. package/lib/commonjs/components/FormField/FormField.js +1 -14
  14. package/lib/commonjs/components/FullscreenModal/FullscreenModal.js +5 -1
  15. package/lib/commonjs/components/ListItem/ListItem.js +6 -11
  16. package/lib/commonjs/components/MessageField/MessageField.js +1 -13
  17. package/lib/commonjs/components/MetricLegendItem/MetricLegendItem.js +7 -1
  18. package/lib/commonjs/components/PaymentFeedback/PaymentFeedback.js +12 -9
  19. package/lib/commonjs/components/PlanComparisonCard/PlanComparisonCard.js +69 -160
  20. package/lib/commonjs/components/Spinner/Spinner.js +217 -0
  21. package/lib/commonjs/components/TextInput/TextInput.js +33 -18
  22. package/lib/commonjs/components/index.js +34 -0
  23. package/lib/commonjs/design-tokens/Coin Variables-variables-full.json +1 -1
  24. package/lib/commonjs/icons/components/IconArrowdown.js +19 -0
  25. package/lib/commonjs/icons/components/IconArrowup.js +19 -0
  26. package/lib/commonjs/icons/components/IconChevrondowncircle.js +19 -0
  27. package/lib/commonjs/icons/components/IconChevronleftcircle.js +19 -0
  28. package/lib/commonjs/icons/components/IconChevronrightcircle.js +19 -0
  29. package/lib/commonjs/icons/components/IconChevronupcircle.js +19 -0
  30. package/lib/commonjs/icons/components/IconOsnavback.js +19 -0
  31. package/lib/commonjs/icons/components/IconOsnavcenter.js +19 -0
  32. package/lib/commonjs/icons/components/IconOsnavhome.js +19 -0
  33. package/lib/commonjs/icons/components/IconOsnavtask.js +19 -0
  34. package/lib/commonjs/icons/components/IconSignin.js +19 -0
  35. package/lib/commonjs/icons/components/IconSignout.js +19 -0
  36. package/lib/commonjs/icons/components/index.js +132 -0
  37. package/lib/commonjs/icons/registry.js +2 -2
  38. package/lib/module/components/AppBar/AppBar.js +70 -6
  39. package/lib/module/components/AreaLineChart/AreaLineChart.js +859 -0
  40. package/lib/module/components/AreaLineChart/chartMath.js +242 -0
  41. package/lib/module/components/Attached/Attached.js +76 -7
  42. package/lib/module/components/BubbleChart/BubbleChart.js +185 -0
  43. package/lib/module/components/BubbleChart/bubblePacking.js +370 -0
  44. package/lib/module/components/Checkbox/Checkbox.js +18 -2
  45. package/lib/module/components/ClusterBubble/ClusterBubble.js +267 -0
  46. package/lib/module/components/Drawer/Drawer.js +6 -1
  47. package/lib/module/components/DropdownInput/DropdownInput.js +30 -6
  48. package/lib/module/components/ExpandableCheckbox/ExpandableCheckbox.js +17 -11
  49. package/lib/module/components/FormField/FormField.js +3 -16
  50. package/lib/module/components/FullscreenModal/FullscreenModal.js +5 -1
  51. package/lib/module/components/ListItem/ListItem.js +6 -11
  52. package/lib/module/components/MessageField/MessageField.js +3 -15
  53. package/lib/module/components/MetricLegendItem/MetricLegendItem.js +7 -1
  54. package/lib/module/components/PaymentFeedback/PaymentFeedback.js +13 -9
  55. package/lib/module/components/PlanComparisonCard/PlanComparisonCard.js +72 -160
  56. package/lib/module/components/Spinner/Spinner.js +212 -0
  57. package/lib/module/components/TextInput/TextInput.js +34 -19
  58. package/lib/module/components/index.js +4 -0
  59. package/lib/module/design-tokens/Coin Variables-variables-full.json +1 -1
  60. package/lib/module/icons/components/IconArrowdown.js +12 -0
  61. package/lib/module/icons/components/IconArrowup.js +12 -0
  62. package/lib/module/icons/components/IconChevrondowncircle.js +12 -0
  63. package/lib/module/icons/components/IconChevronleftcircle.js +12 -0
  64. package/lib/module/icons/components/IconChevronrightcircle.js +12 -0
  65. package/lib/module/icons/components/IconChevronupcircle.js +12 -0
  66. package/lib/module/icons/components/IconOsnavback.js +12 -0
  67. package/lib/module/icons/components/IconOsnavcenter.js +12 -0
  68. package/lib/module/icons/components/IconOsnavhome.js +12 -0
  69. package/lib/module/icons/components/IconOsnavtask.js +12 -0
  70. package/lib/module/icons/components/IconSignin.js +12 -0
  71. package/lib/module/icons/components/IconSignout.js +12 -0
  72. package/lib/module/icons/components/index.js +12 -0
  73. package/lib/module/icons/registry.js +2 -2
  74. package/lib/typescript/src/components/AppBar/AppBar.d.ts +12 -1
  75. package/lib/typescript/src/components/AreaLineChart/AreaLineChart.d.ts +212 -0
  76. package/lib/typescript/src/components/AreaLineChart/chartMath.d.ts +90 -0
  77. package/lib/typescript/src/components/Attached/Attached.d.ts +19 -16
  78. package/lib/typescript/src/components/BubbleChart/BubbleChart.d.ts +81 -0
  79. package/lib/typescript/src/components/BubbleChart/bubblePacking.d.ts +83 -0
  80. package/lib/typescript/src/components/ClusterBubble/ClusterBubble.d.ts +76 -0
  81. package/lib/typescript/src/components/DropdownInput/DropdownInput.d.ts +3 -2
  82. package/lib/typescript/src/components/ListItem/ListItem.d.ts +3 -3
  83. package/lib/typescript/src/components/MetricLegendItem/MetricLegendItem.d.ts +7 -1
  84. package/lib/typescript/src/components/PaymentFeedback/PaymentFeedback.d.ts +5 -1
  85. package/lib/typescript/src/components/PlanComparisonCard/PlanComparisonCard.d.ts +10 -8
  86. package/lib/typescript/src/components/Spinner/Spinner.d.ts +45 -0
  87. package/lib/typescript/src/components/index.d.ts +4 -0
  88. package/lib/typescript/src/icons/components/IconArrowdown.d.ts +3 -0
  89. package/lib/typescript/src/icons/components/IconArrowup.d.ts +3 -0
  90. package/lib/typescript/src/icons/components/IconChevrondowncircle.d.ts +3 -0
  91. package/lib/typescript/src/icons/components/IconChevronleftcircle.d.ts +3 -0
  92. package/lib/typescript/src/icons/components/IconChevronrightcircle.d.ts +3 -0
  93. package/lib/typescript/src/icons/components/IconChevronupcircle.d.ts +3 -0
  94. package/lib/typescript/src/icons/components/IconOsnavback.d.ts +3 -0
  95. package/lib/typescript/src/icons/components/IconOsnavcenter.d.ts +3 -0
  96. package/lib/typescript/src/icons/components/IconOsnavhome.d.ts +3 -0
  97. package/lib/typescript/src/icons/components/IconOsnavtask.d.ts +3 -0
  98. package/lib/typescript/src/icons/components/IconSignin.d.ts +3 -0
  99. package/lib/typescript/src/icons/components/IconSignout.d.ts +3 -0
  100. package/lib/typescript/src/icons/components/index.d.ts +12 -0
  101. package/lib/typescript/src/icons/registry.d.ts +1 -1
  102. package/package.json +3 -2
  103. package/src/components/AppBar/AppBar.tsx +92 -12
  104. package/src/components/AreaLineChart/AreaLineChart.tsx +1161 -0
  105. package/src/components/AreaLineChart/chartMath.ts +265 -0
  106. package/src/components/Attached/Attached.tsx +94 -7
  107. package/src/components/BubbleChart/BubbleChart.tsx +319 -0
  108. package/src/components/BubbleChart/bubblePacking.ts +397 -0
  109. package/src/components/Checkbox/Checkbox.tsx +14 -2
  110. package/src/components/ClusterBubble/ClusterBubble.tsx +359 -0
  111. package/src/components/Drawer/Drawer.tsx +4 -0
  112. package/src/components/DropdownInput/DropdownInput.tsx +54 -20
  113. package/src/components/ExpandableCheckbox/ExpandableCheckbox.tsx +13 -9
  114. package/src/components/FormField/FormField.tsx +3 -19
  115. package/src/components/FullscreenModal/FullscreenModal.tsx +3 -0
  116. package/src/components/ListItem/ListItem.tsx +14 -16
  117. package/src/components/MessageField/MessageField.tsx +3 -18
  118. package/src/components/MetricLegendItem/MetricLegendItem.tsx +20 -6
  119. package/src/components/PaymentFeedback/PaymentFeedback.tsx +15 -8
  120. package/src/components/PlanComparisonCard/PlanComparisonCard.tsx +82 -192
  121. package/src/components/Spinner/Spinner.tsx +273 -0
  122. package/src/components/TextInput/TextInput.tsx +37 -19
  123. package/src/components/index.ts +4 -0
  124. package/src/design-tokens/Coin Variables-variables-full.json +1 -1
  125. package/src/icons/components/IconArrowdown.tsx +11 -0
  126. package/src/icons/components/IconArrowup.tsx +11 -0
  127. package/src/icons/components/IconChevrondowncircle.tsx +11 -0
  128. package/src/icons/components/IconChevronleftcircle.tsx +11 -0
  129. package/src/icons/components/IconChevronrightcircle.tsx +11 -0
  130. package/src/icons/components/IconChevronupcircle.tsx +11 -0
  131. package/src/icons/components/IconOsnavback.tsx +11 -0
  132. package/src/icons/components/IconOsnavcenter.tsx +11 -0
  133. package/src/icons/components/IconOsnavhome.tsx +11 -0
  134. package/src/icons/components/IconOsnavtask.tsx +11 -0
  135. package/src/icons/components/IconSignin.tsx +11 -0
  136. package/src/icons/components/IconSignout.tsx +11 -0
  137. package/src/icons/components/index.ts +12 -0
  138. package/src/icons/registry.ts +49 -1
@@ -0,0 +1,397 @@
1
+ /**
2
+ * Dependency-free layout math for `BubbleChart`.
3
+ *
4
+ * Instead of a rigid ring, bubbles are arranged with a small **force
5
+ * simulation** — think of bubbles floating in a rectangular pool: each one
6
+ * repels the others (collision), a gentle pull keeps the cluster balanced
7
+ * (gravity), and the pool walls confine everything inside the container box so
8
+ * nothing overflows. Radii are first `sqrt`-scaled from the data magnitudes and
9
+ * down-scaled to fit the available area. Finally, for bubbles whose text must
10
+ * sit *outside* the circle, a direction chooser picks the side (top / bottom /
11
+ * left / right) with the most free space so labels avoid further collisions.
12
+ */
13
+
14
+ export type LabelDirection = 'top' | 'bottom' | 'left' | 'right'
15
+
16
+ export type PositionedNode = {
17
+ x: number
18
+ y: number
19
+ r: number
20
+ }
21
+
22
+ export type LabelBox = {
23
+ /** Estimated rendered width of the outside label, in px. */
24
+ w: number
25
+ /** Estimated rendered height of the outside label, in px. */
26
+ h: number
27
+ }
28
+
29
+ type Rect = { x: number; y: number; w: number; h: number }
30
+
31
+ // --- Radius scaling --------------------------------------------------------
32
+
33
+ /**
34
+ * Map magnitudes to radii so a bubble's *area* scales with its value
35
+ * (`sqrt`-encoding), clamped into `[minRadius, maxRadius]`.
36
+ */
37
+ export function scaleRadii(magnitudes: number[], minRadius: number, maxRadius: number): number[] {
38
+ if (magnitudes.length === 0) return []
39
+
40
+ const safe = magnitudes.map((m) => (Number.isFinite(m) && m > 0 ? m : 0))
41
+ let lo = Infinity
42
+ let hi = -Infinity
43
+ for (const m of safe) {
44
+ if (m < lo) lo = m
45
+ if (m > hi) hi = m
46
+ }
47
+
48
+ if (!Number.isFinite(lo) || !Number.isFinite(hi) || hi <= 0 || hi === lo) {
49
+ return safe.map(() => maxRadius)
50
+ }
51
+
52
+ const sqrtLo = Math.sqrt(lo)
53
+ const sqrtHi = Math.sqrt(hi)
54
+ const span = sqrtHi - sqrtLo
55
+
56
+ return safe.map((m) => {
57
+ if (m <= 0) return minRadius
58
+ const t = span > 0 ? (Math.sqrt(m) - sqrtLo) / span : 1
59
+ return minRadius + t * (maxRadius - minRadius)
60
+ })
61
+ }
62
+
63
+ /**
64
+ * Uniformly shrink radii (preserving relative proportions) so the bubbles plus
65
+ * some breathing room and label allowance fit inside the `width × height` box.
66
+ * Returns the radii unchanged when they already fit.
67
+ */
68
+ export function fitRadiiToBox(
69
+ radii: number[],
70
+ width: number,
71
+ height: number,
72
+ options: { density?: number; labelArea?: number; minRadius?: number } = {}
73
+ ): number[] {
74
+ const { density = 0.58, labelArea = 0, minRadius = 6 } = options
75
+ if (radii.length === 0 || width <= 0 || height <= 0) return radii
76
+
77
+ let circleArea = 0
78
+ for (const r of radii) circleArea += Math.PI * r * r
79
+
80
+ const required = circleArea / density + labelArea
81
+ const available = width * height
82
+ if (required <= available) return radii
83
+
84
+ // Area scales with the square of the linear factor; ignore the (non-scaling)
85
+ // label term in the factor for simplicity — the sim's hard clamp covers the
86
+ // small remainder.
87
+ const factor = Math.sqrt(Math.max(0.05, (available - labelArea) / circleArea) * density)
88
+ return radii.map((r) => Math.max(minRadius, r * Math.min(1, factor)))
89
+ }
90
+
91
+ // --- Force simulation ------------------------------------------------------
92
+
93
+ /** Tiny deterministic PRNG so layouts are reproducible across renders. */
94
+ function mulberry32(seed: number): () => number {
95
+ let s = seed >>> 0
96
+ return () => {
97
+ s = (s + 0x6d2b79f5) >>> 0
98
+ let t = s
99
+ t = Math.imul(t ^ (t >>> 15), t | 1)
100
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
101
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Arrange circles inside a `width × height` box with a collision + gravity
107
+ * relaxation. Bubbles never overlap (separated by at least `gap`) and are hard-
108
+ * clamped within the walls so the cluster stays inside the container.
109
+ */
110
+ export function simulateCluster(
111
+ radii: number[],
112
+ options: {
113
+ width: number
114
+ height: number
115
+ gap?: number
116
+ iterations?: number
117
+ gravity?: number
118
+ /** Horizontal wall inset reserved for outside labels. */
119
+ insetX?: number
120
+ /** Vertical wall inset reserved for outside labels. */
121
+ insetY?: number
122
+ /**
123
+ * Per-node flag: `true` nodes are pulled toward the perimeter ring
124
+ * (instead of the center) so their outside labels reach the open band
125
+ * along the walls. Typically the small / outside-labelled bubbles.
126
+ */
127
+ perimeter?: boolean[]
128
+ }
129
+ ): PositionedNode[] {
130
+ const {
131
+ width,
132
+ height,
133
+ gap = 8,
134
+ iterations = 500,
135
+ gravity = 0.02,
136
+ insetX = 0,
137
+ insetY = 0,
138
+ perimeter,
139
+ } = options
140
+ const n = radii.length
141
+ const nodes: PositionedNode[] = radii.map((r) => ({ x: 0, y: 0, r }))
142
+ if (n === 0) return nodes
143
+
144
+ const cx = width / 2
145
+ const cy = height / 2
146
+ const rand = mulberry32(0x9e3779b9 ^ (n * 2654435761))
147
+
148
+ // Seed positions on a phyllotaxis spiral so the relaxation starts spread out.
149
+ const spread = Math.min(width - 2 * insetX, height - 2 * insetY) * 0.45
150
+ for (let i = 0; i < n; i++) {
151
+ const nd = nodes[i]!
152
+ const a = i * 2.399963229728653 // golden angle
153
+ const rad = Math.sqrt((i + 0.5) / n) * spread
154
+ nd.x = cx + Math.cos(a) * rad + (rand() - 0.5) * 2
155
+ nd.y = cy + Math.sin(a) * rad + (rand() - 0.5) * 2
156
+ }
157
+
158
+ const margin = gap / 2
159
+ let maxR = 0
160
+ for (const r of radii) if (r > maxR) maxR = r
161
+ maxR = maxR || 1
162
+
163
+ const clamp = () => {
164
+ for (const nd of nodes) {
165
+ const minX = insetX + nd.r + margin
166
+ const maxX = Math.max(minX, width - insetX - nd.r - margin)
167
+ const minY = insetY + nd.r + margin
168
+ const maxY = Math.max(minY, height - insetY - nd.r - margin)
169
+ nd.x = Math.min(maxX, Math.max(minX, nd.x))
170
+ nd.y = Math.min(maxY, Math.max(minY, nd.y))
171
+ }
172
+ }
173
+
174
+ for (let it = 0; it < iterations; it++) {
175
+ // Big bubbles are pulled to the center; "perimeter" bubbles (the small,
176
+ // outside-labelled ones) are pulled toward the wall ring so their labels
177
+ // land in the open band — like small bubbles floating around a big one.
178
+ for (let i = 0; i < n; i++) {
179
+ const nd = nodes[i]!
180
+ if (perimeter && perimeter[i]) {
181
+ const dx = nd.x - cx
182
+ const dy = nd.y - cy
183
+ const d = Math.sqrt(dx * dx + dy * dy) || 1
184
+ const targetD = Math.max(
185
+ 0,
186
+ Math.min(
187
+ width / 2 - insetX - nd.r - margin,
188
+ height / 2 - insetY - nd.r - margin
189
+ )
190
+ )
191
+ const f = ((targetD - d) / d) * gravity * 2
192
+ nd.x += dx * f
193
+ nd.y += dy * f
194
+ } else {
195
+ const ratio = nd.r / maxR
196
+ const g = gravity * (0.4 + 0.6 * ratio)
197
+ nd.x += (cx - nd.x) * g
198
+ nd.y += (cy - nd.y) * g
199
+ }
200
+ }
201
+
202
+ // Collision: push overlapping pairs apart. Two passes per tick keeps it
203
+ // stable for dense clusters. Smaller bubbles move more than bigger ones.
204
+ for (let pass = 0; pass < 2; pass++) {
205
+ for (let i = 0; i < n; i++) {
206
+ for (let j = i + 1; j < n; j++) {
207
+ const a = nodes[i]!
208
+ const b = nodes[j]!
209
+ let dx = b.x - a.x
210
+ let dy = b.y - a.y
211
+ let dist = Math.sqrt(dx * dx + dy * dy)
212
+ const min = a.r + b.r + gap
213
+ if (dist < min) {
214
+ if (dist < 1e-6) {
215
+ dx = rand() - 0.5 || 0.01
216
+ dy = rand() - 0.5 || 0.01
217
+ dist = Math.sqrt(dx * dx + dy * dy)
218
+ }
219
+ const overlap = min - dist
220
+ const ux = dx / dist
221
+ const uy = dy / dist
222
+ const total = a.r + b.r
223
+ const aShare = b.r / total
224
+ const bShare = a.r / total
225
+ a.x -= ux * overlap * aShare
226
+ a.y -= uy * overlap * aShare
227
+ b.x += ux * overlap * bShare
228
+ b.y += uy * overlap * bShare
229
+ }
230
+ }
231
+ }
232
+ clamp()
233
+ }
234
+ }
235
+
236
+ return nodes
237
+ }
238
+
239
+ // --- Outside-label sizing + direction --------------------------------------
240
+
241
+ /**
242
+ * Roughly estimate the rendered size of an outside label block (a bold value
243
+ * line stacked over a lighter caption line), used purely for layout scoring.
244
+ */
245
+ export function estimateLabelBox(
246
+ value: string,
247
+ label: string,
248
+ options: { valueFontSize?: number; labelFontSize?: number } = {}
249
+ ): LabelBox {
250
+ const { valueFontSize = 24, labelFontSize = 14 } = options
251
+ // Slightly over-estimate width so the direction chooser reserves enough
252
+ // room and never picks a side that would clip the (wider) rendered text.
253
+ const valueW = value.length * valueFontSize * 0.68
254
+ const labelW = label.length * labelFontSize * 0.6
255
+ const valueH = value ? valueFontSize * 1.18 : 0
256
+ const labelH = label ? labelFontSize * 1.32 : 0
257
+ const gapBetween = value && label ? 2 : 0
258
+ return {
259
+ w: Math.max(valueW, labelW, 1),
260
+ h: Math.max(valueH + labelH + gapBetween, 1),
261
+ }
262
+ }
263
+
264
+ const DIRECTIONS: LabelDirection[] = ['bottom', 'right', 'left', 'top']
265
+
266
+ function directionVector(dir: LabelDirection): { x: number; y: number } {
267
+ switch (dir) {
268
+ case 'top':
269
+ return { x: 0, y: -1 }
270
+ case 'bottom':
271
+ return { x: 0, y: 1 }
272
+ case 'left':
273
+ return { x: -1, y: 0 }
274
+ case 'right':
275
+ return { x: 1, y: 0 }
276
+ }
277
+ }
278
+
279
+ /** The rect an outside label occupies for a given direction (at `r + gap`). */
280
+ function labelRect(node: PositionedNode, box: LabelBox, dir: LabelDirection, gap: number): Rect {
281
+ const { x: cx, y: cy, r } = node
282
+ switch (dir) {
283
+ case 'bottom':
284
+ return { x: cx - box.w / 2, y: cy + r + gap, w: box.w, h: box.h }
285
+ case 'top':
286
+ return { x: cx - box.w / 2, y: cy - r - gap - box.h, w: box.w, h: box.h }
287
+ case 'right':
288
+ return { x: cx + r + gap, y: cy - box.h / 2, w: box.w, h: box.h }
289
+ case 'left':
290
+ return { x: cx - r - gap - box.w, y: cy - box.h / 2, w: box.w, h: box.h }
291
+ }
292
+ }
293
+
294
+ function rectOverlapArea(a: Rect, b: Rect): number {
295
+ const ox = Math.max(0, Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x))
296
+ const oy = Math.max(0, Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y))
297
+ return ox * oy
298
+ }
299
+
300
+ /** Approximate a circle as its bounding square for cheap rect/circle overlap. */
301
+ function rectCircleOverlap(rect: Rect, node: PositionedNode): number {
302
+ const circleRect: Rect = {
303
+ x: node.x - node.r,
304
+ y: node.y - node.r,
305
+ w: node.r * 2,
306
+ h: node.r * 2,
307
+ }
308
+ return rectOverlapArea(rect, circleRect)
309
+ }
310
+
311
+ /**
312
+ * For each node that has an outside label (`labels[i]` non-null), pick the
313
+ * direction whose label rect best avoids the other circles, already-placed
314
+ * labels, and the container walls — biased to point *outward* (away from the
315
+ * cluster centroid, toward free space).
316
+ *
317
+ * Returns a direction per node, or `null` for nodes without an outside label.
318
+ */
319
+ export function chooseLabelDirections(
320
+ nodes: PositionedNode[],
321
+ labels: Array<LabelBox | null>,
322
+ options: { width: number; height: number; gap?: number }
323
+ ): Array<LabelDirection | null> {
324
+ const { width, height, gap = 8 } = options
325
+ const result: Array<LabelDirection | null> = nodes.map(() => null)
326
+ if (nodes.length === 0) return result
327
+
328
+ let gx = 0
329
+ let gy = 0
330
+ for (const nd of nodes) {
331
+ gx += nd.x
332
+ gy += nd.y
333
+ }
334
+ gx /= nodes.length
335
+ gy /= nodes.length
336
+
337
+ // Place outer bubbles first — they sit near free space and deserve the best slot.
338
+ const order = nodes
339
+ .map((_, i) => i)
340
+ .filter((i) => labels[i])
341
+ .sort((a, b) => {
342
+ const na = nodes[a]!
343
+ const nb = nodes[b]!
344
+ const da = (na.x - gx) ** 2 + (na.y - gy) ** 2
345
+ const db = (nb.x - gx) ** 2 + (nb.y - gy) ** 2
346
+ return db - da
347
+ })
348
+
349
+ const placed: Rect[] = []
350
+
351
+ for (const i of order) {
352
+ const node = nodes[i]!
353
+ const box = labels[i]!
354
+ const ox = node.x - gx
355
+ const oy = node.y - gy
356
+ const outLen = Math.hypot(ox, oy) || 1
357
+
358
+ let best: LabelDirection = 'bottom'
359
+ let bestScore = Infinity
360
+
361
+ for (const dir of DIRECTIONS) {
362
+ const rect = labelRect(node, box, dir, gap)
363
+ let score = 0
364
+
365
+ // Out-of-bounds is forbidden whenever any in-bounds option exists:
366
+ // a clipped label is the worst possible outcome. A large fixed
367
+ // penalty plus a proportional term enforces this.
368
+ const outX = Math.max(0, -rect.x) + Math.max(0, rect.x + rect.w - width)
369
+ const outY = Math.max(0, -rect.y) + Math.max(0, rect.y + rect.h - height)
370
+ if (outX > 0 || outY > 0) score += 1_000_000 + (outX + outY) * 150
371
+
372
+ // Overlap with other circles.
373
+ for (let k = 0; k < nodes.length; k++) {
374
+ if (k === i) continue
375
+ score += rectCircleOverlap(rect, nodes[k]!)
376
+ }
377
+
378
+ // Overlap with previously-placed labels (penalized extra to spread).
379
+ for (const p of placed) score += rectOverlapArea(rect, p) * 2
380
+
381
+ // Mild preference for pointing outward (toward open space).
382
+ const dv = directionVector(dir)
383
+ const align = (dv.x * ox + dv.y * oy) / outLen // -1..1
384
+ score += (1 - align) * 15
385
+
386
+ if (score < bestScore) {
387
+ bestScore = score
388
+ best = dir
389
+ }
390
+ }
391
+
392
+ result[i] = best
393
+ placed.push(labelRect(node, box, best, gap))
394
+ }
395
+
396
+ return result
397
+ }
@@ -55,12 +55,21 @@ function useFocusVisible() {
55
55
  const MIN_TOUCH_TARGET = 44
56
56
 
57
57
  const touchTargetStyle: ViewStyle = {
58
- minWidth: MIN_TOUCH_TARGET,
59
- minHeight: MIN_TOUCH_TARGET,
60
58
  alignItems: 'center',
61
59
  justifyContent: 'center',
62
60
  }
63
61
 
62
+ /**
63
+ * Expands the tappable region to the 44pt minimum without changing layout.
64
+ * `hitSlop` extends the press-responder bounds beyond the visual box on both
65
+ * native and web (react-native-web ≥ 0.19), so the Pressable keeps its natural
66
+ * checkbox-sized footprint and sibling alignment stays intact.
67
+ */
68
+ function invisibleTouchHitSlop(checkboxSize: number) {
69
+ const slop = Math.max(0, Math.ceil((MIN_TOUCH_TARGET - checkboxSize) / 2))
70
+ return { top: slop, bottom: slop, left: slop, right: slop }
71
+ }
72
+
64
73
  export interface CheckboxProps {
65
74
  /** Whether the checkbox is checked (controlled) */
66
75
  checked?: boolean
@@ -216,9 +225,12 @@ function Checkbox({
216
225
  ? (disabledActiveMark as string)
217
226
  : (selectedMarkColor as string)
218
227
 
228
+ const hitSlop = invisibleTouchHitSlop(size as number)
229
+
219
230
  return (
220
231
  <Pressable
221
232
  style={[touchTargetStyle, style]}
233
+ hitSlop={hitSlop}
222
234
  onPress={handlePress}
223
235
  disabled={disabled}
224
236
  onHoverIn={() => setIsHovered(true)}