import 'dart:math' as math; import 'dart:ui'; /// Physik der Bubble-Anrufansicht (Bernd 2026-10-09: „animierte dynamische /// Bubbles, die sich aneinander anpassen und aussehen, als würden sie /// aneinander gedrückt“). /// /// Jede Person ist ein Kreis. Eine Feder zieht alle Kreise zur Mitte, Kreise /// stoßen sich ab, sobald sie sich mehr als [overlap] überlappen, und die /// Ränder halten sie im Bild. Im Gleichgewicht drücken sie also leicht /// gegeneinander; [clipPath] schneidet jeden Kreis an der Berührungslinie /// (Potenzgerade) gerade ab – wie Seifenblasen in Schaum. /// /// Wer spricht, wird größer und schiebt die anderen sanft beiseite; neue /// Teilnehmer wachsen aus dem Nichts heraus, Gehende schrumpfen weg. class BubbleBody { final String id; Offset pos; Offset vel = Offset.zero; double r; double rVel = 0; double targetR = 0; bool leaving = false; BubbleBody(this.id, this.pos, {this.r = 0}); } class BubbleSim { /// Anteil der Kreisdurchmesser, um den sich Nachbarn überlappen dürfen – /// daraus entstehen die flachen Berührungsflächen. static const overlap = 0.10; /// Wie viel größer ein sprechender Kreis wird. static const speakingScale = 1.22; final Map bodies = {}; Size _size = Size.zero; Size get size => _size; Offset get _center => Offset(_size.width / 2, _size.height / 2); /// Gleicht die Kreise mit den aktuellen Teilnehmern ab: neue kommen dazu, /// fehlende schrumpfen weg, die Zielgrößen werden neu verteilt. /// [speaking] = IDs, die gerade sprechen. void sync(List ids, Set speaking, Size size) { _size = size; final present = ids.toSet(); for (final b in bodies.values) { b.leaving = !present.contains(b.id); } for (final id in ids) { if (bodies.containsKey(id)) continue; // Neu: nahe der Mitte (leicht versetzt, damit es die anderen // auseinanderschiebt statt exakt auf ihnen zu liegen). final h = id.hashCode; final angle = (h % 360) * math.pi / 180; final spread = math.min(size.width, size.height) * 0.08; bodies[id] = BubbleBody( id, _center + Offset(math.cos(angle), math.sin(angle)) * spread, ); } _assignTargets(ids, speaking); } void _assignTargets(List ids, Set speaking) { final w = _size.width, h = _size.height; if (w <= 0 || h <= 0) return; final shortSide = math.min(w, h); double weight(String id) => speaking.contains(id) ? speakingScale : 1.0; final active = ids.where((id) => bodies.containsKey(id)).toList(); double base; if (active.length <= 1) { base = shortSide * 0.30; } else { // Kreise sollen zusammen gut 40 % der Fläche bedecken. final sumW2 = active.fold(0, (s, id) => s + weight(id) * weight(id)); base = math.sqrt(0.42 * w * h / (math.pi * sumW2)); } final maxR = shortSide * (active.length <= 2 ? 0.30 : 0.36); for (final b in bodies.values) { if (b.leaving) { b.targetR = 0; } else { b.targetR = (base * weight(b.id)).clamp(28.0, maxR); } } } /// Ein Zeitschritt in Sekunden. Liefert `true`, solange sich noch etwas /// bewegt (sonst kann die Animation pausieren). bool step(double dt) { if (_size.isEmpty) return false; dt = dt.clamp(0.0, 1 / 30); // Zwei Unterschritte: stabil auch bei großen Kräften. var moving = false; for (var i = 0; i < 2; i++) { moving = _substep(dt / 2) || moving; } bodies.removeWhere((_, b) => b.leaving && b.r < 1); return moving; } bool _substep(double dt) { const centerK = 18.0; // Zug zur Mitte const pushK = 260.0; // Abstoßung bei zu starker Überlappung const wallK = 160.0; const damping = 9.0; const radiusK = 110.0; const radiusDamping = 13.0; const margin = 10.0; final list = bodies.values.toList(); final forces = List.filled(list.length, Offset.zero); final c = _center; for (var i = 0; i < list.length; i++) { final a = list[i]; // Die Mitte zieht, aber gestaucht in der langen Richtung, damit sich // die Kreise im Hochformat übereinander, im Querformat nebeneinander // anordnen. final d = a.pos - c; final ax = _size.width >= _size.height ? 0.55 : 1.0; final ay = _size.height > _size.width ? 0.55 : 1.0; forces[i] += Offset(-d.dx * centerK * ax, -d.dy * centerK * ay); // Ränder final left = a.pos.dx - a.r - margin; final right = _size.width - margin - (a.pos.dx + a.r); final top = a.pos.dy - a.r - margin; final bottom = _size.height - margin - (a.pos.dy + a.r); if (left < 0) forces[i] += Offset(-left * wallK, 0); if (right < 0) forces[i] += Offset(right * wallK, 0); if (top < 0) forces[i] += Offset(0, -top * wallK); if (bottom < 0) forces[i] += Offset(0, bottom * wallK); for (var j = i + 1; j < list.length; j++) { final b = list[j]; var delta = b.pos - a.pos; var dist = delta.distance; if (dist < 0.01) { // exakt übereinander: in eine feste Richtung auseinander delta = Offset(math.cos(i + j * 1.7), math.sin(i + j * 1.7)); dist = 0.01; } final rest = (a.r + b.r) * (1 - overlap); if (dist < rest) { final u = delta / dist; final f = u * ((rest - dist) * pushK); forces[i] -= f; forces[j] += f; } } } var moving = false; final decay = math.exp(-damping * dt); for (var i = 0; i < list.length; i++) { final b = list[i]; b.vel = (b.vel + forces[i] * dt) * decay; b.pos += b.vel * dt; // Größe federt zum Ziel (leichtes Überschwingen beim Erscheinen). b.rVel += (radiusK * (b.targetR - b.r) - radiusDamping * b.rVel) * dt; b.r = math.max(0, b.r + b.rVel * dt); if (b.vel.distance > 2 || b.rVel.abs() > 2 || (b.targetR - b.r).abs() > 0.5) { moving = true; } } return moving; } /// Umriss von [id] in Bühnen-Koordinaten: der Kreis, an jeder Berührung /// mit einem Nachbarn gerade abgeschnitten (mit [seam] Pixel Fuge). Path clipPath(String id, {double seam = 3}) { final a = bodies[id]!; final circle = Rect.fromCircle(center: a.pos, radius: math.max(a.r, 0.01)); var path = Path()..addOval(circle); for (final b in bodies.values) { if (identical(a, b) || b.r < 1) continue; final delta = b.pos - a.pos; final d = delta.distance; if (d < 0.01 || d >= a.r + b.r) continue; final u = delta / d; // Abstand von a zur Potenzgerade der beiden Kreise final cut = (d * d + a.r * a.r - b.r * b.r) / (2 * d) - seam / 2; if (cut >= a.r) continue; final n = Offset(-u.dy, u.dx); final big = a.r * 4 + 10; final base = a.pos + u * cut; final half = Path() ..addPolygon([ base + n * big, base - n * big, base - n * big - u * big, base + n * big - u * big, ], true); path = Path.combine(PathOperation.intersect, path, half); } return path; } }