jfs-components 0.0.84 → 0.0.86

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 (51) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/lib/commonjs/components/AllocationComparisonChart/AllocationComparisonChart.js +299 -0
  3. package/lib/commonjs/components/AppBar/AppBar.js +36 -22
  4. package/lib/commonjs/components/AreaLineChart/AreaLineChart.js +866 -0
  5. package/lib/commonjs/components/AreaLineChart/chartMath.js +252 -0
  6. package/lib/commonjs/components/Attached/Attached.js +34 -4
  7. package/lib/commonjs/components/BubbleChart/BubbleChart.js +191 -0
  8. package/lib/commonjs/components/BubbleChart/bubblePacking.js +378 -0
  9. package/lib/commonjs/components/ClusterBubble/ClusterBubble.js +272 -0
  10. package/lib/commonjs/components/FullscreenModal/FullscreenModal.js +52 -89
  11. package/lib/commonjs/components/MetricLegendItem/MetricLegendItem.js +7 -1
  12. package/lib/commonjs/components/index.js +34 -0
  13. package/lib/commonjs/design-tokens/Coin Variables-variables-full.json +1 -1
  14. package/lib/commonjs/icons/registry.js +1 -1
  15. package/lib/module/components/AllocationComparisonChart/AllocationComparisonChart.js +293 -0
  16. package/lib/module/components/AppBar/AppBar.js +36 -22
  17. package/lib/module/components/AreaLineChart/AreaLineChart.js +859 -0
  18. package/lib/module/components/AreaLineChart/chartMath.js +242 -0
  19. package/lib/module/components/Attached/Attached.js +34 -4
  20. package/lib/module/components/BubbleChart/BubbleChart.js +185 -0
  21. package/lib/module/components/BubbleChart/bubblePacking.js +370 -0
  22. package/lib/module/components/ClusterBubble/ClusterBubble.js +267 -0
  23. package/lib/module/components/FullscreenModal/FullscreenModal.js +53 -90
  24. package/lib/module/components/MetricLegendItem/MetricLegendItem.js +7 -1
  25. package/lib/module/components/index.js +4 -0
  26. package/lib/module/design-tokens/Coin Variables-variables-full.json +1 -1
  27. package/lib/module/icons/registry.js +1 -1
  28. package/lib/typescript/src/components/AllocationComparisonChart/AllocationComparisonChart.d.ts +118 -0
  29. package/lib/typescript/src/components/AreaLineChart/AreaLineChart.d.ts +212 -0
  30. package/lib/typescript/src/components/AreaLineChart/chartMath.d.ts +90 -0
  31. package/lib/typescript/src/components/BubbleChart/BubbleChart.d.ts +81 -0
  32. package/lib/typescript/src/components/BubbleChart/bubblePacking.d.ts +83 -0
  33. package/lib/typescript/src/components/ClusterBubble/ClusterBubble.d.ts +76 -0
  34. package/lib/typescript/src/components/FullscreenModal/FullscreenModal.d.ts +21 -25
  35. package/lib/typescript/src/components/MetricLegendItem/MetricLegendItem.d.ts +7 -1
  36. package/lib/typescript/src/components/index.d.ts +4 -0
  37. package/lib/typescript/src/icons/registry.d.ts +1 -1
  38. package/package.json +1 -1
  39. package/src/components/AllocationComparisonChart/AllocationComparisonChart.tsx +450 -0
  40. package/src/components/AppBar/AppBar.tsx +37 -24
  41. package/src/components/AreaLineChart/AreaLineChart.tsx +1161 -0
  42. package/src/components/AreaLineChart/chartMath.ts +265 -0
  43. package/src/components/Attached/Attached.tsx +36 -5
  44. package/src/components/BubbleChart/BubbleChart.tsx +319 -0
  45. package/src/components/BubbleChart/bubblePacking.ts +397 -0
  46. package/src/components/ClusterBubble/ClusterBubble.tsx +359 -0
  47. package/src/components/FullscreenModal/FullscreenModal.tsx +61 -119
  48. package/src/components/MetricLegendItem/MetricLegendItem.tsx +20 -6
  49. package/src/components/index.ts +4 -0
  50. package/src/design-tokens/Coin Variables-variables-full.json +1 -1
  51. package/src/icons/registry.ts +1 -1
@@ -0,0 +1,265 @@
1
+ /**
2
+ * Pure, framework-agnostic math helpers for `AreaLineChart`.
3
+ *
4
+ * Everything here is intentionally dependency-free (no d3, no react-native)
5
+ * so the chart can stay lightweight and be unit-reasoned in isolation. The
6
+ * functions cover the four primitives a line/area chart needs:
7
+ * 1. linear scales (data domain -> pixel range)
8
+ * 2. "nice" tick generation for the value axis
9
+ * 3. SVG path generation for lines and filled areas (straight + smooth)
10
+ * 4. interaction lookup (which data index is nearest a touch x)
11
+ */
12
+
13
+ /** A resolved point in data space. `x` is the categorical/linear index. */
14
+ export type ResolvedPoint = {
15
+ /** Position along the x domain (defaults to the array index). */
16
+ x: number
17
+ /** Value along the y domain. */
18
+ y: number
19
+ /**
20
+ * When true, the line segment that *ends* at this point is rendered
21
+ * dashed (used for projected / low-confidence data).
22
+ */
23
+ projected: boolean
24
+ }
25
+
26
+ /** A point already projected into pixel space. */
27
+ export type PixelPoint = { x: number; y: number; projected: boolean }
28
+
29
+ /** Linear interpolation scale: maps a value from `domain` into `range`. */
30
+ export type LinearScale = {
31
+ (value: number): number
32
+ domain: readonly [number, number]
33
+ range: readonly [number, number]
34
+ }
35
+
36
+ /**
37
+ * Create a linear scale mapping `domain` -> `range`. Mirrors the subset of
38
+ * `d3-scale.scaleLinear` we rely on, without the dependency. A zero-width
39
+ * domain collapses to the midpoint of the range so we never divide by zero.
40
+ */
41
+ export function createLinearScale(
42
+ domain: readonly [number, number],
43
+ range: readonly [number, number]
44
+ ): LinearScale {
45
+ const [d0, d1] = domain
46
+ const [r0, r1] = range
47
+ const domainSpan = d1 - d0
48
+
49
+ const scale = ((value: number): number => {
50
+ if (domainSpan === 0) {
51
+ return (r0 + r1) / 2
52
+ }
53
+ const t = (value - d0) / domainSpan
54
+ return r0 + t * (r1 - r0)
55
+ }) as LinearScale
56
+
57
+ scale.domain = domain
58
+ scale.range = range
59
+ return scale
60
+ }
61
+
62
+ /**
63
+ * Generate up to ~`count` "nice" evenly-spaced tick values spanning
64
+ * `[min, max]`, snapping the step to a 1/2/5 * 10^n progression so labels
65
+ * read cleanly (e.g. 0, 30K, 60K, 90K). Returns ascending values that
66
+ * always include the rounded-down min and rounded-up max bounds.
67
+ */
68
+ export function niceTicks(min: number, max: number, count = 5): number[] {
69
+ if (!Number.isFinite(min) || !Number.isFinite(max)) {
70
+ return []
71
+ }
72
+ if (min === max) {
73
+ return [min]
74
+ }
75
+ const safeCount = Math.max(1, Math.floor(count))
76
+ const span = max - min
77
+ const rawStep = span / safeCount
78
+ const magnitude = Math.pow(10, Math.floor(Math.log10(rawStep)))
79
+ const normalized = rawStep / magnitude
80
+
81
+ let niceNormalized: number
82
+ if (normalized <= 1) niceNormalized = 1
83
+ else if (normalized <= 2) niceNormalized = 2
84
+ else if (normalized <= 5) niceNormalized = 5
85
+ else niceNormalized = 10
86
+
87
+ const step = niceNormalized * magnitude
88
+ const niceMin = Math.floor(min / step) * step
89
+ const niceMax = Math.ceil(max / step) * step
90
+
91
+ const ticks: number[] = []
92
+ // Guard against floating point drift by rounding to the step's precision.
93
+ const decimals = Math.max(0, -Math.floor(Math.log10(step)))
94
+ for (let v = niceMin; v <= niceMax + step / 2; v += step) {
95
+ ticks.push(Number(v.toFixed(decimals)))
96
+ }
97
+ return ticks
98
+ }
99
+
100
+ /**
101
+ * Compute the [min, max] extent of an array of resolved points along the
102
+ * requested axis. Returns `[0, 0]` for an empty list.
103
+ */
104
+ export function extent(points: ResolvedPoint[], axis: 'x' | 'y'): [number, number] {
105
+ if (points.length === 0) {
106
+ return [0, 0]
107
+ }
108
+ let min = Infinity
109
+ let max = -Infinity
110
+ for (const p of points) {
111
+ const v = p[axis]
112
+ if (v < min) min = v
113
+ if (v > max) max = v
114
+ }
115
+ return [min, max]
116
+ }
117
+
118
+ /**
119
+ * Normalize the loose `number[] | ChartPoint[]` series data into a uniform
120
+ * `ResolvedPoint[]`. Bare numbers use their array index as the x value.
121
+ */
122
+ export function resolvePoints(
123
+ data: ReadonlyArray<number | { x?: number | string; y: number; projected?: boolean }>
124
+ ): ResolvedPoint[] {
125
+ return data.map((entry, index) => {
126
+ if (typeof entry === 'number') {
127
+ return { x: index, y: entry, projected: false }
128
+ }
129
+ const x = typeof entry.x === 'number' ? entry.x : index
130
+ return { x, y: entry.y, projected: entry.projected === true }
131
+ })
132
+ }
133
+
134
+ /**
135
+ * Catmull-Rom -> cubic Bézier control point helper. Produces a smooth,
136
+ * monotone-ish curve through the points without overshooting wildly. Used
137
+ * for the `curve="monotone"` mode. `tension` of 0 yields a standard
138
+ * Catmull-Rom spline.
139
+ */
140
+ function controlPoints(
141
+ p0: PixelPoint,
142
+ p1: PixelPoint,
143
+ p2: PixelPoint,
144
+ p3: PixelPoint
145
+ ): { cp1x: number; cp1y: number; cp2x: number; cp2y: number } {
146
+ const t = 1 / 6
147
+ return {
148
+ cp1x: p1.x + (p2.x - p0.x) * t,
149
+ cp1y: p1.y + (p2.y - p0.y) * t,
150
+ cp2x: p2.x - (p3.x - p1.x) * t,
151
+ cp2y: p2.y - (p3.y - p1.y) * t,
152
+ }
153
+ }
154
+
155
+ export type Curve = 'linear' | 'monotone'
156
+
157
+ /**
158
+ * One drawable line segment. `dashed` mirrors the `projected` flag of the
159
+ * point the segment ends at, letting the renderer split the polyline into
160
+ * solid and dashed `Path`s.
161
+ */
162
+ export type LineSegment = { d: string; dashed: boolean }
163
+
164
+ /**
165
+ * Build the SVG path(s) for a polyline through `points`, split into runs of
166
+ * solid vs dashed segments. Each individual segment ("move to A, line/curve
167
+ * to B") is emitted as its own sub-path so that a single projected point can
168
+ * dash exactly one segment while leaving its neighbours solid.
169
+ */
170
+ export function buildLineSegments(points: PixelPoint[], curve: Curve): LineSegment[] {
171
+ if (points.length < 2) {
172
+ return []
173
+ }
174
+
175
+ const segments: LineSegment[] = []
176
+
177
+ for (let i = 0; i < points.length - 1; i++) {
178
+ const start = points[i]
179
+ const end = points[i + 1]
180
+ const dashed = end.projected
181
+
182
+ let d: string
183
+ if (curve === 'monotone') {
184
+ const p0 = points[i - 1] ?? start
185
+ const p3 = points[i + 2] ?? end
186
+ const { cp1x, cp1y, cp2x, cp2y } = controlPoints(p0, start, end, p3)
187
+ d = `M ${start.x} ${start.y} C ${cp1x} ${cp1y} ${cp2x} ${cp2y} ${end.x} ${end.y}`
188
+ } else {
189
+ d = `M ${start.x} ${start.y} L ${end.x} ${end.y}`
190
+ }
191
+ segments.push({ d, dashed })
192
+ }
193
+
194
+ // Merge adjacent segments that share the same dashed-ness into one path
195
+ // string to minimize the number of rendered <Path> nodes.
196
+ const merged: LineSegment[] = []
197
+ for (const seg of segments) {
198
+ const last = merged[merged.length - 1]
199
+ if (last && last.dashed === seg.dashed) {
200
+ // Drop the redundant leading "M x y" of the appended segment.
201
+ const continuation = seg.d.replace(/^M [^A-Za-z]+/, '')
202
+ last.d += ' ' + continuation
203
+ } else {
204
+ merged.push({ ...seg })
205
+ }
206
+ }
207
+ return merged
208
+ }
209
+
210
+ /**
211
+ * Build a single closed area path (the line, then down to `baselineY` and
212
+ * back) for a filled area fill. Curve smoothing matches `buildLineSegments`.
213
+ */
214
+ export function buildAreaPath(
215
+ points: PixelPoint[],
216
+ baselineY: number,
217
+ curve: Curve
218
+ ): string {
219
+ if (points.length === 0) {
220
+ return ''
221
+ }
222
+ if (points.length === 1) {
223
+ const p = points[0]
224
+ return `M ${p.x} ${baselineY} L ${p.x} ${p.y} Z`
225
+ }
226
+
227
+ let d = `M ${points[0].x} ${baselineY} L ${points[0].x} ${points[0].y}`
228
+
229
+ for (let i = 0; i < points.length - 1; i++) {
230
+ const start = points[i]
231
+ const end = points[i + 1]
232
+ if (curve === 'monotone') {
233
+ const p0 = points[i - 1] ?? start
234
+ const p3 = points[i + 2] ?? end
235
+ const { cp1x, cp1y, cp2x, cp2y } = controlPoints(p0, start, end, p3)
236
+ d += ` C ${cp1x} ${cp1y} ${cp2x} ${cp2y} ${end.x} ${end.y}`
237
+ } else {
238
+ d += ` L ${end.x} ${end.y}`
239
+ }
240
+ }
241
+
242
+ const lastX = points[points.length - 1].x
243
+ d += ` L ${lastX} ${baselineY} Z`
244
+ return d
245
+ }
246
+
247
+ /**
248
+ * Find the index of the point whose pixel-x is nearest to `targetX`. Used to
249
+ * snap the crosshair / tooltip to the closest data point during hover/drag.
250
+ */
251
+ export function nearestIndex(pixelXs: number[], targetX: number): number {
252
+ if (pixelXs.length === 0) {
253
+ return -1
254
+ }
255
+ let best = 0
256
+ let bestDist = Infinity
257
+ for (let i = 0; i < pixelXs.length; i++) {
258
+ const dist = Math.abs(pixelXs[i] - targetX)
259
+ if (dist < bestDist) {
260
+ bestDist = dist
261
+ best = i
262
+ }
263
+ }
264
+ return best
265
+ }
@@ -7,6 +7,7 @@ import {
7
7
  type ViewStyle,
8
8
  } from 'react-native'
9
9
  import { useTokens } from '../../design-tokens/JFSThemeProvider'
10
+ import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
10
11
  import { cloneChildrenWithModes, EMPTY_MODES } from '../../utils/react-utils'
11
12
 
12
13
  /**
@@ -178,6 +179,32 @@ function Attached({
178
179
  }
179
180
  }, [badgeSize, badgeRadius])
180
181
 
182
+ // Forced slot ring. Like the corner radius, the border color/width are driven
183
+ // by design tokens (`attached/slot/border`, `attached/slot/borderWidth`) and
184
+ // applied to the slot regardless of the node passed. Rendered as an *outside*
185
+ // border: the ring lives on a wrapper that is pulled out by `-borderWidth` on
186
+ // every side (negative margin) so it grows outward instead of eating into the
187
+ // content, and its layout footprint stays equal to the slot box (keeping the
188
+ // anchor centering intact).
189
+ const slotBorderStyle = useMemo<ViewStyle | null>(() => {
190
+ const borderColor = getVariableByName('attached/slot/border', modes)
191
+ const borderWidth = parseFloat(getVariableByName('attached/slot/borderWidth', modes) || '0')
192
+ if (!borderColor || !(borderWidth > 0)) return null
193
+
194
+ // Match the inner clip radius so the ring stays concentric. The outer edge
195
+ // sits `borderWidth` further out, hence `innerRadius + borderWidth`.
196
+ const innerRadius =
197
+ badgeBoxStyle != null
198
+ ? (badgeBoxStyle.borderRadius as number)
199
+ : badgeRadius ?? 9999
200
+ return {
201
+ borderWidth,
202
+ borderColor,
203
+ borderRadius: (typeof innerRadius === 'number' ? innerRadius : 9999) + borderWidth,
204
+ margin: -borderWidth,
205
+ }
206
+ }, [modes, badgeBoxStyle, badgeRadius])
207
+
181
208
  const badgePlacement = useMemo<ViewStyle>(() => {
182
209
  const { fx, fy } = resolveAnchorFractions(position)
183
210
  const measured = mainSize.width > 0 && measuredBadgeSize.width > 0
@@ -214,11 +241,15 @@ function Attached({
214
241
  <View onLayout={onMainLayout}>{mainChildren}</View>
215
242
  {badgeChildren != null && (
216
243
  <View style={badgePlacement} onLayout={onBadgeLayout} pointerEvents="box-none">
217
- {badgeBoxStyle != null ? (
218
- <View style={badgeBoxStyle}>{forceBadgeFill(badgeChildren)}</View>
219
- ) : (
220
- badgeChildren
221
- )}
244
+ {(() => {
245
+ const slot =
246
+ badgeBoxStyle != null ? (
247
+ <View style={badgeBoxStyle}>{forceBadgeFill(badgeChildren)}</View>
248
+ ) : (
249
+ badgeChildren
250
+ )
251
+ return slotBorderStyle != null ? <View style={slotBorderStyle}>{slot}</View> : slot
252
+ })()}
222
253
  </View>
223
254
  )}
224
255
  </View>
@@ -0,0 +1,319 @@
1
+ import React, { useCallback, useMemo, useState } from 'react'
2
+ import {
3
+ StyleSheet,
4
+ View,
5
+ type LayoutChangeEvent,
6
+ type StyleProp,
7
+ type ViewStyle,
8
+ } from 'react-native'
9
+ import { EMPTY_MODES } from '../../utils/react-utils'
10
+ import ClusterBubble, {
11
+ type ClusterBubbleLabelDirection,
12
+ type ClusterBubbleLabelPlacement,
13
+ } from '../ClusterBubble/ClusterBubble'
14
+ import {
15
+ chooseLabelDirections,
16
+ estimateLabelBox,
17
+ fitRadiiToBox,
18
+ scaleRadii,
19
+ simulateCluster,
20
+ type LabelBox,
21
+ } from './bubblePacking'
22
+
23
+ /** One bubble in the chart. */
24
+ export type BubbleDatum = {
25
+ /** Stable React key (falls back to the array index). */
26
+ id?: React.Key
27
+ /**
28
+ * Numeric magnitude controlling the bubble's *area*. Larger values produce
29
+ * larger circles (`sqrt`-scaled between `minBubbleSize` and `maxBubbleSize`).
30
+ */
31
+ value: number
32
+ /**
33
+ * Bold primary text shown in/under the bubble — e.g. `"40%"`, `"₹270K"`.
34
+ * Defaults to the stringified `value`.
35
+ */
36
+ display?: React.ReactNode
37
+ /** Secondary caption beside the primary text — e.g. `"Recommended"`. */
38
+ label?: React.ReactNode
39
+ /** `Appearance / DataViz` mode for the fill. Cycles automatically if omitted. */
40
+ appearance?: string
41
+ /** Hard color override (bypasses token resolution). */
42
+ color?: string
43
+ /** Force this bubble's label placement, overriding the chart-level default. */
44
+ labelPlacement?: ClusterBubbleLabelPlacement
45
+ /** Per-bubble accessibility label. */
46
+ accessibilityLabel?: string
47
+ }
48
+
49
+ export type BubbleChartProps = {
50
+ /** The bubbles to lay out and render. */
51
+ data: BubbleDatum[]
52
+ /** Smallest bubble diameter in px. Defaults to `48`. */
53
+ minBubbleSize?: number
54
+ /** Largest bubble diameter in px. Defaults to `170`. */
55
+ maxBubbleSize?: number
56
+ /** Minimum spacing between packed bubbles in px. Defaults to `8`. */
57
+ gap?: number
58
+ /** Gap in px between a circle's edge and its outside label. Defaults to `gap`. */
59
+ labelGap?: number
60
+ /**
61
+ * Fixed pool height in px. When omitted, the height is derived from the
62
+ * measured width and the total bubble area so the cluster fits comfortably.
63
+ * The cluster is always confined to the `width × height` box and never
64
+ * overflows.
65
+ */
66
+ height?: number
67
+ /** Default label placement for every bubble. Defaults to `auto`. */
68
+ labelPlacement?: ClusterBubbleLabelPlacement
69
+ /** Diameter (px) at/above which `auto` placement renders the label inside. Defaults to `88`. */
70
+ autoInsideMinSize?: number
71
+ /**
72
+ * Ordering of `appearance` colors assigned to bubbles that don't specify
73
+ * one. Cycles through this list in input order.
74
+ */
75
+ appearanceCycle?: string[]
76
+ /** Number of force-simulation iterations. Higher = more settled. Defaults to `500`. */
77
+ iterations?: number
78
+ /** Notified when a bubble is pressed. Makes every bubble pressable. */
79
+ onBubblePress?: (datum: BubbleDatum, index: number) => void
80
+ /** Design token modes for theming (e.g. `{ 'Color Mode': 'Light' }`). */
81
+ modes?: Record<string, any>
82
+ /** Container style override. */
83
+ style?: StyleProp<ViewStyle>
84
+ /** Accessibility label for the whole chart. */
85
+ accessibilityLabel?: string
86
+ }
87
+
88
+ const DEFAULT_APPEARANCE_CYCLE = [
89
+ 'Primary',
90
+ 'Secondary',
91
+ 'Tertiary',
92
+ 'Quaternary',
93
+ 'Quinary',
94
+ 'Senary',
95
+ 'Neutral',
96
+ ]
97
+
98
+ const toText = (node: React.ReactNode, fallback = ''): string =>
99
+ typeof node === 'string' || typeof node === 'number' ? String(node) : fallback
100
+
101
+ type RenderBubble = {
102
+ datum: BubbleDatum
103
+ index: number
104
+ appearance: string
105
+ placement: 'inside' | 'outside'
106
+ direction: ClusterBubbleLabelDirection
107
+ left: number
108
+ top: number
109
+ size: number
110
+ }
111
+
112
+ /**
113
+ * `BubbleChart` arranges a set of `ClusterBubble`s with a lightweight **force
114
+ * simulation** — bubbles repel one another (collision), a gentle gravity keeps
115
+ * the cluster balanced, and the container box acts as the walls of a pool, so
116
+ * nothing ever overflows. Each datum's `value` drives its area (`sqrt`-scaled),
117
+ * colors resolve from the `dataViz/bg` token via a cycling `appearance`, and the
118
+ * emphasis comes from the `Emphasis / DataViz` mode. When a bubble is too small
119
+ * to hold its text, the label is anchored just outside the circle on whichever
120
+ * side has the most free space.
121
+ *
122
+ * @component
123
+ */
124
+ function BubbleChart({
125
+ data,
126
+ minBubbleSize = 48,
127
+ maxBubbleSize = 170,
128
+ gap = 8,
129
+ labelGap,
130
+ height,
131
+ labelPlacement = 'auto',
132
+ autoInsideMinSize = 88,
133
+ appearanceCycle = DEFAULT_APPEARANCE_CYCLE,
134
+ iterations = 500,
135
+ onBubblePress,
136
+ modes: propModes = EMPTY_MODES,
137
+ style,
138
+ accessibilityLabel,
139
+ }: BubbleChartProps) {
140
+ const modes = propModes
141
+ const resolvedLabelGap = labelGap ?? gap
142
+
143
+ const [width, setWidth] = useState(0)
144
+
145
+ const handleLayout = useCallback((e: LayoutChangeEvent) => {
146
+ const w = e.nativeEvent.layout.width
147
+ setWidth((prev) => (Math.abs(prev - w) > 0.5 ? w : prev))
148
+ }, [])
149
+
150
+ const layout = useMemo(() => {
151
+ if (data.length === 0 || width <= 0) {
152
+ return { bubbles: [] as RenderBubble[], poolHeight: height ?? 0 }
153
+ }
154
+
155
+ const minR = Math.max(1, minBubbleSize / 2)
156
+ const maxR = Math.max(minR, maxBubbleSize / 2)
157
+ const baseRadii = scaleRadii(
158
+ data.map((d) => d.value),
159
+ minR,
160
+ maxR
161
+ )
162
+
163
+ // Estimate label sizes up front (independent of radius) so we can
164
+ // reserve a margin band around the bubbles for any outside labels.
165
+ const labelEstimates: LabelBox[] = data.map((d) =>
166
+ estimateLabelBox(toText(d.display, String(d.value)), toText(d.label))
167
+ )
168
+ const mayHaveOutside =
169
+ labelPlacement !== 'inside' &&
170
+ (minBubbleSize < autoInsideMinSize ||
171
+ data.some((d) => d.labelPlacement === 'outside'))
172
+ let insetX = gap
173
+ let insetY = gap
174
+ if (mayHaveOutside) {
175
+ let maxHalfW = 0
176
+ let maxH = 0
177
+ for (const e of labelEstimates) {
178
+ if (e.w / 2 > maxHalfW) maxHalfW = e.w / 2
179
+ if (e.h > maxH) maxH = e.h
180
+ }
181
+ insetX = Math.max(gap, maxHalfW + resolvedLabelGap)
182
+ insetY = Math.max(gap, maxH + resolvedLabelGap)
183
+ }
184
+
185
+ // Derive a pool height from the bubble area when none is supplied, then
186
+ // shrink radii (if needed) so everything fits inside the inner box.
187
+ let circleArea = 0
188
+ for (const r of baseRadii) circleArea += Math.PI * r * r
189
+ const derivedHeight = Math.min(
190
+ width * 1.35,
191
+ Math.max(width * 0.6, circleArea / 0.42 / width + 2 * insetY)
192
+ )
193
+ const poolHeight = Math.max(1, height ?? derivedHeight)
194
+
195
+ const innerW = Math.max(1, width - 2 * insetX)
196
+ const innerH = Math.max(1, poolHeight - 2 * insetY)
197
+
198
+ const radii = fitRadiiToBox(baseRadii, innerW, innerH, {
199
+ density: 0.5,
200
+ labelArea: 0,
201
+ minRadius: 6,
202
+ })
203
+
204
+ // Resolve placement per bubble (outside-labelled bubbles want the
205
+ // perimeter so their labels reach the open band along the walls).
206
+ const placements: Array<'inside' | 'outside'> = data.map((d, i) => {
207
+ const pref = d.labelPlacement ?? labelPlacement
208
+ const diameter = (radii[i] ?? 0) * 2
209
+ if (pref === 'auto') return diameter >= autoInsideMinSize ? 'inside' : 'outside'
210
+ return pref
211
+ })
212
+ const perimeter = placements.map((p) => p === 'outside')
213
+
214
+ const nodes = simulateCluster(radii, {
215
+ width,
216
+ height: poolHeight,
217
+ gap,
218
+ iterations,
219
+ insetX,
220
+ insetY,
221
+ perimeter,
222
+ })
223
+
224
+ const labelBoxes: Array<LabelBox | null> = data.map((d, i) => {
225
+ if (placements[i] !== 'outside') return null
226
+ const v = toText(d.display, String(d.value))
227
+ const l = toText(d.label)
228
+ if (!v && !l) return null
229
+ return labelEstimates[i] ?? estimateLabelBox(v, l)
230
+ })
231
+
232
+ const directions = chooseLabelDirections(nodes, labelBoxes, {
233
+ width,
234
+ height: poolHeight,
235
+ gap: resolvedLabelGap,
236
+ })
237
+
238
+ const bubbles: RenderBubble[] = data.map((datum, index) => {
239
+ const node = nodes[index]!
240
+ const r = radii[index] ?? 0
241
+ return {
242
+ datum,
243
+ index,
244
+ appearance:
245
+ datum.appearance ??
246
+ appearanceCycle[index % appearanceCycle.length] ??
247
+ 'Primary',
248
+ placement: placements[index] ?? 'inside',
249
+ direction: directions[index] ?? 'bottom',
250
+ left: node.x - r,
251
+ top: node.y - r,
252
+ size: r * 2,
253
+ }
254
+ })
255
+
256
+ return { bubbles, poolHeight }
257
+ }, [
258
+ data,
259
+ width,
260
+ height,
261
+ minBubbleSize,
262
+ maxBubbleSize,
263
+ gap,
264
+ resolvedLabelGap,
265
+ labelPlacement,
266
+ autoInsideMinSize,
267
+ appearanceCycle,
268
+ iterations,
269
+ ])
270
+
271
+ return (
272
+ <View
273
+ style={[styles.container, { height: layout.poolHeight }, style]}
274
+ onLayout={handleLayout}
275
+ accessibilityRole="image"
276
+ accessibilityLabel={accessibilityLabel}
277
+ >
278
+ {layout.bubbles.map((b) => (
279
+ <View
280
+ key={b.datum.id ?? b.index}
281
+ style={[styles.bubble, { left: b.left, top: b.top }]}
282
+ pointerEvents="box-none"
283
+ >
284
+ <ClusterBubble
285
+ size={b.size}
286
+ value={b.datum.display ?? String(b.datum.value)}
287
+ label={b.datum.label}
288
+ appearance={b.appearance}
289
+ labelPlacement={b.placement}
290
+ labelDirection={b.direction}
291
+ labelGap={resolvedLabelGap}
292
+ autoInsideMinSize={autoInsideMinSize}
293
+ modes={modes}
294
+ {...(b.datum.color ? { color: b.datum.color } : null)}
295
+ {...(b.datum.accessibilityLabel
296
+ ? { accessibilityLabel: b.datum.accessibilityLabel }
297
+ : null)}
298
+ {...(onBubblePress
299
+ ? { onPress: () => onBubblePress(b.datum, b.index) }
300
+ : null)}
301
+ />
302
+ </View>
303
+ ))}
304
+ </View>
305
+ )
306
+ }
307
+
308
+ const styles = StyleSheet.create({
309
+ container: {
310
+ width: '100%',
311
+ position: 'relative',
312
+ overflow: 'hidden',
313
+ },
314
+ bubble: {
315
+ position: 'absolute',
316
+ },
317
+ })
318
+
319
+ export default BubbleChart