import 'dart:math' as math; import 'dart:ui'; /// Layout der Anrufansicht als „aufgeblasene“ Formen (Bernd 2026-10-10, /// Referenz: aufgepumpte Buchstaben in einer Kiste): Jede Person bekommt eine /// Zelle, alle Zellen zusammen füllen den ganzen Anrufbereich bis an die /// Ränder. Wo zwei Zellen aneinanderstoßen, sind sie plattgedrückt, außen /// liegen sie am Fensterrand an, die Ecken sind weich gerundet ([outline]). /// /// Technik: gewichtetes Voronoi-Diagramm (Potenzdiagramm) im Rechteck. Jede /// Zelle hat einen Anteil an der Fläche ([InflatedCell.share], wer spricht /// bekommt mehr). In jedem Schritt wandert der Mittelpunkt einer Zelle zu /// ihrem Schwerpunkt (gleichmäßige, ruhige Formen) und ihr Gewicht wird so /// nachgeführt, dass die Fläche dem Anteil entspricht. Weil das schrittweise /// geschieht, sieht man es als federndes Auf- und Abpumpen. Je mehr Personen, /// desto kleiner die Zellen. class InflatedCell { InflatedCell(this.id, this.site, this.weight, {this.share = 1}); final String id; /// Mittelpunkt (Erzeuger) der Zelle. Offset site; /// Platz im Raster, zu dem die Zelle sanft gezogen wird (aufgeräumte /// Anordnung); null = keiner (z. B. beim Gehen). Offset? slot; /// Gewicht im Potenzdiagramm (Einheit: Fläche, wie Radius²). double weight; /// Aktueller (animierter) und gewünschter Flächenanteil. double share; double targetShare = 1; bool get leaving => targetShare == 0; /// Ergebnis der letzten Berechnung. List polygon = const []; double area = 0; Offset centroid = Offset.zero; } class InflatedLayout { InflatedLayout({this.gap = 10, this.margin = 12}); /// Wie viel mehr Fläche bekommt, wer gerade spricht. static const speakingShare = 1.7; /// Abstand zwischen zwei Zellen bzw. zum Fensterrand. final double gap; final double margin; final Map cells = {}; Size _size = Size.zero; Size get size => _size; Rect get bounds => Rect.fromLTWH( margin, margin, math.max(0, _size.width - 2 * margin), math.max(0, _size.height - 2 * margin), ); /// Gleicht die Zellen mit den Teilnehmern ab ([speaking] = wer spricht). void sync(List ids, Set speaking, Size size) { final old = _size; _size = size; if (size != old && old.width > 0 && old.height > 0 && cells.isNotEmpty) { final flipped = (old.width >= old.height) != (size.width >= size.height); if (flipped) { // Hochkant ↔ quer: neu anordnen, sonst bleiben z. B. zwei Zellen // übereinander, obwohl nebeneinander viel besser passt. _placeOnGrid(cells.values.toList()); } else { final sx = size.width / old.width, sy = size.height / old.height; for (final c in cells.values) { c.site = Offset(c.site.dx * sx, c.site.dy * sy); c.weight *= sx * sy; } } } final present = ids.toSet(); for (final c in cells.values) { if (!present.contains(c.id)) c.targetShare = 0; } final fresh = ids.where((id) => !cells.containsKey(id)).toList(); if (cells.isEmpty) { // Erster Aufbau: gleich voll da, im Raster angeordnet. final created = [for (final id in fresh) InflatedCell(id, Offset.zero, 0)]; _placeOnGrid(created); for (final c in created) { cells[c.id] = c; } } else { for (final id in fresh) { // Neu dazu: entsteht in der größten Zelle und pumpt sich dort auf. final host = cells.values.reduce((a, b) => a.area >= b.area ? a : b); final h = id.hashCode; final dir = Offset(math.cos(h % 360.0), math.sin(h % 360.0)); final spread = math.sqrt(math.max(host.area, 1)) * 0.2; final site = _inside(host.centroid + dir * spread); // Gewicht knapp über der Grenze, ab der die Zelle leer wäre // (w ≤ w_host − d²): sie beginnt winzig und pumpt sich dann auf. final d2 = (site - host.site).distanceSquared; cells[id] = InflatedCell( id, site, host.weight - d2 * 0.9, share: 0.15, ); } } for (final id in ids) { cells[id]!.targetShare = speaking.contains(id) ? speakingShare : 1.0; } final slots = _gridPoints(ids.length); for (var i = 0; i < ids.length; i++) { cells[ids[i]]!.slot = i < slots.length ? slots[i] : null; } for (final c in cells.values) { if (c.leaving) c.slot = null; } _compute(); } void _placeOnGrid(List list) { final points = _gridPoints(list.length); for (var i = 0; i < list.length && i < points.length; i++) { list[i] ..site = points[i] ..weight = 0; } } /// Mittelpunkte eines Rasters für [n] Zellen: möglichst quadratische /// Zellen, wenig Lücken; die letzte Reihe wird mittig verteilt. List _gridPoints(int n) { final b = bounds; if (n == 0 || b.isEmpty) return const []; var cols = 1; var best = double.infinity; for (var c = 1; c <= n; c++) { final r = (n / c).ceil(); final aspect = (b.width / c) / (b.height / r); final score = math.log(aspect).abs() + 0.35 * (c * r - n); if (score < best) { best = score; cols = c; } } final rows = (n / cols).ceil(); return [ for (var i = 0; i < n; i++) () { final row = i ~/ cols; final inRow = row == rows - 1 ? n - row * cols : cols; final col = i % cols; // Letzte (unvollständige) Reihe: gleichmäßig über die Breite. final x = row == rows - 1 && inRow < cols ? b.left + b.width * (col + 0.5) / inRow : b.left + b.width * (col + 0.5) / cols; return Offset(x, b.top + b.height * (row + 0.5) / rows); }(), ]; } Offset _inside(Offset p) { final b = bounds.deflate(1); return Offset(p.dx.clamp(b.left, b.right), p.dy.clamp(b.top, b.bottom)); } /// Ein Zeitschritt (Sekunden). `true`, solange sich noch etwas bewegt. bool step(double dt) { if (_size.isEmpty || cells.isEmpty) return false; dt = dt.clamp(0.0, 1 / 20); var moving = false; final shareRate = 1 - math.exp(-dt * 6); for (final c in cells.values) { final d = c.targetShare - c.share; if (d.abs() > 0.01) moving = true; c.share += d * shareRate; } cells.removeWhere((_, c) => c.leaving && c.share < 0.03); if (cells.isEmpty) return false; _compute(); final total = cells.values.fold(0, (s, c) => s + c.area); final shareSum = cells.values.fold(0, (s, c) => s + c.share); final f = math.min(1.0, dt * 10); final move = 1 - math.exp(-dt * 8); for (final c in cells.values) { // Fläche zum Anteil passend machen: Gewicht direkt um den Fehler // verschieben (Gewicht und Fläche haben dieselbe Einheit; ein Plus an // Gewicht schiebt alle Grenzen der Zelle nach außen). Gedämpft, damit // es federnd statt ruckartig wirkt. final target = total * c.share / shareSum; final error = target - c.area; if (error.abs() > target * 0.02) moving = true; c.weight += 0.4 * f * error; // … und den Mittelpunkt zu Schwerpunkt bzw. Rasterplatz ziehen. if (c.area > 1) { final goal = c.slot == null ? c.centroid : Offset.lerp(c.centroid, c.slot!, 0.5)!; final d = goal - c.site; // Ruhe, sobald sich nichts mehr sichtbar bewegt (< ~3 px/s). if (d.distance * move > 0.05) moving = true; c.site = _inside(c.site + d * move); } } // Nur Unterschiede zählen – Mittelwert bei 0 halten. final mean = cells.values.fold(0, (s, c) => s + c.weight) / cells.length; for (final c in cells.values) { c.weight -= mean; } _clampWeights(); _compute(); return moving; } /// Ein Erzeuger muss in seiner eigenen Zelle liegen: w_j − w_i ≤ d². void _clampWeights() { final list = cells.values.toList(); for (var i = 0; i < list.length; i++) { for (var j = 0; j < list.length; j++) { if (i == j) continue; final d2 = (list[i].site - list[j].site).distanceSquared; if (list[j].weight - list[i].weight > d2) { list[j].weight = list[i].weight + d2; } } } } void _compute() { final b = bounds; final list = cells.values.toList(); for (final c in list) { var poly = [b.topLeft, b.topRight, b.bottomRight, b.bottomLeft]; for (final o in list) { if (identical(o, c) || poly.isEmpty) continue; final n = (o.site - c.site) * 2; final len = n.distance; if (len < 1e-6) continue; // Grenze des Potenzdiagramms, um die halbe Lücke zu c hin versetzt. final limit = o.site.distanceSquared - c.site.distanceSquared + c.weight - o.weight - gap / 2 * len; poly = _clip(poly, n, limit); } c.polygon = poly; _measure(c); } } /// Polygon auf die Halbebene n·x ≤ limit beschneiden. static List _clip(List poly, Offset n, double limit) { final out = []; for (var i = 0; i < poly.length; i++) { final a = poly[i], b = poly[(i + 1) % poly.length]; final da = n.dx * a.dx + n.dy * a.dy - limit; final db = n.dx * b.dx + n.dy * b.dy - limit; if (da <= 0) out.add(a); if ((da <= 0) != (db <= 0)) { out.add(a + (b - a) * (da / (da - db))); } } return out; } static void _measure(InflatedCell c) { final p = c.polygon; if (p.length < 3) { c.area = 0; return; } var a2 = 0.0, cx = 0.0, cy = 0.0; for (var i = 0; i < p.length; i++) { final q = p[i], r = p[(i + 1) % p.length]; final cross = q.dx * r.dy - r.dx * q.dy; a2 += cross; cx += (q.dx + r.dx) * cross; cy += (q.dy + r.dy) * cross; } if (a2.abs() < 1e-6) { c.area = 0; return; } c.area = a2.abs() / 2; c.centroid = Offset(cx / (3 * a2), cy / (3 * a2)); } /// Umriss von [id]: die Zelle mit weich gerundeten Ecken („aufgeblasen“). Path outline(String id) { final c = cells[id]; final path = Path(); if (c == null) return path; final p = []; for (final v in c.polygon) { if (p.isEmpty || (v - p.last).distance > 0.5) p.add(v); } if (p.length > 2 && (p.first - p.last).distance <= 0.5) p.removeLast(); if (p.length < 3) return path; final rcMax = math.min(72.0, math.sqrt(c.area) * 0.34); for (var i = 0; i < p.length; i++) { final v = p[i]; final prev = p[(i - 1 + p.length) % p.length]; final next = p[(i + 1) % p.length]; final lp = (prev - v).distance, ln = (next - v).distance; final r = math.min(rcMax, math.min(lp, ln) * 0.5); final a = v + (prev - v) / lp * r; final b = v + (next - v) / ln * r; if (i == 0) { path.moveTo(a.dx, a.dy); } else { path.lineTo(a.dx, a.dy); } // Kubische Ecke (etwas voller als ein Viertelkreis – wirkt prall). final c1 = a + (v - a) * 0.62, c2 = b + (v - b) * 0.62; path.cubicTo(c1.dx, c1.dy, c2.dx, c2.dy, b.dx, b.dy); } path.close(); return path; } /// Rechteck um die Zelle (für Inhalte wie Video). Rect cellBounds(String id) { final p = cells[id]?.polygon ?? const []; if (p.isEmpty) return Rect.zero; var l = p.first.dx, t = p.first.dy, r = l, b = t; for (final v in p) { l = math.min(l, v.dx); t = math.min(t, v.dy); r = math.max(r, v.dx); b = math.max(b, v.dy); } return Rect.fromLTRB(l, t, r, b); } }