feat(call): aufgeblasene Formen statt runder Bubbles (Bernds Referenzbild)
- 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]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
d4473c7d64
commit
ab84701e73
@@ -0,0 +1,344 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user