@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.
@@ -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
- * varying sizes nestle together inside a round bounding box (a large primary in
11
- * the middle/front, smaller members packed around it with a uniform gap).
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 very small counts (1, 2,
21
- * 3) where a relaxation degenerates into a line or a lone pair — use explicit
22
- * iMessage-style arrangements instead.
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`. Every bubble is guaranteed to
27
- * sit fully inside the `[0, 1]` box (the packed result is scaled + translated to
28
- * fit), so the cluster drops in exactly where a single round Avatar would.
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). Reads as consistent spacing everywhere in the cluster.
49
- const LAYOUT_GAP = 0.16;
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; the remaining members taper from
54
- // SECONDARY_MAX (nearest the primary) down to SECONDARY_MIN (outermost) so size
55
- // decreases outward and later/overflow members are the smallest.
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.72;
58
- const SECONDARY_MIN = 0.5;
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 4+
110
- * members; 1–3 are handled as explicit arrangements in
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: the primary is at the origin, so scale so the
149
- // outermost circle edge lands on the box radius (0.5) and place the primary at
150
- // the box centre.
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: a larger primary set
176
- * low-left with a smaller member tucked behind it to the upper-right.
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+` → the deterministic force-directed pack (primary centred, the rest
180
- * packed magnetically around it, denser as the count grows).
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.42,
192
- cy: 0.56,
193
- d: 0.66
230
+ cx: 0.35,
231
+ cy: 0.65,
232
+ d: 0.64
194
233
  }, {
195
- cx: 0.68,
196
- cy: 0.36,
197
- d: 0.5
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":"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","ignoreList":[]}
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":";;;;;;AAAA;AACA;AACA;AACA;AACA;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;AACA,MAAMC,UAAU,GAAG,IAAI;AACvB;AACA;AACA,MAAMC,YAAY,GAAG,GAAG;AACxB;AACA;AACA;AACA;AACA;AACA,MAAMC,cAAc,GAAG,CAAC;AACxB,MAAMC,aAAa,GAAG,GAAG;AACzB,MAAMC,aAAa,GAAG,GAAG;AACzB,MAAMC,OAAO,GAAG,IAAI;;AAEpB;AACA,SAASC,cAAcA,CAACC,KAAa,EAAEC,KAAa,EAAU;EAC5D,IAAID,KAAK,KAAK,CAAC,EAAE,OAAOL,cAAc;EACtC,IAAIM,KAAK,IAAI,CAAC,EAAE,OAAOL,aAAa;EACpC,MAAMM,CAAC,GAAG,CAACF,KAAK,GAAG,CAAC,KAAKC,KAAK,GAAG,CAAC,CAAC;EACnC,OAAOL,aAAa,GAAG,CAACC,aAAa,GAAGD,aAAa,IAAIM,CAAC;AAC5D;;AAEA;AACA;AACA;AACA;AACA;AACA,SAASC,QAAQA,CAACC,EAAY,EAAEC,EAAY,EAAEC,KAAe,EAAEL,KAAa,EAAQ;EAClF,KAAK,IAAIM,CAAC,GAAG,CAAC,EAAEA,CAAC,GAAGN,KAAK,EAAEM,CAAC,EAAE,EAAE;IAC9B,KAAK,IAAIC,CAAC,GAAGD,CAAC,GAAG,CAAC,EAAEC,CAAC,GAAGP,KAAK,EAAEO,CAAC,EAAE,EAAE;MAClC,IAAIC,EAAE,GAAG,CAACL,EAAE,CAACI,CAAC,CAAC,IAAI,CAAC,KAAKJ,EAAE,CAACG,CAAC,CAAC,IAAI,CAAC,CAAC;MACpC,IAAIG,EAAE,GAAG,CAACL,EAAE,CAACG,CAAC,CAAC,IAAI,CAAC,KAAKH,EAAE,CAACE,CAAC,CAAC,IAAI,CAAC,CAAC;MACpC,IAAII,IAAI,GAAGzB,IAAI,CAAC0B,KAAK,CAACH,EAAE,EAAEC,EAAE,CAAC;MAC7B,MAAMG,OAAO,GAAG,CAACP,KAAK,CAACC,CAAC,CAAC,IAAI,CAAC,KAAKD,KAAK,CAACE,CAAC,CAAC,IAAI,CAAC,CAAC,GAAGf,UAAU;MAC9D,IAAIkB,IAAI,IAAIE,OAAO,EAAE;MACrB,IAAIF,IAAI,GAAGb,OAAO,EAAE;QAClB;QACA;QACA,MAAMgB,CAAC,GAAG,CAACP,CAAC,GAAG,CAAC,IAAItB,YAAY,GAAGuB,CAAC;QACpCC,EAAE,GAAGvB,IAAI,CAAC6B,GAAG,CAACD,CAAC,CAAC;QAChBJ,EAAE,GAAGxB,IAAI,CAAC8B,GAAG,CAACF,CAAC,CAAC;QAChBH,IAAI,GAAG,CAAC;MACV;MACA,MAAMM,OAAO,GAAG,CAACJ,OAAO,GAAGF,IAAI,IAAInB,mBAAmB;MACtD,MAAM0B,EAAE,GAAGT,EAAE,GAAGE,IAAI;MACpB,MAAMQ,EAAE,GAAGT,EAAE,GAAGC,IAAI;MACpB,IAAIJ,CAAC,KAAK,CAAC,EAAE;QACX;QACAH,EAAE,CAACI,CAAC,CAAC,GAAG,CAACJ,EAAE,CAACI,CAAC,CAAC,IAAI,CAAC,IAAIU,EAAE,GAAGD,OAAO;QACnCZ,EAAE,CAACG,CAAC,CAAC,GAAG,CAACH,EAAE,CAACG,CAAC,CAAC,IAAI,CAAC,IAAIW,EAAE,GAAGF,OAAO;MACrC,CAAC,MAAM;QACLb,EAAE,CAACG,CAAC,CAAC,GAAG,CAACH,EAAE,CAACG,CAAC,CAAC,IAAI,CAAC,IAAKW,EAAE,GAAGD,OAAO,GAAI,CAAC;QACzCZ,EAAE,CAACE,CAAC,CAAC,GAAG,CAACF,EAAE,CAACE,CAAC,CAAC,IAAI,CAAC,IAAKY,EAAE,GAAGF,OAAO,GAAI,CAAC;QACzCb,EAAE,CAACI,CAAC,CAAC,GAAG,CAACJ,EAAE,CAACI,CAAC,CAAC,IAAI,CAAC,IAAKU,EAAE,GAAGD,OAAO,GAAI,CAAC;QACzCZ,EAAE,CAACG,CAAC,CAAC,GAAG,CAACH,EAAE,CAACG,CAAC,CAAC,IAAI,CAAC,IAAKW,EAAE,GAAGF,OAAO,GAAI,CAAC;MAC3C;IACF;EACF;AACF;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA,SAASG,WAAWA,CAACnB,KAAa,EAAmB;EACnD,MAAMK,KAAK,GAAG,IAAIe,KAAK,CAASpB,KAAK,CAAC;EACtC,MAAMG,EAAE,GAAG,IAAIiB,KAAK,CAASpB,KAAK,CAAC;EACnC,MAAMI,EAAE,GAAG,IAAIgB,KAAK,CAASpB,KAAK,CAAC;EAEnC,KAAK,IAAIM,CAAC,GAAG,CAAC,EAAEA,CAAC,GAAGN,KAAK,EAAEM,CAAC,EAAE,EAAE;IAC9BD,KAAK,CAACC,CAAC,CAAC,GAAGR,cAAc,CAACQ,CAAC,EAAEN,KAAK,CAAC;IACnC,IAAIM,CAAC,KAAK,CAAC,EAAE;MACXH,EAAE,CAACG,CAAC,CAAC,GAAG,CAAC;MACTF,EAAE,CAACE,CAAC,CAAC,GAAG,CAAC;IACX,CAAC,MAAM;MACL;MACA,MAAMe,KAAK,GAAG5B,YAAY,GAAGR,IAAI,CAACE,IAAI,CAACmB,CAAC,CAAC;MACzC,MAAMgB,KAAK,GAAGhB,CAAC,GAAGtB,YAAY;MAC9BmB,EAAE,CAACG,CAAC,CAAC,GAAGe,KAAK,GAAGpC,IAAI,CAAC6B,GAAG,CAACQ,KAAK,CAAC;MAC/BlB,EAAE,CAACE,CAAC,CAAC,GAAGe,KAAK,GAAGpC,IAAI,CAAC8B,GAAG,CAACO,KAAK,CAAC;IACjC;EACF;EAEA,KAAK,IAAIC,IAAI,GAAG,CAAC,EAAEA,IAAI,GAAGnC,UAAU,EAAEmC,IAAI,EAAE,EAAE;IAC5C;IACA,KAAK,IAAIjB,CAAC,GAAG,CAAC,EAAEA,CAAC,GAAGN,KAAK,EAAEM,CAAC,EAAE,EAAE;MAC9BH,EAAE,CAACG,CAAC,CAAC,GAAG,CAACH,EAAE,CAACG,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,GAAGhB,SAAS,CAAC;MACtCc,EAAE,CAACE,CAAC,CAAC,GAAG,CAACF,EAAE,CAACE,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,GAAGhB,SAAS,CAAC;IACxC;IACA;IACAY,QAAQ,CAACC,EAAE,EAAEC,EAAE,EAAEC,KAAK,EAAEL,KAAK,CAAC;IAC9BG,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC;IACTC,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC;EACX;EACA;EACA,KAAK,IAAImB,IAAI,GAAG,CAAC,EAAEA,IAAI,GAAGlC,kBAAkB,EAAEkC,IAAI,EAAE,EAAE;IACpDrB,QAAQ,CAACC,EAAE,EAAEC,EAAE,EAAEC,KAAK,EAAEL,KAAK,CAAC;IAC9BG,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC;IACTC,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC;EACX;;EAEA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA,IAAIoB,IAAI,GAAGC,QAAQ;EACnB,IAAIC,IAAI,GAAG,CAACD,QAAQ;EACpB,IAAIE,IAAI,GAAGF,QAAQ;EACnB,IAAIG,IAAI,GAAG,CAACH,QAAQ;EACpB,KAAK,IAAInB,CAAC,GAAG,CAAC,EAAEA,CAAC,GAAGN,KAAK,EAAEM,CAAC,EAAE,EAAE;IAC9B,MAAMuB,CAAC,GAAG1B,EAAE,CAACG,CAAC,CAAC,IAAI,CAAC;IACpB,MAAMwB,CAAC,GAAG1B,EAAE,CAACE,CAAC,CAAC,IAAI,CAAC;IACpB,MAAMyB,CAAC,GAAG1B,KAAK,CAACC,CAAC,CAAC,IAAI,CAAC;IACvB,IAAIuB,CAAC,GAAGE,CAAC,GAAGP,IAAI,EAAEA,IAAI,GAAGK,CAAC,GAAGE,CAAC;IAC9B,IAAIF,CAAC,GAAGE,CAAC,GAAGL,IAAI,EAAEA,IAAI,GAAGG,CAAC,GAAGE,CAAC;IAC9B,IAAID,CAAC,GAAGC,CAAC,GAAGJ,IAAI,EAAEA,IAAI,GAAGG,CAAC,GAAGC,CAAC;IAC9B,IAAID,CAAC,GAAGC,CAAC,GAAGH,IAAI,EAAEA,IAAI,GAAGE,CAAC,GAAGC,CAAC;EAChC;EACA,MAAMC,OAAO,GAAG,CAACR,IAAI,GAAGE,IAAI,IAAI,CAAC;EACjC,MAAMO,OAAO,GAAG,CAACN,IAAI,GAAGC,IAAI,IAAI,CAAC;EACjC,IAAIM,KAAK,GAAG,CAAC;EACb,KAAK,IAAI5B,CAAC,GAAG,CAAC,EAAEA,CAAC,GAAGN,KAAK,EAAEM,CAAC,EAAE,EAAE;IAC9B,MAAM6B,KAAK,GACTlD,IAAI,CAAC0B,KAAK,CAAC,CAACR,EAAE,CAACG,CAAC,CAAC,IAAI,CAAC,IAAI0B,OAAO,EAAE,CAAC5B,EAAE,CAACE,CAAC,CAAC,IAAI,CAAC,IAAI2B,OAAO,CAAC,IAAI5B,KAAK,CAACC,CAAC,CAAC,IAAI,CAAC,CAAC;IAC9E,IAAI6B,KAAK,GAAGD,KAAK,EAAEA,KAAK,GAAGC,KAAK;EAClC;EACA,MAAMC,KAAK,GAAGF,KAAK,GAAGrC,OAAO,GAAG,GAAG,GAAGqC,KAAK,GAAG,GAAG;EAEjD,MAAMG,OAAO,GAAG,IAAIjB,KAAK,CAAgBpB,KAAK,CAAC;EAC/C,KAAK,IAAIM,CAAC,GAAG,CAAC,EAAEA,CAAC,GAAGN,KAAK,EAAEM,CAAC,EAAE,EAAE;IAC9B+B,OAAO,CAAC/B,CAAC,CAAC,GAAG;MACXgC,EAAE,EAAE,GAAG,GAAG,CAAC,CAACnC,EAAE,CAACG,CAAC,CAAC,IAAI,CAAC,IAAI0B,OAAO,IAAII,KAAK;MAC1CG,EAAE,EAAE,GAAG,GAAG,CAAC,CAACnC,EAAE,CAACE,CAAC,CAAC,IAAI,CAAC,IAAI2B,OAAO,IAAIG,KAAK;MAC1CI,CAAC,EAAE,CAAC,IAAInC,KAAK,CAACC,CAAC,CAAC,IAAI,CAAC,CAAC,GAAG8B;IAC3B,CAAC;EACH;EACA,OAAOC,OAAO;AAChB;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACO,SAASI,oBAAoBA,CAACzC,KAAa,EAAmB;EACnE,IAAIA,KAAK,IAAI,CAAC,EAAE,OAAO,EAAE;EACzB,IAAIA,KAAK,KAAK,CAAC,EAAE,OAAO,CAAC;IAAEsC,EAAE,EAAE,GAAG;IAAEC,EAAE,EAAE,GAAG;IAAEC,CAAC,EAAE;EAAE,CAAC,CAAC;EACpD,IAAIxC,KAAK,KAAK,CAAC,EAAE;IACf;IACA;IACA;IACA;IACA,OAAO,CACL;MAAEsC,EAAE,EAAE,IAAI;MAAEC,EAAE,EAAE,IAAI;MAAEC,CAAC,EAAE;IAAK,CAAC,EAC/B;MAAEF,EAAE,EAAE,IAAI;MAAEC,EAAE,EAAE,IAAI;MAAEC,CAAC,EAAE;IAAK,CAAC,CAChC;EACH;EACA,IAAIxC,KAAK,KAAK,CAAC,EAAE;IACf,OAAO,CACL;MAAEsC,EAAE,EAAE,GAAG;MAAEC,EAAE,EAAE,IAAI;MAAEC,CAAC,EAAE;IAAK,CAAC,EAC9B;MAAEF,EAAE,EAAE,KAAK;MAAEC,EAAE,EAAE,IAAI;MAAEC,CAAC,EAAE;IAAI,CAAC,EAC/B;MAAEF,EAAE,EAAE,KAAK;MAAEC,EAAE,EAAE,IAAI;MAAEC,CAAC,EAAE;IAAI,CAAC,CAChC;EACH;EACA,IAAIxC,KAAK,KAAK,CAAC,EAAE;IACf;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA,MAAM0C,aAAa,GAAG,CAAC,GAAGlD,UAAU,GAAG,CAAC;IACxC,MAAMgD,CAAC,GAAG,GAAG,IAAKE,aAAa,GAAGzD,IAAI,CAAC0D,KAAK,GAAI,CAAC,GAAG,GAAG,CAAC;IACxD,MAAMC,IAAI,GAAIJ,CAAC,GAAGE,aAAa,GAAI,CAAC;IACpC,OAAO,CACL;MAAEJ,EAAE,EAAE,GAAG,GAAGM,IAAI;MAAEL,EAAE,EAAE,GAAG,GAAGK,IAAI;MAAEJ;IAAE,CAAC,EACrC;MAAEF,EAAE,EAAE,GAAG,GAAGM,IAAI;MAAEL,EAAE,EAAE,GAAG,GAAGK,IAAI;MAAEJ;IAAE,CAAC,EACrC;MAAEF,EAAE,EAAE,GAAG,GAAGM,IAAI;MAAEL,EAAE,EAAE,GAAG,GAAGK,IAAI;MAAEJ;IAAE,CAAC,EACrC;MAAEF,EAAE,EAAE,GAAG,GAAGM,IAAI;MAAEL,EAAE,EAAE,GAAG,GAAGK,IAAI;MAAEJ;IAAE,CAAC,CACtC;EACH;EACA,OAAOrB,WAAW,CAACnB,KAAK,CAAC;AAC3B","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
- * varying sizes nestle together inside a round bounding box (a large primary in
7
- * the middle/front, smaller members packed around it with a uniform gap).
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 very small counts (1, 2,
17
- * 3) where a relaxation degenerates into a line or a lone pair — use explicit
18
- * iMessage-style arrangements instead.
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`. Every bubble is guaranteed to
23
- * sit fully inside the `[0, 1]` box (the packed result is scaled + translated to
24
- * fit), so the cluster drops in exactly where a single round Avatar would.
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). Reads as consistent spacing everywhere in the cluster.
45
- const LAYOUT_GAP = 0.16;
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; the remaining members taper from
50
- // SECONDARY_MAX (nearest the primary) down to SECONDARY_MIN (outermost) so size
51
- // decreases outward and later/overflow members are the smallest.
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.72;
54
- const SECONDARY_MIN = 0.5;
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 4+
106
- * members; 1–3 are handled as explicit arrangements in
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: the primary is at the origin, so scale so the
145
- // outermost circle edge lands on the box radius (0.5) and place the primary at
146
- // the box centre.
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: a larger primary set
172
- * low-left with a smaller member tucked behind it to the upper-right.
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+` → the deterministic force-directed pack (primary centred, the rest
176
- * packed magnetically around it, denser as the count grows).
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.42,
188
- cy: 0.56,
189
- d: 0.66
226
+ cx: 0.35,
227
+ cy: 0.65,
228
+ d: 0.64
190
229
  }, {
191
- cx: 0.68,
192
- cy: 0.36,
193
- d: 0.5
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":"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","ignoreList":[]}
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
- * 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).
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 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.
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`. 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.
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: a larger primary set
41
- * low-left with a smaller member tucked behind it to the upper-right.
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+` → the deterministic force-directed pack (primary centred, the rest
45
- * packed magnetically around it, denser as the count grows).
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;;;;;;;;;;;;;;;;;;;;;;GAsBG;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;AA4ID;;;;;;;;;;;;;GAaG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,MAAM,GAAG,aAAa,EAAE,CAiBnE"}
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
- * 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).
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 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.
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`. 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.
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: a larger primary set
41
- * low-left with a smaller member tucked behind it to the upper-right.
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+` → the deterministic force-directed pack (primary centred, the rest
45
- * packed magnetically around it, denser as the count grows).
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;;;;;;;;;;;;;;;;;;;;;;GAsBG;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;AA4ID;;;;;;;;;;;;;GAaG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,MAAM,GAAG,aAAa,EAAE,CAiBnE"}
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/bloom",
3
- "version": "0.35.0",
3
+ "version": "0.35.2",
4
4
  "description": "Bloom UI — Oxy ecosystem component library for React Native + Expo + Web",
5
5
  "main": "lib/commonjs/index.js",
6
6
  "module": "lib/module/index.js",
@@ -74,11 +74,37 @@ describe('computeClusterLayout', () => {
74
74
  }
75
75
  });
76
76
 
77
- it('centres the primary for the force-packed counts (4+)', () => {
78
- for (const count of [4, 5, 8, 12, 20]) {
79
- const primary = bubbleAt(computeClusterLayout(count), 0);
80
- expect(primary.cx).toBeCloseTo(0.5, 5);
81
- expect(primary.cy).toBeCloseTo(0.5, 5);
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
- * varying-size avatars packed into a round box (`size` = box diameter). The
161
- * primary is largest and centred/in front; the rest nestle around it with a
162
- * uniform gap. `showInitials` renders a colored letter for avatar-less members
163
- * so the packing reads clearly. Shown at counts 3, 5, 8, 12, and 20.
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
- * 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).
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 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.
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`. 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.
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). Reads as consistent spacing everywhere in the cluster.
51
- const LAYOUT_GAP = 0.16;
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; 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.
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.72;
60
- const SECONDARY_MIN = 0.5;
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 4+
112
- * members; 1–3 are handled as explicit arrangements in
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: 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.
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 = Math.hypot(xs[i] ?? 0, ys[i] ?? 0) + (radii[i] ?? 0);
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: a larger primary set
181
- * low-left with a smaller member tucked behind it to the upper-right.
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+` → the deterministic force-directed pack (primary centred, the rest
185
- * packed magnetically around it, denser as the count grows).
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.42, cy: 0.56, d: 0.66 },
193
- { cx: 0.68, cy: 0.36, d: 0.5 },
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
  }