@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.
- package/lib/commonjs/avatar-group/AvatarGroup.js +3 -2
- package/lib/commonjs/avatar-group/AvatarGroup.js.map +1 -1
- package/lib/commonjs/avatar-group/AvatarGroup.web.js +5 -5
- package/lib/commonjs/avatar-group/AvatarGroupBase.js +217 -48
- package/lib/commonjs/avatar-group/AvatarGroupBase.js.map +1 -1
- package/lib/commonjs/avatar-group/cluster-layout.js +217 -0
- package/lib/commonjs/avatar-group/cluster-layout.js.map +1 -0
- package/lib/commonjs/zoomable-image-gallery/ZoomableImageGallery.js +23 -13
- package/lib/commonjs/zoomable-image-gallery/ZoomableImageGallery.js.map +1 -1
- package/lib/module/avatar-group/AvatarGroup.js +3 -2
- package/lib/module/avatar-group/AvatarGroup.js.map +1 -1
- package/lib/module/avatar-group/AvatarGroup.web.js +5 -5
- package/lib/module/avatar-group/AvatarGroupBase.js +217 -48
- package/lib/module/avatar-group/AvatarGroupBase.js.map +1 -1
- package/lib/module/avatar-group/cluster-layout.js +213 -0
- package/lib/module/avatar-group/cluster-layout.js.map +1 -0
- package/lib/module/zoomable-image-gallery/ZoomableImageGallery.js +23 -13
- package/lib/module/zoomable-image-gallery/ZoomableImageGallery.js.map +1 -1
- package/lib/typescript/commonjs/avatar-group/AvatarGroup.d.ts.map +1 -1
- package/lib/typescript/commonjs/avatar-group/AvatarGroupBase.d.ts.map +1 -1
- package/lib/typescript/commonjs/avatar-group/cluster-layout.d.ts +48 -0
- package/lib/typescript/commonjs/avatar-group/cluster-layout.d.ts.map +1 -0
- package/lib/typescript/commonjs/avatar-group/types.d.ts +23 -5
- package/lib/typescript/commonjs/avatar-group/types.d.ts.map +1 -1
- package/lib/typescript/commonjs/zoomable-image-gallery/ZoomableImageGallery.d.ts.map +1 -1
- package/lib/typescript/module/avatar-group/AvatarGroup.d.ts.map +1 -1
- package/lib/typescript/module/avatar-group/AvatarGroupBase.d.ts.map +1 -1
- package/lib/typescript/module/avatar-group/cluster-layout.d.ts +48 -0
- package/lib/typescript/module/avatar-group/cluster-layout.d.ts.map +1 -0
- package/lib/typescript/module/avatar-group/types.d.ts +23 -5
- package/lib/typescript/module/avatar-group/types.d.ts.map +1 -1
- package/lib/typescript/module/zoomable-image-gallery/ZoomableImageGallery.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/AvatarGroupCluster.test.tsx +123 -0
- package/src/avatar-group/AvatarGroup.stories.tsx +68 -0
- package/src/avatar-group/AvatarGroup.tsx +3 -2
- package/src/avatar-group/AvatarGroup.web.tsx +5 -5
- package/src/avatar-group/AvatarGroupBase.tsx +298 -89
- package/src/avatar-group/cluster-layout.ts +204 -0
- package/src/avatar-group/types.ts +23 -5
- 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
|
|
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
|
-
/**
|
|
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
|
-
<
|
|
725
|
-
<
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
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
|