Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,20 @@ package io.writeopia.forcegraph

import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.BoxWithConstraintsScope
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.rememberTextMeasurer
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import androidx.compose.ui.unit.sp
import kotlin.math.sqrt

class Node(
Expand All @@ -27,12 +26,14 @@ class Node(
initialVx: Float = 0f,
initialVy: Float = 0f,
val isFolder: Boolean,
selected: Boolean
) {
var x by mutableStateOf(initialX)
var y by mutableStateOf(initialY)
var vx by mutableStateOf(initialVx)
var vy by mutableStateOf(initialVy)
var isDragged by mutableStateOf(false)
var showName by mutableStateOf(selected || isFolder)
}

data class Link(
Expand All @@ -41,22 +42,36 @@ data class Link(
)

@Composable
fun BoxWithConstraintsScope.ForceDirectedGraph(nodes: List<Node>, links: List<Link>) {
// Physics animation loop
LaunchedEffect(nodes, links) {
while (isActive) {
tick(nodes, links, dt = 0.016f) // ~60fps
delay(16) // Changed from 200 to 16 for smoother animation
}
}

fun ForceDirectedGraph(
nodes: List<Node>,
links: List<Link>,
onNodeSelected: (String) -> Unit
) {
val textMeasurer = rememberTextMeasurer()
val textStyle =
MaterialTheme.typography.bodySmall.copy(color = MaterialTheme.colorScheme.onBackground)
MaterialTheme.typography.labelSmall.copy(
color = Color.White,
background = Color.Blue,
fontSize = 10.sp
)

Canvas(
modifier = Modifier
.fillMaxSize()
.pointerInput(nodes) {
detectTapGestures(
onTap = { offset ->
nodes.find { node ->
val dx = node.x - offset.x
val dy = node.y - offset.y

sqrt(dx * dx + dy * dy) < 20F
}?.also {
onNodeSelected(it.id)
}
}
)
}
.pointerInput(nodes) {
var draggingNode: Node? = null
detectDragGestures(
Expand All @@ -78,7 +93,7 @@ fun BoxWithConstraintsScope.ForceDirectedGraph(nodes: List<Node>, links: List<Li
draggingNode?.isDragged = false
draggingNode = null
},
onDrag = { change, dragAmount ->
onDrag = { _, dragAmount ->
draggingNode?.let { node ->
node.x += dragAmount.x
node.y += dragAmount.y
Expand Down Expand Up @@ -114,85 +129,22 @@ fun BoxWithConstraintsScope.ForceDirectedGraph(nodes: List<Node>, links: List<Li
center = Offset(node.x, node.y),
radius = if (node.isFolder) 12f else 6f
)

// if (node.isFolder) {
// drawText(
// textMeasurer,
// text = node.label,
// topLeft = Offset(node.x, node.y),
// style = textStyle
// )
// }
}
}
}

fun BoxWithConstraintsScope.tick(nodes: List<Node>, links: List<Link>, dt: Float) {
applyLinkForce(links)
applyChargeForce(nodes)
applyCenteringForce(nodes)
updatePositions(nodes, dt)
}

fun applyLinkForce(links: List<Link>) {
val linkDistance = 40F
val strength = 0.15f

for (link in links) {
val dx = link.target.x - link.source.x
val dy = link.target.y - link.source.y
val distance = sqrt(dx * dx + dy * dy).coerceAtLeast(0.01f)
val force = (distance - linkDistance) * strength
val fx = force * dx / distance
val fy = force * dy / distance

link.source.vx += fx
link.source.vy += fy
link.target.vx -= fx
link.target.vy -= fy
}
}

fun applyChargeForce(nodes: List<Node>) {
val chargeStrength = 3000f // Reduced from 5000f

for (i in nodes.indices) {
for (j in i + 1 until nodes.size) {
val nodeA = nodes[i]
val nodeB = nodes[j]
val dx = nodeB.x - nodeA.x
val dy = nodeB.y - nodeA.y
val distanceSq = (dx * dx + dy * dy).coerceAtLeast(0.01f)
val force = chargeStrength / distanceSq
val fx = force * dx / sqrt(distanceSq)
val fy = force * dy / sqrt(distanceSq)

nodeA.vx -= fx
nodeA.vy -= fy
nodeB.vx += fx
nodeB.vy += fy
nodes.forEach { node ->
if (node.showName) {
val x = node.x
val y = node.y

if (x >= 0 && y >= 0) {
drawText(
textMeasurer,
text = " ${node.label} ",
topLeft = Offset(node.x - 30, node.y + 20),
style = textStyle
)
}
}
}
}
}

fun BoxWithConstraintsScope.applyCenteringForce(nodes: List<Node>) {
val centerX = this.maxWidth.value / 2
val centerY = this.maxHeight.value / 2
val strength = 0.001f // Increased from 0.005f

for (node in nodes) {
node.vx += (centerX - node.x) * strength
node.vy += (centerY - node.y) * strength
}
}

fun updatePositions(nodes: List<Node>, dt: Float) {
val damping = 0.95f // Increased from 0.9f for more stability

for (node in nodes) {
node.vx *= damping
node.vy *= damping
node.x += node.vx * dt // Changed from /dt to *dt
node.y += node.vy * dt // Changed from /dt to *dt
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ data class ItemData(
override val id: String,
val title: String,
override val parentId: String,
val isFolder: Boolean
val isFolder: Boolean,
val selected: Boolean = false
) : Traversable
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@ internal fun Map<String, List<ItemData>>.toGraph(maxWidth: Float, maxHeight: Flo
Node(
id = item.id,
label = item.title,
initialX = (200..700).random().toFloat() / 1000 * maxWidth,
initialY = (300..600).random().toFloat() / 1000 * maxHeight,
isFolder = item.isFolder
initialX = (200..700).random().toFloat() / 1000 * maxWidth * 2,
initialY = (300..600).random().toFloat() / 1000 * maxHeight * 2,
isFolder = item.isFolder,
selected = item.selected
)
}
}
Expand All @@ -27,8 +28,7 @@ internal fun Map<String, List<ItemData>>.toGraph(maxWidth: Float, maxHeight: Flo
nodes.map { node ->
Link(sourceNode, node)
}
}.values
.flatten()
}.values.flatten()

return Graph(nodes, links)
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,13 @@ package io.writeopia.documents.graph.navigation
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import io.writeopia.common.utils.Destinations
import io.writeopia.documents.graph.di.DocumentsGraphInjection
import io.writeopia.documents.graph.extensions.toGraph
import io.writeopia.forcegraph.ForceDirectedGraph

fun NavController.navigateToForceGraph() {
Expand All @@ -27,19 +25,15 @@ fun NavGraphBuilder.documentsGraphNavigation(
route = graphForce(),
) {
val viewModel = documentsGraphInjection.injectViewModel()
val state by viewModel.graphState.collectAsState()

BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val graph by derivedStateOf {
state.toGraph(
this.maxWidth.value,
this.maxHeight.value
)
}
viewModel.initSize(maxWidth.value, maxHeight.value)
val state by viewModel.graphSelectedState.collectAsState()

ForceDirectedGraph(
nodes = graph.nodes,
links = graph.links
nodes = state.nodes,
links = state.links,
onNodeSelected = viewModel::selectNode
)
}
}
Expand Down
Loading