fix: performance issue in graph and update layout building

This commit is contained in:
Nikolai Giman
2026-08-24 22:05:39 +02:00
parent c1685e42b8
commit a596e023db
6 changed files with 154 additions and 93 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "web-nuxt-ui",
"private": true,
"type": "module",
"version": "1.52.2",
"version": "1.52.7",
"scripts": {
"dev": "vite",
"build:packages": "pnpm --filter taskview-db-schemas build && pnpm --filter taskview-api build && pnpm --filter capacitor-widget-bridge build",
@@ -4,7 +4,7 @@
v-model:nodes="store.nodes"
v-model:edges="store.edges"
:min-zoom="-2"
fit-view-on-init
only-render-visible-elements
elevate-edges-on-select
elevate-nodes-on-select
:pan-on-scroll-mode="PanOnScrollMode.Free"
@@ -14,6 +14,7 @@
:nodes-connectable="canManageGraph"
:nodes-draggable="canManageGraph"
class="h-full w-full"
:class="{ 'opacity-0': !layoutReady }"
@connect-start="onConnectStart"
@connect-end="onConnectEnd"
>
@@ -118,7 +119,9 @@ const applyFilters = () => {
const nodeIds = new Set(filtered.map((n) => n.id))
store.nodes = filtered
store.edges = store.allEdges.filter((e) => nodeIds.has(e.source) && nodeIds.has(e.target))
setTimeout(() => layoutGraph(layoutDirection.value), 50)
// Node sizes are estimated from data, so the layout can run right away —
// no need to wait for nodes to render and be measured
layoutGraph(layoutDirection.value)
}
watch(listIds, applyFilters, { deep: true })
@@ -132,8 +135,6 @@ const {
onEdgesChange,
onEdgeClick,
onNodeDragStop,
getEdges,
updateEdgeData,
removeEdges,
screenToFlowCoordinate,
} = useVueFlow()
@@ -144,6 +145,8 @@ const store = useGraphStore()
const { t } = useI18n()
const { canManageGraph, canViewGraph } = useGoalPermissions()
const layoutReady = ref(false)
const addNewTaskToGraph = ref(false)
const currentSession = ref<number | null>(null)
const successfulSession = ref<number | null>(null)
@@ -152,7 +155,7 @@ const nodePosition = ref<{ x: number; y: number } | undefined>(undefined)
const defaultEdgeOptions: DefaultEdgeOptions = {
type: 'smoothstep',
animated: true,
animated: false,
style: {
strokeWidth: 3,
},
@@ -170,6 +173,7 @@ watch(
projectId,
(id) => {
if (!id) return
layoutReady.value = false
store.fetchAllTasksAndLists(id).then(() => {
applyFilters()
})
@@ -199,10 +203,15 @@ onConnect(async (params) => {
addEdges([newEdge])
})
function setAnimatedEdge(id: string | null) {
store.edges = store.edges.map((edge) => ({ ...edge, animated: edge.id === id }))
}
onEdgesChange((params) => {
params.forEach((param) => {
if (param.type === 'select' && !param.selected) {
selectedEdge.value = null
setAnimatedEdge(null)
}
if (param.type === 'remove') {
deleteSelectedEdge(+param.id)
@@ -213,6 +222,7 @@ onEdgesChange((params) => {
onEdgeClick((params) => {
selectedEdge.value = params.edge
setAnimatedEdge(params.edge.id)
})
const newToken = () => {
@@ -285,9 +295,7 @@ const layoutGraph = async (direction: 'LR' | 'TB') => {
store.nodes = layout(store.nodes, store.edges, direction)
nextTick(() => {
fitView()
getEdges.value.forEach((edge) => {
updateEdgeData(edge.id, edge)
})
layoutReady.value = true
})
}
</script>
@@ -27,10 +27,10 @@
:style="sourceHandleStyle"
/>
<TaskItem
:task="props.data.task"
<TaskItemCard
:task="props.data.task"
class="w-full"
@toggle="toggleComplete($event)"
@toggle="toggleComplete($event)"
/>
</div>
</template>
@@ -39,6 +39,7 @@
import { Handle, Position } from '@vue-flow/core'
import { computed } from 'vue'
import type { TaskItem } from '@/types/tasks.types'
import TaskItemCard from '@/components/features/tasks/parts/TaskItem.vue'
import { useTasksStore } from '@/stores/tasks.store'
import { Task } from 'taskview-api'
@@ -1,56 +1,106 @@
import dagre from '@dagrejs/dagre'
import { type Edge, type Node, Position, useVueFlow } from '@vue-flow/core'
import { ref } from 'vue'
import { type Edge, type Node, Position } from '@vue-flow/core'
const NODE_WIDTH = 288 // w-72 wrapper in TaskNode
const NODE_MIN_HEIGHT = 74 // checkbox + priority column with paddings
const NODE_PADDING_Y = 28 // p-3.5 top + bottom
const TITLE_LINE_HEIGHT = 24 // text-base
// Conservative: word-wrapping rarely fills lines completely, better to
// overestimate height than to let ranks overlap
const TITLE_CHARS_PER_LINE = 22
const BADGE_ROW_HEIGHT = 30
const BADGE_ROW_GAP = 8
const TITLE_BADGES_GAP = 4
// Handles stick out ~8px beyond the card on both sides, and the height estimate
// can be off by a line — this safety margin keeps neighbors from touching
const NODE_SAFETY = 24
const CONTENT_WIDTH = 230 // node width minus paddings and the checkbox column
const BADGE_CHROME_WIDTH = 34 // badge paddings + icon
const BADGE_CHAR_WIDTH = 6.5
const BADGE_GAP = 8
const ISOLATED_GAP_X = 40
const ISOLATED_GAP_Y = 32
const ISOLATED_BLOCK_OFFSET = 120
const ISOLATED_MIN_ROW_WIDTH = 1200
// Estimates the rendered TaskNode size from task data alone, so the layout can
// run before (and without) rendering every node — a prerequisite for
// only-render-visible-elements, where offscreen nodes are never measured.
function estimateNodeSize(node: Node): { width: number; height: number } {
const task = node.data?.task
if (!task) return { width: NODE_WIDTH, height: NODE_MIN_HEIGHT }
const titleLines = Math.max(1, Math.ceil((task.description?.length ?? 0) / TITLE_CHARS_PER_LINE))
// Estimated pixel widths of the badges TaskItem renders, in render order
const badgeWidth = (labelLength: number) =>
Math.min(CONTENT_WIDTH, BADGE_CHROME_WIDTH + labelLength * BADGE_CHAR_WIDTH)
const badgeWidths: number[] = []
if (task.endDate) badgeWidths.push(badgeWidth(11)) // dd.Mon.yyyy
if (task.recurrenceRuleId) badgeWidths.push(BADGE_CHROME_WIDTH) // icon-only
if (task.goalListId) badgeWidths.push(badgeWidth(10)) // list name (unknown here)
if (task.amount) badgeWidths.push(badgeWidth(String(task.amount).length + 1))
for (let i = 0; i < (task.assignedUsers?.length ?? 0); i++) badgeWidths.push(badgeWidth(20)) // email
for (let i = 0; i < (task.tags?.length ?? 0); i++) badgeWidths.push(badgeWidth(9)) // tag name (unknown here)
// Greedy flex-wrap simulation: how many rows the badges take
let badgeRows = 0
let rowRemaining = 0
for (const width of badgeWidths) {
if (width + (badgeRows === 0 || rowRemaining === CONTENT_WIDTH ? 0 : BADGE_GAP) > rowRemaining) {
badgeRows += 1
rowRemaining = CONTENT_WIDTH - width
} else {
rowRemaining -= width + BADGE_GAP
}
}
const height =
NODE_PADDING_Y +
titleLines * TITLE_LINE_HEIGHT +
(badgeRows > 0 ? TITLE_BADGES_GAP + badgeRows * BADGE_ROW_HEIGHT + (badgeRows - 1) * BADGE_ROW_GAP : 0)
return { width: NODE_WIDTH, height: Math.max(NODE_MIN_HEIGHT, height) + NODE_SAFETY }
}
/**
* Composable to run the layout algorithm on the graph.
* It uses the `dagre` library to calculate the layout of the nodes and edges.
* Connected nodes are laid out with `dagre`; isolated nodes (no edges) are
* arranged in a grid below the graph so they don't push linked nodes apart.
* Node sizes are estimated from data, so no prior render is required.
*/
export function useLayout() {
const { findNode } = useVueFlow()
const graph = ref(new dagre.graphlib.Graph())
const previousDirection = ref('LR')
function layout(nodes: Node[], edges: Edge[], direction: 'LR' | 'TB') {
// we create a new graph instance, in case some nodes/edges were removed, otherwise dagre would act as if they were still there
const dagreGraph = new dagre.graphlib.Graph()
graph.value = dagreGraph
dagreGraph.setDefaultEdgeLabel(() => ({}))
const isHorizontal = direction === 'LR'
// Isolated nodes would become extra dagre roots and push linked nodes apart —
// lay out only the connected subgraph, grid the rest separately
const connectedIds = new Set(edges.flatMap((edge) => [edge.source, edge.target]))
const connectedNodes = nodes.filter((node) => connectedIds.has(node.id))
const isolatedNodes = nodes.filter((node) => !connectedIds.has(node.id))
const dagreGraph = new dagre.graphlib.Graph()
dagreGraph.setDefaultEdgeLabel(() => ({}))
dagreGraph.setGraph({
rankdir: direction,
// align: 'UL', // Align to upper left
nodesep: 50, // Minimum space between nodes
ranksep: 100, // Minimum space between ranks
marginx: 20,
marginy: 20,
})
previousDirection.value = direction
for (const node of nodes) {
// if you need width+height of nodes for your layout, you can use the dimensions property of the internal node (`GraphNode` type)
const graphNode = findNode(node.id)
dagreGraph.setNode(node.id, {
width: graphNode?.dimensions.width || 150,
height: graphNode?.dimensions.height || 50,
})
for (const node of connectedNodes) {
dagreGraph.setNode(node.id, estimateNodeSize(node))
}
for (const edge of edges) {
dagreGraph.setEdge(edge.source, edge.target)
}
dagre.layout(dagreGraph)
// set nodes with updated positions
const layoutedNodes = nodes.map((node) => {
// dagre returns node centers — keep them as centers for the TB inversion below
const layoutedConnected = connectedNodes.map((node) => {
const nodeWithPosition = dagreGraph.node(node.id)
return {
@@ -63,58 +113,60 @@ export function useLayout() {
// For TB mode, invert Y coordinates to put root at top
if (!isHorizontal) {
const maxY = Math.max(...layoutedNodes.map((node) => node.position.y))
const maxY = Math.max(...layoutedConnected.map((node) => node.position.y))
layoutedNodes.forEach((node) => {
const graphNode = findNode(node.id)
const nodeHeight = graphNode?.dimensions.height || 50
// Invert Y coordinate and adjust for node height to keep center aligned
node.position.y = maxY - node.position.y + nodeHeight
layoutedConnected.forEach((node) => {
node.position.y = maxY - node.position.y
})
}
return layoutedNodes
// Convert centers to top-left corners (what vue-flow positions actually are)
layoutedConnected.forEach((node) => {
const { width, height } = estimateNodeSize(node)
node.position.x -= width / 2
node.position.y -= height / 2
})
// Grid for isolated nodes below the connected graph
const hasConnected = layoutedConnected.length > 0
const boundsBottom = hasConnected
? Math.max(...layoutedConnected.map((node) => node.position.y + estimateNodeSize(node).height))
: 0
const boundsLeft = hasConnected
? Math.min(...layoutedConnected.map((node) => node.position.x))
: 0
const boundsWidth = hasConnected
? Math.max(...layoutedConnected.map((node) => node.position.x + estimateNodeSize(node).width)) - boundsLeft
: 0
const rowWidth = Math.max(boundsWidth, ISOLATED_MIN_ROW_WIDTH)
let x = boundsLeft
let y = boundsBottom + (hasConnected ? ISOLATED_BLOCK_OFFSET : 0)
let rowHeight = 0
const layoutedIsolated = isolatedNodes.map((node) => {
const { width, height } = estimateNodeSize(node)
if (x > boundsLeft && x + width > boundsLeft + rowWidth) {
x = boundsLeft
y += rowHeight + ISOLATED_GAP_Y
rowHeight = 0
}
const position = { x, y }
x += width + ISOLATED_GAP_X
rowHeight = Math.max(rowHeight, height)
return {
...node,
targetPosition: isHorizontal ? Position.Left : Position.Top,
sourcePosition: isHorizontal ? Position.Right : Position.Bottom,
position,
}
})
return [...layoutedConnected, ...layoutedIsolated]
}
// function layout(nodes: Node[], edges: Edge[], direction: 'LR' | 'TB') {
// // we create a new graph instance, in case some nodes/edges were removed, otherwise dagre would act as if they were still there
// const dagreGraph = new dagre.graphlib.Graph()
// graph.value = dagreGraph
// dagreGraph.setDefaultEdgeLabel(() => ({}))
// const isHorizontal = direction === 'LR'
// dagreGraph.setGraph({ rankdir: direction })
// previousDirection.value = direction
// for (const node of nodes) {
// // if you need width+height of nodes for your layout, you can use the dimensions property of the internal node (`GraphNode` type)
// const graphNode = findNode(node.id)
// dagreGraph.setNode(node.id, { width: graphNode.dimensions.width || 150, height: graphNode.dimensions.height || 50 })
// }
// for (const edge of edges) {
// dagreGraph.setEdge(edge.source, edge.target)
// }
// dagre.layout(dagreGraph)
// // set nodes with updated positions
// return nodes.map((node) => {
// const nodeWithPosition = dagreGraph.node(node.id)
// return {
// ...node,
// targetPosition: isHorizontal ? Position.Left : Position.Top,
// sourcePosition: isHorizontal ? Position.Right : Position.Bottom,
// position: { x: nodeWithPosition.x, y: nodeWithPosition.y },
// }
// })
// }
return { graph, layout, previousDirection }
return { layout }
}
+2 -2
View File
@@ -29,9 +29,9 @@
<div class="relative h-full">
<div
v-if="showFilters"
class="rounded absolute top-0 left-0 right-0 z-10 overflow-x-auto border-b border-default bg-elevated/90 backdrop-blur-sm px-3 py-2"
class="rounded-3xl absolute top-0 left-0 right-0 z-10 overflow-x-auto border-b border-default bg-elevated/90 backdrop-blur-sm "
>
<div class="flex items-center justify-end gap-2 w-fit ml-auto">
<div class="flex items-center justify-end gap-2 w-fit ml-auto p-2 px-2.5">
<TvKanbanFilters
v-model:list-ids="selectedListIds"
v-model:assignee-ids="selectedAssigneeIds"
+1 -1
View File
@@ -30,6 +30,7 @@ export const useGraphStore = defineStore('use-graph-store', {
await Promise.all([
goalListsStore.fetchLists(goalId),
tasksStore.fetchAllTasks(goalId, 1, 1, true),
this.fetchAllEdges(goalId),
])
const nodes = tasksStore.tasks.map((task) => ({
@@ -41,7 +42,6 @@ export const useGraphStore = defineStore('use-graph-store', {
}))
this.allNodes = nodes
this.nodes = [...nodes]
await this.fetchAllEdges(goalId)
},
async addNode(task: TaskItem) {