Feat/graph force (#581)

* mesh topology wip

* graph

* graph animated
This commit is contained in:
callebtc
2026-01-13 05:11:20 +07:00
committed by GitHub
parent 66012e9fe9
commit ce71de29f7
4 changed files with 467 additions and 80 deletions
@@ -549,13 +549,18 @@ class BluetoothMeshService(private val context: Context) {
val nick = getPeerNicknames()[peerID]
val route = packet.route
val routeInfo = if (!route.isNullOrEmpty()) "routed: ${route.size} hops" else null
// Convert route to hex strings for visualization
val routeStrings = route?.map { it.toHexString() }
com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().logIncoming(
packetType = packet.type.toString(),
fromPeerID = peerID,
fromNickname = nick,
fromDeviceAddress = device?.address,
packetVersion = packet.version,
routeInfo = routeInfo
routeInfo = routeInfo,
route = routeStrings
)
} catch (_: Exception) { }
packetProcessor.processPacket(RoutedPacket(packet, peerID, device?.address))
@@ -3,6 +3,8 @@ package com.bitchat.android.ui.debug
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import java.util.Date
import java.util.concurrent.ConcurrentLinkedQueue
@@ -465,13 +467,40 @@ class DebugSettingsManager private constructor() {
// Do not update counters here; this path is for readable logs only.
}
// MARK: - Debug Events for Animation
sealed class MeshVisualEvent {
data class PacketActivity(val peerID: String) : MeshVisualEvent()
data class RouteActivity(val route: List<String>) : MeshVisualEvent()
}
private val _meshVisualEvents = kotlinx.coroutines.flow.MutableSharedFlow<MeshVisualEvent>(
extraBufferCapacity = 64,
onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST
)
val meshVisualEvents: kotlinx.coroutines.flow.SharedFlow<MeshVisualEvent> = _meshVisualEvents.asSharedFlow()
fun emitVisualEvent(event: MeshVisualEvent) {
if (_debugSheetVisible.value) {
_meshVisualEvents.tryEmit(event)
}
}
// Explicit incoming/outgoing logging to avoid double counting
fun logIncoming(packetType: String, fromPeerID: String?, fromNickname: String?, fromDeviceAddress: String?, packetVersion: UByte = 1u, routeInfo: String? = null) {
fun logIncoming(packetType: String, fromPeerID: String?, fromNickname: String?, fromDeviceAddress: String?, packetVersion: UByte = 1u, routeInfo: String? = null, route: List<String>? = null) {
if (verboseLoggingEnabled.value) {
val who = fromNickname ?: fromPeerID ?: "unknown"
val routeStr = if (routeInfo != null) " $routeInfo" else ""
addDebugMessage(DebugMessage.PacketEvent("📥 Incoming v$packetVersion $packetType from $who (${fromPeerID ?: "?"}, ${fromDeviceAddress ?: "?"})$routeStr"))
}
// Emit visual events
if (fromPeerID != null) {
emitVisualEvent(MeshVisualEvent.PacketActivity(fromPeerID))
}
if (!route.isNullOrEmpty()) {
emitVisualEvent(MeshVisualEvent.RouteActivity(route))
}
val now = System.currentTimeMillis()
val visible = _debugSheetVisible.value
if (visible) incomingTimestamps.offer(now)
@@ -53,85 +53,29 @@ fun MeshTopologySection() {
if (empty) {
Text("no gossip yet", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f))
} else {
androidx.compose.foundation.Canvas(Modifier.fillMaxWidth().height(220.dp).background(colorScheme.surface.copy(alpha = 0.4f))) {
val w = size.width
val h = size.height
val cx = w / 2f
val cy = h / 2f
val radius = (minOf(w, h) * 0.36f)
val n = nodes.size
if (n == 1) {
// Single node centered
drawCircle(color = Color(0xFF00C851), radius = 12f, center = androidx.compose.ui.geometry.Offset(cx, cy))
} else {
// Circular layout
val positions = nodes.mapIndexed { i, node ->
val angle = (2 * Math.PI * i.toDouble()) / n
val x = cx + (radius * Math.cos(angle)).toFloat()
val y = cy + (radius * Math.sin(angle)).toFloat()
node.peerID to androidx.compose.ui.geometry.Offset(x, y)
}.toMap()
// Draw edges
edges.forEach { e ->
val p1 = positions[e.a]
val p2 = positions[e.b]
if (p1 != null && p2 != null) {
if (e.isConfirmed) {
drawLine(color = Color(0xFF4A90E2), start = p1, end = p2, strokeWidth = 2f)
} else {
// Unconfirmed: draw "solid" from declarer, "dashed" from other
val start = if (e.confirmedBy == e.a) p1 else p2
val end = if (e.confirmedBy == e.a) p2 else p1
val midX = (start.x + end.x) / 2
val midY = (start.y + end.y) / 2
val mid = androidx.compose.ui.geometry.Offset(midX, midY)
// Solid half
drawLine(color = Color(0xFF4A90E2), start = start, end = mid, strokeWidth = 2f)
// Dotted half
drawLine(
color = Color(0xFF4A90E2),
start = mid,
end = end,
strokeWidth = 2f,
pathEffect = androidx.compose.ui.graphics.PathEffect.dashPathEffect(floatArrayOf(5f, 5f), 0f)
)
}
}
}
// Draw nodes
nodes.forEach { node ->
val pos = positions[node.peerID] ?: androidx.compose.ui.geometry.Offset(cx, cy)
drawCircle(color = Color(0xFF00C851), radius = 10f, center = pos)
}
// Draw labels near nodes (nickname or short ID)
val labelColor = colorScheme.onSurface.toArgb()
val textSizePx = 10.sp.toPx()
drawIntoCanvas { canvas ->
val paint = android.graphics.Paint().apply {
isAntiAlias = true
color = labelColor
textSize = textSizePx
}
nodes.forEach { node ->
val pos = positions[node.peerID] ?: androidx.compose.ui.geometry.Offset(cx, cy)
val label = (node.nickname?.takeIf { it.isNotBlank() } ?: node.peerID.take(8))
canvas.nativeCanvas.drawText(label, pos.x + 12f, pos.y - 12f, paint)
}
}
}
}
// Label list for clarity under the canvas
LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 140.dp)) {
items(nodes.size) { i ->
val node = nodes[i]
ForceDirectedMeshGraph(
nodes = nodes,
edges = edges,
modifier = Modifier
.fillMaxWidth()
.height(300.dp)
.background(colorScheme.surface.copy(alpha = 0.4f))
)
// Flexible peer list
FlowRow(
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
nodes.forEach { node ->
val label = "${node.peerID.take(8)}${node.nickname ?: "unknown"}"
Text(label, fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.85f))
Text(
text = label,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
color = colorScheme.onSurface.copy(alpha = 0.85f)
)
}
}
}
@@ -0,0 +1,409 @@
package com.bitchat.android.ui.debug
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.bitchat.android.services.meshgraph.MeshGraphService
import kotlin.math.*
import kotlin.random.Random
import androidx.compose.material3.MaterialTheme
import com.bitchat.android.ui.debug.DebugSettingsManager.MeshVisualEvent
// Physics constants
private const val REPULSION_FORCE = 100000f
private const val SPRING_LENGTH = 150f
private const val SPRING_STRENGTH = 0.02f
private const val CENTER_GRAVITY = 0.02f
private const val DAMPING = 0.85f
private const val MAX_VELOCITY = 30f
private const val PULSE_DECAY = 0.05f
private const val ROUTE_DECAY = 0.02f
private class GraphNodeState(
val id: String,
var label: String,
var x: Float,
var y: Float
) {
var vx: Float = 0f
var vy: Float = 0f
var isDragged: Boolean = false
var pulseLevel: Float = 0f // 0f to 1f, used for size/glow animation
}
private class Simulation {
val nodes = mutableMapOf<String, GraphNodeState>()
// Storing edges as pairs of IDs
val edges = mutableListOf<MeshGraphService.GraphEdge>()
// Active routes being animated: List of peerIDs in order -> intensity (1.0..0.0)
val activeRoutes = mutableListOf<Pair<List<String>, Float>>()
// Bounds for initial placement and centering
var width: Float = 1000f
var height: Float = 1000f
fun updateTopology(
newNodes: List<MeshGraphService.GraphNode>,
newEdges: List<MeshGraphService.GraphEdge>
) {
// Remove stale nodes
val newIds = newNodes.map { it.peerID }.toSet()
nodes.keys.toList().forEach { id ->
if (id !in newIds) nodes.remove(id)
}
// Add/Update nodes
newNodes.forEach { n ->
val existing = nodes[n.peerID]
val displayLabel = n.nickname ?: n.peerID.take(8)
if (existing != null) {
existing.label = displayLabel
} else {
// Spawn near center with random jitter
val angle = Random.nextFloat() * 2 * PI
val radius = 50f + Random.nextFloat() * 50f
nodes[n.peerID] = GraphNodeState(
id = n.peerID,
label = displayLabel,
x = (width / 2f) + (cos(angle) * radius).toFloat(),
y = (height / 2f) + (sin(angle) * radius).toFloat()
)
}
}
// Update edges
edges.clear()
edges.addAll(newEdges)
}
fun triggerNodePulse(peerID: String) {
nodes[peerID]?.pulseLevel = 1f
}
fun triggerRouteAnimation(route: List<String>) {
if (route.size > 1) {
activeRoutes.add(route to 1f)
}
}
fun step() {
val nodeList = nodes.values.toList()
val cx = width / 2f
val cy = height / 2f
// 1. Repulsion (Node-Node)
for (i in nodeList.indices) {
val n1 = nodeList[i]
for (j in i + 1 until nodeList.size) {
val n2 = nodeList[j]
val dx = n1.x - n2.x
val dy = n1.y - n2.y
val distSq = dx * dx + dy * dy
if (distSq > 0.1f) {
val dist = sqrt(distSq)
val force = REPULSION_FORCE / distSq
val fx = (dx / dist) * force
val fy = (dy / dist) * force
if (!n1.isDragged) {
n1.vx += fx
n1.vy += fy
}
if (!n2.isDragged) {
n2.vx -= fx
n2.vy -= fy
}
}
}
}
// 2. Attraction (Edges)
edges.forEach { edge ->
val n1 = nodes[edge.a]
val n2 = nodes[edge.b]
if (n1 != null && n2 != null) {
val dx = n1.x - n2.x
val dy = n1.y - n2.y
val dist = sqrt(dx * dx + dy * dy)
if (dist > 0.1f) {
val force = (dist - SPRING_LENGTH) * SPRING_STRENGTH
val fx = (dx / dist) * force
val fy = (dy / dist) * force
if (!n1.isDragged) {
n1.vx -= fx
n1.vy -= fy
}
if (!n2.isDragged) {
n2.vx += fx
n2.vy += fy
}
}
}
}
// 3. Center Gravity & Integration & Animation Decay
nodeList.forEach { n ->
if (!n.isDragged) {
// Pull to center
val dx = n.x - cx
val dy = n.y - cy
n.vx -= dx * CENTER_GRAVITY
n.vy -= dy * CENTER_GRAVITY
// Apply velocity
val vMag = sqrt(n.vx * n.vx + n.vy * n.vy)
if (vMag > MAX_VELOCITY) {
n.vx = (n.vx / vMag) * MAX_VELOCITY
n.vy = (n.vy / vMag) * MAX_VELOCITY
}
n.x += n.vx
n.y += n.vy
// Damping
n.vx *= DAMPING
n.vy *= DAMPING
} else {
n.vx = 0f
n.vy = 0f
}
// Decay pulse
if (n.pulseLevel > 0f) {
n.pulseLevel = (n.pulseLevel - PULSE_DECAY).coerceAtLeast(0f)
}
}
// Decay active routes
val iter = activeRoutes.iterator()
while (iter.hasNext()) {
val (route, intensity) = iter.next()
val newIntensity = intensity - ROUTE_DECAY
if (newIntensity <= 0f) {
iter.remove()
} else {
// Ugly mutation but efficient for simulation loop
// We need to replace the pair since pairs are immutable
// Finding index is O(N) but N is small
val idx = activeRoutes.indexOfFirst { it.first === route && it.second == intensity }
if (idx >= 0) {
activeRoutes[idx] = route to newIntensity
}
}
}
}
}
@Composable
fun ForceDirectedMeshGraph(
nodes: List<MeshGraphService.GraphNode>,
edges: List<MeshGraphService.GraphEdge>,
modifier: Modifier = Modifier
) {
val density = LocalDensity.current
val simulation = remember { Simulation() }
val colorScheme = MaterialTheme.colorScheme
// Listen for visual events
val debugManager = remember { DebugSettingsManager.getInstance() }
LaunchedEffect(Unit) {
debugManager.meshVisualEvents.collect { event ->
when (event) {
is MeshVisualEvent.PacketActivity -> simulation.triggerNodePulse(event.peerID)
is MeshVisualEvent.RouteActivity -> simulation.triggerRouteAnimation(event.route)
}
}
}
// We need a state that changes on every tick to trigger redraw
var tick by remember { mutableLongStateOf(0L) }
// Update topology when input data changes
LaunchedEffect(nodes, edges) {
simulation.updateTopology(nodes, edges)
}
// Animation Loop
LaunchedEffect(Unit) {
while (true) {
withFrameNanos {
simulation.step()
tick++
}
}
}
BoxWithConstraints(modifier = modifier) {
val w = maxWidth.value * density.density
val h = maxHeight.value * density.density
// Update simulation bounds if size changes
SideEffect {
simulation.width = w
simulation.height = h
}
Canvas(
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
detectDragGestures(
onDragStart = { offset ->
// Find closest node
val closest = simulation.nodes.values.minByOrNull {
val dx = it.x - offset.x
val dy = it.y - offset.y
dx*dx + dy*dy
}
if (closest != null) {
val dist = sqrt((closest.x - offset.x).pow(2) + (closest.y - offset.y).pow(2))
if (dist < 80f) { // Touch radius
closest.isDragged = true
}
}
},
onDragEnd = {
simulation.nodes.values.forEach { it.isDragged = false }
},
onDragCancel = {
simulation.nodes.values.forEach { it.isDragged = false }
},
onDrag = { change, dragAmount ->
change.consume()
val dragged = simulation.nodes.values.find { it.isDragged }
if (dragged != null) {
dragged.x += dragAmount.x
dragged.y += dragAmount.y
}
}
)
}
) {
// Read tick to ensure recomposition
val t = tick
val nodeMap = simulation.nodes
// Draw Edges
simulation.edges.forEach { edge ->
val n1 = nodeMap[edge.a]
val n2 = nodeMap[edge.b]
if (n1 != null && n2 != null) {
val start = Offset(n1.x, n1.y)
val end = Offset(n2.x, n2.y)
val baseColor = Color(0xFF4A90E2)
if (edge.isConfirmed) {
drawLine(
color = baseColor,
start = start,
end = end,
strokeWidth = 5f
)
} else {
// Unconfirmed: draw "solid" from declarer, "dashed" from other
val isA = (edge.confirmedBy == edge.a)
val solidStart = if (isA) start else end
val solidEnd = if (isA) end else start
val midX = (start.x + end.x) / 2
val midY = (start.y + end.y) / 2
val mid = Offset(midX, midY)
drawLine(
color = baseColor,
start = solidStart,
end = mid,
strokeWidth = 4f
)
drawLine(
color = baseColor.copy(alpha = 0.6f),
start = mid,
end = solidEnd,
strokeWidth = 4f,
pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f), 0f)
)
}
}
}
// Draw Active Routes (overlay)
simulation.activeRoutes.forEach { (route, intensity) ->
val routeColor = Color(0xFFFFD700).copy(alpha = intensity) // Gold
val strokeW = 4f * intensity + 2f
for (i in 0 until route.size - 1) {
val p1 = nodeMap[route[i]]
val p2 = nodeMap[route[i+1]]
if (p1 != null && p2 != null) {
drawLine(
color = routeColor,
start = Offset(p1.x, p1.y),
end = Offset(p2.x, p2.y),
strokeWidth = strokeW,
cap = androidx.compose.ui.graphics.StrokeCap.Round
)
}
}
}
// Draw Nodes
val labelColor = colorScheme.onSurface.toArgb()
val textPaint = android.graphics.Paint().apply {
isAntiAlias = true
textSize = 12.sp.toPx()
this.color = labelColor
}
nodeMap.values.forEach { node ->
val center = Offset(node.x, node.y)
val pulse = node.pulseLevel
// Pulse glow
if (pulse > 0.05f) {
drawCircle(
color = Color(0xFF00FF00).copy(alpha = pulse * 0.6f),
radius = 16f + (pulse * 20f),
center = center
)
}
drawCircle(
color = Color(0xFF00C851),
radius = 16f + (pulse * 4f), // Slight scale up
center = center
)
drawCircle(
color = Color.White,
radius = 12f + (pulse * 3f),
center = center,
style = Stroke(width = 2f)
)
// Label
drawContext.canvas.nativeCanvas.drawText(
node.label,
node.x + 22f + (pulse * 5f),
node.y + 4f,
textPaint
)
}
}
}
}