Flow Visualization
Add interactive graph visualizations and DAG rendering to your Nuxt Crouton collections with the @friendlyinternet/nuxt-crouton-flow package. Perfect for workflow builders, decision trees, entity relationships, and any data with parent-child relationships.
Quick Start
Installation
pnpm add @friendlyinternet/nuxt-crouton-flow
Configuration
Add the layer to your nuxt.config.ts:
export default defineNuxtConfig({
extends: [
'@friendlyinternet/nuxt-crouton',
'@friendlyinternet/nuxt-crouton-flow'
]
})
Basic Usage
<script setup>
const { data: decisions } = await useCollectionQuery('decisions')
</script>
<template>
<CroutonFlowFlow
:rows="decisions"
collection="decisions"
parent-field="parentId"
position-field="position"
/>
</template>
Features
- Automatic edge generation from
parentIdfield (tree/DAG structures) - Dagre auto-layout for initial positioning
- Drag-and-drop node positioning with persistence
- Custom node components per collection
- Controls, minimap, and background built-in
- Dark mode support
- Real-time collaboration with Yjs CRDTs (multiplayer sync)
- Presence indicators showing other users' cursors and selections
Components
CroutonFlowFlow
The main wrapper component that renders your collection as an interactive graph.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
rows | Record<string, unknown>[] | required | Collection data to display |
collection | string | required | Collection name for mutations and component resolution |
parentField | string | 'parentId' | Field containing parent ID for edges |
positionField | string | 'position' | Field storing node { x, y } position |
labelField | string | 'title' | Field to use as node label |
controls | boolean | true | Show zoom/pan controls |
minimap | boolean | false | Show minimap overlay |
background | boolean | true | Show background pattern |
backgroundPattern | 'dots' | 'lines' | 'dots' | Background pattern type |
draggable | boolean | true | Allow node dragging |
fitViewOnMount | boolean | true | Fit graph to viewport on mount |
flowConfig | FlowConfig | undefined | Advanced flow configuration |
sync | boolean | false | Enable real-time multiplayer sync |
flowId | string | undefined | Unique ID for the sync room (required when sync is true) |
Events
| Event | Payload | Description |
|---|---|---|
nodeClick | (nodeId: string, data: Record<string, unknown>) | Node was clicked |
nodeDblClick | (nodeId: string, data: Record<string, unknown>) | Node was double-clicked |
nodeMove | (nodeId: string, position: { x: number, y: number }) | Node was dragged to new position |
edgeClick | (edgeId: string) | Edge was clicked |
Example
<template>
<CroutonFlowFlow
:rows="decisions"
collection="decisions"
parent-field="parentId"
position-field="position"
:controls="true"
:minimap="true"
@nodeClick="handleNodeClick"
@nodeDblClick="openNodeDetail"
/>
</template>
<script setup>
const handleNodeClick = (nodeId, data) => {
console.log('Clicked:', data.title)
}
const openNodeDetail = (nodeId, data) => {
// Open slideover or modal
useCrouton().open('update', 'decisions', [nodeId])
}
</script>
CroutonFlowNode
The default node component used when no custom node exists. Displays the item's title/name in a styled card.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
data | Record<string, unknown> | required | The collection item data |
selected | boolean | false | Whether node is selected |
dragging | boolean | false | Whether node is being dragged |
label | string | '' | Override label text |
Custom Node Components
You can create custom node components for each collection. The flow layer will automatically detect and use them.
Convention
Create a component named [Collection]Node.vue in your app's components directory:
app/components/
└── DecisionsNode.vue ← Custom node for "decisions" collection
Example Custom Node
<!-- app/components/DecisionsNode.vue -->
<script setup lang="ts">
import { Handle, Position } from '@vue-flow/core'
interface Props {
data: {
id: string
content: string
type: 'idea' | 'insight' | 'decision'
starred: boolean
}
selected?: boolean
dragging?: boolean
}
const props = defineProps<Props>()
const typeIcons = {
idea: 'i-heroicons-light-bulb',
insight: 'i-heroicons-eye',
decision: 'i-heroicons-check-circle'
}
</script>
<template>
<div
class="decision-node"
:class="{
'decision-node--selected': selected,
'decision-node--starred': data.starred
}"
>
<Handle type="target" :position="Position.Top" />
<div class="flex items-center gap-2">
<UIcon :name="typeIcons[data.type]" class="w-4 h-4" />
<span class="font-medium truncate">{{ data.content }}</span>
<UIcon
v-if="data.starred"
name="i-heroicons-star-solid"
class="w-4 h-4 text-yellow-500"
/>
</div>
<Handle type="source" :position="Position.Bottom" />
</div>
</template>
<style scoped>
.decision-node {
@apply px-4 py-2 rounded-lg border bg-white dark:bg-neutral-900;
@apply border-neutral-200 dark:border-neutral-700;
@apply shadow-sm min-w-[150px] max-w-[250px];
}
.decision-node--selected {
@apply border-primary-500 ring-2 ring-primary-500/20;
}
.decision-node--starred {
@apply bg-yellow-50 dark:bg-yellow-900/20;
}
</style>
Real-time Collaboration
Overview
The flow layer supports real-time multiplayer editing using:
- Yjs CRDTs for conflict-free collaborative editing
- Cloudflare Durable Objects for WebSocket-based sync
- Presence awareness showing other users' cursors and selections
Enabling Sync Mode
Add the sync and flowId props to enable multiplayer:
<script setup>
const projectId = useRoute().params.id
</script>
<template>
<CroutonFlowFlow
collection="decisions"
sync
:flow-id="projectId"
/>
</template>
When sync is enabled:
- Node positions sync in real-time across all connected clients
- Node CRUD operations are broadcast to all users
- User presence (cursors, selections) is visible to everyone
- Changes persist automatically to the database
How It Works
┌─────────────────────────────────────────────────────────────┐
│ Clients │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Client A │ │ Client B │ │ Client C │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ └─────────────┼─────────────┘ │
│ │ WebSocket │
│ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Cloudflare Durable Object │ │
│ │ (FlowRoom) │ │
│ │ - Manages Yjs Y.Doc per flow │ │
│ │ - Merges updates from all clients │ │
│ │ - Persists to D1 on changes │ │
│ └──────────────────────┬──────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ D1 (SQLite) │ │
│ │ yjs_flow_states (Yjs blob) + collection tables │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
Sync Mode vs Standard Mode
| Feature | Standard Mode | Sync Mode |
|---|---|---|
| Position persistence | Debounced API calls | Real-time via Yjs |
| Multi-user editing | Not supported | Full support |
| Presence indicators | Not available | Users, cursors, selections |
| Offline support | Crouton cache | Yjs CRDT merge on reconnect |
| Infrastructure | Standard API | Durable Objects + WebSocket |
Infrastructure Setup
Sync mode requires Cloudflare Durable Objects. Add to your wrangler.toml:
[[durable_objects.bindings]]
name = "FLOW_ROOMS"
class_name = "FlowRoom"
[[migrations]]
tag = "v1"
new_classes = ["FlowRoom"]
Run the D1 migration to create the state table:
CREATE TABLE IF NOT EXISTS yjs_flow_states (
flow_id TEXT PRIMARY KEY,
collection_name TEXT NOT NULL,
state BLOB NOT NULL,
version INTEGER DEFAULT 1,
created_at INTEGER DEFAULT (unixepoch()),
updated_at INTEGER DEFAULT (unixepoch())
);
Enable WebSocket in your Nuxt config:
export default defineNuxtConfig({
nitro: {
experimental: {
websocket: true
}
}
})
Sync Mode Components
FlowPresence
Displays presence indicators for other connected users.
<template>
<FlowPresence
:users="users"
:current-user-id="currentUserId"
/>
</template>
Props
| Prop | Type | Description |
|---|---|---|
users | YjsAwarenessState[] | Array of connected users |
currentUserId | string | Current user's ID (to exclude from display) |
Features:
- User avatars in top-right corner with initials and colors
- Live cursor positions on canvas
- Cursor labels with usernames
FlowConnectionStatus
Shows the current sync connection state.
<template>
<FlowConnectionStatus
:connected="connected"
:synced="synced"
:error="error"
/>
</template>
Props
| Prop | Type | Description |
|---|---|---|
connected | boolean | WebSocket connected |
synced | boolean | Initial sync complete |
error | Error | null | Connection error if any |
States:
- Green (connected + synced): Fully operational
- Yellow (connecting): WebSocket connected, waiting for initial sync
- Red (disconnected): Connection lost, will auto-reconnect
Composables
useFlowData
Converts collection rows into Vue Flow nodes and edges.
const { nodes, edges, getNode, getItem } = useFlowData(
computed(() => rows),
{
parentField: 'parentId',
positionField: 'position',
labelField: 'title'
}
)
Options
| Option | Type | Default | Description |
|---|---|---|---|
parentField | string | 'parentId' | Field containing parent ID |
positionField | string | 'position' | Field containing { x, y } |
labelField | string | 'title' | Field for node label |
Returns
| Property | Type | Description |
|---|---|---|
nodes | ComputedRef<Node[]> | Vue Flow nodes |
edges | ComputedRef<Edge[]> | Vue Flow edges |
getNode | (id: string) => Node | Get node by ID |
getItem | (id: string) => T | Get original item by ID |
useFlowLayout
Provides dagre-based automatic layout for graphs.
const { applyLayout, needsLayout } = useFlowLayout({
direction: 'TB', // TB, LR, BT, RL
nodeSpacing: 50, // Horizontal spacing
rankSpacing: 100 // Vertical spacing
})
// Apply layout if needed
if (needsLayout(nodes)) {
const layoutedNodes = applyLayout(nodes, edges)
}
useFlowMutation
Handles position persistence via the crouton mutation system.
const { updatePosition, pending, error } = useFlowMutation(
'decisions',
'position' // Position field name
)
// Update a node's position
await updatePosition('node-123', { x: 100, y: 200 })
useDebouncedPositionUpdate
Debounced version for drag operations (prevents excessive API calls).
const { debouncedUpdate, pending } = useDebouncedPositionUpdate(
'decisions',
'position',
500 // Debounce delay in ms
)
// Called on each drag event
debouncedUpdate(nodeId, { x, y })
useFlowSync
Real-time flow synchronization via Yjs. Handles WebSocket connection, node CRUD, and presence.
const {
// State (readonly)
nodes, // Ref<YjsFlowNode[]>
connected, // ComputedRef<boolean>
synced, // ComputedRef<boolean>
error, // ComputedRef<Error | null>
users, // ComputedRef<YjsAwarenessState[]>
user, // Current user info
// Node operations
createNode,
updateNode,
updatePosition,
deleteNode,
getNode,
// Presence
updateCursor,
selectNode,
updateGhostNode,
clearGhostNode,
// Advanced (Yjs internals)
ydoc,
nodesMap
} = useFlowSync({
flowId: 'flow-123',
collection: 'decisions'
})
Options
| Option | Type | Required | Description |
|---|---|---|---|
flowId | string | Yes | Unique identifier for the sync room |
collection | string | Yes | Collection name for persistence |
Returns
State
| Property | Type | Description |
|---|---|---|
nodes | Readonly<Ref<YjsFlowNode[]>> | All nodes in the flow |
connected | ComputedRef<boolean> | WebSocket connected |
synced | ComputedRef<boolean> | Initial sync complete |
error | ComputedRef<Error | null> | Connection error |
users | ComputedRef<YjsAwarenessState[]> | Connected users |
user | ComputedRef<{id, name, color} | null> | Current user (from session) |
Node Operations
| Method | Signature | Description |
|---|---|---|
createNode | (data: Partial<YjsFlowNode>) => string | Create node, returns ID |
updateNode | (id: string, updates: Partial<YjsFlowNode>) => void | Update node fields |
updatePosition | (id: string, position: {x, y}) => void | Update node position |
deleteNode | (id: string) => void | Delete node |
getNode | (id: string) => YjsFlowNode | undefined | Get node by ID |
Presence
| Method | Signature | Description |
|---|---|---|
updateCursor | (cursor: {x, y} | null) => void | Update cursor position |
selectNode | (nodeId: string | null) => void | Broadcast node selection |
updateGhostNode | (ghost: YjsGhostNode | null) => void | Show drag preview to others |
clearGhostNode | () => void | Clear ghost node |
Example: Custom Sync Controls
<script setup>
const {
nodes,
connected,
synced,
users,
createNode,
deleteNode
} = useFlowSync({
flowId: props.flowId,
collection: 'decisions'
})
const addNode = () => {
createNode({
title: 'New Decision',
position: { x: 100, y: 100 }
})
}
</script>
<template>
<div class="flex items-center gap-2">
<div class="flex items-center gap-1">
<span
class="w-2 h-2 rounded-full"
:class="connected && synced ? 'bg-green-500' : 'bg-yellow-500'"
/>
<span class="text-sm">{{ users.length }} online</span>
</div>
<UButton @click="addNode">Add Node</UButton>
</div>
</template>
useFlowPresence
Helper composable for presence UI. Provides utilities for rendering user indicators.
const {
otherUsers, // Other connected users (excludes current)
getUsersSelectingNode, // Get users selecting a specific node
getNodePresenceStyle // Get style for node presence border
} = useFlowPresence({
users: computed(() => syncState.users),
currentUserId: currentUser.id
})
Options
| Option | Type | Description |
|---|---|---|
users | Ref<YjsAwarenessState[]> | Connected users from useFlowSync |
currentUserId | string | Current user's ID to exclude |
Returns
| Property | Type | Description |
|---|---|---|
otherUsers | ComputedRef<YjsAwarenessState[]> | Users excluding current |
getUsersSelectingNode | (nodeId: string) => ComputedRef<YjsAwarenessState[]> | Users selecting a node |
getNodePresenceStyle | (nodeId: string) => ComputedRef<CSSProperties> | Presence border style |
Example: Node Presence Indicator
<script setup>
const { getNodePresenceStyle } = useFlowPresence({
users: users,
currentUserId: currentUser.id
})
// Get style for a specific node
const nodeStyle = getNodePresenceStyle('node-123')
</script>
<template>
<div :style="nodeStyle">
<!-- Node content with presence border -->
</div>
</template>
Data Model
Position Field
Add a position field to your collection schema to persist node positions:
{
"position": {
"type": "json",
"meta": {
"description": "Node position for flow visualization"
}
}
}
The position is stored as:
{
position: {
x: 150,
y: 200
}
}
Parent Field
The flow layer builds edges from parentId relationships:
{
"parentId": {
"type": "string",
"refTarget": "decisions",
"meta": {
"description": "Parent decision for tree structure"
}
}
}
Flow Configuration
For advanced configuration, use the flowConfig prop:
<CroutonFlowFlow
:rows="decisions"
collection="decisions"
:flow-config="{
direction: 'LR', // Left to right layout
nodeSpacing: 80,
rankSpacing: 150,
autoLayout: 'dagre'
}"
/>
| Option | Type | Default | Description |
|---|---|---|---|
direction | 'TB' | 'LR' | 'BT' | 'RL' | 'TB' | Layout direction |
nodeSpacing | number | 50 | Horizontal spacing between nodes |
rankSpacing | number | 100 | Vertical spacing between ranks |
autoLayout | 'dagre' | 'none' | 'dagre' | Auto-layout algorithm |
positionField | string | 'position' | Position storage field |
Examples
Decision Tree
<script setup>
const { data: decisions } = await useCollectionQuery('decisions', {
sort: { field: 'createdAt', direction: 'asc' }
})
</script>
<template>
<div class="h-[600px]">
<CroutonFlowFlow
:rows="decisions"
collection="decisions"
parent-field="parentId"
:minimap="true"
@nodeDblClick="(id) => useCrouton().open('update', 'decisions', [id])"
/>
</div>
</template>
Workflow Builder
<script setup>
const { data: steps } = await useCollectionQuery('workflowSteps')
const handleConnect = async (sourceId: string, targetId: string) => {
// Create edge relationship
await useCollectionMutation('workflowSteps').update(targetId, {
parentId: sourceId
})
}
</script>
<template>
<CroutonFlowFlow
:rows="steps"
collection="workflowSteps"
:flow-config="{ direction: 'LR' }"
:controls="true"
>
<!-- Custom slot content -->
<template #default>
<div class="absolute top-4 right-4">
<UButton @click="addStep">Add Step</UButton>
</div>
</template>
</CroutonFlowFlow>
</template>
Multiplayer Flow
Real-time collaborative editing with presence:
<script setup>
const route = useRoute()
const { user: currentUser } = useUserSession()
// Use sync mode for real-time collaboration
const {
nodes,
connected,
synced,
users,
error,
createNode
} = useFlowSync({
flowId: route.params.id,
collection: 'decisions'
})
// Presence helpers
const { otherUsers } = useFlowPresence({
users,
currentUserId: currentUser.value?.id
})
const addDecision = () => {
createNode({
title: 'New Decision',
position: { x: Math.random() * 400, y: Math.random() * 300 }
})
}
</script>
<template>
<div class="h-screen relative">
<!-- Main flow with sync enabled -->
<CroutonFlowFlow
collection="decisions"
sync
:flow-id="route.params.id"
:minimap="true"
/>
<!-- Connection status -->
<FlowConnectionStatus
:connected="connected"
:synced="synced"
:error="error"
/>
<!-- Presence overlay -->
<FlowPresence
:users="users"
:current-user-id="currentUser?.id"
/>
<!-- Toolbar -->
<div class="absolute top-4 left-4 flex items-center gap-4">
<div class="flex items-center gap-2 bg-white/80 dark:bg-neutral-900/80 px-3 py-1.5 rounded-lg">
<div
class="w-2 h-2 rounded-full"
:class="connected && synced ? 'bg-green-500' : 'bg-yellow-500'"
/>
<span class="text-sm">{{ otherUsers.length + 1 }} online</span>
</div>
<UButton @click="addDecision" icon="i-heroicons-plus">
Add Decision
</UButton>
</div>
</div>
</template>
Styling
The flow component supports dark mode automatically. Override styles using CSS:
/* Custom node styles */
:deep(.vue-flow__node) {
/* Your styles */
}
/* Custom edge styles */
:deep(.vue-flow__edge-path) {
stroke: theme('colors.primary.500');
stroke-width: 2px;
}
/* Selected state */
:deep(.vue-flow__node.selected) {
/* Selected node styles */
}
Related
- Vue Flow Documentation
- Dagre Layout
- Yjs Documentation - CRDT library for real-time sync
- Cloudflare Durable Objects - WebSocket infrastructure