
Interactive graph visualization supporting hierarchical and force-directed layouts, custom layout plugins, customizable nodes and edges, zooming/panning, resizable canvas, layout animations and automatic node measurement.
ALPHA RELEASE - This library is in early development. The API is subject to change and may contain bugs. Feedback and bug reports are welcome at GitHub Issues.
Kuiver is available on Maven Central.
For multiplatform projects, add to your common source set:
kotlin {
sourceSets {
commonMain.dependencies {
implementation("io.github.justdeko:kuiver:0.2.5")
}
}
}Or for a specific platform only:
kotlin {
sourceSets {
androidMain.dependencies {
implementation("io.github.justdeko:kuiver-android:0.2.5")
}
iosMain.dependencies {
implementation("io.github.justdeko:kuiver-iosarm64:0.2.5")
}
// etc.
}
}@Composable
fun MyGraphViewer() {
// Create graph structure
val kuiver = remember {
buildKuiver {
// Add nodes
nodes("A", "B", "C")
// Add edges
edges(
"A" to "B",
"B" to "C",
"A" to "C"
)
}
}
// Configure layout
val layoutConfig = LayoutConfig.Hierarchical(
direction = LayoutDirection.HORIZONTAL
)
// Create viewer state
val viewerState = rememberKuiverViewerState(
initialKuiver = kuiver,
layoutConfig = layoutConfig
)
// Render the graph
KuiverViewer(
state = viewerState,
nodeContent = { node ->
// Customize node appearance
Box(
modifier = Modifier
.size(80.dp)
.background(Color.Blue, CircleShape),
contentAlignment = Alignment.Center
) {
Text(node.id, color = Color.White)
}
},
edgeContent = { edge, from, to ->
// Customize edge appearance
EdgeContent(from, to, color = Color.Gray)
}
)
}Kuiver only handles visual graph structure using node IDs. Store your application data separately
and look it up by node ID in your nodeContent composable.
The edgeContent lambda receives the edge data and start/end positions (from: Offset,
to: Offset). You can use built-in components or create custom rendering with Canvas:
// Using built-in styled edges (automatically styles FORWARD, BACK, CROSS, SELF_LOOP)
edgeContent = { edge, from, to ->
StyledEdgeContent(
edge = edge,
from = from,
to = to,
baseColor = Color.Black,
backEdgeColor = Color(0xFFFF6B6B),
strokeWidth = 3f
)
}
// Custom edge rendering
edgeContent = { edge, from, to ->
Canvas(modifier = Modifier.fillMaxSize()) {
drawLine(
color = Color.Blue,
start = from,
end = to,
strokeWidth = 2.dp.toPx()
)
// Draw custom arrows, labels, etc.
}
}Use EdgeContentWithLabel (or StyledEdgeContent) to display text along an edge. Labels
automatically hide on edges shorter than minEdgeLengthForLabel and can optionally rotate
to follow the edge direction.
edgeContent = { edge, from, to ->
EdgeContentWithLabel(
from = from,
to = to,
label = "my label",
labelPlacement = LabelPlacement.CENTER, // START, CENTER, or END
labelStyle = EdgeLabelStyle(
textColor = Color.Black,
backgroundColor = Color.White.copy(alpha = 0.9f),
fontSize = 12.sp,
rotateWithEdge = false
)
)
}
// or use a custom composable as the label
edgeContent = { edge, from, to ->
EdgeContentWithLabel(
from = from,
to = to,
label = "custom",
labelContent = { text ->
Text(text, color = Color.Red, fontWeight = FontWeight.Bold)
}
)
}StyledEdgeContent also accepts the same label parameters, so you can combine automatic
edge styling with labels in one call.
Replace the default filled-triangle arrow with any DrawScope lambda via the arrowDrawer
parameter, available on all edge composables:
val circleArrow: ArrowDrawer = { arrowTip, direction, color ->
drawCircle(color = color, radius = 8f, center = arrowTip)
}
edgeContent = { edge, from, to ->
EdgeContent(from, to, arrowDrawer = circleArrow)
}Kuiver automatically measures node dimensions from your nodeContent. You can also specify
dimensions explicitly:
buildKuiver {
// Auto-measured (recommended)
nodes("A")
// Explicit dimensions
addNode(
KuiverNode(
id = "B",
dimensions = NodeDimensions(width = 120.dp, height = 80.dp)
)
)
}By default, edges point and connect to the node center (with consideration of the node boundaries). For precise control, you can define custom anchor points:
nodeContent = { node ->
Box(modifier = Modifier.size(120.dp, 80.dp).background(Color.Blue)) {
// Define anchors with optional visual indicators
KuiverAnchor(
anchorId = "left",
nodeId = node.id,
modifier = Modifier.align(Alignment.CenterStart)
) {
Box(
Modifier
.size(8.dp)
.background(Color.White, CircleShape)
)
}
KuiverAnchor(
anchorId = "right",
nodeId = node.id,
modifier = Modifier.align(Alignment.CenterEnd)
)
Text("Node ${node.id}", modifier = Modifier.align(Alignment.Center))
}
}
// Reference anchors in edges
buildKuiver {
nodes("A", "B")
edge(
from = "A",
to = "B",
fromAnchor = "right",
toAnchor = "left"
)
}Things to keep in mind:
See ProcessDiagramDemo.kt for a complete example with multiple anchors per side.
Note: The layout algorithms are simple implementations based on established graph layouting techniques. While inspired by academic research, they are not direct ports of published implementations. Expect flaws and suboptimal layouts on complex graphs.
Best for directed acyclic graphs (DAGs) and tree structures. Automatically handles cycles by classifying back edges.
val layoutConfig = LayoutConfig.Hierarchical(
direction = LayoutDirection.HORIZONTAL, // or VERTICAL
levelSpacing = 150f, // Distance between hierarchy levels
nodeSpacing = 100f // Distance between nodes in same level
)Edge Types in Hierarchical Layout:
FORWARD - Edges to descendants (typical parent-child edges)BACK - Edges to ancestors (creates cycles, rendered as dashed by StyledEdgeContent)CROSS - Edges between nodes at similar hierarchy levelsSELF_LOOP - Edges from a node to itselfBest for understanding relationships in general graphs. Creates organic, balanced layouts using physics simulation.
val layoutConfig = LayoutConfig.ForceDirected(
iterations = 200, // Simulation steps (more = better layout, slower)
repulsionStrength = 500f, // How strongly nodes push apart
attractionStrength = 0.02f, // How strongly connected nodes pull together
damping = 0.85f // Velocity damping (stability vs convergence speed)
)You can provide your own layout algorithm using LayoutConfig.Custom. This gives you full
control over node positioning.
// Define a custom circular layout
val circularLayout: LayoutProvider = { kuiver, config ->
val nodesList = kuiver.nodes.values.toList()
val radius = minOf(config.width, config.height) * 0.4f
val centerX = config.width / 2f
val centerY = config.height / 2f
val updatedNodes = nodesList.mapIndexed { index, node ->
val angle = (index.toFloat() / nodesList.size) * 2f * PI.toFloat()
node.copy(
position = Offset(
x = centerX + radius * cos(angle),
y = centerY + radius * sin(angle)
)
)
}
buildKuiverWithClassifiedEdges(updatedNodes, kuiver.edges)
}
// Use the custom layout
val layoutConfig = LayoutConfig.Custom(
provider = circularLayout
)Custom Layout Tips:
Kuiver graph and LayoutConfig (use LayoutConfig.Custom)config.width and config.height
buildKuiverWithClassifiedEdges(updatedNodes, kuiver.edges) to construct the resultremember to stabilize your layout function in Compose to avoid unnecessary recompositionsCustomize viewer behavior with KuiverViewerConfig:
KuiverViewer(
state = viewerState,
config = KuiverViewerConfig(
// Visual
showDebugBounds = false, // Show node bounding boxes for debugging
// Viewport
fitToContent = true, // Auto-fit graph to viewport on load
contentPadding = 0.8f, // Padding around content (0-1 scale)
// Zoom
minScale = 0.1f, // Minimum zoom level (10%)
maxScale = 5f, // Maximum zoom level (500%)
// Pan
panVelocity = 1.0f, // Scroll sensitivity (platform-specific default)
// Animations
scaleAnimationSpec = spring( // Zoom animation
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow
),
offsetAnimationSpec = spring(), // Pan animation
nodeAnimationSpec = spring(), // Node position animation (during layout)
// Desktop-specific
zoomConditionDesktop = { event -> // When to zoom vs pan on desktop
event.keyboardModifiers.isCtrlPressed
}
),
nodeContent = { node -> /* ... */ },
edgeContent = { edge, from, to -> /* ... */ }
)// Zoom and navigation
viewerState.zoomIn() // Zoom in (1.2x)
viewerState.zoomOut() // Zoom out (1/1.2x)
viewerState.centerGraph() // Center graph in viewport
// Direct control
viewerState.updateTransform(scale = 1.5f, offset = Offset(100f, 100f))
// Access current state
val currentScale = viewerState.scale
val currentOffset = viewerState.offsetUse rememberSaveableKuiverViewerState to preserve zoom/pan across process death.
Update the graph structure by passing an updated or new Kuiver instance with
viewerState.updateKuiver(newKuiver).
val kuiver = buildKuiver {
nodes("A", "B", "C")
edges(
"A" to "B",
"B" to "C"
)
// Check before adding edge that would create a cycle
if (!wouldCreateCycle(from = "C", to = "A")) {
edge("C", "A")
} else {
println("Skipping edge C -> A: would create a cycle")
}
}
// Check existing graph
if (kuiver.hasCycles()) {
val components = kuiver.findStronglyConnectedComponents()
println("Strongly connected components: $components")
}val kuiver = buildKuiver {
nodes("A", "B", "C")
edges(
"A" to "B",
"B" to "C",
"C" to "A" // Back edge (creates cycle)
)
}
// Classify all edges
val edgeTypes = kuiver.classifyAllEdges()
edgeTypes.forEach { (edge, type) ->
println("${edge.fromId} -> ${edge.toId}: $type")
}
// Output:
// A -> B: FORWARD
// B -> C: FORWARD
// C -> A: BACK// For DAGs or graphs with back edges removed
val order = kuiver.getTopologicalOrder()
println("Topological order: $order")
// Useful for dependency resolution, task scheduling, etc.A complete demo app is included in /sample. Open the project in IntelliJ IDEA or Android Studio, sync, and select a run configuration (Desktop/Android/iOS/Web) from the dropdown.
You can also run from the command line:
./gradlew :sample:composeApp:run # DesktopThe Web target is experimental and has known issues.
The library implements several web-specific adjustments to handle browser limitations:
4f (vs 30f on native platforms) to
compensate for higher scroll sensitivity in browsers
core/src/wasmJsMain/kotlin/com/dk/kuiver/renderer/PlatformDefaults.wasmJs.kt:4
core/src/wasmJsMain/kotlin/com/dk/kuiver/renderer/PlatformDefaults.wasmJs.kt:5
As an alpha release, the public API may change between versions. Breaking changes will be noted in the changelog.
Contributions are welcome! Please see CONTRIBUTING.md for guidelines on:
In mathematics, a quiver is a directed graph in its most general sense.
"K" instead of "Q" for Kotlin. Just pronounce it like quiver: /ˈkwɪvər/
From Wikipedia:
"a quiver is another name for a multidigraph; that is, a directed graph where loops and multiple arrows between two vertices are allowed."
Technically this library is not quite a "true" quiver, as it doesn't support multiple edges between the same two nodes.
ALPHA RELEASE - This library is in early development. The API is subject to change and may contain bugs. Feedback and bug reports are welcome at GitHub Issues.
Kuiver is available on Maven Central.
For multiplatform projects, add to your common source set:
kotlin {
sourceSets {
commonMain.dependencies {
implementation("io.github.justdeko:kuiver:0.2.5")
}
}
}Or for a specific platform only:
kotlin {
sourceSets {
androidMain.dependencies {
implementation("io.github.justdeko:kuiver-android:0.2.5")
}
iosMain.dependencies {
implementation("io.github.justdeko:kuiver-iosarm64:0.2.5")
}
// etc.
}
}@Composable
fun MyGraphViewer() {
// Create graph structure
val kuiver = remember {
buildKuiver {
// Add nodes
nodes("A", "B", "C")
// Add edges
edges(
"A" to "B",
"B" to "C",
"A" to "C"
)
}
}
// Configure layout
val layoutConfig = LayoutConfig.Hierarchical(
direction = LayoutDirection.HORIZONTAL
)
// Create viewer state
val viewerState = rememberKuiverViewerState(
initialKuiver = kuiver,
layoutConfig = layoutConfig
)
// Render the graph
KuiverViewer(
state = viewerState,
nodeContent = { node ->
// Customize node appearance
Box(
modifier = Modifier
.size(80.dp)
.background(Color.Blue, CircleShape),
contentAlignment = Alignment.Center
) {
Text(node.id, color = Color.White)
}
},
edgeContent = { edge, from, to ->
// Customize edge appearance
EdgeContent(from, to, color = Color.Gray)
}
)
}Kuiver only handles visual graph structure using node IDs. Store your application data separately
and look it up by node ID in your nodeContent composable.
The edgeContent lambda receives the edge data and start/end positions (from: Offset,
to: Offset). You can use built-in components or create custom rendering with Canvas:
// Using built-in styled edges (automatically styles FORWARD, BACK, CROSS, SELF_LOOP)
edgeContent = { edge, from, to ->
StyledEdgeContent(
edge = edge,
from = from,
to = to,
baseColor = Color.Black,
backEdgeColor = Color(0xFFFF6B6B),
strokeWidth = 3f
)
}
// Custom edge rendering
edgeContent = { edge, from, to ->
Canvas(modifier = Modifier.fillMaxSize()) {
drawLine(
color = Color.Blue,
start = from,
end = to,
strokeWidth = 2.dp.toPx()
)
// Draw custom arrows, labels, etc.
}
}Use EdgeContentWithLabel (or StyledEdgeContent) to display text along an edge. Labels
automatically hide on edges shorter than minEdgeLengthForLabel and can optionally rotate
to follow the edge direction.
edgeContent = { edge, from, to ->
EdgeContentWithLabel(
from = from,
to = to,
label = "my label",
labelPlacement = LabelPlacement.CENTER, // START, CENTER, or END
labelStyle = EdgeLabelStyle(
textColor = Color.Black,
backgroundColor = Color.White.copy(alpha = 0.9f),
fontSize = 12.sp,
rotateWithEdge = false
)
)
}
// or use a custom composable as the label
edgeContent = { edge, from, to ->
EdgeContentWithLabel(
from = from,
to = to,
label = "custom",
labelContent = { text ->
Text(text, color = Color.Red, fontWeight = FontWeight.Bold)
}
)
}StyledEdgeContent also accepts the same label parameters, so you can combine automatic
edge styling with labels in one call.
Replace the default filled-triangle arrow with any DrawScope lambda via the arrowDrawer
parameter, available on all edge composables:
val circleArrow: ArrowDrawer = { arrowTip, direction, color ->
drawCircle(color = color, radius = 8f, center = arrowTip)
}
edgeContent = { edge, from, to ->
EdgeContent(from, to, arrowDrawer = circleArrow)
}Kuiver automatically measures node dimensions from your nodeContent. You can also specify
dimensions explicitly:
buildKuiver {
// Auto-measured (recommended)
nodes("A")
// Explicit dimensions
addNode(
KuiverNode(
id = "B",
dimensions = NodeDimensions(width = 120.dp, height = 80.dp)
)
)
}By default, edges point and connect to the node center (with consideration of the node boundaries). For precise control, you can define custom anchor points:
nodeContent = { node ->
Box(modifier = Modifier.size(120.dp, 80.dp).background(Color.Blue)) {
// Define anchors with optional visual indicators
KuiverAnchor(
anchorId = "left",
nodeId = node.id,
modifier = Modifier.align(Alignment.CenterStart)
) {
Box(
Modifier
.size(8.dp)
.background(Color.White, CircleShape)
)
}
KuiverAnchor(
anchorId = "right",
nodeId = node.id,
modifier = Modifier.align(Alignment.CenterEnd)
)
Text("Node ${node.id}", modifier = Modifier.align(Alignment.Center))
}
}
// Reference anchors in edges
buildKuiver {
nodes("A", "B")
edge(
from = "A",
to = "B",
fromAnchor = "right",
toAnchor = "left"
)
}Things to keep in mind:
See ProcessDiagramDemo.kt for a complete example with multiple anchors per side.
Note: The layout algorithms are simple implementations based on established graph layouting techniques. While inspired by academic research, they are not direct ports of published implementations. Expect flaws and suboptimal layouts on complex graphs.
Best for directed acyclic graphs (DAGs) and tree structures. Automatically handles cycles by classifying back edges.
val layoutConfig = LayoutConfig.Hierarchical(
direction = LayoutDirection.HORIZONTAL, // or VERTICAL
levelSpacing = 150f, // Distance between hierarchy levels
nodeSpacing = 100f // Distance between nodes in same level
)Edge Types in Hierarchical Layout:
FORWARD - Edges to descendants (typical parent-child edges)BACK - Edges to ancestors (creates cycles, rendered as dashed by StyledEdgeContent)CROSS - Edges between nodes at similar hierarchy levelsSELF_LOOP - Edges from a node to itselfBest for understanding relationships in general graphs. Creates organic, balanced layouts using physics simulation.
val layoutConfig = LayoutConfig.ForceDirected(
iterations = 200, // Simulation steps (more = better layout, slower)
repulsionStrength = 500f, // How strongly nodes push apart
attractionStrength = 0.02f, // How strongly connected nodes pull together
damping = 0.85f // Velocity damping (stability vs convergence speed)
)You can provide your own layout algorithm using LayoutConfig.Custom. This gives you full
control over node positioning.
// Define a custom circular layout
val circularLayout: LayoutProvider = { kuiver, config ->
val nodesList = kuiver.nodes.values.toList()
val radius = minOf(config.width, config.height) * 0.4f
val centerX = config.width / 2f
val centerY = config.height / 2f
val updatedNodes = nodesList.mapIndexed { index, node ->
val angle = (index.toFloat() / nodesList.size) * 2f * PI.toFloat()
node.copy(
position = Offset(
x = centerX + radius * cos(angle),
y = centerY + radius * sin(angle)
)
)
}
buildKuiverWithClassifiedEdges(updatedNodes, kuiver.edges)
}
// Use the custom layout
val layoutConfig = LayoutConfig.Custom(
provider = circularLayout
)Custom Layout Tips:
Kuiver graph and LayoutConfig (use LayoutConfig.Custom)config.width and config.height
buildKuiverWithClassifiedEdges(updatedNodes, kuiver.edges) to construct the resultremember to stabilize your layout function in Compose to avoid unnecessary recompositionsCustomize viewer behavior with KuiverViewerConfig:
KuiverViewer(
state = viewerState,
config = KuiverViewerConfig(
// Visual
showDebugBounds = false, // Show node bounding boxes for debugging
// Viewport
fitToContent = true, // Auto-fit graph to viewport on load
contentPadding = 0.8f, // Padding around content (0-1 scale)
// Zoom
minScale = 0.1f, // Minimum zoom level (10%)
maxScale = 5f, // Maximum zoom level (500%)
// Pan
panVelocity = 1.0f, // Scroll sensitivity (platform-specific default)
// Animations
scaleAnimationSpec = spring( // Zoom animation
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow
),
offsetAnimationSpec = spring(), // Pan animation
nodeAnimationSpec = spring(), // Node position animation (during layout)
// Desktop-specific
zoomConditionDesktop = { event -> // When to zoom vs pan on desktop
event.keyboardModifiers.isCtrlPressed
}
),
nodeContent = { node -> /* ... */ },
edgeContent = { edge, from, to -> /* ... */ }
)// Zoom and navigation
viewerState.zoomIn() // Zoom in (1.2x)
viewerState.zoomOut() // Zoom out (1/1.2x)
viewerState.centerGraph() // Center graph in viewport
// Direct control
viewerState.updateTransform(scale = 1.5f, offset = Offset(100f, 100f))
// Access current state
val currentScale = viewerState.scale
val currentOffset = viewerState.offsetUse rememberSaveableKuiverViewerState to preserve zoom/pan across process death.
Update the graph structure by passing an updated or new Kuiver instance with
viewerState.updateKuiver(newKuiver).
val kuiver = buildKuiver {
nodes("A", "B", "C")
edges(
"A" to "B",
"B" to "C"
)
// Check before adding edge that would create a cycle
if (!wouldCreateCycle(from = "C", to = "A")) {
edge("C", "A")
} else {
println("Skipping edge C -> A: would create a cycle")
}
}
// Check existing graph
if (kuiver.hasCycles()) {
val components = kuiver.findStronglyConnectedComponents()
println("Strongly connected components: $components")
}val kuiver = buildKuiver {
nodes("A", "B", "C")
edges(
"A" to "B",
"B" to "C",
"C" to "A" // Back edge (creates cycle)
)
}
// Classify all edges
val edgeTypes = kuiver.classifyAllEdges()
edgeTypes.forEach { (edge, type) ->
println("${edge.fromId} -> ${edge.toId}: $type")
}
// Output:
// A -> B: FORWARD
// B -> C: FORWARD
// C -> A: BACK// For DAGs or graphs with back edges removed
val order = kuiver.getTopologicalOrder()
println("Topological order: $order")
// Useful for dependency resolution, task scheduling, etc.A complete demo app is included in /sample. Open the project in IntelliJ IDEA or Android Studio, sync, and select a run configuration (Desktop/Android/iOS/Web) from the dropdown.
You can also run from the command line:
./gradlew :sample:composeApp:run # DesktopThe Web target is experimental and has known issues.
The library implements several web-specific adjustments to handle browser limitations:
4f (vs 30f on native platforms) to
compensate for higher scroll sensitivity in browsers
core/src/wasmJsMain/kotlin/com/dk/kuiver/renderer/PlatformDefaults.wasmJs.kt:4
core/src/wasmJsMain/kotlin/com/dk/kuiver/renderer/PlatformDefaults.wasmJs.kt:5
As an alpha release, the public API may change between versions. Breaking changes will be noted in the changelog.
Contributions are welcome! Please see CONTRIBUTING.md for guidelines on:
In mathematics, a quiver is a directed graph in its most general sense.
"K" instead of "Q" for Kotlin. Just pronounce it like quiver: /ˈkwɪvər/
From Wikipedia:
"a quiver is another name for a multidigraph; that is, a directed graph where loops and multiple arrows between two vertices are allowed."
Technically this library is not quite a "true" quiver, as it doesn't support multiple edges between the same two nodes.