Skip to content

Instantly share code, notes, and snippets.

@cavin-macwan
Created February 20, 2025 17:52
Show Gist options
  • Save cavin-macwan/ff1c720043a2627560b57bc2e6b84814 to your computer and use it in GitHub Desktop.
Save cavin-macwan/ff1c720043a2627560b57bc2e6b84814 to your computer and use it in GitHub Desktop.
Interactive Particle Network
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import com.example.demoapplication.Dot.Companion.distanceTo
import com.example.demoapplication.Dot.Companion.next
import com.example.demoapplication.DotsAndLinesState.Companion.next
import com.example.demoapplication.DotsAndLinesState.Companion.populationControl
import com.example.demoapplication.DotsAndLinesState.Companion.sizeChanged
import com.example.demoapplication.ui.theme.DemoApplicationTheme
import kotlinx.coroutines.android.awaitFrame
import kotlinx.coroutines.isActive
import kotlin.math.pow
import kotlin.math.roundToInt
import kotlin.math.sqrt
@Composable
fun DotsAndLinesDemo() {
var threshold by remember { mutableFloatStateOf(0.06f) }
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface)
.dotsAndLines(
contentColor = MaterialTheme.colorScheme.onSurface,
threshold = threshold,
maxThickness = 6f,
dotRadius = 4f,
speed = 0.05f,
populationFactor = 0.3f
)
) {
Column(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(16.dp)
) {
Text("Connectivity: $threshold")
Slider(
value = threshold,
valueRange = 0f..0.2f,
onValueChange = { threshold = it },
modifier = Modifier.width(200.dp)
)
}
}
}
data class Dot(
val position: Offset,
val vector: Offset
) {
companion object {
infix fun Dot.distanceTo(another: Dot): Float {
return (position - another.position).getDistance()
}
fun Dot.next(
borders: IntSize,
durationMillis: Long,
dotRadius: Float,
speedCoefficient: Float
): Dot {
val speed = vector * speedCoefficient
return Dot(
position = position + Offset(
x = speed.x / 1000f * durationMillis,
y = speed.y / 1000f * durationMillis,
),
vector = vector
).let { (position, vector) ->
val borderTop = dotRadius
val borderLeft = dotRadius
val borderBottom = borders.height - dotRadius
val borderRight = borders.width - dotRadius
Dot(
position = Offset(
x = when {
position.x < borderLeft -> borderLeft - (position.x - borderLeft)
position.x > borderRight -> borderRight - (position.x - borderRight)
else -> position.x
},
y = when {
position.y < borderTop -> borderTop - (position.y - borderTop)
position.y > borderBottom -> borderBottom - (position.y - borderBottom)
else -> position.y
}
),
vector = Offset(
x = when {
position.x < borderLeft -> -vector.x
position.x > borderRight -> -vector.x
else -> vector.x
},
y = when {
position.y < borderTop -> -vector.y
position.y > borderBottom -> -vector.y
else -> vector.y
}
)
)
}
}
fun create(borders: IntSize): Dot {
return Dot(
position = Offset(
(0..borders.width).random().toFloat(),
(0..borders.height).random().toFloat()
),
vector = Offset(
listOf(
-1f,
1f
).random() * ((borders.width.toFloat() / 100f).toInt()..(borders.width.toFloat() / 10f).toInt()).random()
.toFloat(),
listOf(
-1f,
1f
).random() * ((borders.height.toFloat() / 100f).toInt()..(borders.height.toFloat() / 10f).toInt()).random()
.toFloat()
)
)
}
fun Offset.normalize(): Offset {
val l = 1.0f / length()
return Offset(x * l, y * l)
}
private fun Offset.length(): Float {
return sqrt(x * x + y * y)
}
}
}
fun Modifier.dotsAndLines(
contentColor: Color = Color.White,
threshold: Float,
maxThickness: Float,
dotRadius: Float,
speed: Float,
populationFactor: Float
) = this.composed {
val dotsAndLinesModel = remember {
DotsAndLinesModel(
DotsAndLinesState(
dotRadius = dotRadius,
speed = speed
)
)
}
LaunchedEffect(speed, dotRadius, populationFactor) {
dotsAndLinesModel.populationControl(speed, dotRadius, populationFactor)
}
LaunchedEffect(Unit) {
var lastFrame = 0L
while (isActive) {
val nextFrame = awaitFrame() / 100_000L
if (lastFrame != 0L) {
val period = nextFrame - lastFrame
dotsAndLinesModel.next(period)
}
lastFrame = nextFrame
}
}
pointerInput(Unit) {
detectDragGestures(
onDragStart = { offset ->
dotsAndLinesModel.pointerDown(offset)
},
onDragEnd = {
dotsAndLinesModel.pointerUp()
},
onDragCancel = {
dotsAndLinesModel.pointerUp()
},
onDrag = { change, dragAmount ->
dotsAndLinesModel.pointerMove(dragAmount)
change.consume()
}
)
}
.onSizeChanged {
dotsAndLinesModel.sizeChanged(it, populationFactor)
}
.drawBehind {
val allDots =
with(dotsAndLinesModel.dotsAndLinesState) { (dots + pointer).filterNotNull() }
allDots.forEach {
drawCircle(contentColor, radius = dotRadius, center = it.position)
}
val realThreshold = threshold * sqrt(size.width.pow(2) + size.height.pow(2))
allDots.nestedForEach { first, second ->
val distance = first distanceTo second
if (distance <= realThreshold) {
drawLine(
contentColor,
first.position,
second.position,
0.5f + (realThreshold - distance) * maxThickness / realThreshold
)
}
}
}
}
@Immutable
class DotsAndLinesModel(
initialDotsAndLinesState: DotsAndLinesState
) {
var dotsAndLinesState by mutableStateOf(initialDotsAndLinesState)
fun populationControl(
speed: Float,
dotRadius: Float,
populationFactor: Float
) {
dotsAndLinesState = dotsAndLinesState.copy(
speed = speed,
dotRadius = dotRadius
).populationControl(populationFactor)
}
fun next(period: Long) {
dotsAndLinesState = dotsAndLinesState.next(period)
}
fun sizeChanged(size: IntSize, populationFactor: Float) {
dotsAndLinesState = dotsAndLinesState.sizeChanged(
size = size,
populationFactor = populationFactor
)
}
fun pointerDown(offset: Offset) {
dotsAndLinesState = dotsAndLinesState.copy(
pointer = Dot(
position = offset,
vector = Offset.Zero
)
)
}
fun pointerMove(offset: Offset) {
val currentPointer = dotsAndLinesState.pointer ?: return
dotsAndLinesState = dotsAndLinesState.copy(
pointer = dotsAndLinesState.pointer?.copy(
position = currentPointer.position + offset,
vector = Offset.Zero
)
)
}
fun pointerUp() {
dotsAndLinesState = dotsAndLinesState.copy(pointer = null)
}
}
private fun <T> List<T>.nestedForEach(block: (T, T) -> Unit) {
for (i in this.indices) {
for (j in i + 1 until this.size) {
block(this[i], this[j])
}
}
}
data class DotsAndLinesState(
val dots: List<Dot> = emptyList(),
val pointer: Dot? = null,
val dotRadius: Float,
val size: IntSize = IntSize.Zero,
val speed: Float
) {
companion object {
fun DotsAndLinesState.sizeChanged(
size: IntSize,
populationFactor: Float
): DotsAndLinesState {
if (size == this.size) return this
return copy(
dots = (0..size.realPopulation(populationFactor)).map {
Dot.create(size)
},
size = size
)
}
fun DotsAndLinesState.next(durationMillis: Long): DotsAndLinesState {
return copy(
dots = dots.map {
it.next(size, durationMillis, dotRadius, speed)
}
)
}
fun DotsAndLinesState.populationControl(populationFactor: Float): DotsAndLinesState {
val count = size.realPopulation(populationFactor = populationFactor)
return if (count < dots.size) {
copy(dots = dots.shuffled().take(count))
} else {
copy(dots = dots + (0..count - dots.size).map { Dot.create(size) })
}
}
private fun IntSize.realPopulation(populationFactor: Float): Int {
return (width * height / 10_000 * populationFactor).roundToInt()
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment