diff --git a/web/package.json b/web/package.json index 844430d..f5e008a 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/src/components/features/graph/ProjectGraph.vue b/web/src/components/features/graph/ProjectGraph.vue index 2d2910e..0013f5d 100644 --- a/web/src/components/features/graph/ProjectGraph.vue +++ b/web/src/components/features/graph/ProjectGraph.vue @@ -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(null) const successfulSession = ref(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 }) } diff --git a/web/src/components/features/graph/TaskNode.vue b/web/src/components/features/graph/TaskNode.vue index 1591713..9cad17b 100644 --- a/web/src/components/features/graph/TaskNode.vue +++ b/web/src/components/features/graph/TaskNode.vue @@ -27,10 +27,10 @@ :style="sourceHandleStyle" /> - @@ -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' diff --git a/web/src/components/features/graph/composables/useLayout.ts b/web/src/components/features/graph/composables/useLayout.ts index cc0063e..ab908f7 100644 --- a/web/src/components/features/graph/composables/useLayout.ts +++ b/web/src/components/features/graph/composables/useLayout.ts @@ -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 } } diff --git a/web/src/pages/user/graph.vue b/web/src/pages/user/graph.vue index 6113eb4..8c68dbd 100644 --- a/web/src/pages/user/graph.vue +++ b/web/src/pages/user/graph.vue @@ -29,9 +29,9 @@
-
+
({ @@ -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) {