Arcology Engine

Graph Screen — Node Graph Visualization

Contents

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.

kotlin#+name: graph-models
/**
 * 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.

kotlin#+name: graph-viewmodel
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.

kotlin#+name: graphscreen-preamble
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.koinViewModel

graphscreen-composable

kotlin#+name: graphscreen-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

kotlin#+name: 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 a Kuiver model from GraphData, configures a ForceDirected layout, and renders nodes/edges via Kuiver's KuiverViewer composable. Node content is delegated to GraphNodeComposable.

  • 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 — a SegmentedButton depth selector (1, 2, 3, All) and a recenter button, rendered in a semi-transparent Surface at the top of the screen.

kotlin#+name: graph-kuiverview
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()
        )
    }
}
kotlin#+name: graph-nodecomposable
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("")
    }
}
kotlin#+name: graph-controls
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)
                )
            }
        }
    }
}
kotlin:tangle ../app/src/main/kotlin/computer/whatthefuck/arcology/app/ui/components/graph/KuiverGraphView.kt:noweb yes
package computer.whatthefuck.arcology.app.ui.components.graph

<<graph-kuiverview>>
kotlin:tangle ../app/src/main/kotlin/computer/whatthefuck/arcology/app/ui/components/graph/GraphNodeComposable.kt:noweb yes
package computer.whatthefuck.arcology.app.ui.components.graph

<<graph-nodecomposable>>
kotlin:tangle ../app/src/main/kotlin/computer/whatthefuck/arcology/app/ui/components/graph/GraphControls.kt:noweb yes
package computer.whatthefuck.arcology.app.ui.components.graph

<<graph-controls>>

GraphViewModel Assembly

kotlin:tangle ../app/src/main/kotlin/computer/whatthefuck/arcology/app/viewmodel/GraphViewModel.kt:noweb yes
<<graph-viewmodel>>

GraphScreen Assembly

kotlin:tangle ../app/src/main/kotlin/computer/whatthefuck/arcology/app/ui/screens/GraphScreen.kt:noweb yes
<<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 GraphViewModel factory with editor factory pattern

  • App Data LayerAppPreferences for directory URI, OrgDocumentEditor for pin persistence

  • Search Screen — cross-mode wiring via context menu (Center on Graph, Pin/Unpin)

  • Models & RepositoryRoamRepository for link/node queries

  • Document EditorOrgDocumentEditor for property writes