The graph mode in the Find tab: a Kuiver-based force-directed graph visualization with BFS traversal, depth controls, pin management, and node navigation. The screen composable and its ViewModel are documented together.
Introduction
GraphScreen renders a force-directed graph of the org-roam knowledge base using KuiverGraphView (Kuiver library). It shows nodes and edges around one or more focus nodes, with controls for traversal depth (1-3 or unlimited) and a "recenter" button that snaps to today's daily note.
GraphViewModel implements BFS traversal from a center node plus all pinned nodes, respecting a depth limit and a hard cap of 250 nodes (MAX_GRAPH_NODES) to prevent unbounded graph expansion. Pin operations write to org files via OrgDocumentEditor (using a factory for file system injection), then update the database and rebuild the graph. The ViewModel exposes pinnedNodeIds as a StateFlow consumed by the cross-mode wiring in FindScreen.
Design Decisions
**Kuiver over custom Canvas rendering
Kuiver is a Compose-native force-directed graph library that handles layout, physics simulation, and touch interaction (pan, zoom, node drag). Building this from scratch with Canvas would require implementing Verlet integration, gesture detection, and viewport transforms — easily hundreds of lines. Kuiver provides these out of the box while allowing custom node/edge composables.
**BFS with depth limit and node cap
The graph is built using BFS from the focus node + all pinned nodes. This is a trade-off: a full graph of a 50k-heading org-roam database would be unusable (250 cap) and slow (depth limit). The default depth of 2 gives a useful "local neighborhood" view — one hop out from the center, showing directly connected nodes. The 250-node cap prevents runaway expansion when a node has hundreds of backlinks (common for hub nodes like project indexes).
**Depth 0 = unlimited (all connected)
Setting depth to 0 disables the depth limit entirely. The BFS still respects the 250-node cap, but all reachable nodes within that cap are included. A graph-wide updateCenterMarker() optimization avoids full rebuild when depth is 0 — only the center flag is toggled, a cheap map operation that doesn't trigger layout recalculation.
**File-first pin operations
pinNode() and unpinNode() write the PINNED property to the org file via OrgDocumentEditor first, then update the database and in-memory state. This follows the architecture-wide principle that org files are canonical — the SQLite database is a query accelerator, not the source of truth. Pin persistence survives database rebuilds because the property lives in plaintext.
**Factory pattern for file system injection
GraphViewModel receives a documentEditorFactory: (AndroidFileSystem) -> OrgDocumentEditor rather than constructing the editor itself. This follows the same Koin factory pattern documented in app/bootstrap.org: AndroidFileSystem needs a runtime Context and Uri, so the factory is resolved in the Koin module and passed to the ViewModel. Tests can inject a mock file system through the factory.
Graph Data Models
GraphNode, GraphEdge, and GraphData define the graph structure consumed by KuiverGraphView. GraphNode carries display properties (title, level, isCenter, isPinned, depth) used by the graph renderer for styling (center nodes get a distinct color, pinned nodes get a star indicator).
MAX_GRAPH_NODES (250) and PINNED_PROPERTY_KEY ("PINNED") are top-level constants shared with SearchViewModel for pinned-node cross-referencing.
/**
* Property key for pinned nodes.
*/
const val PINNED_PROPERTY_KEY = "PINNED"
/**
* Maximum number of nodes to render in the graph for performance.
* Beyond this limit, the graph will stop expanding and show a warning.
*/
const val MAX_GRAPH_NODES = 250
/**
* A node in the graph visualization.
*/
data class GraphNode(
val id: String,
val title: String,
val level: Int,
val isCenter: Boolean,
val isPinned: Boolean,
val depth: Int
)
/**
* An edge connecting two nodes in the graph.
*/
data class GraphEdge(
val from: String,
val to: String
)
/**
* Complete graph data for visualization.
*/
data class GraphData(
val nodes: List<GraphNode>,
val edges: List<GraphEdge>
) {
companion object {
val EMPTY = GraphData(emptyList(), emptyList())
}
}GraphViewModel
The ViewModel manages: focus node state (_focusNodeId, _focusNodeTitle), depth (0-3), graph data (_graphData), loading state, limit-reached flag, and pinned node IDs cache.
Initialization loads pinned nodes from the repository and sets focus to today's daily note (searched by date string in title). The BFS implementation (buildLocalGraph) uses a queue with visited-nodes map tracking depth per node, iterating outgoing links (repository.getLinksFrom) and incoming links (repository.getLinksTo) simultaneously.
package computer.whatthefuck.arcology.app.viewmodel
import android.content.Context
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import computer.whatthefuck.arcology.app.data.AppPreferences
import computer.whatthefuck.arcology.database.RoamRepository
import computer.whatthefuck.arcology.domain.NodeProperty
import computer.whatthefuck.arcology.editor.EditResult
import computer.whatthefuck.arcology.editor.OrgDocumentEditor
import computer.whatthefuck.arcology.indexer.AndroidFileSystem
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import kotlin.time.ExperimentalTime
private const val TAG = "GraphViewModel"
/**
* Property key for pinned nodes.
*/
const val PINNED_PROPERTY_KEY = "PINNED"
/**
* Maximum number of nodes to render in the graph for performance.
* Beyond this limit, the graph will stop expanding and show a warning.
*/
const val MAX_GRAPH_NODES = 250
/**
* A node in the graph visualization.
*/
data class GraphNode(
val id: String,
val title: String,
val level: Int,
val isCenter: Boolean,
val isPinned: Boolean,
val depth: Int
)
/**
* An edge connecting two nodes in the graph.
*/
data class GraphEdge(
val from: String,
val to: String
)
/**
* Complete graph data for visualization.
*/
data class GraphData(
val nodes: List<GraphNode>,
val edges: List<GraphEdge>
) {
companion object {
val EMPTY = GraphData(emptyList(), emptyList())
}
}
/**
* ViewModel for the graph visualization screen.
* Implements BFS traversal to build a local graph around focus nodes.
*
* The graph includes:
* - Today's daily note (or specified focus node)
* - All pinned nodes (nodes with PINNED property)
* - Nodes connected within the specified depth
*/
class GraphViewModel(
private val repository: RoamRepository,
private val appPreferences: AppPreferences,
private val documentEditorFactory: (AndroidFileSystem) -> OrgDocumentEditor
) : ViewModel() {
private val _focusNodeId = MutableStateFlow<String?>(null)
val focusNodeId: StateFlow<String?> = _focusNodeId.asStateFlow()
// Depth: 1-3 for limited depth, 0 for unlimited (all connected nodes)
private val _depth = MutableStateFlow(2)
val depth: StateFlow<Int> = _depth.asStateFlow()
private val _graphData = MutableStateFlow(GraphData.EMPTY)
val graphData: StateFlow<GraphData> = _graphData.asStateFlow()
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
private val _focusNodeTitle = MutableStateFlow<String?>(null)
val focusNodeTitle: StateFlow<String?> = _focusNodeTitle.asStateFlow()
// True if the graph was truncated due to node limit
private val _isLimitReached = MutableStateFlow(false)
val isLimitReached: StateFlow<Boolean> = _isLimitReached.asStateFlow()
// Cache of pinned node IDs
private val _pinnedNodeIds = MutableStateFlow<Set<String>>(emptySet())
val pinnedNodeIds: StateFlow<Set<String>> = _pinnedNodeIds.asStateFlow()
init {
// Load pinned nodes and set focus to daily note
viewModelScope.launch {
loadPinnedNodes()
setFocusToTodaysDailyNote()
}
}
/**
* Load pinned node IDs from the repository.
*/
private suspend fun loadPinnedNodes() {
val pinned = repository.getNodesByPropertyKey(PINNED_PROPERTY_KEY)
.filter { it.second?.lowercase() == "t" || it.second?.lowercase() == "true" }
.map { it.first }
.toSet()
_pinnedNodeIds.value = pinned
}
/**
* Pin a node (add PINNED property to the org file, then update database).
* The org file is the source of truth; database is updated after successful write.
*/
fun pinNode(context: Context, nodeId: String) {
val treeUri = appPreferences.getSelectedDirectoryUri()
if (treeUri == null) {
Log.w(TAG, "No directory selected, cannot pin node")
return
}
viewModelScope.launch {
val fs = AndroidFileSystem(context, treeUri)
val editor = documentEditorFactory(fs)
val result = editor.setProperty(nodeId, PINNED_PROPERTY_KEY, "t")
when (result) {
is EditResult.Success -> {
repository.insertHeadingProperty(
NodeProperty(nodeId, PINNED_PROPERTY_KEY, "t")
)
_pinnedNodeIds.value = _pinnedNodeIds.value + nodeId
rebuildGraph()
}
is EditResult.Error -> {
Log.e(TAG, "Failed to pin node $nodeId: ${result.message}")
}
}
}
}
/**
* Unpin a node (remove PINNED property from the org file, then update database).
* The org file is the source of truth; database is updated after successful write.
*/
fun unpinNode(context: Context, nodeId: String) {
val treeUri = appPreferences.getSelectedDirectoryUri()
if (treeUri == null) {
Log.w(TAG, "No directory selected, cannot unpin node")
return
}
viewModelScope.launch {
val fs = AndroidFileSystem(context, treeUri)
val editor = documentEditorFactory(fs)
val result = editor.removeProperty(nodeId, PINNED_PROPERTY_KEY)
when (result) {
is EditResult.Success -> {
repository.deleteHeadingProperty(nodeId, PINNED_PROPERTY_KEY)
_pinnedNodeIds.value = _pinnedNodeIds.value - nodeId
rebuildGraph()
}
is EditResult.Error -> {
Log.e(TAG, "Failed to unpin node $nodeId: ${result.message}")
}
}
}
}
/**
* Check if a node is pinned.
*/
fun isNodePinned(nodeId: String): Boolean {
return nodeId in _pinnedNodeIds.value
}
/**
* Set the focus node by ID.
* At depth 0 (all connected nodes), just updates the center marker without
* rebuilding - the graph structure is the same, only styling changes.
* At limited depth, rebuilds since different nodes may be in range.
*/
fun setFocusNode(nodeId: String?) {
if (nodeId == _focusNodeId.value) return
val oldFocusId = _focusNodeId.value
_focusNodeId.value = nodeId
// Update focus node title
viewModelScope.launch {
val node = nodeId?.let { repository.getNodeById(it) }
_focusNodeTitle.value = node?.title
}
if (_depth.value == 0) {
// Depth 0 = all connected nodes, structure doesn't change
// Just update the isCenter flag without relayout
updateCenterMarker(oldFocusId, nodeId)
} else {
// Limited depth - different center means different visible nodes
rebuildGraph()
}
}
/**
* Update which node is marked as center without rebuilding the graph.
* Used when graph structure doesn't change (depth=0 mode).
*/
private fun updateCenterMarker(oldCenterId: String?, newCenterId: String?) {
val currentData = _graphData.value
val updatedNodes = currentData.nodes.map { node ->
when (node.id) {
oldCenterId -> node.copy(isCenter = false)
newCenterId -> node.copy(isCenter = true)
else -> node
}
}
_graphData.value = currentData.copy(nodes = updatedNodes)
}
/**
* Set the traversal depth and rebuild the graph.
* 0 = unlimited (all connected nodes)
* 1-3 = limited depth
*/
fun setDepth(newDepth: Int) {
val clampedDepth = newDepth.coerceIn(0, 3)
if (clampedDepth == _depth.value) return
_depth.value = clampedDepth
rebuildGraph()
}
/**
* Reset focus to today's daily note.
*/
@OptIn(ExperimentalTime::class)
fun setFocusToTodaysDailyNote() {
viewModelScope.launch {
val today = Clock.System.now()
.toLocalDateTime(TimeZone.currentSystemDefault())
.date
val dateStr = "%04d-%02d-%02d".format(today.year, today.monthNumber, today.dayOfMonth)
// Search for a node with today's date in the title
val nodes = repository.searchNodesByTitle(dateStr)
val dailyNote = nodes.firstOrNull { it.level == 0 }
?: nodes.firstOrNull()
if (dailyNote != null) {
_focusNodeId.value = dailyNote.id
rebuildGraph()
} else if (_pinnedNodeIds.value.isNotEmpty()) {
// No daily note found, but we have pinned nodes - still build graph
rebuildGraph()
}
}
}
/**
* Rebuild the graph using BFS from focus nodes.
*/
private fun rebuildGraph() {
viewModelScope.launch {
_isLoading.value = true
try {
val graphData = buildLocalGraph(_focusNodeId.value, _pinnedNodeIds.value, _depth.value)
_graphData.value = graphData
} finally {
_isLoading.value = false
}
}
}
/**
* Build a local graph using BFS traversal.
* Starts from the center node and all pinned nodes.
* Stops when MAX_GRAPH_NODES is reached to prevent performance issues.
*
* @param centerNodeId The primary focus node (or null)
* @param pinnedIds Set of pinned node IDs to include as starting points
* @param maxDepth Maximum traversal depth (0 = unlimited)
*/
private suspend fun buildLocalGraph(
centerNodeId: String?,
pinnedIds: Set<String>,
maxDepth: Int
): GraphData {
val visitedNodes = mutableMapOf<String, Int>() // nodeId -> depth
val edges = mutableSetOf<GraphEdge>()
val queue = ArrayDeque<Pair<String, Int>>() // nodeId, depth
var limitReached = false
// Start BFS from center node
if (centerNodeId != null) {
queue.add(centerNodeId to 0)
visitedNodes[centerNodeId] = 0
}
// Add pinned nodes as starting points (depth 0)
for (pinnedId in pinnedIds) {
if (pinnedId !in visitedNodes) {
queue.add(pinnedId to 0)
visitedNodes[pinnedId] = 0
}
}
// BFS traversal - stop if we hit the node limit
while (queue.isNotEmpty() && visitedNodes.size < MAX_GRAPH_NODES) {
val (currentNodeId, currentDepth) = queue.removeFirst()
// Check depth limit (0 means unlimited)
val shouldExplore = maxDepth == 0 || currentDepth < maxDepth
if (shouldExplore) {
// Get outgoing links
val outgoingLinks = repository.getLinksFrom(currentNodeId)
for (link in outgoingLinks) {
val targetId = link.toNode ?: continue
// Add edge (even if we can't visit the node)
edges.add(GraphEdge(currentNodeId, targetId))
// Check node limit before adding new nodes
if (visitedNodes.size >= MAX_GRAPH_NODES) {
limitReached = true
break
}
// Visit target if not visited or found at closer depth
val existingDepth = visitedNodes[targetId]
if (existingDepth == null || existingDepth > currentDepth + 1) {
visitedNodes[targetId] = currentDepth + 1
queue.add(targetId to currentDepth + 1)
}
}
if (limitReached) break
// Get incoming links (backlinks)
val incomingLinks = repository.getLinksTo(currentNodeId)
for (link in incomingLinks) {
val sourceId = link.fromNode
// Add edge (even if we can't visit the node)
edges.add(GraphEdge(sourceId, currentNodeId))
// Check node limit before adding new nodes
if (visitedNodes.size >= MAX_GRAPH_NODES) {
limitReached = true
break
}
// Visit source if not visited or found at closer depth
val existingDepth = visitedNodes[sourceId]
if (existingDepth == null || existingDepth > currentDepth + 1) {
visitedNodes[sourceId] = currentDepth + 1
queue.add(sourceId to currentDepth + 1)
}
}
}
}
// Check if we stopped due to limit
if (queue.isNotEmpty()) {
limitReached = true
}
_isLimitReached.value = limitReached
// Build graph nodes from visited nodes
val graphNodes = mutableListOf<GraphNode>()
for ((nodeId, nodeDepth) in visitedNodes) {
val orgNode = repository.getNodeById(nodeId) ?: continue
graphNodes.add(
GraphNode(
id = nodeId,
title = orgNode.title ?: "Untitled",
level = orgNode.level,
isCenter = nodeId == centerNodeId,
isPinned = nodeId in pinnedIds,
depth = nodeDepth
)
)
// Update focus node title if this is the center
if (nodeId == centerNodeId) {
_focusNodeTitle.value = orgNode.title
}
}
// Filter edges to only include edges between visited nodes
val validNodeIds = visitedNodes.keys
val validEdges = edges.filter { it.from in validNodeIds && it.to in validNodeIds }
return GraphData(graphNodes, validEdges)
}
}GraphScreen Composable
GraphScreen collects state from GraphViewModel and renders three states: loading spinner, empty state (no graph data), and the graph view with KuiverGraphView. A GraphControls bar at the top provides depth selector and recenter button. A GraphInfoBar at the bottom-left shows node/edge counts and the focus node title, with error-container styling when the node limit is reached.
package computer.whatthefuck.arcology.app.ui.screens
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import computer.whatthefuck.arcology.app.ui.components.graph.GraphControls
import computer.whatthefuck.arcology.app.ui.components.graph.KuiverGraphView
import computer.whatthefuck.arcology.app.viewmodel.GraphViewModel
import computer.whatthefuck.arcology.app.viewmodel.MAX_GRAPH_NODES
import org.koin.androidx.compose.koinViewModelgraphscreen-composable
/**
* Main graph visualization screen.
* Shows a local graph around a focus node with controls for depth and recentering.
*
* @param onNodeSelected Called when a node is long-pressed to navigate to editor
*/
@Composable
fun GraphScreen(
viewModel: GraphViewModel = koinViewModel(),
onNodeSelected: (nodeId: String) -> Unit
) {
val graphData by viewModel.graphData.collectAsState()
val depth by viewModel.depth.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
val focusNodeTitle by viewModel.focusNodeTitle.collectAsState()
val isLimitReached by viewModel.isLimitReached.collectAsState()
Column(modifier = Modifier.fillMaxSize()) {
// Controls bar
GraphControls(
depth = depth,
onDepthChange = { viewModel.setDepth(it) },
onRecenter = { viewModel.setFocusToTodaysDailyNote() },
modifier = Modifier.fillMaxWidth()
)
// Graph content
Box(modifier = Modifier.fillMaxSize()) {
when {
isLoading -> {
// Loading state
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center)
)
}
graphData.nodes.isEmpty() -> {
// Empty state
Column(
modifier = Modifier.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "No graph data",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "Try indexing your files first",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
)
}
}
else -> {
// Graph view
KuiverGraphView(
graphData = graphData,
onNodeClick = { nodeId ->
// Re-center graph on clicked node
viewModel.setFocusNode(nodeId)
},
onNodeLongClick = { nodeId ->
// Navigate to editor
onNodeSelected(nodeId)
},
modifier = Modifier.fillMaxSize()
)
// Info bar showing node/edge count and focus node
GraphInfoBar(
nodeCount = graphData.nodes.size,
edgeCount = graphData.edges.size,
focusNodeTitle = focusNodeTitle,
isLimitReached = isLimitReached,
modifier = Modifier
.align(Alignment.BottomStart)
.padding(16.dp)
)
}
}
}
}
}graphscreen-infobar
/**
* Info bar showing graph statistics.
*/
@Composable
private fun GraphInfoBar(
nodeCount: Int,
edgeCount: Int,
focusNodeTitle: String?,
isLimitReached: Boolean,
modifier: Modifier = Modifier
) {
Surface(
modifier = modifier,
shape = RoundedCornerShape(8.dp),
color = if (isLimitReached) {
MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.9f)
} else {
MaterialTheme.colorScheme.surface.copy(alpha = 0.9f)
},
tonalElevation = 2.dp
) {
Column(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)
) {
focusNodeTitle?.let { title ->
Text(
text = title,
style = MaterialTheme.typography.labelLarge,
color = if (isLimitReached) {
MaterialTheme.colorScheme.onErrorContainer
} else {
MaterialTheme.colorScheme.onSurface
},
maxLines = 1
)
}
Text(
text = if (isLimitReached) {
"$nodeCount nodes (limit $MAX_GRAPH_NODES), $edgeCount edges"
} else {
"$nodeCount nodes, $edgeCount edges"
},
style = MaterialTheme.typography.labelSmall,
color = if (isLimitReached) {
MaterialTheme.colorScheme.onErrorContainer
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
)
if (isLimitReached) {
Text(
text = "Reduce depth to see full graph",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onErrorContainer.copy(alpha = 0.8f)
)
}
}
}
}Graph UI Components
Three composable components in ui.components.graph that compose the graph visualization:
KuiverGraphView— wraps the Kuiver force-directed graph library. Builds aKuivermodel fromGraphData, configures aForceDirectedlayout, and renders nodes/edges via Kuiver'sKuiverViewercomposable. Node content is delegated toGraphNodeComposable.GraphNodeComposable— renders an individual node as a colored circle with a 2-letter abbreviation. Sizing and coloring are depth-aware: center nodes get primary color at 48dp, pinned nodes get tertiary at 40dp with a border, depth-1 nodes get secondary, deeper nodes get surfaceVariant.GraphControls— aSegmentedButtondepth selector (1, 2, 3, All) and a recenter button, rendered in a semi-transparentSurfaceat the top of the screen.
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.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import com.dk.kuiver.rememberKuiverViewerState
import com.dk.kuiver.renderer.KuiverViewer
import com.dk.kuiver.renderer.KuiverViewerConfig
import com.dk.kuiver.model.buildKuiver
import com.dk.kuiver.model.edges
import com.dk.kuiver.model.nodes
import com.dk.kuiver.model.layout.LayoutConfig
import com.dk.kuiver.ui.StyledEdgeContent
import computer.whatthefuck.arcology.app.viewmodel.GraphData
import computer.whatthefuck.arcology.app.viewmodel.GraphNode
@Composable
fun KuiverGraphView(
graphData: GraphData,
onNodeClick: (nodeId: String) -> Unit,
onNodeLongClick: (nodeId: String) -> Unit,
modifier: Modifier = Modifier
) {
val edgeColor = MaterialTheme.colorScheme.outline
val kuiver = remember(graphData) {
if (graphData.nodes.isEmpty()) {
buildKuiver { }
} else {
buildKuiver {
nodes(graphData.nodes.map { it.id })
edges(*graphData.edges.map { it.from to it.to }.toTypedArray())
}
}
}
val layoutConfig = remember {
LayoutConfig.ForceDirected(
iterations = 80,
repulsionStrength = 400f,
attractionStrength = 0.03f,
damping = 0.9f
)
}
val viewerState = rememberKuiverViewerState(
initialKuiver = kuiver,
layoutConfig = layoutConfig
)
LaunchedEffect(kuiver) {
viewerState.updateKuiver(kuiver)
}
val nodeMap = remember(graphData) {
graphData.nodes.associateBy { it.id }
}
if (graphData.nodes.isNotEmpty()) {
KuiverViewer(
state = viewerState,
config = KuiverViewerConfig(
showDebugBounds = false,
fitToContent = true,
contentPadding = 0.8f,
minScale = 0.1f,
maxScale = 3f,
panVelocity = 1.0f
),
nodeContent = { kuiverNode ->
val graphNode = nodeMap[kuiverNode.id]
if (graphNode != null) {
GraphNodeComposable(
node = graphNode,
onClick = { onNodeClick(graphNode.id) },
onLongClick = { onNodeLongClick(graphNode.id) }
)
}
},
edgeContent = { edge, from, to ->
StyledEdgeContent(
edge = edge,
from = from,
to = to,
baseColor = edgeColor
)
},
modifier = modifier.fillMaxSize()
)
}
}import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import computer.whatthefuck.arcology.app.viewmodel.GraphNode
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun GraphNodeComposable(
node: GraphNode,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier
) {
val size = when {
node.isCenter -> 48.dp
node.isPinned && !node.isCenter -> 40.dp
node.depth == 1 -> 36.dp
else -> 28.dp
}
val backgroundColor = when {
node.isCenter -> MaterialTheme.colorScheme.primary
node.isPinned -> MaterialTheme.colorScheme.tertiaryContainer
node.depth == 1 -> MaterialTheme.colorScheme.secondary
else -> MaterialTheme.colorScheme.surfaceVariant
}
val textColor = when {
node.isCenter -> MaterialTheme.colorScheme.onPrimary
node.isPinned -> MaterialTheme.colorScheme.onTertiaryContainer
node.depth == 1 -> MaterialTheme.colorScheme.onSecondary
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
val borderColor = if (node.isPinned) {
MaterialTheme.colorScheme.tertiary
} else {
null
}
val abbreviation = getAbbreviation(node.title)
Box(
modifier = modifier
.size(size)
.clip(CircleShape)
.background(backgroundColor)
.then(
if (borderColor != null) {
Modifier.border(2.dp, borderColor, CircleShape)
} else {
Modifier
}
)
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick
),
contentAlignment = Alignment.Center
) {
Text(
text = abbreviation,
color = textColor,
style = when {
node.isCenter -> MaterialTheme.typography.labelLarge
node.isPinned -> MaterialTheme.typography.labelMedium
node.depth == 1 -> MaterialTheme.typography.labelMedium
else -> MaterialTheme.typography.labelSmall
},
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Clip
)
}
}
private fun getAbbreviation(title: String): String {
val words = title.trim().split(Regex("\\s+"))
.filter { it.isNotEmpty() }
return when {
words.isEmpty() -> "?"
words.size == 1 -> words[0].take(2).uppercase()
else -> words.take(2)
.mapNotNull { it.firstOrNull()?.uppercaseChar() }
.joinToString("")
}
}import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CenterFocusStrong
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
private val DEPTH_OPTIONS = listOf(1, 2, 3, 0)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun GraphControls(
depth: Int,
onDepthChange: (Int) -> Unit,
onRecenter: () -> Unit,
modifier: Modifier = Modifier
) {
Surface(
modifier = modifier,
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.95f),
tonalElevation = 2.dp
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "Depth:",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
SingleChoiceSegmentedButtonRow {
DEPTH_OPTIONS.forEachIndexed { index, depthValue ->
SegmentedButton(
selected = depth == depthValue,
onClick = { onDepthChange(depthValue) },
shape = SegmentedButtonDefaults.itemShape(
index = index,
count = DEPTH_OPTIONS.size
)
) {
Text(if (depthValue == 0) "All" else depthValue.toString())
}
}
}
}
IconButton(onClick = onRecenter) {
Icon(
imageVector = Icons.Default.CenterFocusStrong,
contentDescription = "Recenter to daily note",
modifier = Modifier.size(24.dp)
)
}
}
}
}package computer.whatthefuck.arcology.app.ui.components.graph
<<graph-kuiverview>>package computer.whatthefuck.arcology.app.ui.components.graph
<<graph-nodecomposable>>package computer.whatthefuck.arcology.app.ui.components.graph
<<graph-controls>>GraphViewModel Assembly
<<graph-viewmodel>>GraphScreen Assembly
<<graphscreen-preamble>>
<<graphscreen-composable>>
<<graphscreen-infobar>>Future Work
Graph View Performance for Large Graphs
The current implementation uses Kuiver with Compose nodes, limited to 250 nodes for performance. To render full 50k+ node graphs, consider:
WebView + D3.js/Cytoscape.js (battle-tested for 10k+ nodes)
Canvas-based rendering with custom force layout
Level-of-detail clustering (show clusters when zoomed out, expand on zoom)
GPU-accelerated layout computation
Related Modules
App Bootstrap — Koin DI provides
GraphViewModelfactory with editor factory patternApp Data Layer —
AppPreferencesfor directory URI,OrgDocumentEditorfor pin persistenceSearch Screen — cross-mode wiring via context menu (Center on Graph, Pin/Unpin)
Models & Repository —
RoamRepositoryfor link/node queriesDocument Editor —
OrgDocumentEditorfor property writes