@oxyhq/bloom 0.34.3 → 0.35.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 (34) hide show
  1. package/lib/commonjs/avatar-group/AvatarGroup.js +3 -2
  2. package/lib/commonjs/avatar-group/AvatarGroup.js.map +1 -1
  3. package/lib/commonjs/avatar-group/AvatarGroup.web.js +5 -5
  4. package/lib/commonjs/avatar-group/AvatarGroupBase.js +217 -48
  5. package/lib/commonjs/avatar-group/AvatarGroupBase.js.map +1 -1
  6. package/lib/commonjs/avatar-group/cluster-layout.js +217 -0
  7. package/lib/commonjs/avatar-group/cluster-layout.js.map +1 -0
  8. package/lib/module/avatar-group/AvatarGroup.js +3 -2
  9. package/lib/module/avatar-group/AvatarGroup.js.map +1 -1
  10. package/lib/module/avatar-group/AvatarGroup.web.js +5 -5
  11. package/lib/module/avatar-group/AvatarGroupBase.js +217 -48
  12. package/lib/module/avatar-group/AvatarGroupBase.js.map +1 -1
  13. package/lib/module/avatar-group/cluster-layout.js +213 -0
  14. package/lib/module/avatar-group/cluster-layout.js.map +1 -0
  15. package/lib/typescript/commonjs/avatar-group/AvatarGroup.d.ts.map +1 -1
  16. package/lib/typescript/commonjs/avatar-group/AvatarGroupBase.d.ts.map +1 -1
  17. package/lib/typescript/commonjs/avatar-group/cluster-layout.d.ts +48 -0
  18. package/lib/typescript/commonjs/avatar-group/cluster-layout.d.ts.map +1 -0
  19. package/lib/typescript/commonjs/avatar-group/types.d.ts +23 -5
  20. package/lib/typescript/commonjs/avatar-group/types.d.ts.map +1 -1
  21. package/lib/typescript/module/avatar-group/AvatarGroup.d.ts.map +1 -1
  22. package/lib/typescript/module/avatar-group/AvatarGroupBase.d.ts.map +1 -1
  23. package/lib/typescript/module/avatar-group/cluster-layout.d.ts +48 -0
  24. package/lib/typescript/module/avatar-group/cluster-layout.d.ts.map +1 -0
  25. package/lib/typescript/module/avatar-group/types.d.ts +23 -5
  26. package/lib/typescript/module/avatar-group/types.d.ts.map +1 -1
  27. package/package.json +1 -1
  28. package/src/__tests__/AvatarGroupCluster.test.tsx +123 -0
  29. package/src/avatar-group/AvatarGroup.stories.tsx +68 -0
  30. package/src/avatar-group/AvatarGroup.tsx +3 -2
  31. package/src/avatar-group/AvatarGroup.web.tsx +5 -5
  32. package/src/avatar-group/AvatarGroupBase.tsx +298 -89
  33. package/src/avatar-group/cluster-layout.ts +204 -0
  34. package/src/avatar-group/types.ts +23 -5
@@ -4,12 +4,15 @@ import {
4
4
  StyleSheet,
5
5
  Text,
6
6
  View,
7
+ type StyleProp,
8
+ type TextStyle,
7
9
  type ViewStyle,
8
10
  } from 'react-native';
9
11
 
10
12
  import { Avatar } from '../avatar';
11
13
  import { useTheme } from '../theme/use-theme';
12
14
  import { fontSize } from '../styles/tokens';
15
+ import { computeClusterLayout } from './cluster-layout';
13
16
  import type { AvatarGroupItem, AvatarGroupProps } from './types';
14
17
 
15
18
  /** Ring (border) thickness around each avatar, in pixels. Matches the
@@ -18,6 +21,15 @@ const RING_WIDTH = 1;
18
21
  /** Default horizontal overlap as a fraction of avatar size — the original
19
22
  * stack pulled each avatar left by `size / 3`. */
20
23
  const DEFAULT_OVERLAP_RATIO = 1 / 3;
24
+ /** Default `max` for the `stack`/`row` facepile layouts. */
25
+ const DEFAULT_MAX = 5;
26
+ /** Default `max` (visible cap) for the `cluster` layout — it packs densely. */
27
+ const CLUSTER_DEFAULT_MAX = 20;
28
+ /** Separator ring thickness for cluster bubbles, as a fraction of the box. */
29
+ const CLUSTER_RING_RATIO = 0.02;
30
+ /** Overflow "+N" text color — white reads on the grey chip in light + dark,
31
+ * matching the original Mention count circle (shared by every layout). */
32
+ const OVERFLOW_TEXT_COLOR = '#FFFFFF';
21
33
 
22
34
  function getItemName(item: AvatarGroupItem): string | undefined {
23
35
  return item.displayName ?? item.name ?? item.username;
@@ -39,12 +51,103 @@ interface AvatarGroupBaseProps extends AvatarGroupProps {
39
51
  hoverHandlers?: AvatarGroupCellHoverHandlers;
40
52
  }
41
53
 
54
+ /**
55
+ * A single avatar cell shared by every layout: a circular, clipped container
56
+ * (`cellStyle`) holding one {@link Avatar}, positioned by `wrapperStyle`, and
57
+ * made pressable/hoverable when the relevant handlers are supplied. Layout
58
+ * (margins for the facepile, absolute coords for the cluster) lives entirely in
59
+ * the caller-provided styles so this stays layout-agnostic.
60
+ */
61
+ function AvatarGroupCell({
62
+ item,
63
+ index,
64
+ innerSize,
65
+ variant,
66
+ showInitials,
67
+ cellStyle,
68
+ wrapperStyle,
69
+ onPressItem,
70
+ hoverHandlers,
71
+ }: {
72
+ item: AvatarGroupItem;
73
+ index: number;
74
+ innerSize: number;
75
+ variant?: string;
76
+ showInitials: boolean;
77
+ cellStyle: StyleProp<ViewStyle>;
78
+ wrapperStyle: StyleProp<ViewStyle>;
79
+ onPressItem?: (item: AvatarGroupItem, index: number) => void;
80
+ hoverHandlers?: AvatarGroupCellHoverHandlers;
81
+ }) {
82
+ const name = getItemName(item);
83
+ const accessibilityLabel = item.username
84
+ ? `${name ?? item.username} (@${item.username})`
85
+ : name;
86
+ const interactive = typeof onPressItem === 'function';
87
+ const hoverable =
88
+ typeof hoverHandlers?.onHoverIn === 'function' ||
89
+ typeof hoverHandlers?.onHoverOut === 'function';
90
+
91
+ const cell = (
92
+ <View
93
+ ref={
94
+ hoverHandlers?.registerCellRef
95
+ ? (node) => hoverHandlers.registerCellRef?.(index, node)
96
+ : undefined
97
+ }
98
+ collapsable={false}
99
+ style={cellStyle}
100
+ >
101
+ {/*
102
+ Route the item's avatar value through Avatar's `source` prop (not `uri`):
103
+ full URLs pass through directly, while resolver-handled ids (e.g. Oxy
104
+ file IDs) are resolved by the consumer's ImageResolver. `variant`
105
+ (default `'thumb'`) is forwarded so the resolver builds a small rendition.
106
+ A missing avatar shows Avatar's neutral default placeholder unless
107
+ `showInitials` is set, in which case the item's name renders a colored
108
+ initial.
109
+ */}
110
+ <Avatar
111
+ source={item.uri ?? undefined}
112
+ name={showInitials ? name : undefined}
113
+ variant={variant}
114
+ size={innerSize}
115
+ />
116
+ </View>
117
+ );
118
+
119
+ if (interactive || hoverable) {
120
+ return (
121
+ <Pressable
122
+ onPress={interactive ? () => onPressItem?.(item, index) : undefined}
123
+ onHoverIn={
124
+ hoverHandlers?.onHoverIn
125
+ ? () => hoverHandlers.onHoverIn?.(item, index)
126
+ : undefined
127
+ }
128
+ onHoverOut={
129
+ hoverHandlers?.onHoverOut
130
+ ? () => hoverHandlers.onHoverOut?.(item, index)
131
+ : undefined
132
+ }
133
+ accessibilityRole={interactive ? 'button' : undefined}
134
+ accessibilityLabel={accessibilityLabel}
135
+ style={wrapperStyle}
136
+ >
137
+ {cell}
138
+ </Pressable>
139
+ );
140
+ }
141
+
142
+ return <View style={wrapperStyle}>{cell}</View>;
143
+ }
144
+
42
145
  const AvatarGroupBaseComponent: React.FC<AvatarGroupBaseProps> = ({
43
146
  items,
44
147
  layout = 'stack',
45
148
  size = 32,
46
149
  variant = 'thumb',
47
- max = 5,
150
+ max,
48
151
  total,
49
152
  overlap,
50
153
  spacing,
@@ -56,6 +159,7 @@ const AvatarGroupBaseComponent: React.FC<AvatarGroupBaseProps> = ({
56
159
  hoverHandlers,
57
160
  }) => {
58
161
  const theme = useTheme();
162
+ const isCluster = layout === 'cluster';
59
163
  const isRow = layout === 'row';
60
164
 
61
165
  // The ring is the thin separator drawn between overlapping avatars. It
@@ -63,6 +167,8 @@ const AvatarGroupBaseComponent: React.FC<AvatarGroupBaseProps> = ({
63
167
  // the surface behind them — matching the original Mention stack, which used
64
168
  // `theme.colors.background`, not a large `card`-colored ring cell.
65
169
  const ring = ringColor ?? theme.colors.background;
170
+
171
+ const effectiveMax = max ?? DEFAULT_MAX;
66
172
  const effectiveOverlap =
67
173
  overlap ?? Math.round(size * DEFAULT_OVERLAP_RATIO);
68
174
  // Each avatar after the first is pulled left by `overlap`. The cell carries a
@@ -75,8 +181,8 @@ const AvatarGroupBaseComponent: React.FC<AvatarGroupBaseProps> = ({
75
181
  const itemMargin = isRow ? rowGap : negativeMargin;
76
182
 
77
183
  const shown = useMemo(
78
- () => items.slice(0, Math.max(0, max)),
79
- [items, max],
184
+ () => items.slice(0, Math.max(0, effectiveMax)),
185
+ [items, effectiveMax],
80
186
  );
81
187
 
82
188
  const realTotal = total ?? items.length;
@@ -109,18 +215,15 @@ const AvatarGroupBaseComponent: React.FC<AvatarGroupBaseProps> = ({
109
215
  }, [isRow, size, ring]);
110
216
 
111
217
  const overflowTextStyle = useMemo(
112
- () => ({
113
- color: '#FFFFFF',
218
+ (): TextStyle => ({
219
+ color: OVERFLOW_TEXT_COLOR,
114
220
  fontSize: Math.max(fontSize._2xs, Math.round(size * 0.36)),
115
- fontWeight: '600' as const,
221
+ fontWeight: '600',
116
222
  }),
117
223
  [size],
118
224
  );
119
225
 
120
226
  const interactive = typeof onPressItem === 'function';
121
- const hoverable =
122
- typeof hoverHandlers?.onHoverIn === 'function' ||
123
- typeof hoverHandlers?.onHoverOut === 'function';
124
227
 
125
228
  // Pressing the "+N" circle surfaces the first hidden member (if any), so
126
229
  // consumers can route it to a full member list.
@@ -130,14 +233,29 @@ const AvatarGroupBaseComponent: React.FC<AvatarGroupBaseProps> = ({
130
233
  ? () => onPressItem?.(firstHidden, shown.length)
131
234
  : undefined;
132
235
 
236
+ // The cluster is a compact 2D bubble pack with its own absolute-positioned
237
+ // renderer; hooks above still run unconditionally so the branch is safe.
238
+ if (isCluster) {
239
+ return (
240
+ <ClusterAvatarGroup
241
+ items={items}
242
+ size={size}
243
+ variant={variant}
244
+ max={max}
245
+ total={total}
246
+ ring={ring}
247
+ showInitials={showInitials}
248
+ onPressItem={onPressItem}
249
+ style={style}
250
+ testID={testID}
251
+ hoverHandlers={hoverHandlers}
252
+ />
253
+ );
254
+ }
255
+
133
256
  return (
134
257
  <View style={[styles.row, style]} testID={testID}>
135
258
  {shown.map((item, index) => {
136
- const name = getItemName(item);
137
- const accessibilityLabel = item.username
138
- ? `${name ?? item.username} (@${item.username})`
139
- : name;
140
-
141
259
  // The horizontal margin and stacking order live on the outermost row
142
260
  // element so layout and hit-testing stay aligned with the visual
143
261
  // overlap. In stack mode earlier siblings render on top of later ones
@@ -148,66 +266,19 @@ const AvatarGroupBaseComponent: React.FC<AvatarGroupBaseProps> = ({
148
266
  ...(isRow ? {} : { zIndex: shown.length - index }),
149
267
  };
150
268
 
151
- const cell = (
152
- <View
153
- ref={
154
- hoverHandlers?.registerCellRef
155
- ? (node) => hoverHandlers.registerCellRef?.(index, node)
156
- : undefined
157
- }
158
- collapsable={false}
159
- style={cellStyle}
160
- >
161
- {/*
162
- Route the item's avatar value through Avatar's `source` prop (not
163
- `uri`): full URLs pass through directly, while resolver-handled ids
164
- (e.g. Oxy file IDs) are resolved by the consumer's ImageResolver —
165
- exactly like the original Mention stack. `variant` (default
166
- `'thumb'`) is forwarded so the resolver builds a small rendition
167
- for these stacked avatars. No `name` is passed, so a missing
168
- avatar shows Avatar's neutral default placeholder rather than a
169
- colored deterministic initial. When `showInitials` is set the item's
170
- name IS passed, so an avatar-less item renders a colored initial.
171
- */}
172
- <Avatar
173
- source={item.uri ?? undefined}
174
- name={showInitials ? name : undefined}
175
- variant={variant}
176
- size={innerSize}
177
- />
178
- </View>
179
- );
180
-
181
- if (interactive || hoverable) {
182
- return (
183
- <Pressable
184
- key={getItemKey(item, index)}
185
- onPress={
186
- interactive ? () => onPressItem?.(item, index) : undefined
187
- }
188
- onHoverIn={
189
- hoverHandlers?.onHoverIn
190
- ? () => hoverHandlers.onHoverIn?.(item, index)
191
- : undefined
192
- }
193
- onHoverOut={
194
- hoverHandlers?.onHoverOut
195
- ? () => hoverHandlers.onHoverOut?.(item, index)
196
- : undefined
197
- }
198
- accessibilityRole={interactive ? 'button' : undefined}
199
- accessibilityLabel={accessibilityLabel}
200
- style={wrapperStyle}
201
- >
202
- {cell}
203
- </Pressable>
204
- );
205
- }
206
-
207
269
  return (
208
- <View key={getItemKey(item, index)} style={wrapperStyle}>
209
- {cell}
210
- </View>
270
+ <AvatarGroupCell
271
+ key={getItemKey(item, index)}
272
+ item={item}
273
+ index={index}
274
+ innerSize={innerSize}
275
+ variant={variant}
276
+ showInitials={showInitials}
277
+ cellStyle={cellStyle}
278
+ wrapperStyle={wrapperStyle}
279
+ onPressItem={onPressItem}
280
+ hoverHandlers={hoverHandlers}
281
+ />
211
282
  );
212
283
  })}
213
284
 
@@ -215,8 +286,10 @@ const AvatarGroupBaseComponent: React.FC<AvatarGroupBaseProps> = ({
215
286
  <OverflowCircle
216
287
  count={overflow}
217
288
  cellStyle={cellStyle}
218
- marginLeft={shown.length > 0 ? itemMargin : 0}
219
- isRow={isRow}
289
+ wrapperStyle={{
290
+ ...(shown.length > 0 && { marginLeft: itemMargin }),
291
+ ...(isRow ? {} : { zIndex: 0 }),
292
+ }}
220
293
  textStyle={overflowTextStyle}
221
294
  onPress={overflowOnPress}
222
295
  />
@@ -225,32 +298,168 @@ const AvatarGroupBaseComponent: React.FC<AvatarGroupBaseProps> = ({
225
298
  );
226
299
  };
227
300
 
301
+ interface ClusterAvatarGroupProps {
302
+ items: AvatarGroupItem[];
303
+ size: number;
304
+ variant?: string;
305
+ max?: number;
306
+ total?: number;
307
+ ring: string;
308
+ showInitials: boolean;
309
+ onPressItem?: (item: AvatarGroupItem, index: number) => void;
310
+ style?: StyleProp<ViewStyle>;
311
+ testID?: string;
312
+ hoverHandlers?: AvatarGroupCellHoverHandlers;
313
+ }
314
+
315
+ /**
316
+ * The `cluster` layout: an iMessage-style magnetic bubble pack. Members are laid
317
+ * out by {@link computeClusterLayout} (a deterministic force-directed pack) into
318
+ * box-fraction bubbles, then absolutely positioned inside a `size × size` box so
319
+ * the whole cluster drops in where a single round Avatar would. The largest
320
+ * member (index 0) sits centred and in front; the rest pack around it with a
321
+ * uniform gap. Overflow beyond `max` collapses into a trailing "+N" bubble.
322
+ */
323
+ const ClusterAvatarGroup: React.FC<ClusterAvatarGroupProps> = ({
324
+ items,
325
+ size,
326
+ variant,
327
+ max,
328
+ total,
329
+ ring,
330
+ showInitials,
331
+ onPressItem,
332
+ style,
333
+ testID,
334
+ hoverHandlers,
335
+ }) => {
336
+ const cap = Math.max(1, max ?? CLUSTER_DEFAULT_MAX);
337
+ const realTotal = total ?? items.length;
338
+ const hasOverflow = realTotal > cap;
339
+ // Reserve the last bubble for the "+N" chip when there are more members than
340
+ // the cap; otherwise every capped member gets its own bubble.
341
+ const avatarCap = hasOverflow ? Math.max(0, cap - 1) : cap;
342
+ const shown = useMemo(
343
+ () => items.slice(0, avatarCap),
344
+ [items, avatarCap],
345
+ );
346
+ const overflow = hasOverflow ? Math.max(0, realTotal - shown.length) : 0;
347
+ const bubbleCount = shown.length + (overflow > 0 ? 1 : 0);
348
+ const bubbles = useMemo(
349
+ () => computeClusterLayout(bubbleCount),
350
+ [bubbleCount],
351
+ );
352
+
353
+ const ringWidth = Math.max(1, Math.round(size * CLUSTER_RING_RATIO));
354
+ const interactive = typeof onPressItem === 'function';
355
+
356
+ // Pressing "+N" surfaces the first hidden member (parity with the stack).
357
+ const firstHidden = items[shown.length];
358
+ const overflowOnPress =
359
+ interactive && firstHidden
360
+ ? () => onPressItem?.(firstHidden, shown.length)
361
+ : undefined;
362
+
363
+ const overflowBubble = overflow > 0 ? bubbles[bubbleCount - 1] : undefined;
364
+ let overflowElement: React.ReactNode = null;
365
+ if (overflow > 0 && overflowBubble) {
366
+ const diameter = overflowBubble.d * size;
367
+ overflowElement = (
368
+ <OverflowCircle
369
+ count={overflow}
370
+ cellStyle={{
371
+ width: diameter,
372
+ height: diameter,
373
+ borderRadius: diameter / 2,
374
+ borderWidth: ringWidth,
375
+ borderColor: ring,
376
+ alignItems: 'center',
377
+ justifyContent: 'center',
378
+ }}
379
+ wrapperStyle={{
380
+ position: 'absolute',
381
+ left: overflowBubble.cx * size - diameter / 2,
382
+ top: overflowBubble.cy * size - diameter / 2,
383
+ width: diameter,
384
+ height: diameter,
385
+ zIndex: 0,
386
+ }}
387
+ textStyle={{
388
+ color: OVERFLOW_TEXT_COLOR,
389
+ fontSize: Math.max(fontSize._2xs, Math.round(diameter * 0.34)),
390
+ fontWeight: '600',
391
+ }}
392
+ onPress={overflowOnPress}
393
+ />
394
+ );
395
+ }
396
+
397
+ return (
398
+ <View style={[{ width: size, height: size }, style]} testID={testID}>
399
+ {shown.map((item, index) => {
400
+ const bubble = bubbles[index];
401
+ if (!bubble) return null;
402
+ const diameter = bubble.d * size;
403
+ const inner = Math.max(1, diameter - ringWidth * 2);
404
+ const wrapperStyle: ViewStyle = {
405
+ position: 'absolute',
406
+ left: bubble.cx * size - diameter / 2,
407
+ top: bubble.cy * size - diameter / 2,
408
+ width: diameter,
409
+ height: diameter,
410
+ // Primary (index 0) is largest and sits in front; later members tuck
411
+ // behind in descending order (the "+N" chip lands lowest).
412
+ zIndex: bubbleCount - index,
413
+ };
414
+ const cellStyle: ViewStyle = {
415
+ width: diameter,
416
+ height: diameter,
417
+ borderRadius: diameter / 2,
418
+ borderWidth: ringWidth,
419
+ borderColor: ring,
420
+ overflow: 'hidden',
421
+ alignItems: 'center',
422
+ justifyContent: 'center',
423
+ };
424
+ return (
425
+ <AvatarGroupCell
426
+ key={getItemKey(item, index)}
427
+ item={item}
428
+ index={index}
429
+ innerSize={inner}
430
+ variant={variant}
431
+ showInitials={showInitials}
432
+ cellStyle={cellStyle}
433
+ wrapperStyle={wrapperStyle}
434
+ onPressItem={onPressItem}
435
+ hoverHandlers={hoverHandlers}
436
+ />
437
+ );
438
+ })}
439
+ {overflowElement}
440
+ </View>
441
+ );
442
+ };
443
+
228
444
  function OverflowCircle({
229
445
  count,
230
446
  cellStyle,
231
- marginLeft,
232
- isRow,
447
+ wrapperStyle,
233
448
  textStyle,
234
449
  onPress,
235
450
  }: {
236
451
  count: number;
237
- cellStyle: ViewStyle;
238
- marginLeft: number;
239
- isRow: boolean;
240
- textStyle: { color: string; fontSize: number; fontWeight: '600' };
452
+ cellStyle: StyleProp<ViewStyle>;
453
+ wrapperStyle: StyleProp<ViewStyle>;
454
+ textStyle: TextStyle;
241
455
  onPress?: () => void;
242
456
  }) {
243
457
  const theme = useTheme();
244
- // In stack mode the count circle is anchored at the lowest stacking order so
245
- // it tucks behind the last avatar's ring; row mode has no overlap. The margin
246
- // lives on the outer element to keep layout aligned with the spacing.
247
- const wrapperStyle: ViewStyle = {
248
- ...(marginLeft !== 0 && { marginLeft }),
249
- ...(isRow ? {} : { zIndex: 0 }),
250
- };
251
458
 
252
459
  // A solid "+N" count circle: secondary-text-colored fill with white text,
253
- // matching the original Mention `ResponsiveAvatarStack` count circle.
460
+ // matching the original Mention `ResponsiveAvatarStack` count circle. Layout
461
+ // (margin for the facepile, absolute coords for the cluster) is supplied by
462
+ // the caller via `wrapperStyle`.
254
463
  const circle = (
255
464
  <View
256
465
  style={[cellStyle, { backgroundColor: theme.colors.textSecondary }]}
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Deterministic organic circle-packing for the {@link AvatarGroup} `cluster`
3
+ * layout — the iMessage-style "magnetic bubble cluster" where several avatars of
4
+ * varying sizes nestle together inside a round bounding box (a large primary in
5
+ * the middle/front, smaller members packed around it with a uniform gap).
6
+ *
7
+ * For 4+ members the layout is produced by a small, FULLY DETERMINISTIC
8
+ * force-directed relaxation (no `Math.random`): the primary is pinned at the
9
+ * centre and the remaining members are seeded on a golden-angle spiral, then a
10
+ * fixed number of relaxation passes (a) pull every non-primary circle toward the
11
+ * centre and (b) push any two circles apart until they clear a uniform gap. A
12
+ * fixed iteration count + deterministic seed means the same `count` always
13
+ * yields byte-identical positions, so native and web render the cluster
14
+ * identically with no `onLayout`/DOM measurement. The very small counts (1, 2,
15
+ * 3) — where a relaxation degenerates into a line or a lone pair — use explicit
16
+ * iMessage-style arrangements instead.
17
+ *
18
+ * Output is resolution-independent: each bubble is expressed as a fraction of
19
+ * the group's bounding box (`cx`/`cy` centre, `d` diameter, all 0..1), so the
20
+ * consumer just multiplies by the pixel `size`. Every bubble is guaranteed to
21
+ * sit fully inside the `[0, 1]` box (the packed result is scaled + translated to
22
+ * fit), so the cluster drops in exactly where a single round Avatar would.
23
+ */
24
+
25
+ /** A single packed bubble, expressed as fractions of the bounding box (0..1). */
26
+ export interface ClusterBubble {
27
+ /** Centre X as a fraction of the box width. */
28
+ cx: number;
29
+ /** Centre Y as a fraction of the box height. */
30
+ cy: number;
31
+ /** Diameter as a fraction of the box size. */
32
+ d: number;
33
+ }
34
+
35
+ // Golden angle (~137.5°) — spreads the seed points evenly with no directional
36
+ // bias, which is what gives the relaxed result its organic, non-grid feel.
37
+ const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5));
38
+ // Fixed relaxation passes + a final separation-only cleanup so the last thing
39
+ // that happens is overlap resolution (gaps end uniform and non-negative).
40
+ const ITERATIONS = 500;
41
+ const CLEANUP_ITERATIONS = 80;
42
+ // How hard each pass pulls every non-primary circle toward the centre. This is
43
+ // the only compacting force; separation only ever pushes apart, so any positive
44
+ // value packs the cluster fully — this just controls convergence speed.
45
+ const CENTERING = 0.05;
46
+ // Fraction of an overlap resolved per pass. 1 fully separates each pass; with
47
+ // the pinned primary and many passes this stays stable and converges.
48
+ const SEPARATION_STRENGTH = 1;
49
+ // Uniform gap kept between every touching pair, in primary-radius units
50
+ // (primary radius = 1). Reads as consistent spacing everywhere in the cluster.
51
+ const LAYOUT_GAP = 0.16;
52
+ // Initial spiral spacing. Only affects convergence (the result is re-fitted to
53
+ // the box afterwards), not the final scale.
54
+ const SEED_SPACING = 1.7;
55
+ // Relative radii: the primary is the largest; the remaining members taper from
56
+ // SECONDARY_MAX (nearest the primary) down to SECONDARY_MIN (outermost) so size
57
+ // decreases outward and later/overflow members are the smallest.
58
+ const PRIMARY_RADIUS = 1;
59
+ const SECONDARY_MAX = 0.72;
60
+ const SECONDARY_MIN = 0.5;
61
+ const EPSILON = 1e-6;
62
+
63
+ /** Relative radius for member `index` of a `count`-member cluster. */
64
+ function relativeRadius(index: number, count: number): number {
65
+ if (index === 0) return PRIMARY_RADIUS;
66
+ if (count <= 2) return SECONDARY_MAX;
67
+ const t = (index - 1) / (count - 2);
68
+ return SECONDARY_MAX + (SECONDARY_MIN - SECONDARY_MAX) * t;
69
+ }
70
+
71
+ /**
72
+ * One separation pass: push any pair closer than `(r_i + r_j + gap)` apart. The
73
+ * primary (index 0) is pinned — when a pair involves it, only the other circle
74
+ * moves — which keeps the largest avatar dead-centre and in front.
75
+ */
76
+ function separate(xs: number[], ys: number[], radii: number[], count: number): void {
77
+ for (let i = 0; i < count; i++) {
78
+ for (let j = i + 1; j < count; j++) {
79
+ let dx = (xs[j] ?? 0) - (xs[i] ?? 0);
80
+ let dy = (ys[j] ?? 0) - (ys[i] ?? 0);
81
+ let dist = Math.hypot(dx, dy);
82
+ const minDist = (radii[i] ?? 0) + (radii[j] ?? 0) + LAYOUT_GAP;
83
+ if (dist >= minDist) continue;
84
+ if (dist < EPSILON) {
85
+ // Coincident points: pick a deterministic direction from the indices so
86
+ // the split is stable (never random).
87
+ const a = (i + 1) * GOLDEN_ANGLE + j;
88
+ dx = Math.cos(a);
89
+ dy = Math.sin(a);
90
+ dist = 1;
91
+ }
92
+ const overlap = (minDist - dist) * SEPARATION_STRENGTH;
93
+ const nx = dx / dist;
94
+ const ny = dy / dist;
95
+ if (i === 0) {
96
+ // Primary pinned: move only the other circle by the full overlap.
97
+ xs[j] = (xs[j] ?? 0) + nx * overlap;
98
+ ys[j] = (ys[j] ?? 0) + ny * overlap;
99
+ } else {
100
+ xs[i] = (xs[i] ?? 0) - (nx * overlap) / 2;
101
+ ys[i] = (ys[i] ?? 0) - (ny * overlap) / 2;
102
+ xs[j] = (xs[j] ?? 0) + (nx * overlap) / 2;
103
+ ys[j] = (ys[j] ?? 0) + (ny * overlap) / 2;
104
+ }
105
+ }
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Deterministic force-directed pack of `count` circles (primary pinned at the
111
+ * centre), returned as box-fraction bubbles. Used for every cluster with 4+
112
+ * members; 1–3 are handled as explicit arrangements in
113
+ * {@link computeClusterLayout}.
114
+ */
115
+ function packCluster(count: number): ClusterBubble[] {
116
+ const radii = new Array<number>(count);
117
+ const xs = new Array<number>(count);
118
+ const ys = new Array<number>(count);
119
+
120
+ for (let i = 0; i < count; i++) {
121
+ radii[i] = relativeRadius(i, count);
122
+ if (i === 0) {
123
+ xs[i] = 0;
124
+ ys[i] = 0;
125
+ } else {
126
+ // Golden-angle spiral seed around the pinned primary.
127
+ const seedR = SEED_SPACING * Math.sqrt(i);
128
+ const angle = i * GOLDEN_ANGLE;
129
+ xs[i] = seedR * Math.cos(angle);
130
+ ys[i] = seedR * Math.sin(angle);
131
+ }
132
+ }
133
+
134
+ for (let iter = 0; iter < ITERATIONS; iter++) {
135
+ // (a) Centering: pull every non-primary circle toward the centre.
136
+ for (let i = 1; i < count; i++) {
137
+ xs[i] = (xs[i] ?? 0) * (1 - CENTERING);
138
+ ys[i] = (ys[i] ?? 0) * (1 - CENTERING);
139
+ }
140
+ // (b) Separation resolves any resulting overlaps (primary stays pinned).
141
+ separate(xs, ys, radii, count);
142
+ xs[0] = 0;
143
+ ys[0] = 0;
144
+ }
145
+ // Final separation-only passes so the cluster ends with clean uniform gaps.
146
+ for (let iter = 0; iter < CLEANUP_ITERATIONS; iter++) {
147
+ separate(xs, ys, radii, count);
148
+ xs[0] = 0;
149
+ ys[0] = 0;
150
+ }
151
+
152
+ // Fit to the unit box: the primary is at the origin, so scale so the
153
+ // outermost circle edge lands on the box radius (0.5) and place the primary at
154
+ // the box centre.
155
+ let bound = 0;
156
+ for (let i = 0; i < count; i++) {
157
+ const reach = Math.hypot(xs[i] ?? 0, ys[i] ?? 0) + (radii[i] ?? 0);
158
+ if (reach > bound) bound = reach;
159
+ }
160
+ const scale = bound > EPSILON ? 0.5 / bound : 0.5;
161
+
162
+ const bubbles = new Array<ClusterBubble>(count);
163
+ for (let i = 0; i < count; i++) {
164
+ bubbles[i] = {
165
+ cx: 0.5 + (xs[i] ?? 0) * scale,
166
+ cy: 0.5 + (ys[i] ?? 0) * scale,
167
+ d: 2 * (radii[i] ?? 0) * scale,
168
+ };
169
+ }
170
+ return bubbles;
171
+ }
172
+
173
+ /**
174
+ * Deterministic cluster layout for `count` bubbles, as box-fraction bubbles
175
+ * ordered primary-first. `count` includes the `+N` overflow bubble when present
176
+ * (it is simply the last, smallest member of the pack).
177
+ *
178
+ * - `<= 0` → empty.
179
+ * - `1` → a single bubble filling the box.
180
+ * - `2` → the iMessage "one in front, one behind" pair: a larger primary set
181
+ * low-left with a smaller member tucked behind it to the upper-right.
182
+ * - `3` → an iMessage-style triangle: the larger primary along the bottom with
183
+ * two smaller members above it.
184
+ * - `4+` → the deterministic force-directed pack (primary centred, the rest
185
+ * packed magnetically around it, denser as the count grows).
186
+ */
187
+ export function computeClusterLayout(count: number): ClusterBubble[] {
188
+ if (count <= 0) return [];
189
+ if (count === 1) return [{ cx: 0.5, cy: 0.5, d: 1 }];
190
+ if (count === 2) {
191
+ return [
192
+ { cx: 0.42, cy: 0.56, d: 0.66 },
193
+ { cx: 0.68, cy: 0.36, d: 0.5 },
194
+ ];
195
+ }
196
+ if (count === 3) {
197
+ return [
198
+ { cx: 0.5, cy: 0.69, d: 0.54 },
199
+ { cx: 0.285, cy: 0.24, d: 0.4 },
200
+ { cx: 0.715, cy: 0.24, d: 0.4 },
201
+ ];
202
+ }
203
+ return packCluster(count);
204
+ }