Compare commits

...

2 Commits

Author SHA1 Message Date
eb126a6090 Finalize collective demo 2024-06-12 16:31:14 +03:00
a5bb42706b Change visualization for collective 2024-06-12 11:56:27 +03:00
8 changed files with 275 additions and 132 deletions

View File

@ -73,7 +73,7 @@ public data class PropertySetMessage(
public val property: String, public val property: String,
public val value: Meta, public val value: Meta,
override val sourceDevice: Name? = null, override val sourceDevice: Name? = null,
override val targetDevice: Name, override val targetDevice: Name?,
override val comment: String? = null, override val comment: String? = null,
@EncodeDefault override val time: Instant = Clock.System.now(), @EncodeDefault override val time: Instant = Clock.System.now(),
) : DeviceMessage() { ) : DeviceMessage() {

View File

@ -68,7 +68,7 @@ public suspend fun Device.respondMessage(deviceTarget: Name, request: DeviceMess
/** /**
* Process incoming [DeviceMessage], using hub naming to find target. * Process incoming [DeviceMessage], using hub naming to find target.
* If the `targetDevice` is `null`, then message is sent to each device in this hub * If the `targetDevice` is `null`, then the message is sent to each device in this hub
*/ */
public suspend fun DeviceHub.respondHubMessage(request: DeviceMessage): List<DeviceMessage> { public suspend fun DeviceHub.respondHubMessage(request: DeviceMessage): List<DeviceMessage> {
return try { return try {

View File

@ -21,7 +21,7 @@ public interface PeerConnection {
address: String, address: String,
contentId: String, contentId: String,
requestMeta: Meta = Meta.EMPTY, requestMeta: Meta = Meta.EMPTY,
): Envelope ): Envelope?
/** /**
* Send an [envelope] to a device on a given [address] * Send an [envelope] to a device on a given [address]

View File

@ -23,7 +23,11 @@ class CollectiveDeviceConfiguration(deviceId: CollectiveDeviceId) : Scheme() {
var deviceId by string(deviceId) var deviceId by string(deviceId)
var description by string() var description by string()
var reportInterval by int(500) var reportInterval by int(500)
var radioFrequency by string(default = "169 MHz") var radioFrequency by string(default = DEFAULT_FREQUENCY)
companion object {
const val DEFAULT_FREQUENCY = "169 MHz"
}
} }
typealias CollectiveDeviceRoster = Map<CollectiveDeviceId, CollectiveDeviceConfiguration> typealias CollectiveDeviceRoster = Map<CollectiveDeviceId, CollectiveDeviceConfiguration>
@ -81,12 +85,12 @@ class CollectiveDeviceConstructor(
val position = registerAsProperty( val position = registerAsProperty(
CollectiveDevice.position, CollectiveDevice.position,
position.sample(configuration.reportInterval.milliseconds) position.debounce(configuration.reportInterval.milliseconds)
) )
val velocity = registerAsProperty( val velocity = registerAsProperty(
CollectiveDevice.velocity, CollectiveDevice.velocity,
velocity.sample(configuration.reportInterval.milliseconds) velocity.debounce(configuration.reportInterval.milliseconds)
) )
private val _visibleNeighbors: MutableDeviceState<Collection<CollectiveDeviceId>> = stateOf(emptyList()) private val _visibleNeighbors: MutableDeviceState<Collection<CollectiveDeviceId>> = stateOf(emptyList())

View File

@ -2,27 +2,28 @@ package space.kscience.controls.demo.collective
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.sample import kotlinx.coroutines.flow.sample
import space.kscience.controls.constructor.DeviceState import space.kscience.controls.constructor.DeviceState
import space.kscience.controls.constructor.MutableDeviceState import space.kscience.controls.constructor.MutableDeviceState
import kotlin.time.Duration import kotlin.time.Duration
@OptIn(FlowPreview::class) @OptIn(FlowPreview::class)
class SampleDeviceState<T>( class DebounceDeviceState<T>(
val origin: DeviceState<T>, val origin: DeviceState<T>,
val interval: Duration, val interval: Duration,
) : DeviceState<T> { ) : DeviceState<T> {
override val value: T by origin::value override val value: T by origin::value
override val valueFlow: Flow<T> get() = origin.valueFlow.sample(interval) override val valueFlow: Flow<T> get() = origin.valueFlow.debounce(interval)
override fun toString(): String = "DebounceDeviceState($value, interval=$interval)" override fun toString(): String = "DebounceDeviceState($value, interval=$interval)"
} }
fun <T> DeviceState<T>.sample(interval: Duration) = SampleDeviceState(this, interval) fun <T> DeviceState<T>.debounce(interval: Duration) = DebounceDeviceState(this, interval)
@OptIn(FlowPreview::class) @OptIn(FlowPreview::class)
class MutableSampleDeviceState<T>( class MutableDebounceDeviceState<T>(
val origin: MutableDeviceState<T>, val origin: MutableDeviceState<T>,
val interval: Duration, val interval: Duration,
) : MutableDeviceState<T> { ) : MutableDeviceState<T> {
@ -32,4 +33,4 @@ class MutableSampleDeviceState<T>(
override fun toString(): String = "DebounceDeviceState($value, interval=$interval)" override fun toString(): String = "DebounceDeviceState($value, interval=$interval)"
} }
fun <T> MutableDeviceState<T>.sample(interval: Duration) = MutableSampleDeviceState(this, interval) fun <T> MutableDeviceState<T>.debounce(interval: Duration) = MutableDebounceDeviceState(this, interval)

View File

@ -1,27 +1,53 @@
package space.kscience.controls.demo.collective package space.kscience.controls.demo.collective
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.*
import kotlinx.coroutines.Dispatchers import kotlinx.io.writeString
import kotlinx.coroutines.Job import kotlinx.serialization.json.Json
import kotlinx.coroutines.launch import space.kscience.controls.api.DeviceMessage
import space.kscience.controls.api.PropertySetMessage
import space.kscience.controls.client.DeviceClient
import space.kscience.controls.client.launchMagixService import space.kscience.controls.client.launchMagixService
import space.kscience.controls.client.write
import space.kscience.controls.constructor.DeviceState
import space.kscience.controls.constructor.ModelConstructor import space.kscience.controls.constructor.ModelConstructor
import space.kscience.controls.constructor.MutableDeviceState import space.kscience.controls.constructor.MutableDeviceState
import space.kscience.controls.constructor.onTimer import space.kscience.controls.constructor.onTimer
import space.kscience.controls.manager.DeviceManager import space.kscience.controls.manager.DeviceManager
import space.kscience.controls.manager.install import space.kscience.controls.manager.install
import space.kscience.controls.manager.respondMessage
import space.kscience.controls.peer.PeerConnection import space.kscience.controls.peer.PeerConnection
import space.kscience.controls.spec.name
import space.kscience.controls.spec.write
import space.kscience.dataforge.context.Context import space.kscience.dataforge.context.Context
import space.kscience.dataforge.context.request import space.kscience.dataforge.context.request
import space.kscience.dataforge.io.Envelope import space.kscience.dataforge.io.Envelope
import space.kscience.dataforge.io.toByteArray
import space.kscience.dataforge.meta.Meta import space.kscience.dataforge.meta.Meta
import space.kscience.dataforge.names.parseAsName import space.kscience.dataforge.names.parseAsName
import space.kscience.kmath.geometry.degrees
import space.kscience.kmath.geometry.radians
import space.kscience.magix.api.MagixEndpoint import space.kscience.magix.api.MagixEndpoint
import space.kscience.magix.rsocket.rSocketWithWebSockets import space.kscience.magix.rsocket.rSocketWithWebSockets
import space.kscience.magix.server.startMagixServer import space.kscience.magix.server.startMagixServer
import space.kscience.maps.coordinates.* import space.kscience.maps.coordinates.*
import kotlin.math.PI
import kotlin.random.Random
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
private val deviceVelocity = 0.1.kilometers
private val center = Gmc.ofDegrees(55.925, 37.514)
private val radius = 0.01.degrees
private val json = Json {
ignoreUnknownKeys = true
prettyPrint = true
}
internal data class CollectiveDeviceState( internal data class CollectiveDeviceState(
val id: CollectiveDeviceId, val id: CollectiveDeviceId,
val configuration: CollectiveDeviceConfiguration, val configuration: CollectiveDeviceConfiguration,
@ -29,7 +55,7 @@ internal data class CollectiveDeviceState(
val velocity: MutableDeviceState<GmcVelocity>, val velocity: MutableDeviceState<GmcVelocity>,
) )
internal fun VirtualDeviceState( internal fun CollectiveDeviceState(
id: CollectiveDeviceId, id: CollectiveDeviceId,
position: Gmc, position: Gmc,
configuration: CollectiveDeviceConfiguration.() -> Unit = {}, configuration: CollectiveDeviceConfiguration.() -> Unit = {},
@ -40,13 +66,12 @@ internal fun VirtualDeviceState(
MutableDeviceState(GmcVelocity.zero) MutableDeviceState(GmcVelocity.zero)
) )
internal class DeviceCollectiveModel( internal class DeviceCollectiveModel(
context: Context, context: Context,
val deviceStates: Collection<CollectiveDeviceState>, val deviceStates: Collection<CollectiveDeviceState>,
val visibilityRange: Distance = 0.5.kilometers, val visibilityRange: Distance = 0.5.kilometers,
val radioRange: Distance = 5.kilometers, val radioRange: Distance = 1.kilometers,
) : ModelConstructor(context), PeerConnection { ) : ModelConstructor(context) {
/** /**
* Propagate movement * Propagate movement
@ -70,33 +95,88 @@ internal class DeviceCollectiveModel(
return allCurves.filterValues { it.distance in 0.kilometers..visibilityRange } return allCurves.filterValues { it.distance in 0.kilometers..visibilityRange }
} }
val devices = deviceStates.associate { inner class RadioPeerConnectionModel(private val position: DeviceState<Gmc>) : PeerConnection {
override suspend fun receive(address: String, contentId: String, requestMeta: Meta): Envelope? = null
override suspend fun send(address: String, envelope: Envelope, requestMeta: Meta) {
devices.values.filter { it.configuration.radioFrequency == address }.filter {
GeoEllipsoid.WGS84.curveBetween(position.value, it.position.value).distance < radioRange
}.forEach { target ->
check(envelope.data != null) { "Envelope data is empty" }
val message = json.decodeFromString(
DeviceMessage.serializer(),
envelope.data?.toByteArray()?.decodeToString() ?: ""
)
target.respondMessage(target.configuration.deviceId.parseAsName(), message)
}
}
}
val devices = deviceStates.associate { state ->
val device = CollectiveDeviceConstructor( val device = CollectiveDeviceConstructor(
context = context, context = context,
configuration = it.configuration, configuration = state.configuration,
position = it.position, position = state.position,
velocity = it.velocity, velocity = state.velocity,
peerConnection = this, peerConnection = RadioPeerConnectionModel(state.position),
) { ) {
locateVisible(it.id) locateVisible(state.id)
} }
//start movement program state.id to device
device.moveInCircles() }
it.id to device
internal fun createTrawler(position: Gmc, id: CollectiveDeviceId = "trawler"): CollectiveDeviceConstructor {
val state = CollectiveDeviceState(
id = id,
configuration = CollectiveDeviceConfiguration(id),
position = MutableDeviceState(position),
velocity = MutableDeviceState(GmcVelocity.zero)
)
val result = CollectiveDeviceConstructor(
context = context,
configuration = state.configuration,
position = state.position,
velocity = state.velocity,
peerConnection = RadioPeerConnectionModel(state.position),
) {
locateVisible(state.id)
}
// TODO move to CollectiveDeviceState
onTimer { prev, next ->
val delta = (next - prev)
state.position.value = state.position.value.moveWith(state.velocity.value, delta)
}
result.onTimer(1.seconds) { _, _ ->
val envelope = Envelope {
data {
writeString(
json.encodeToString(
DeviceMessage.serializer(),
PropertySetMessage(
property = CollectiveDevice.velocity.name,
value = gmcVelocityMetaConverter.convert(state.velocity.value),
targetDevice = null
)
)
)
}
}
result.peerConnection.send(
CollectiveDeviceConfiguration.DEFAULT_FREQUENCY,
envelope
)
}
return result
} }
val roster = deviceStates.associate { it.id to it.configuration } val roster = deviceStates.associate { it.id to it.configuration }
override suspend fun receive(address: String, contentId: String, requestMeta: Meta): Envelope {
TODO("Not yet implemented")
} }
override suspend fun send(address: String, envelope: Envelope, requestMeta: Meta) {
// devices.values.filter { it.configuration.radioFrequency == address }.forEach { device ->
// ```
// }
}
}
internal fun CoroutineScope.launchCollectiveMagixServer( internal fun CoroutineScope.launchCollectiveMagixServer(
collectiveModel: DeviceCollectiveModel, collectiveModel: DeviceCollectiveModel,
@ -119,3 +199,57 @@ internal fun CoroutineScope.launchCollectiveMagixServer(
deviceContext.request(DeviceManager).launchMagixService(deviceEndpoint, id) deviceContext.request(DeviceManager).launchMagixService(deviceEndpoint, id)
} }
} }
internal fun generateModel(
context: Context,
size: Int = 50,
reportInterval: Duration = 500.milliseconds,
additionalConfiguration: CollectiveDeviceConfiguration.() -> Unit = {},
): DeviceCollectiveModel {
val devices: List<CollectiveDeviceState> = List(size) { index ->
val id = "device[$index]"
CollectiveDeviceState(
id = id,
Gmc(
center.latitude + radius * Random.nextDouble(),
center.longitude + radius * Random.nextDouble()
)
) {
deviceId = id
description = "Virtual remote device $id"
this.reportInterval = reportInterval.inWholeMilliseconds.toInt()
additionalConfiguration()
}
}
val model = DeviceCollectiveModel(context, devices)
return model
}
fun DeviceClient.moveInCircles(scope: CoroutineScope = this): Job = scope.launch {
var bearing = Random.nextDouble(-PI, PI).radians
write(CollectiveDevice.velocity, GmcVelocity(bearing, deviceVelocity))
while (isActive) {
delay(500)
bearing += 5.degrees
write(CollectiveDevice.velocity, GmcVelocity(bearing, deviceVelocity))
}
}
internal fun CollectiveDeviceConstructor.moveTo(
targetPosition: Gmc,
speedLimit: Distance = deviceVelocity,
scope: CoroutineScope = this,
): Job = scope.launch {
do {
val curve = GeoEllipsoid.WGS84.curveBetween(position.value, targetPosition)
write(CollectiveDevice.velocity, GmcVelocity(curve.forward.bearing, speedLimit))
delay(1.seconds)
} while (curve.distance > 0.1.kilometers)
write(CollectiveDevice.velocity, GmcVelocity.zero)
}

View File

@ -1,60 +0,0 @@
package space.kscience.controls.demo.collective
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import space.kscience.controls.spec.write
import space.kscience.dataforge.context.Context
import space.kscience.kmath.geometry.degrees
import space.kscience.kmath.geometry.radians
import space.kscience.maps.coordinates.Gmc
import space.kscience.maps.coordinates.kilometers
import kotlin.math.PI
import kotlin.random.Random
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
private val deviceVelocity = 0.1.kilometers
private val center = Gmc.ofDegrees(55.925, 37.514)
private val radius = 0.01.degrees
internal fun generateModel(
context: Context,
size: Int = 50,
reportInterval: Duration = 500.milliseconds,
additionalConfiguration: CollectiveDeviceConfiguration.() -> Unit = {},
): DeviceCollectiveModel {
val devices: List<CollectiveDeviceState> = List(size) { index ->
val id = "device[$index]"
VirtualDeviceState(
id = id,
Gmc(
center.latitude + radius * Random.nextDouble(),
center.longitude + radius * Random.nextDouble()
)
) {
deviceId = id
description = "Virtual remote device $id"
this.reportInterval = reportInterval.inWholeMilliseconds.toInt()
additionalConfiguration()
}
}
val model = DeviceCollectiveModel(context, devices)
return model
}
fun CollectiveDevice.moveInCircles(): Job = launch {
var bearing = Random.nextDouble(-PI, PI).radians
write(CollectiveDevice.velocity, GmcVelocity(bearing, deviceVelocity))
while (isActive) {
delay(500)
bearing += 5.degrees
write(CollectiveDevice.velocity, GmcVelocity(bearing, deviceVelocity))
}
}

View File

@ -7,30 +7,26 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.Card import androidx.compose.material.*
import androidx.compose.material.Checkbox
import androidx.compose.material.Text
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.isSecondaryPressed
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Window import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application import androidx.compose.ui.window.application
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO import io.ktor.client.engine.cio.CIO
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.withContext
import org.jetbrains.compose.splitpane.ExperimentalSplitPaneApi import org.jetbrains.compose.splitpane.ExperimentalSplitPaneApi
import org.jetbrains.compose.splitpane.HorizontalSplitPane import org.jetbrains.compose.splitpane.HorizontalSplitPane
import org.jetbrains.compose.splitpane.rememberSplitPaneState import org.jetbrains.compose.splitpane.rememberSplitPaneState
@ -51,6 +47,7 @@ import space.kscience.maps.coordinates.Gmc
import space.kscience.maps.coordinates.meters import space.kscience.maps.coordinates.meters
import space.kscience.maps.features.* import space.kscience.maps.features.*
import java.nio.file.Path import java.nio.file.Path
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@ -59,7 +56,8 @@ fun rememberContext(name: String, contextBuilder: ContextBuilder.() -> Unit = {}
Context(name, contextBuilder) Context(name, contextBuilder)
} }
private val gmcMetaConverter = MetaConverter.serializable<Gmc>() internal val gmcMetaConverter = MetaConverter.serializable<Gmc>()
internal val gmcVelocityMetaConverter = MetaConverter.serializable<GmcVelocity>()
@Composable @Composable
fun App() { fun App() {
@ -81,7 +79,6 @@ fun App() {
val devices = remember { mutableStateMapOf<CollectiveDeviceId, DeviceClient>() } val devices = remember { mutableStateMapOf<CollectiveDeviceId, DeviceClient>() }
LaunchedEffect(collectiveModel) { LaunchedEffect(collectiveModel) {
launchCollectiveMagixServer(collectiveModel) launchCollectiveMagixServer(collectiveModel)
@ -92,7 +89,8 @@ fun App() {
collectiveModel.roster.forEach { (id, config) -> collectiveModel.roster.forEach { (id, config) ->
scope.launch { scope.launch {
devices[id] = magixClient.remoteDevice(parentContext, "listener", id, id.parseAsName()) val deviceClient = magixClient.remoteDevice(parentContext, "listener", id, id.parseAsName())
devices[id] = deviceClient
} }
} }
} }
@ -111,10 +109,29 @@ fun App() {
var showOnlyVisible by remember { mutableStateOf(false) } var showOnlyVisible by remember { mutableStateOf(false) }
var movementProgram: Job? by remember { mutableStateOf(null) }
val trawler: CollectiveDeviceConstructor = remember {
collectiveModel.createTrawler(Gmc.ofDegrees(55.925, 37.50))
}
HorizontalSplitPane( HorizontalSplitPane(
splitPaneState = rememberSplitPaneState(0.9f) splitPaneState = rememberSplitPaneState(0.9f)
) { ) {
first(400.dp) { first(400.dp) {
var clickPoint by remember { mutableStateOf<Gmc?>(null) }
CursorDropdownMenu(clickPoint != null, { clickPoint = null }) {
clickPoint?.let { point ->
TextButton({
trawler.moveTo(point)
clickPoint = null
}) {
Text("Move trawler here")
}
}
}
MapView( MapView(
mapTileProvider = remember { mapTileProvider = remember {
OpenStreetMapTileProvider( OpenStreetMapTileProvider(
@ -122,45 +139,64 @@ fun App() {
cacheDirectory = Path.of("mapCache") cacheDirectory = Path.of("mapCache")
) )
}, },
config = ViewConfig() config = ViewConfig(
onClick = { event, point ->
if (event.buttons.isSecondaryPressed) {
clickPoint = point.focus
}
}
)
) { ) {
//draw real positions
collectiveModel.deviceStates.forEach { device -> collectiveModel.deviceStates.forEach { device ->
circle(device.position.value, id = device.id + ".position").color(Color.Red) circle(device.position.value, id = device.id + ".position").color(Color.Red)
device.position.valueFlow.onEach { device.position.valueFlow.sample(50.milliseconds).onEach {
circle(device.position.value, id = device.id + ".position", size = 3.dp) val activeDevice = selectedDeviceId?.let { devices[it] }
.color(Color.Red) val color = if (selectedDeviceId == device.id) {
Color.Magenta
} else if (
showOnlyVisible &&
activeDevice != null &&
device.id in activeDevice.request(CollectiveDevice.visibleNeighbors)
) {
Color.Cyan
} else {
Color.Red
}
circle(
device.position.value,
id = device.id + ".position",
size = if (selectedDeviceId == device.id) 6.dp else 3.dp
)
.color(color)
.modifyAttribute(ZAttribute, 10f)
.modifyAttribute(AlphaAttribute, if (selectedDeviceId == device.id) 1f else 0.5f)
.modifyAttribute(AlphaAttribute, 0.5f) // does not work right now .modifyAttribute(AlphaAttribute, 0.5f) // does not work right now
}.launchIn(scope) }.launchIn(scope)
} }
//draw received data
scope.launch { scope.launch {
client.await().subscribe(DeviceManager.magixFormat).onEach { (magixMessage, deviceMessage) -> client.await().subscribe(DeviceManager.magixFormat).onEach { (magixMessage, deviceMessage) ->
if (deviceMessage is PropertyChangedMessage && deviceMessage.property == "position") { if (deviceMessage is PropertyChangedMessage && deviceMessage.property == "position") {
val id = magixMessage.sourceEndpoint val id = magixMessage.sourceEndpoint
val position = gmcMetaConverter.read(deviceMessage.value) val position = gmcMetaConverter.read(deviceMessage.value)
val activeDevice = selectedDeviceId?.let { devices[it] }
if (
activeDevice == null ||
id == selectedDeviceId ||
!showOnlyVisible ||
id in activeDevice.request(CollectiveDevice.visibleNeighbors)
) {
rectangle( rectangle(
position, position,
id = id, id = id,
size = if (selectedDeviceId == id) DpSize(10.dp, 10.dp) else DpSize(5.dp, 5.dp) ).color(Color.Blue).onClick { selectedDeviceId = id }
).color(if (selectedDeviceId == id) Color.Magenta else Color.Blue)
.modifyAttribute(AlphaAttribute, if (selectedDeviceId == id) 1f else 0.5f)
.onClick { selectedDeviceId = id }
} else {
removeFeature(id)
}
} }
}.launchIn(scope) }.launchIn(scope)
} }
// draw trawler
trawler.position.valueFlow.onEach {
circle(it, id = "trawler").color(Color.Black)
}.launchIn(scope)
} }
} }
second(200.dp) { second(200.dp) {
@ -168,6 +204,34 @@ fun App() {
Column( Column(
modifier = Modifier.verticalScroll(rememberScrollState()) modifier = Modifier.verticalScroll(rememberScrollState())
) { ) {
Button(
onClick = {
if (movementProgram == null) {
//start movement program
movementProgram = parentContext.launch {
devices.values.forEach { device ->
device.moveInCircles(this)
}
}
} else {
movementProgram?.cancel()
parentContext.launch {
devices.values.forEach { device ->
device.write(CollectiveDevice.velocity, GmcVelocity.zero)
}
}
movementProgram = null
}
},
modifier = Modifier.fillMaxWidth()
) {
if (movementProgram == null) {
Text("Move")
} else {
Text("Stop")
}
}
collectiveModel.roster.forEach { (id, _) -> collectiveModel.roster.forEach { (id, _) ->
Card( Card(
elevation = 16.dp, elevation = 16.dp,