- Neues Layout lib/features/call_ui/inflated_layout.dart: gewichtetes Voronoi-/Potenzdiagramm im Anrufbereich, Zellen fuellen das Fenster bis an die Raender, an Nachbarn plattgedrueckt, weich gerundete Ecken; Lloyd-Relaxation + sanfter Zug ins Raster (aufgeraeumt), Flaechen per Gewicht nachgefuehrt (Sprecher 1,7-fach), Neue pumpen sich auf, Gehende schrumpfen weg, je mehr Personen desto kleiner - Darstellung nur als Kontur mit weichem Innenleuchten, Profilbild mit Kontur + Name in der Mitte, Sprecher gruen pulsierend, Kamera-Bild fuellt die Form - Tests: test/inflated_layout_test.dart (7), Vorschau-/Animationsbilder integration_test/call_bubbles_demo_test.dart; alte Kugel-Physik entfernt - Version 0.1.43 Co-Authored-By: Claude Opus 5.5 <[email protected]>
345 lines
12 KiB
Dart
345 lines
12 KiB
Dart
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<Offset> 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<String, InflatedCell> 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<String> ids, Set<String> 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<InflatedCell> 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<Offset> _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<double>(0, (s, c) => s + c.area);
|
||
final shareSum = cells.values.fold<double>(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<double>(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 = <Offset>[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<Offset> _clip(List<Offset> poly, Offset n, double limit) {
|
||
final out = <Offset>[];
|
||
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 = <Offset>[];
|
||
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);
|
||
}
|
||
}
|