Created
February 20, 2025 17:52
-
-
Save cavin-macwan/ff1c720043a2627560b57bc2e6b84814 to your computer and use it in GitHub Desktop.
Interactive Particle Network
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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