@oxyhq/bloom 0.34.2 → 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 (41) 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/commonjs/zoomable-image-gallery/ZoomableImageGallery.js +23 -13
  9. package/lib/commonjs/zoomable-image-gallery/ZoomableImageGallery.js.map +1 -1
  10. package/lib/module/avatar-group/AvatarGroup.js +3 -2
  11. package/lib/module/avatar-group/AvatarGroup.js.map +1 -1
  12. package/lib/module/avatar-group/AvatarGroup.web.js +5 -5
  13. package/lib/module/avatar-group/AvatarGroupBase.js +217 -48
  14. package/lib/module/avatar-group/AvatarGroupBase.js.map +1 -1
  15. package/lib/module/avatar-group/cluster-layout.js +213 -0
  16. package/lib/module/avatar-group/cluster-layout.js.map +1 -0
  17. package/lib/module/zoomable-image-gallery/ZoomableImageGallery.js +23 -13
  18. package/lib/module/zoomable-image-gallery/ZoomableImageGallery.js.map +1 -1
  19. package/lib/typescript/commonjs/avatar-group/AvatarGroup.d.ts.map +1 -1
  20. package/lib/typescript/commonjs/avatar-group/AvatarGroupBase.d.ts.map +1 -1
  21. package/lib/typescript/commonjs/avatar-group/cluster-layout.d.ts +48 -0
  22. package/lib/typescript/commonjs/avatar-group/cluster-layout.d.ts.map +1 -0
  23. package/lib/typescript/commonjs/avatar-group/types.d.ts +23 -5
  24. package/lib/typescript/commonjs/avatar-group/types.d.ts.map +1 -1
  25. package/lib/typescript/commonjs/zoomable-image-gallery/ZoomableImageGallery.d.ts.map +1 -1
  26. package/lib/typescript/module/avatar-group/AvatarGroup.d.ts.map +1 -1
  27. package/lib/typescript/module/avatar-group/AvatarGroupBase.d.ts.map +1 -1
  28. package/lib/typescript/module/avatar-group/cluster-layout.d.ts +48 -0
  29. package/lib/typescript/module/avatar-group/cluster-layout.d.ts.map +1 -0
  30. package/lib/typescript/module/avatar-group/types.d.ts +23 -5
  31. package/lib/typescript/module/avatar-group/types.d.ts.map +1 -1
  32. package/lib/typescript/module/zoomable-image-gallery/ZoomableImageGallery.d.ts.map +1 -1
  33. package/package.json +1 -1
  34. package/src/__tests__/AvatarGroupCluster.test.tsx +123 -0
  35. package/src/avatar-group/AvatarGroup.stories.tsx +68 -0
  36. package/src/avatar-group/AvatarGroup.tsx +3 -2
  37. package/src/avatar-group/AvatarGroup.web.tsx +5 -5
  38. package/src/avatar-group/AvatarGroupBase.tsx +298 -89
  39. package/src/avatar-group/cluster-layout.ts +204 -0
  40. package/src/avatar-group/types.ts +23 -5
  41. package/src/zoomable-image-gallery/ZoomableImageGallery.tsx +27 -12
@@ -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
+ }
@@ -30,14 +30,27 @@ export interface AvatarGroupProps {
30
30
  items: AvatarGroupItem[];
31
31
  /**
32
32
  * How the avatars are arranged.
33
- * - `'stack'` (default): overlapping facepile with a thin separator ring, a
34
- * trailing `+N` overflow chip, and the first item on top.
33
+ * - `'stack'` (default): overlapping horizontal facepile with a thin separator
34
+ * ring, a trailing `+N` overflow chip, and the first item on top.
35
35
  * - `'row'`: avatars placed adjacent with a positive `spacing` gap and NO
36
36
  * separator ring — an "adjacent row" of icons (e.g. a top-tokens strip).
37
37
  * Still collapses into the `+N` overflow chip at `max`.
38
+ * - `'cluster'`: a compact 2D "magnetic bubble cluster" (iMessage-style) —
39
+ * avatars of VARYING sizes deterministically packed into a round bounding
40
+ * box with a uniform gap: the first item (primary) is the largest and sits
41
+ * centred/in front, the rest nestle around it. Scales from 2 (a "front +
42
+ * behind" pair) up through a dense pack (~20), collapsing into a trailing
43
+ * `+N` bubble past `max`. Unlike the horizontal layouts, `size` is the
44
+ * overall box diameter (see `size`) and `max` defaults to 20 (see `max`), so
45
+ * it drops in where a single round Avatar would.
46
+ */
47
+ layout?: 'stack' | 'row' | 'cluster';
48
+ /**
49
+ * Diameter in pixels. For `'stack'`/`'row'` this is the diameter of EACH
50
+ * avatar. For `'cluster'` this is the diameter of the whole bounding box (the
51
+ * packed bubbles are sized as fractions of it), so the cluster occupies the
52
+ * same footprint as a single `size`-px Avatar.
38
53
  */
39
- layout?: 'stack' | 'row';
40
- /** Diameter of each avatar in pixels. */
41
54
  size?: number;
42
55
  /**
43
56
  * Rendition variant forwarded to each {@link Avatar}'s `variant` prop, which
@@ -46,7 +59,12 @@ export interface AvatarGroupProps {
46
59
  * `'thumb'`; pass `undefined` to request full-size renditions.
47
60
  */
48
61
  variant?: string;
49
- /** Maximum number of avatars to render before collapsing into the overflow chip. */
62
+ /**
63
+ * Maximum number of avatars to render before collapsing into the overflow
64
+ * chip. Defaults to 5 for `'stack'`/`'row'`, and to 20 for `'cluster'` (which
65
+ * packs densely). In `'cluster'` the cap is inclusive of the `+N` bubble: past
66
+ * the cap the last slot becomes the overflow bubble.
67
+ */
50
68
  max?: number;
51
69
  /**
52
70
  * Real total count of members. When provided and larger than the number of
@@ -421,6 +421,19 @@ const ZoomableImageGalleryInner = React.forwardRef<ZoomableImageGalleryHandle, Z
421
421
 
422
422
  React.useImperativeHandle(ref, () => ({ open }), [open]);
423
423
 
424
+ // Tapping the backdrop dismisses. Uses the same Gesture system as every
425
+ // other interaction in this component (image tap, pinch, pan, double-tap)
426
+ // instead of a plain RN Pressable, so the backdrop isn't the one interaction
427
+ // mixing two different event-handling systems under the same
428
+ // GestureHandlerRootView.
429
+ const backdropTapGesture = useMemo(
430
+ () =>
431
+ Gesture.Tap().onEnd(() => {
432
+ runOnJS(handleDismiss)();
433
+ }),
434
+ [handleDismiss]
435
+ );
436
+
424
437
  const panGesture = useMemo(
425
438
  () =>
426
439
  Gesture.Pan()
@@ -721,18 +734,20 @@ const ZoomableImageGalleryInner = React.forwardRef<ZoomableImageGalleryHandle, Z
721
734
 
722
735
  const renderContent = () => (
723
736
  <GestureHandlerRootView style={styles.modalContainer}>
724
- <Pressable style={StyleSheet.absoluteFill} onPress={handleDismiss} hitSlop={0}>
725
- <AnimatedBlurView
726
- intensity={80}
727
- tint={theme.isDark ? 'dark' : 'light'}
728
- experimentalBlurMethod="dimezisBlurView"
729
- style={[StyleSheet.absoluteFill, backdropStyle]}
730
- >
731
- <Animated.View
732
- style={[StyleSheet.absoluteFill, { backgroundColor: theme.colors.overlay }, backdropStyle]}
733
- />
734
- </AnimatedBlurView>
735
- </Pressable>
737
+ <GestureDetector gesture={backdropTapGesture}>
738
+ <Animated.View style={StyleSheet.absoluteFill}>
739
+ <AnimatedBlurView
740
+ intensity={80}
741
+ tint={theme.isDark ? 'dark' : 'light'}
742
+ experimentalBlurMethod="dimezisBlurView"
743
+ style={[StyleSheet.absoluteFill, backdropStyle]}
744
+ >
745
+ <Animated.View
746
+ style={[StyleSheet.absoluteFill, { backgroundColor: theme.colors.overlay }, backdropStyle]}
747
+ />
748
+ </AnimatedBlurView>
749
+ </Animated.View>
750
+ </GestureDetector>
736
751
 
737
752
  <GestureDetector gesture={panGesture}>
738
753
  <Animated.View