mirror of
https://github.com/Gimanh/taskview-community.git
synced 2026-09-11 21:38:56 +00:00
fix: performance issue in graph and update layout building
This commit is contained in:
+1
-1
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user