@oxyhq/bloom 0.35.0 → 0.35.2
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/cluster-layout.js +105 -33
- package/lib/commonjs/avatar-group/cluster-layout.js.map +1 -1
- package/lib/module/avatar-group/cluster-layout.js +105 -33
- package/lib/module/avatar-group/cluster-layout.js.map +1 -1
- package/lib/typescript/commonjs/avatar-group/cluster-layout.d.ts +23 -12
- package/lib/typescript/commonjs/avatar-group/cluster-layout.d.ts.map +1 -1
- package/lib/typescript/module/avatar-group/cluster-layout.d.ts +23 -12
- package/lib/typescript/module/avatar-group/cluster-layout.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/AvatarGroupCluster.test.tsx +106 -5
- package/src/avatar-group/AvatarGroup.stories.tsx +6 -5
- package/src/avatar-group/cluster-layout.ts +91 -29
|
@@ -7,8 +7,9 @@ exports.computeClusterLayout = computeClusterLayout;
|
|
|
7
7
|
/**
|
|
8
8
|
* Deterministic organic circle-packing for the {@link AvatarGroup} `cluster`
|
|
9
9
|
* layout — the iMessage-style "magnetic bubble cluster" where several avatars of
|
|
10
|
-
*
|
|
11
|
-
*
|
|
10
|
+
* NEAR-EQUAL size nestle tightly together and fill a round bounding box (a
|
|
11
|
+
* modestly larger primary with the rest only slightly smaller, packed against
|
|
12
|
+
* each other with a small uniform gap — not a big primary ringed by tiny dots).
|
|
12
13
|
*
|
|
13
14
|
* For 4+ members the layout is produced by a small, FULLY DETERMINISTIC
|
|
14
15
|
* force-directed relaxation (no `Math.random`): the primary is pinned at the
|
|
@@ -17,15 +18,18 @@ exports.computeClusterLayout = computeClusterLayout;
|
|
|
17
18
|
* centre and (b) push any two circles apart until they clear a uniform gap. A
|
|
18
19
|
* fixed iteration count + deterministic seed means the same `count` always
|
|
19
20
|
* yields byte-identical positions, so native and web render the cluster
|
|
20
|
-
* identically with no `onLayout`/DOM measurement. The
|
|
21
|
-
*
|
|
22
|
-
*
|
|
21
|
+
* identically with no `onLayout`/DOM measurement. The small counts (1, 2, 3, 4)
|
|
22
|
+
* use explicit iMessage-style arrangements instead — 1–3 because a relaxation
|
|
23
|
+
* there degenerates into a line or a lone pair, and 4 because the iconic
|
|
24
|
+
* 4-person layout is a clean 2×2 grid of equal circles rather than a pack.
|
|
23
25
|
*
|
|
24
26
|
* Output is resolution-independent: each bubble is expressed as a fraction of
|
|
25
27
|
* the group's bounding box (`cx`/`cy` centre, `d` diameter, all 0..1), so the
|
|
26
|
-
* consumer just multiplies by the pixel `size`.
|
|
27
|
-
*
|
|
28
|
-
*
|
|
28
|
+
* consumer just multiplies by the pixel `size`. The relaxed blob is recentred on
|
|
29
|
+
* its own centre and scaled so the outermost bubble edge lands on the box edge,
|
|
30
|
+
* so it fills the round box densely with minimal empty margin while every bubble
|
|
31
|
+
* is guaranteed to sit fully inside the `[0, 1]` box — the cluster drops in
|
|
32
|
+
* exactly where a single round Avatar would.
|
|
29
33
|
*/
|
|
30
34
|
|
|
31
35
|
/** A single packed bubble, expressed as fractions of the bounding box (0..1). */
|
|
@@ -45,17 +49,20 @@ const CENTERING = 0.05;
|
|
|
45
49
|
// the pinned primary and many passes this stays stable and converges.
|
|
46
50
|
const SEPARATION_STRENGTH = 1;
|
|
47
51
|
// Uniform gap kept between every touching pair, in primary-radius units
|
|
48
|
-
// (primary radius = 1).
|
|
49
|
-
|
|
52
|
+
// (primary radius = 1). Small, so near-equal bubbles nestle tightly like
|
|
53
|
+
// magnets rather than floating apart with visible margins.
|
|
54
|
+
const LAYOUT_GAP = 0.12;
|
|
50
55
|
// Initial spiral spacing. Only affects convergence (the result is re-fitted to
|
|
51
56
|
// the box afterwards), not the final scale.
|
|
52
57
|
const SEED_SPACING = 1.7;
|
|
53
|
-
// Relative radii: the primary is the largest;
|
|
54
|
-
// SECONDARY_MAX (nearest the primary) down to SECONDARY_MIN
|
|
55
|
-
//
|
|
58
|
+
// Relative radii: the primary is only MODESTLY the largest; every other member
|
|
59
|
+
// tapers gently from SECONDARY_MAX (nearest the primary) down to SECONDARY_MIN
|
|
60
|
+
// (outermost). The spread is deliberately narrow so the cluster reads as a pack
|
|
61
|
+
// of near-equal magnetic bubbles — a slightly larger primary, not a big primary
|
|
62
|
+
// ringed by tiny dots.
|
|
56
63
|
const PRIMARY_RADIUS = 1;
|
|
57
|
-
const SECONDARY_MAX = 0.
|
|
58
|
-
const SECONDARY_MIN = 0.
|
|
64
|
+
const SECONDARY_MAX = 0.9;
|
|
65
|
+
const SECONDARY_MIN = 0.8;
|
|
59
66
|
const EPSILON = 1e-6;
|
|
60
67
|
|
|
61
68
|
/** Relative radius for member `index` of a `count`-member cluster. */
|
|
@@ -106,8 +113,8 @@ function separate(xs, ys, radii, count) {
|
|
|
106
113
|
|
|
107
114
|
/**
|
|
108
115
|
* Deterministic force-directed pack of `count` circles (primary pinned at the
|
|
109
|
-
* centre), returned as box-fraction bubbles. Used for every cluster with
|
|
110
|
-
* members; 1–
|
|
116
|
+
* centre), returned as box-fraction bubbles. Used for every cluster with 5+
|
|
117
|
+
* members; 1–4 are handled as explicit arrangements in
|
|
111
118
|
* {@link computeClusterLayout}.
|
|
112
119
|
*/
|
|
113
120
|
function packCluster(count) {
|
|
@@ -145,20 +152,41 @@ function packCluster(count) {
|
|
|
145
152
|
ys[0] = 0;
|
|
146
153
|
}
|
|
147
154
|
|
|
148
|
-
// Fit to the unit box
|
|
149
|
-
//
|
|
150
|
-
// the
|
|
155
|
+
// Fit to the unit box so the pack FILLS the round box edge-to-edge with
|
|
156
|
+
// minimal empty margin. The primary was pinned at the origin during
|
|
157
|
+
// relaxation, but the relaxed blob is not centred on it, so we recentre on the
|
|
158
|
+
// cluster's OWN centre (its bounding-box midpoint) rather than the primary:
|
|
159
|
+
// enclose every bubble in a circle around that centre, then scale so the
|
|
160
|
+
// outermost bubble edge lands exactly on the box radius (0.5). Centring the
|
|
161
|
+
// enclosing circle at the box centre keeps every bubble inside the [0, 1] box
|
|
162
|
+
// (the enclosing circle is inscribed in the square) while packing the blob
|
|
163
|
+
// tightly against the edge instead of floating with a gap.
|
|
164
|
+
let minX = Infinity;
|
|
165
|
+
let maxX = -Infinity;
|
|
166
|
+
let minY = Infinity;
|
|
167
|
+
let maxY = -Infinity;
|
|
168
|
+
for (let i = 0; i < count; i++) {
|
|
169
|
+
const x = xs[i] ?? 0;
|
|
170
|
+
const y = ys[i] ?? 0;
|
|
171
|
+
const r = radii[i] ?? 0;
|
|
172
|
+
if (x - r < minX) minX = x - r;
|
|
173
|
+
if (x + r > maxX) maxX = x + r;
|
|
174
|
+
if (y - r < minY) minY = y - r;
|
|
175
|
+
if (y + r > maxY) maxY = y + r;
|
|
176
|
+
}
|
|
177
|
+
const centerX = (minX + maxX) / 2;
|
|
178
|
+
const centerY = (minY + maxY) / 2;
|
|
151
179
|
let bound = 0;
|
|
152
180
|
for (let i = 0; i < count; i++) {
|
|
153
|
-
const reach = Math.hypot(xs[i] ?? 0, ys[i] ?? 0) + (radii[i] ?? 0);
|
|
181
|
+
const reach = Math.hypot((xs[i] ?? 0) - centerX, (ys[i] ?? 0) - centerY) + (radii[i] ?? 0);
|
|
154
182
|
if (reach > bound) bound = reach;
|
|
155
183
|
}
|
|
156
184
|
const scale = bound > EPSILON ? 0.5 / bound : 0.5;
|
|
157
185
|
const bubbles = new Array(count);
|
|
158
186
|
for (let i = 0; i < count; i++) {
|
|
159
187
|
bubbles[i] = {
|
|
160
|
-
cx: 0.5 + (xs[i] ?? 0) * scale,
|
|
161
|
-
cy: 0.5 + (ys[i] ?? 0) * scale,
|
|
188
|
+
cx: 0.5 + ((xs[i] ?? 0) - centerX) * scale,
|
|
189
|
+
cy: 0.5 + ((ys[i] ?? 0) - centerY) * scale,
|
|
162
190
|
d: 2 * (radii[i] ?? 0) * scale
|
|
163
191
|
};
|
|
164
192
|
}
|
|
@@ -172,12 +200,19 @@ function packCluster(count) {
|
|
|
172
200
|
*
|
|
173
201
|
* - `<= 0` → empty.
|
|
174
202
|
* - `1` → a single bubble filling the box.
|
|
175
|
-
* - `2` → the iMessage "one in front, one behind" pair:
|
|
176
|
-
*
|
|
203
|
+
* - `2` → the iMessage "one in front, one behind" pair: two EQUAL-diameter
|
|
204
|
+
* bubbles offset on a diagonal — the front set low-left, the other tucked
|
|
205
|
+
* behind it to the upper-right. Same radius, so neither member reads as
|
|
206
|
+
* secondary; the overlap + separator ring alone convey the stacking.
|
|
177
207
|
* - `3` → an iMessage-style triangle: the larger primary along the bottom with
|
|
178
208
|
* two smaller members above it.
|
|
179
|
-
* - `4
|
|
180
|
-
*
|
|
209
|
+
* - `4` → the iMessage 4-person layout: a 2×2 grid of EQUAL-diameter bubbles,
|
|
210
|
+
* centred in the box with a uniform gap, sized so the outermost edges reach the
|
|
211
|
+
* box edge like the other counts.
|
|
212
|
+
* - `5+` → the deterministic force-directed pack of near-equal bubbles, recentred
|
|
213
|
+
* and scaled to fill the round box edge-to-edge (a modestly larger primary near
|
|
214
|
+
* the centre, the rest packed magnetically around it, denser as the count
|
|
215
|
+
* grows).
|
|
181
216
|
*/
|
|
182
217
|
function computeClusterLayout(count) {
|
|
183
218
|
if (count <= 0) return [];
|
|
@@ -187,14 +222,18 @@ function computeClusterLayout(count) {
|
|
|
187
222
|
d: 1
|
|
188
223
|
}];
|
|
189
224
|
if (count === 2) {
|
|
225
|
+
// Two EQUAL-diameter bubbles, offset symmetrically on the diagonal: the
|
|
226
|
+
// front (index 0, highest zIndex) sits low-left, the other tucks behind it
|
|
227
|
+
// to the upper-right. They overlap (the intentional "front + behind" pair);
|
|
228
|
+
// the separator ring — not a size difference — reads the stacking.
|
|
190
229
|
return [{
|
|
191
|
-
cx: 0.
|
|
192
|
-
cy: 0.
|
|
193
|
-
d: 0.
|
|
230
|
+
cx: 0.35,
|
|
231
|
+
cy: 0.65,
|
|
232
|
+
d: 0.64
|
|
194
233
|
}, {
|
|
195
|
-
cx: 0.
|
|
196
|
-
cy: 0.
|
|
197
|
-
d: 0.
|
|
234
|
+
cx: 0.65,
|
|
235
|
+
cy: 0.35,
|
|
236
|
+
d: 0.64
|
|
198
237
|
}];
|
|
199
238
|
}
|
|
200
239
|
if (count === 3) {
|
|
@@ -212,6 +251,39 @@ function computeClusterLayout(count) {
|
|
|
212
251
|
d: 0.4
|
|
213
252
|
}];
|
|
214
253
|
}
|
|
254
|
+
if (count === 4) {
|
|
255
|
+
// The iMessage 4-person layout: a 2×2 grid of EQUAL-diameter bubbles,
|
|
256
|
+
// centred in the box with the same uniform neighbour gap the rest of the
|
|
257
|
+
// cluster keeps (LAYOUT_GAP, in circle-radius units), sized so the outermost
|
|
258
|
+
// bubble edge reaches the box radius (0.5 from the centre) — exactly as the
|
|
259
|
+
// force-packed counts do — so the four circles fill the round box
|
|
260
|
+
// edge-to-edge with a clean uniform gap and no overlap.
|
|
261
|
+
//
|
|
262
|
+
// With diameter `d` and radius `r = d/2`, the neighbour gap `g = LAYOUT_GAP·r`,
|
|
263
|
+
// so the centre-to-centre spacing is `s = d + g = d·(1 + LAYOUT_GAP/2)`. Each
|
|
264
|
+
// centre sits `(s/2, s/2)` from the box centre, so its farthest point lies
|
|
265
|
+
// `(s/2)·√2 + r` away; setting that to `0.5` and solving for `d`:
|
|
266
|
+
const spacingFactor = 1 + LAYOUT_GAP / 2;
|
|
267
|
+
const d = 0.5 / (spacingFactor * Math.SQRT2 / 2 + 0.5);
|
|
268
|
+
const half = d * spacingFactor / 2;
|
|
269
|
+
return [{
|
|
270
|
+
cx: 0.5 - half,
|
|
271
|
+
cy: 0.5 - half,
|
|
272
|
+
d
|
|
273
|
+
}, {
|
|
274
|
+
cx: 0.5 + half,
|
|
275
|
+
cy: 0.5 - half,
|
|
276
|
+
d
|
|
277
|
+
}, {
|
|
278
|
+
cx: 0.5 - half,
|
|
279
|
+
cy: 0.5 + half,
|
|
280
|
+
d
|
|
281
|
+
}, {
|
|
282
|
+
cx: 0.5 + half,
|
|
283
|
+
cy: 0.5 + half,
|
|
284
|
+
d
|
|
285
|
+
}];
|
|
286
|
+
}
|
|
215
287
|
return packCluster(count);
|
|
216
288
|
}
|
|
217
289
|
//# sourceMappingURL=cluster-layout.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"names":["GOLDEN_ANGLE","Math","PI","sqrt","ITERATIONS","CLEANUP_ITERATIONS","CENTERING","SEPARATION_STRENGTH","LAYOUT_GAP","SEED_SPACING","PRIMARY_RADIUS","SECONDARY_MAX","SECONDARY_MIN","EPSILON","relativeRadius","index","count","t","separate","xs","ys","radii","i","j","dx","dy","dist","hypot","minDist","a","cos","sin","overlap","nx","ny","packCluster","Array","seedR","angle","iter","bound","reach","scale","bubbles","cx","cy","d","computeClusterLayout"],"sourceRoot":"../../../src","sources":["avatar-group/cluster-layout.ts"],"mappings":";;;;;;AAAA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;;AAUA;AACA;AACA,MAAMA,YAAY,GAAGC,IAAI,CAACC,EAAE,IAAI,CAAC,GAAGD,IAAI,CAACE,IAAI,CAAC,CAAC,CAAC,CAAC;AACjD;AACA;AACA,MAAMC,UAAU,GAAG,GAAG;AACtB,MAAMC,kBAAkB,GAAG,EAAE;AAC7B;AACA;AACA;AACA,MAAMC,SAAS,GAAG,IAAI;AACtB;AACA;AACA,MAAMC,mBAAmB,GAAG,CAAC;AAC7B;AACA;AACA,MAAMC,UAAU,GAAG,IAAI;AACvB;AACA;AACA,MAAMC,YAAY,GAAG,GAAG;AACxB;AACA;AACA;AACA,MAAMC,cAAc,GAAG,CAAC;AACxB,MAAMC,aAAa,GAAG,
|
|
1
|
+
{"version":3,"names":["GOLDEN_ANGLE","Math","PI","sqrt","ITERATIONS","CLEANUP_ITERATIONS","CENTERING","SEPARATION_STRENGTH","LAYOUT_GAP","SEED_SPACING","PRIMARY_RADIUS","SECONDARY_MAX","SECONDARY_MIN","EPSILON","relativeRadius","index","count","t","separate","xs","ys","radii","i","j","dx","dy","dist","hypot","minDist","a","cos","sin","overlap","nx","ny","packCluster","Array","seedR","angle","iter","minX","Infinity","maxX","minY","maxY","x","y","r","centerX","centerY","bound","reach","scale","bubbles","cx","cy","d","computeClusterLayout","spacingFactor","SQRT2","half"],"sourceRoot":"../../../src","sources":["avatar-group/cluster-layout.ts"],"mappings":"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","ignoreList":[]}
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* Deterministic organic circle-packing for the {@link AvatarGroup} `cluster`
|
|
5
5
|
* layout — the iMessage-style "magnetic bubble cluster" where several avatars of
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* NEAR-EQUAL size nestle tightly together and fill a round bounding box (a
|
|
7
|
+
* modestly larger primary with the rest only slightly smaller, packed against
|
|
8
|
+
* each other with a small uniform gap — not a big primary ringed by tiny dots).
|
|
8
9
|
*
|
|
9
10
|
* For 4+ members the layout is produced by a small, FULLY DETERMINISTIC
|
|
10
11
|
* force-directed relaxation (no `Math.random`): the primary is pinned at the
|
|
@@ -13,15 +14,18 @@
|
|
|
13
14
|
* centre and (b) push any two circles apart until they clear a uniform gap. A
|
|
14
15
|
* fixed iteration count + deterministic seed means the same `count` always
|
|
15
16
|
* yields byte-identical positions, so native and web render the cluster
|
|
16
|
-
* identically with no `onLayout`/DOM measurement. The
|
|
17
|
-
*
|
|
18
|
-
*
|
|
17
|
+
* identically with no `onLayout`/DOM measurement. The small counts (1, 2, 3, 4)
|
|
18
|
+
* use explicit iMessage-style arrangements instead — 1–3 because a relaxation
|
|
19
|
+
* there degenerates into a line or a lone pair, and 4 because the iconic
|
|
20
|
+
* 4-person layout is a clean 2×2 grid of equal circles rather than a pack.
|
|
19
21
|
*
|
|
20
22
|
* Output is resolution-independent: each bubble is expressed as a fraction of
|
|
21
23
|
* the group's bounding box (`cx`/`cy` centre, `d` diameter, all 0..1), so the
|
|
22
|
-
* consumer just multiplies by the pixel `size`.
|
|
23
|
-
*
|
|
24
|
-
*
|
|
24
|
+
* consumer just multiplies by the pixel `size`. The relaxed blob is recentred on
|
|
25
|
+
* its own centre and scaled so the outermost bubble edge lands on the box edge,
|
|
26
|
+
* so it fills the round box densely with minimal empty margin while every bubble
|
|
27
|
+
* is guaranteed to sit fully inside the `[0, 1]` box — the cluster drops in
|
|
28
|
+
* exactly where a single round Avatar would.
|
|
25
29
|
*/
|
|
26
30
|
|
|
27
31
|
/** A single packed bubble, expressed as fractions of the bounding box (0..1). */
|
|
@@ -41,17 +45,20 @@ const CENTERING = 0.05;
|
|
|
41
45
|
// the pinned primary and many passes this stays stable and converges.
|
|
42
46
|
const SEPARATION_STRENGTH = 1;
|
|
43
47
|
// Uniform gap kept between every touching pair, in primary-radius units
|
|
44
|
-
// (primary radius = 1).
|
|
45
|
-
|
|
48
|
+
// (primary radius = 1). Small, so near-equal bubbles nestle tightly like
|
|
49
|
+
// magnets rather than floating apart with visible margins.
|
|
50
|
+
const LAYOUT_GAP = 0.12;
|
|
46
51
|
// Initial spiral spacing. Only affects convergence (the result is re-fitted to
|
|
47
52
|
// the box afterwards), not the final scale.
|
|
48
53
|
const SEED_SPACING = 1.7;
|
|
49
|
-
// Relative radii: the primary is the largest;
|
|
50
|
-
// SECONDARY_MAX (nearest the primary) down to SECONDARY_MIN
|
|
51
|
-
//
|
|
54
|
+
// Relative radii: the primary is only MODESTLY the largest; every other member
|
|
55
|
+
// tapers gently from SECONDARY_MAX (nearest the primary) down to SECONDARY_MIN
|
|
56
|
+
// (outermost). The spread is deliberately narrow so the cluster reads as a pack
|
|
57
|
+
// of near-equal magnetic bubbles — a slightly larger primary, not a big primary
|
|
58
|
+
// ringed by tiny dots.
|
|
52
59
|
const PRIMARY_RADIUS = 1;
|
|
53
|
-
const SECONDARY_MAX = 0.
|
|
54
|
-
const SECONDARY_MIN = 0.
|
|
60
|
+
const SECONDARY_MAX = 0.9;
|
|
61
|
+
const SECONDARY_MIN = 0.8;
|
|
55
62
|
const EPSILON = 1e-6;
|
|
56
63
|
|
|
57
64
|
/** Relative radius for member `index` of a `count`-member cluster. */
|
|
@@ -102,8 +109,8 @@ function separate(xs, ys, radii, count) {
|
|
|
102
109
|
|
|
103
110
|
/**
|
|
104
111
|
* Deterministic force-directed pack of `count` circles (primary pinned at the
|
|
105
|
-
* centre), returned as box-fraction bubbles. Used for every cluster with
|
|
106
|
-
* members; 1–
|
|
112
|
+
* centre), returned as box-fraction bubbles. Used for every cluster with 5+
|
|
113
|
+
* members; 1–4 are handled as explicit arrangements in
|
|
107
114
|
* {@link computeClusterLayout}.
|
|
108
115
|
*/
|
|
109
116
|
function packCluster(count) {
|
|
@@ -141,20 +148,41 @@ function packCluster(count) {
|
|
|
141
148
|
ys[0] = 0;
|
|
142
149
|
}
|
|
143
150
|
|
|
144
|
-
// Fit to the unit box
|
|
145
|
-
//
|
|
146
|
-
// the
|
|
151
|
+
// Fit to the unit box so the pack FILLS the round box edge-to-edge with
|
|
152
|
+
// minimal empty margin. The primary was pinned at the origin during
|
|
153
|
+
// relaxation, but the relaxed blob is not centred on it, so we recentre on the
|
|
154
|
+
// cluster's OWN centre (its bounding-box midpoint) rather than the primary:
|
|
155
|
+
// enclose every bubble in a circle around that centre, then scale so the
|
|
156
|
+
// outermost bubble edge lands exactly on the box radius (0.5). Centring the
|
|
157
|
+
// enclosing circle at the box centre keeps every bubble inside the [0, 1] box
|
|
158
|
+
// (the enclosing circle is inscribed in the square) while packing the blob
|
|
159
|
+
// tightly against the edge instead of floating with a gap.
|
|
160
|
+
let minX = Infinity;
|
|
161
|
+
let maxX = -Infinity;
|
|
162
|
+
let minY = Infinity;
|
|
163
|
+
let maxY = -Infinity;
|
|
164
|
+
for (let i = 0; i < count; i++) {
|
|
165
|
+
const x = xs[i] ?? 0;
|
|
166
|
+
const y = ys[i] ?? 0;
|
|
167
|
+
const r = radii[i] ?? 0;
|
|
168
|
+
if (x - r < minX) minX = x - r;
|
|
169
|
+
if (x + r > maxX) maxX = x + r;
|
|
170
|
+
if (y - r < minY) minY = y - r;
|
|
171
|
+
if (y + r > maxY) maxY = y + r;
|
|
172
|
+
}
|
|
173
|
+
const centerX = (minX + maxX) / 2;
|
|
174
|
+
const centerY = (minY + maxY) / 2;
|
|
147
175
|
let bound = 0;
|
|
148
176
|
for (let i = 0; i < count; i++) {
|
|
149
|
-
const reach = Math.hypot(xs[i] ?? 0, ys[i] ?? 0) + (radii[i] ?? 0);
|
|
177
|
+
const reach = Math.hypot((xs[i] ?? 0) - centerX, (ys[i] ?? 0) - centerY) + (radii[i] ?? 0);
|
|
150
178
|
if (reach > bound) bound = reach;
|
|
151
179
|
}
|
|
152
180
|
const scale = bound > EPSILON ? 0.5 / bound : 0.5;
|
|
153
181
|
const bubbles = new Array(count);
|
|
154
182
|
for (let i = 0; i < count; i++) {
|
|
155
183
|
bubbles[i] = {
|
|
156
|
-
cx: 0.5 + (xs[i] ?? 0) * scale,
|
|
157
|
-
cy: 0.5 + (ys[i] ?? 0) * scale,
|
|
184
|
+
cx: 0.5 + ((xs[i] ?? 0) - centerX) * scale,
|
|
185
|
+
cy: 0.5 + ((ys[i] ?? 0) - centerY) * scale,
|
|
158
186
|
d: 2 * (radii[i] ?? 0) * scale
|
|
159
187
|
};
|
|
160
188
|
}
|
|
@@ -168,12 +196,19 @@ function packCluster(count) {
|
|
|
168
196
|
*
|
|
169
197
|
* - `<= 0` → empty.
|
|
170
198
|
* - `1` → a single bubble filling the box.
|
|
171
|
-
* - `2` → the iMessage "one in front, one behind" pair:
|
|
172
|
-
*
|
|
199
|
+
* - `2` → the iMessage "one in front, one behind" pair: two EQUAL-diameter
|
|
200
|
+
* bubbles offset on a diagonal — the front set low-left, the other tucked
|
|
201
|
+
* behind it to the upper-right. Same radius, so neither member reads as
|
|
202
|
+
* secondary; the overlap + separator ring alone convey the stacking.
|
|
173
203
|
* - `3` → an iMessage-style triangle: the larger primary along the bottom with
|
|
174
204
|
* two smaller members above it.
|
|
175
|
-
* - `4
|
|
176
|
-
*
|
|
205
|
+
* - `4` → the iMessage 4-person layout: a 2×2 grid of EQUAL-diameter bubbles,
|
|
206
|
+
* centred in the box with a uniform gap, sized so the outermost edges reach the
|
|
207
|
+
* box edge like the other counts.
|
|
208
|
+
* - `5+` → the deterministic force-directed pack of near-equal bubbles, recentred
|
|
209
|
+
* and scaled to fill the round box edge-to-edge (a modestly larger primary near
|
|
210
|
+
* the centre, the rest packed magnetically around it, denser as the count
|
|
211
|
+
* grows).
|
|
177
212
|
*/
|
|
178
213
|
export function computeClusterLayout(count) {
|
|
179
214
|
if (count <= 0) return [];
|
|
@@ -183,14 +218,18 @@ export function computeClusterLayout(count) {
|
|
|
183
218
|
d: 1
|
|
184
219
|
}];
|
|
185
220
|
if (count === 2) {
|
|
221
|
+
// Two EQUAL-diameter bubbles, offset symmetrically on the diagonal: the
|
|
222
|
+
// front (index 0, highest zIndex) sits low-left, the other tucks behind it
|
|
223
|
+
// to the upper-right. They overlap (the intentional "front + behind" pair);
|
|
224
|
+
// the separator ring — not a size difference — reads the stacking.
|
|
186
225
|
return [{
|
|
187
|
-
cx: 0.
|
|
188
|
-
cy: 0.
|
|
189
|
-
d: 0.
|
|
226
|
+
cx: 0.35,
|
|
227
|
+
cy: 0.65,
|
|
228
|
+
d: 0.64
|
|
190
229
|
}, {
|
|
191
|
-
cx: 0.
|
|
192
|
-
cy: 0.
|
|
193
|
-
d: 0.
|
|
230
|
+
cx: 0.65,
|
|
231
|
+
cy: 0.35,
|
|
232
|
+
d: 0.64
|
|
194
233
|
}];
|
|
195
234
|
}
|
|
196
235
|
if (count === 3) {
|
|
@@ -208,6 +247,39 @@ export function computeClusterLayout(count) {
|
|
|
208
247
|
d: 0.4
|
|
209
248
|
}];
|
|
210
249
|
}
|
|
250
|
+
if (count === 4) {
|
|
251
|
+
// The iMessage 4-person layout: a 2×2 grid of EQUAL-diameter bubbles,
|
|
252
|
+
// centred in the box with the same uniform neighbour gap the rest of the
|
|
253
|
+
// cluster keeps (LAYOUT_GAP, in circle-radius units), sized so the outermost
|
|
254
|
+
// bubble edge reaches the box radius (0.5 from the centre) — exactly as the
|
|
255
|
+
// force-packed counts do — so the four circles fill the round box
|
|
256
|
+
// edge-to-edge with a clean uniform gap and no overlap.
|
|
257
|
+
//
|
|
258
|
+
// With diameter `d` and radius `r = d/2`, the neighbour gap `g = LAYOUT_GAP·r`,
|
|
259
|
+
// so the centre-to-centre spacing is `s = d + g = d·(1 + LAYOUT_GAP/2)`. Each
|
|
260
|
+
// centre sits `(s/2, s/2)` from the box centre, so its farthest point lies
|
|
261
|
+
// `(s/2)·√2 + r` away; setting that to `0.5` and solving for `d`:
|
|
262
|
+
const spacingFactor = 1 + LAYOUT_GAP / 2;
|
|
263
|
+
const d = 0.5 / (spacingFactor * Math.SQRT2 / 2 + 0.5);
|
|
264
|
+
const half = d * spacingFactor / 2;
|
|
265
|
+
return [{
|
|
266
|
+
cx: 0.5 - half,
|
|
267
|
+
cy: 0.5 - half,
|
|
268
|
+
d
|
|
269
|
+
}, {
|
|
270
|
+
cx: 0.5 + half,
|
|
271
|
+
cy: 0.5 - half,
|
|
272
|
+
d
|
|
273
|
+
}, {
|
|
274
|
+
cx: 0.5 - half,
|
|
275
|
+
cy: 0.5 + half,
|
|
276
|
+
d
|
|
277
|
+
}, {
|
|
278
|
+
cx: 0.5 + half,
|
|
279
|
+
cy: 0.5 + half,
|
|
280
|
+
d
|
|
281
|
+
}];
|
|
282
|
+
}
|
|
211
283
|
return packCluster(count);
|
|
212
284
|
}
|
|
213
285
|
//# sourceMappingURL=cluster-layout.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"names":["GOLDEN_ANGLE","Math","PI","sqrt","ITERATIONS","CLEANUP_ITERATIONS","CENTERING","SEPARATION_STRENGTH","LAYOUT_GAP","SEED_SPACING","PRIMARY_RADIUS","SECONDARY_MAX","SECONDARY_MIN","EPSILON","relativeRadius","index","count","t","separate","xs","ys","radii","i","j","dx","dy","dist","hypot","minDist","a","cos","sin","overlap","nx","ny","packCluster","Array","seedR","angle","iter","bound","reach","scale","bubbles","cx","cy","d","computeClusterLayout"],"sourceRoot":"../../../src","sources":["avatar-group/cluster-layout.ts"],"mappings":";;AAAA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;;AAUA;AACA;AACA,MAAMA,YAAY,GAAGC,IAAI,CAACC,EAAE,IAAI,CAAC,GAAGD,IAAI,CAACE,IAAI,CAAC,CAAC,CAAC,CAAC;AACjD;AACA;AACA,MAAMC,UAAU,GAAG,GAAG;AACtB,MAAMC,kBAAkB,GAAG,EAAE;AAC7B;AACA;AACA;AACA,MAAMC,SAAS,GAAG,IAAI;AACtB;AACA;AACA,MAAMC,mBAAmB,GAAG,CAAC;AAC7B;AACA;AACA,MAAMC,UAAU,GAAG,IAAI;AACvB;AACA;AACA,MAAMC,YAAY,GAAG,GAAG;AACxB;AACA;AACA;AACA,MAAMC,cAAc,GAAG,CAAC;AACxB,MAAMC,aAAa,GAAG,
|
|
1
|
+
{"version":3,"names":["GOLDEN_ANGLE","Math","PI","sqrt","ITERATIONS","CLEANUP_ITERATIONS","CENTERING","SEPARATION_STRENGTH","LAYOUT_GAP","SEED_SPACING","PRIMARY_RADIUS","SECONDARY_MAX","SECONDARY_MIN","EPSILON","relativeRadius","index","count","t","separate","xs","ys","radii","i","j","dx","dy","dist","hypot","minDist","a","cos","sin","overlap","nx","ny","packCluster","Array","seedR","angle","iter","minX","Infinity","maxX","minY","maxY","x","y","r","centerX","centerY","bound","reach","scale","bubbles","cx","cy","d","computeClusterLayout","spacingFactor","SQRT2","half"],"sourceRoot":"../../../src","sources":["avatar-group/cluster-layout.ts"],"mappings":"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","ignoreList":[]}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Deterministic organic circle-packing for the {@link AvatarGroup} `cluster`
|
|
3
3
|
* layout — the iMessage-style "magnetic bubble cluster" where several avatars of
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* NEAR-EQUAL size nestle tightly together and fill a round bounding box (a
|
|
5
|
+
* modestly larger primary with the rest only slightly smaller, packed against
|
|
6
|
+
* each other with a small uniform gap — not a big primary ringed by tiny dots).
|
|
6
7
|
*
|
|
7
8
|
* For 4+ members the layout is produced by a small, FULLY DETERMINISTIC
|
|
8
9
|
* force-directed relaxation (no `Math.random`): the primary is pinned at the
|
|
@@ -11,15 +12,18 @@
|
|
|
11
12
|
* centre and (b) push any two circles apart until they clear a uniform gap. A
|
|
12
13
|
* fixed iteration count + deterministic seed means the same `count` always
|
|
13
14
|
* yields byte-identical positions, so native and web render the cluster
|
|
14
|
-
* identically with no `onLayout`/DOM measurement. The
|
|
15
|
-
*
|
|
16
|
-
*
|
|
15
|
+
* identically with no `onLayout`/DOM measurement. The small counts (1, 2, 3, 4)
|
|
16
|
+
* use explicit iMessage-style arrangements instead — 1–3 because a relaxation
|
|
17
|
+
* there degenerates into a line or a lone pair, and 4 because the iconic
|
|
18
|
+
* 4-person layout is a clean 2×2 grid of equal circles rather than a pack.
|
|
17
19
|
*
|
|
18
20
|
* Output is resolution-independent: each bubble is expressed as a fraction of
|
|
19
21
|
* the group's bounding box (`cx`/`cy` centre, `d` diameter, all 0..1), so the
|
|
20
|
-
* consumer just multiplies by the pixel `size`.
|
|
21
|
-
*
|
|
22
|
-
*
|
|
22
|
+
* consumer just multiplies by the pixel `size`. The relaxed blob is recentred on
|
|
23
|
+
* its own centre and scaled so the outermost bubble edge lands on the box edge,
|
|
24
|
+
* so it fills the round box densely with minimal empty margin while every bubble
|
|
25
|
+
* is guaranteed to sit fully inside the `[0, 1]` box — the cluster drops in
|
|
26
|
+
* exactly where a single round Avatar would.
|
|
23
27
|
*/
|
|
24
28
|
/** A single packed bubble, expressed as fractions of the bounding box (0..1). */
|
|
25
29
|
export interface ClusterBubble {
|
|
@@ -37,12 +41,19 @@ export interface ClusterBubble {
|
|
|
37
41
|
*
|
|
38
42
|
* - `<= 0` → empty.
|
|
39
43
|
* - `1` → a single bubble filling the box.
|
|
40
|
-
* - `2` → the iMessage "one in front, one behind" pair:
|
|
41
|
-
*
|
|
44
|
+
* - `2` → the iMessage "one in front, one behind" pair: two EQUAL-diameter
|
|
45
|
+
* bubbles offset on a diagonal — the front set low-left, the other tucked
|
|
46
|
+
* behind it to the upper-right. Same radius, so neither member reads as
|
|
47
|
+
* secondary; the overlap + separator ring alone convey the stacking.
|
|
42
48
|
* - `3` → an iMessage-style triangle: the larger primary along the bottom with
|
|
43
49
|
* two smaller members above it.
|
|
44
|
-
* - `4
|
|
45
|
-
*
|
|
50
|
+
* - `4` → the iMessage 4-person layout: a 2×2 grid of EQUAL-diameter bubbles,
|
|
51
|
+
* centred in the box with a uniform gap, sized so the outermost edges reach the
|
|
52
|
+
* box edge like the other counts.
|
|
53
|
+
* - `5+` → the deterministic force-directed pack of near-equal bubbles, recentred
|
|
54
|
+
* and scaled to fill the round box edge-to-edge (a modestly larger primary near
|
|
55
|
+
* the centre, the rest packed magnetically around it, denser as the count
|
|
56
|
+
* grows).
|
|
46
57
|
*/
|
|
47
58
|
export declare function computeClusterLayout(count: number): ClusterBubble[];
|
|
48
59
|
//# sourceMappingURL=cluster-layout.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cluster-layout.d.ts","sourceRoot":"","sources":["../../../../src/avatar-group/cluster-layout.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"cluster-layout.d.ts","sourceRoot":"","sources":["../../../../src/avatar-group/cluster-layout.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH,iFAAiF;AACjF,MAAM,WAAW,aAAa;IAC5B,+CAA+C;IAC/C,EAAE,EAAE,MAAM,CAAC;IACX,gDAAgD;IAChD,EAAE,EAAE,MAAM,CAAC;IACX,8CAA8C;IAC9C,CAAC,EAAE,MAAM,CAAC;CACX;AAqKD;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,MAAM,GAAG,aAAa,EAAE,CA2CnE"}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Deterministic organic circle-packing for the {@link AvatarGroup} `cluster`
|
|
3
3
|
* layout — the iMessage-style "magnetic bubble cluster" where several avatars of
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* NEAR-EQUAL size nestle tightly together and fill a round bounding box (a
|
|
5
|
+
* modestly larger primary with the rest only slightly smaller, packed against
|
|
6
|
+
* each other with a small uniform gap — not a big primary ringed by tiny dots).
|
|
6
7
|
*
|
|
7
8
|
* For 4+ members the layout is produced by a small, FULLY DETERMINISTIC
|
|
8
9
|
* force-directed relaxation (no `Math.random`): the primary is pinned at the
|
|
@@ -11,15 +12,18 @@
|
|
|
11
12
|
* centre and (b) push any two circles apart until they clear a uniform gap. A
|
|
12
13
|
* fixed iteration count + deterministic seed means the same `count` always
|
|
13
14
|
* yields byte-identical positions, so native and web render the cluster
|
|
14
|
-
* identically with no `onLayout`/DOM measurement. The
|
|
15
|
-
*
|
|
16
|
-
*
|
|
15
|
+
* identically with no `onLayout`/DOM measurement. The small counts (1, 2, 3, 4)
|
|
16
|
+
* use explicit iMessage-style arrangements instead — 1–3 because a relaxation
|
|
17
|
+
* there degenerates into a line or a lone pair, and 4 because the iconic
|
|
18
|
+
* 4-person layout is a clean 2×2 grid of equal circles rather than a pack.
|
|
17
19
|
*
|
|
18
20
|
* Output is resolution-independent: each bubble is expressed as a fraction of
|
|
19
21
|
* the group's bounding box (`cx`/`cy` centre, `d` diameter, all 0..1), so the
|
|
20
|
-
* consumer just multiplies by the pixel `size`.
|
|
21
|
-
*
|
|
22
|
-
*
|
|
22
|
+
* consumer just multiplies by the pixel `size`. The relaxed blob is recentred on
|
|
23
|
+
* its own centre and scaled so the outermost bubble edge lands on the box edge,
|
|
24
|
+
* so it fills the round box densely with minimal empty margin while every bubble
|
|
25
|
+
* is guaranteed to sit fully inside the `[0, 1]` box — the cluster drops in
|
|
26
|
+
* exactly where a single round Avatar would.
|
|
23
27
|
*/
|
|
24
28
|
/** A single packed bubble, expressed as fractions of the bounding box (0..1). */
|
|
25
29
|
export interface ClusterBubble {
|
|
@@ -37,12 +41,19 @@ export interface ClusterBubble {
|
|
|
37
41
|
*
|
|
38
42
|
* - `<= 0` → empty.
|
|
39
43
|
* - `1` → a single bubble filling the box.
|
|
40
|
-
* - `2` → the iMessage "one in front, one behind" pair:
|
|
41
|
-
*
|
|
44
|
+
* - `2` → the iMessage "one in front, one behind" pair: two EQUAL-diameter
|
|
45
|
+
* bubbles offset on a diagonal — the front set low-left, the other tucked
|
|
46
|
+
* behind it to the upper-right. Same radius, so neither member reads as
|
|
47
|
+
* secondary; the overlap + separator ring alone convey the stacking.
|
|
42
48
|
* - `3` → an iMessage-style triangle: the larger primary along the bottom with
|
|
43
49
|
* two smaller members above it.
|
|
44
|
-
* - `4
|
|
45
|
-
*
|
|
50
|
+
* - `4` → the iMessage 4-person layout: a 2×2 grid of EQUAL-diameter bubbles,
|
|
51
|
+
* centred in the box with a uniform gap, sized so the outermost edges reach the
|
|
52
|
+
* box edge like the other counts.
|
|
53
|
+
* - `5+` → the deterministic force-directed pack of near-equal bubbles, recentred
|
|
54
|
+
* and scaled to fill the round box edge-to-edge (a modestly larger primary near
|
|
55
|
+
* the centre, the rest packed magnetically around it, denser as the count
|
|
56
|
+
* grows).
|
|
46
57
|
*/
|
|
47
58
|
export declare function computeClusterLayout(count: number): ClusterBubble[];
|
|
48
59
|
//# sourceMappingURL=cluster-layout.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cluster-layout.d.ts","sourceRoot":"","sources":["../../../../src/avatar-group/cluster-layout.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"cluster-layout.d.ts","sourceRoot":"","sources":["../../../../src/avatar-group/cluster-layout.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH,iFAAiF;AACjF,MAAM,WAAW,aAAa;IAC5B,+CAA+C;IAC/C,EAAE,EAAE,MAAM,CAAC;IACX,gDAAgD;IAChD,EAAE,EAAE,MAAM,CAAC;IACX,8CAA8C;IAC9C,CAAC,EAAE,MAAM,CAAC;CACX;AAqKD;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,MAAM,GAAG,aAAa,EAAE,CA2CnE"}
|
package/package.json
CHANGED
|
@@ -74,11 +74,37 @@ describe('computeClusterLayout', () => {
|
|
|
74
74
|
}
|
|
75
75
|
});
|
|
76
76
|
|
|
77
|
-
it('
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
77
|
+
it('fills the round box edge-to-edge for the force-packed counts (5+)', () => {
|
|
78
|
+
// After the recentre + scale, the outermost bubble edge lands on the box
|
|
79
|
+
// radius (0.5 from the box centre) — the blob fills the round box densely
|
|
80
|
+
// with no big empty margin — and the whole cluster stays roughly centred.
|
|
81
|
+
// (count=4 is the explicit 2×2 grid, covered separately below.)
|
|
82
|
+
for (const count of [5, 8, 12, 20]) {
|
|
83
|
+
const bubbles = computeClusterLayout(count);
|
|
84
|
+
let reach = 0;
|
|
85
|
+
for (const bubble of bubbles) {
|
|
86
|
+
const r = Math.hypot(bubble.cx - 0.5, bubble.cy - 0.5) + bubble.d / 2;
|
|
87
|
+
if (r > reach) reach = r;
|
|
88
|
+
}
|
|
89
|
+
expect(reach).toBeCloseTo(0.5, 6);
|
|
90
|
+
const primary = bubbleAt(bubbles, 0);
|
|
91
|
+
expect(primary.cx).toBeGreaterThan(0.4);
|
|
92
|
+
expect(primary.cx).toBeLessThan(0.6);
|
|
93
|
+
expect(primary.cy).toBeGreaterThan(0.4);
|
|
94
|
+
expect(primary.cy).toBeLessThan(0.6);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('keeps the force-packed bubbles near-equal in size (mild taper)', () => {
|
|
99
|
+
// The pack should read as near-equal magnetic bubbles: a modestly larger
|
|
100
|
+
// primary, everyone else only slightly smaller — not a big primary ringed by
|
|
101
|
+
// tiny dots. The smallest bubble stays at least ~75% of the largest.
|
|
102
|
+
// (count=4 is the explicit 2×2 grid, covered separately below.)
|
|
103
|
+
for (const count of [5, 6, 8, 12, 20]) {
|
|
104
|
+
const diameters = computeClusterLayout(count).map((bubble) => bubble.d);
|
|
105
|
+
const maxD = Math.max(...diameters);
|
|
106
|
+
const minD = Math.min(...diameters);
|
|
107
|
+
expect(minD / maxD).toBeGreaterThan(0.75);
|
|
82
108
|
}
|
|
83
109
|
});
|
|
84
110
|
|
|
@@ -88,6 +114,81 @@ describe('computeClusterLayout', () => {
|
|
|
88
114
|
expect(minEdgeGap(computeClusterLayout(count))).toBeGreaterThan(-1e-3);
|
|
89
115
|
}
|
|
90
116
|
});
|
|
117
|
+
|
|
118
|
+
it('arranges the four-member cluster as an equal-size 2×2 grid', () => {
|
|
119
|
+
// The iMessage 4-person layout: four EQUAL-diameter bubbles at the corners of
|
|
120
|
+
// a 2×2 grid centred in the box, with a clean uniform gap (no overlap), sized
|
|
121
|
+
// so the outermost edges reach the box edge like the other counts.
|
|
122
|
+
const bubbles = computeClusterLayout(4);
|
|
123
|
+
expect(bubbles).toHaveLength(4);
|
|
124
|
+
|
|
125
|
+
// All four share one diameter.
|
|
126
|
+
const d = bubbleAt(bubbles, 0).d;
|
|
127
|
+
for (const bubble of bubbles) {
|
|
128
|
+
expect(bubble.d).toBeCloseTo(d, 10);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Exactly two distinct rows (cy) and two distinct columns (cx), each shared by
|
|
132
|
+
// two bubbles — a true 2×2 grid, centred on the box (rows/cols symmetric
|
|
133
|
+
// about 0.5).
|
|
134
|
+
const round = (v: number) => Math.round(v * 1e6) / 1e6;
|
|
135
|
+
const cols = [...new Set(bubbles.map((b) => round(b.cx)))].sort((a, b) => a - b);
|
|
136
|
+
const rows = [...new Set(bubbles.map((b) => round(b.cy)))].sort((a, b) => a - b);
|
|
137
|
+
expect(cols).toHaveLength(2);
|
|
138
|
+
expect(rows).toHaveLength(2);
|
|
139
|
+
const col0 = cols[0];
|
|
140
|
+
const col1 = cols[1];
|
|
141
|
+
const row0 = rows[0];
|
|
142
|
+
const row1 = rows[1];
|
|
143
|
+
if (col0 === undefined || col1 === undefined || row0 === undefined || row1 === undefined) {
|
|
144
|
+
throw new Error('expected two distinct rows and columns');
|
|
145
|
+
}
|
|
146
|
+
expect((col0 + col1) / 2).toBeCloseTo(0.5, 9);
|
|
147
|
+
expect((row0 + row1) / 2).toBeCloseTo(0.5, 9);
|
|
148
|
+
// Square grid: the row and column spacings match.
|
|
149
|
+
expect(col1 - col0).toBeCloseTo(row1 - row0, 9);
|
|
150
|
+
|
|
151
|
+
// Clean uniform gap between neighbours — no overlap.
|
|
152
|
+
expect(minEdgeGap(bubbles)).toBeGreaterThan(0);
|
|
153
|
+
|
|
154
|
+
// Outermost edge reaches the box radius (fills the round box like the others).
|
|
155
|
+
let reach = 0;
|
|
156
|
+
for (const bubble of bubbles) {
|
|
157
|
+
const r = Math.hypot(bubble.cx - 0.5, bubble.cy - 0.5) + bubble.d / 2;
|
|
158
|
+
if (r > reach) reach = r;
|
|
159
|
+
}
|
|
160
|
+
expect(reach).toBeCloseTo(0.5, 6);
|
|
161
|
+
|
|
162
|
+
// Fully inside the unit box, and deterministic.
|
|
163
|
+
for (const bubble of bubbles) {
|
|
164
|
+
expect(bubble.cx - bubble.d / 2).toBeGreaterThanOrEqual(-1e-6);
|
|
165
|
+
expect(bubble.cx + bubble.d / 2).toBeLessThanOrEqual(1 + 1e-6);
|
|
166
|
+
expect(bubble.cy - bubble.d / 2).toBeGreaterThanOrEqual(-1e-6);
|
|
167
|
+
expect(bubble.cy + bubble.d / 2).toBeLessThanOrEqual(1 + 1e-6);
|
|
168
|
+
}
|
|
169
|
+
expect(computeClusterLayout(4)).toEqual(bubbles);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('gives the two-member cluster equal-diameter, overlapping bubbles', () => {
|
|
173
|
+
const bubbles = computeClusterLayout(2);
|
|
174
|
+
expect(bubbles).toHaveLength(2);
|
|
175
|
+
const front = bubbleAt(bubbles, 0);
|
|
176
|
+
const behind = bubbleAt(bubbles, 1);
|
|
177
|
+
// The fix: both bubbles share the SAME diameter — the one drawn behind is no
|
|
178
|
+
// longer rendered too small.
|
|
179
|
+
expect(front.d).toBeCloseTo(behind.d, 10);
|
|
180
|
+
// Still the intentional iMessage "front + behind" overlap (negative gap).
|
|
181
|
+
expect(minEdgeGap(bubbles)).toBeLessThan(0);
|
|
182
|
+
// Deterministic (no Math.random).
|
|
183
|
+
expect(computeClusterLayout(2)).toEqual(bubbles);
|
|
184
|
+
// Both bubbles sit fully inside the unit box.
|
|
185
|
+
for (const bubble of bubbles) {
|
|
186
|
+
expect(bubble.cx - bubble.d / 2).toBeGreaterThanOrEqual(-1e-6);
|
|
187
|
+
expect(bubble.cx + bubble.d / 2).toBeLessThanOrEqual(1 + 1e-6);
|
|
188
|
+
expect(bubble.cy - bubble.d / 2).toBeGreaterThanOrEqual(-1e-6);
|
|
189
|
+
expect(bubble.cy + bubble.d / 2).toBeLessThanOrEqual(1 + 1e-6);
|
|
190
|
+
}
|
|
191
|
+
});
|
|
91
192
|
});
|
|
92
193
|
|
|
93
194
|
const CLUSTER_ITEMS: AvatarGroupItem[] = Array.from({ length: 25 }, (_, i) => ({
|
|
@@ -157,15 +157,16 @@ const MANY: AvatarGroupItem[] = Array.from({ length: 25 }, (_, i) => ({
|
|
|
157
157
|
|
|
158
158
|
/**
|
|
159
159
|
* Cluster layout: a compact iMessage-style "magnetic bubble cluster" of
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
160
|
+
* NEAR-EQUAL-size avatars packed densely into a round box (`size` = box
|
|
161
|
+
* diameter). A modestly larger primary sits in front; the rest are only slightly
|
|
162
|
+
* smaller and nestle against it with a small uniform gap, filling the round box
|
|
163
|
+
* edge-to-edge. `showInitials` renders a colored letter for avatar-less members
|
|
164
|
+
* so the packing reads clearly. Shown at counts 3, 4, 5, 6, 8, 12, and 20.
|
|
164
165
|
*/
|
|
165
166
|
export const ClusterLayout: Story = {
|
|
166
167
|
render: () => (
|
|
167
168
|
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 24, alignItems: 'center' }}>
|
|
168
|
-
{[3, 5, 8, 12, 20].map((count) => (
|
|
169
|
+
{[3, 4, 5, 6, 8, 12, 20].map((count) => (
|
|
169
170
|
<View key={count} style={{ alignItems: 'center', gap: 8 }}>
|
|
170
171
|
<AvatarGroup
|
|
171
172
|
layout="cluster"
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Deterministic organic circle-packing for the {@link AvatarGroup} `cluster`
|
|
3
3
|
* layout — the iMessage-style "magnetic bubble cluster" where several avatars of
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* NEAR-EQUAL size nestle tightly together and fill a round bounding box (a
|
|
5
|
+
* modestly larger primary with the rest only slightly smaller, packed against
|
|
6
|
+
* each other with a small uniform gap — not a big primary ringed by tiny dots).
|
|
6
7
|
*
|
|
7
8
|
* For 4+ members the layout is produced by a small, FULLY DETERMINISTIC
|
|
8
9
|
* force-directed relaxation (no `Math.random`): the primary is pinned at the
|
|
@@ -11,15 +12,18 @@
|
|
|
11
12
|
* centre and (b) push any two circles apart until they clear a uniform gap. A
|
|
12
13
|
* fixed iteration count + deterministic seed means the same `count` always
|
|
13
14
|
* yields byte-identical positions, so native and web render the cluster
|
|
14
|
-
* identically with no `onLayout`/DOM measurement. The
|
|
15
|
-
*
|
|
16
|
-
*
|
|
15
|
+
* identically with no `onLayout`/DOM measurement. The small counts (1, 2, 3, 4)
|
|
16
|
+
* use explicit iMessage-style arrangements instead — 1–3 because a relaxation
|
|
17
|
+
* there degenerates into a line or a lone pair, and 4 because the iconic
|
|
18
|
+
* 4-person layout is a clean 2×2 grid of equal circles rather than a pack.
|
|
17
19
|
*
|
|
18
20
|
* Output is resolution-independent: each bubble is expressed as a fraction of
|
|
19
21
|
* the group's bounding box (`cx`/`cy` centre, `d` diameter, all 0..1), so the
|
|
20
|
-
* consumer just multiplies by the pixel `size`.
|
|
21
|
-
*
|
|
22
|
-
*
|
|
22
|
+
* consumer just multiplies by the pixel `size`. The relaxed blob is recentred on
|
|
23
|
+
* its own centre and scaled so the outermost bubble edge lands on the box edge,
|
|
24
|
+
* so it fills the round box densely with minimal empty margin while every bubble
|
|
25
|
+
* is guaranteed to sit fully inside the `[0, 1]` box — the cluster drops in
|
|
26
|
+
* exactly where a single round Avatar would.
|
|
23
27
|
*/
|
|
24
28
|
|
|
25
29
|
/** A single packed bubble, expressed as fractions of the bounding box (0..1). */
|
|
@@ -47,17 +51,20 @@ const CENTERING = 0.05;
|
|
|
47
51
|
// the pinned primary and many passes this stays stable and converges.
|
|
48
52
|
const SEPARATION_STRENGTH = 1;
|
|
49
53
|
// Uniform gap kept between every touching pair, in primary-radius units
|
|
50
|
-
// (primary radius = 1).
|
|
51
|
-
|
|
54
|
+
// (primary radius = 1). Small, so near-equal bubbles nestle tightly like
|
|
55
|
+
// magnets rather than floating apart with visible margins.
|
|
56
|
+
const LAYOUT_GAP = 0.12;
|
|
52
57
|
// Initial spiral spacing. Only affects convergence (the result is re-fitted to
|
|
53
58
|
// the box afterwards), not the final scale.
|
|
54
59
|
const SEED_SPACING = 1.7;
|
|
55
|
-
// Relative radii: the primary is the largest;
|
|
56
|
-
// SECONDARY_MAX (nearest the primary) down to SECONDARY_MIN
|
|
57
|
-
//
|
|
60
|
+
// Relative radii: the primary is only MODESTLY the largest; every other member
|
|
61
|
+
// tapers gently from SECONDARY_MAX (nearest the primary) down to SECONDARY_MIN
|
|
62
|
+
// (outermost). The spread is deliberately narrow so the cluster reads as a pack
|
|
63
|
+
// of near-equal magnetic bubbles — a slightly larger primary, not a big primary
|
|
64
|
+
// ringed by tiny dots.
|
|
58
65
|
const PRIMARY_RADIUS = 1;
|
|
59
|
-
const SECONDARY_MAX = 0.
|
|
60
|
-
const SECONDARY_MIN = 0.
|
|
66
|
+
const SECONDARY_MAX = 0.9;
|
|
67
|
+
const SECONDARY_MIN = 0.8;
|
|
61
68
|
const EPSILON = 1e-6;
|
|
62
69
|
|
|
63
70
|
/** Relative radius for member `index` of a `count`-member cluster. */
|
|
@@ -108,8 +115,8 @@ function separate(xs: number[], ys: number[], radii: number[], count: number): v
|
|
|
108
115
|
|
|
109
116
|
/**
|
|
110
117
|
* Deterministic force-directed pack of `count` circles (primary pinned at the
|
|
111
|
-
* centre), returned as box-fraction bubbles. Used for every cluster with
|
|
112
|
-
* members; 1–
|
|
118
|
+
* centre), returned as box-fraction bubbles. Used for every cluster with 5+
|
|
119
|
+
* members; 1–4 are handled as explicit arrangements in
|
|
113
120
|
* {@link computeClusterLayout}.
|
|
114
121
|
*/
|
|
115
122
|
function packCluster(count: number): ClusterBubble[] {
|
|
@@ -149,12 +156,34 @@ function packCluster(count: number): ClusterBubble[] {
|
|
|
149
156
|
ys[0] = 0;
|
|
150
157
|
}
|
|
151
158
|
|
|
152
|
-
// Fit to the unit box
|
|
153
|
-
//
|
|
154
|
-
// the
|
|
159
|
+
// Fit to the unit box so the pack FILLS the round box edge-to-edge with
|
|
160
|
+
// minimal empty margin. The primary was pinned at the origin during
|
|
161
|
+
// relaxation, but the relaxed blob is not centred on it, so we recentre on the
|
|
162
|
+
// cluster's OWN centre (its bounding-box midpoint) rather than the primary:
|
|
163
|
+
// enclose every bubble in a circle around that centre, then scale so the
|
|
164
|
+
// outermost bubble edge lands exactly on the box radius (0.5). Centring the
|
|
165
|
+
// enclosing circle at the box centre keeps every bubble inside the [0, 1] box
|
|
166
|
+
// (the enclosing circle is inscribed in the square) while packing the blob
|
|
167
|
+
// tightly against the edge instead of floating with a gap.
|
|
168
|
+
let minX = Infinity;
|
|
169
|
+
let maxX = -Infinity;
|
|
170
|
+
let minY = Infinity;
|
|
171
|
+
let maxY = -Infinity;
|
|
172
|
+
for (let i = 0; i < count; i++) {
|
|
173
|
+
const x = xs[i] ?? 0;
|
|
174
|
+
const y = ys[i] ?? 0;
|
|
175
|
+
const r = radii[i] ?? 0;
|
|
176
|
+
if (x - r < minX) minX = x - r;
|
|
177
|
+
if (x + r > maxX) maxX = x + r;
|
|
178
|
+
if (y - r < minY) minY = y - r;
|
|
179
|
+
if (y + r > maxY) maxY = y + r;
|
|
180
|
+
}
|
|
181
|
+
const centerX = (minX + maxX) / 2;
|
|
182
|
+
const centerY = (minY + maxY) / 2;
|
|
155
183
|
let bound = 0;
|
|
156
184
|
for (let i = 0; i < count; i++) {
|
|
157
|
-
const reach =
|
|
185
|
+
const reach =
|
|
186
|
+
Math.hypot((xs[i] ?? 0) - centerX, (ys[i] ?? 0) - centerY) + (radii[i] ?? 0);
|
|
158
187
|
if (reach > bound) bound = reach;
|
|
159
188
|
}
|
|
160
189
|
const scale = bound > EPSILON ? 0.5 / bound : 0.5;
|
|
@@ -162,8 +191,8 @@ function packCluster(count: number): ClusterBubble[] {
|
|
|
162
191
|
const bubbles = new Array<ClusterBubble>(count);
|
|
163
192
|
for (let i = 0; i < count; i++) {
|
|
164
193
|
bubbles[i] = {
|
|
165
|
-
cx: 0.5 + (xs[i] ?? 0) * scale,
|
|
166
|
-
cy: 0.5 + (ys[i] ?? 0) * scale,
|
|
194
|
+
cx: 0.5 + ((xs[i] ?? 0) - centerX) * scale,
|
|
195
|
+
cy: 0.5 + ((ys[i] ?? 0) - centerY) * scale,
|
|
167
196
|
d: 2 * (radii[i] ?? 0) * scale,
|
|
168
197
|
};
|
|
169
198
|
}
|
|
@@ -177,20 +206,31 @@ function packCluster(count: number): ClusterBubble[] {
|
|
|
177
206
|
*
|
|
178
207
|
* - `<= 0` → empty.
|
|
179
208
|
* - `1` → a single bubble filling the box.
|
|
180
|
-
* - `2` → the iMessage "one in front, one behind" pair:
|
|
181
|
-
*
|
|
209
|
+
* - `2` → the iMessage "one in front, one behind" pair: two EQUAL-diameter
|
|
210
|
+
* bubbles offset on a diagonal — the front set low-left, the other tucked
|
|
211
|
+
* behind it to the upper-right. Same radius, so neither member reads as
|
|
212
|
+
* secondary; the overlap + separator ring alone convey the stacking.
|
|
182
213
|
* - `3` → an iMessage-style triangle: the larger primary along the bottom with
|
|
183
214
|
* two smaller members above it.
|
|
184
|
-
* - `4
|
|
185
|
-
*
|
|
215
|
+
* - `4` → the iMessage 4-person layout: a 2×2 grid of EQUAL-diameter bubbles,
|
|
216
|
+
* centred in the box with a uniform gap, sized so the outermost edges reach the
|
|
217
|
+
* box edge like the other counts.
|
|
218
|
+
* - `5+` → the deterministic force-directed pack of near-equal bubbles, recentred
|
|
219
|
+
* and scaled to fill the round box edge-to-edge (a modestly larger primary near
|
|
220
|
+
* the centre, the rest packed magnetically around it, denser as the count
|
|
221
|
+
* grows).
|
|
186
222
|
*/
|
|
187
223
|
export function computeClusterLayout(count: number): ClusterBubble[] {
|
|
188
224
|
if (count <= 0) return [];
|
|
189
225
|
if (count === 1) return [{ cx: 0.5, cy: 0.5, d: 1 }];
|
|
190
226
|
if (count === 2) {
|
|
227
|
+
// Two EQUAL-diameter bubbles, offset symmetrically on the diagonal: the
|
|
228
|
+
// front (index 0, highest zIndex) sits low-left, the other tucks behind it
|
|
229
|
+
// to the upper-right. They overlap (the intentional "front + behind" pair);
|
|
230
|
+
// the separator ring — not a size difference — reads the stacking.
|
|
191
231
|
return [
|
|
192
|
-
{ cx: 0.
|
|
193
|
-
{ cx: 0.
|
|
232
|
+
{ cx: 0.35, cy: 0.65, d: 0.64 },
|
|
233
|
+
{ cx: 0.65, cy: 0.35, d: 0.64 },
|
|
194
234
|
];
|
|
195
235
|
}
|
|
196
236
|
if (count === 3) {
|
|
@@ -200,5 +240,27 @@ export function computeClusterLayout(count: number): ClusterBubble[] {
|
|
|
200
240
|
{ cx: 0.715, cy: 0.24, d: 0.4 },
|
|
201
241
|
];
|
|
202
242
|
}
|
|
243
|
+
if (count === 4) {
|
|
244
|
+
// The iMessage 4-person layout: a 2×2 grid of EQUAL-diameter bubbles,
|
|
245
|
+
// centred in the box with the same uniform neighbour gap the rest of the
|
|
246
|
+
// cluster keeps (LAYOUT_GAP, in circle-radius units), sized so the outermost
|
|
247
|
+
// bubble edge reaches the box radius (0.5 from the centre) — exactly as the
|
|
248
|
+
// force-packed counts do — so the four circles fill the round box
|
|
249
|
+
// edge-to-edge with a clean uniform gap and no overlap.
|
|
250
|
+
//
|
|
251
|
+
// With diameter `d` and radius `r = d/2`, the neighbour gap `g = LAYOUT_GAP·r`,
|
|
252
|
+
// so the centre-to-centre spacing is `s = d + g = d·(1 + LAYOUT_GAP/2)`. Each
|
|
253
|
+
// centre sits `(s/2, s/2)` from the box centre, so its farthest point lies
|
|
254
|
+
// `(s/2)·√2 + r` away; setting that to `0.5` and solving for `d`:
|
|
255
|
+
const spacingFactor = 1 + LAYOUT_GAP / 2;
|
|
256
|
+
const d = 0.5 / ((spacingFactor * Math.SQRT2) / 2 + 0.5);
|
|
257
|
+
const half = (d * spacingFactor) / 2;
|
|
258
|
+
return [
|
|
259
|
+
{ cx: 0.5 - half, cy: 0.5 - half, d },
|
|
260
|
+
{ cx: 0.5 + half, cy: 0.5 - half, d },
|
|
261
|
+
{ cx: 0.5 - half, cy: 0.5 + half, d },
|
|
262
|
+
{ cx: 0.5 + half, cy: 0.5 + half, d },
|
|
263
|
+
];
|
|
264
|
+
}
|
|
203
265
|
return packCluster(count);
|
|
204
266
|
}
|