diff --git a/frontend/src/lib/components/graph/FlowGraphV2.svelte b/frontend/src/lib/components/graph/FlowGraphV2.svelte index acd4f04161..cc6cce33ca 100644 --- a/frontend/src/lib/components/graph/FlowGraphV2.svelte +++ b/frontend/src/lib/components/graph/FlowGraphV2.svelte @@ -71,6 +71,7 @@ import type { AssetWithAltAccessType } from '../assets/lib' import type { AIModuleAction } from '../copilot/chat/flow/core' import { setGraphContext } from './graphContext' + import { computeGroupNoteSpacing } from './groupNoteSpacing' let useDataflow: Writable = writable(false) let showAssets: Writable = writable(true) @@ -318,7 +319,7 @@ } const yOffset = insertable ? 100 : 0 - const initialNodes = dag.descendants().map((des) => ({ + return dag.descendants().map((des) => ({ id: des.data.id, position: { x: des.x @@ -334,45 +335,6 @@ y: (des.y || 0) + yOffset } })) - - // Apply group note spacing adjustments - // First, collect all nodes that need spacing above them - const spacingMap = new Map() - for (const node of initialNodes) { - const groupNoteHeight = noteManager.getGroupNoteHeightForNode( - notes ?? [], - node.id, - initialNodes, - noteTextHeights - ) - if (groupNoteHeight > 0) { - spacingMap.set(node.id, groupNoteHeight) - } - } - - // Apply spacing - move nodes down by the cumulative spacing above them - const adjustedNodes = initialNodes.map((node) => { - let totalSpacingAbove = 0 - - // Calculate total spacing needed above this node from all group notes above it - for (const [spacingNodeId, spacing] of spacingMap) { - const spacingNode = initialNodes.find((n) => n.id === spacingNodeId) - if (spacingNode && spacingNode.position.y <= node.position.y) { - totalSpacingAbove += spacing - } - } - - return { - ...node, - position: { - ...node.position, - y: node.position.y + totalSpacingAbove - } - } - }) - - lastNodes = [nodes, adjustedNodes] - return adjustedNodes } let eventHandler = { @@ -468,7 +430,7 @@ actualSelectionManager.handleKeyDown(event, nodes) } - function handleKeyUp(event: KeyboardEvent) { + function handleKeyUp(_event: KeyboardEvent) { // Keep for potential future use } @@ -507,6 +469,17 @@ ) let newNodes: (Node & NodeLayout)[] = layoutedNodes.map((n) => ({ ...n, ...graph.nodes[n.id] })) + // Apply group note spacing adjustments + const groupNoteSpacingResult = computeGroupNoteSpacing( + newNodes.map((n) => ({ id: n.id, position: n.position })), + notes ?? [], + noteTextHeights + ) + newNodes = newNodes.map((n) => ({ + ...n, + position: groupNoteSpacingResult.newNodePositions[n.id] || n.position + })) + let assetNodesResult = $showAssets ? computeAssetNodes( newNodes.map((n) => ({ @@ -720,7 +693,6 @@ } $inspect('dbg notes & nodes', notes, nodes) - $inspect('dbg noteTextHeights', noteTextHeights) {#if insertable} diff --git a/frontend/src/lib/components/graph/groupNoteSpacing.ts b/frontend/src/lib/components/graph/groupNoteSpacing.ts new file mode 100644 index 0000000000..7964139943 --- /dev/null +++ b/frontend/src/lib/components/graph/groupNoteSpacing.ts @@ -0,0 +1,184 @@ +import type { FlowNote } from '../../gen' +import { deepEqual } from 'fast-equals' +import { clone } from '../../utils' + +export type NodePosition = { + id: string + position: { x: number; y: number } +} + +export type GroupNoteSpacingResult = { + // Nodes need to be offset on the y axis to make space for group notes + newNodePositions: Record +} + +export type TextHeightCache = { + content: string + height: number +} + +// Cache for expensive group note spacing calculations +let computeGroupNoteSpacingCache: + | [NodePosition[], FlowNote[], Record, GroupNoteSpacingResult] + | undefined + +// Text height cache to avoid remeasuring unchanged note text +let textHeightCache: Record = {} + +const GROUP_NOTE_PADDING = 20 + +/** + * Caches text height measurements based on content hash + */ +export function cacheTextHeight(noteId: string, content: string, height: number): void { + textHeightCache[noteId] = { content, height } +} + +/** + * Gets cached text height if content hasn't changed, otherwise returns undefined + */ +export function getCachedTextHeight(noteId: string, content: string): number | undefined { + const cached = textHeightCache[noteId] + if (cached && cached.content === content) { + return cached.height + } + return undefined +} + +/** + * Gets the text height for a note, using cache if available or fallback + */ +function getTextHeight(note: FlowNote, noteTextHeights: Record): number { + // Try cached height first + const cachedHeight = getCachedTextHeight(note.id, note.text) + if (cachedHeight !== undefined) { + return cachedHeight + } + + // Fall back to runtime heights + const runtimeHeight = noteTextHeights[note.id] + if (runtimeHeight !== undefined) { + // Update cache for future use + cacheTextHeight(note.id, note.text, runtimeHeight) + return runtimeHeight + } + + // Default fallback + return 60 +} + +/** + * Finds the topmost node in a group by Y position + */ +function findTopmostNodeInGroup( + groupNote: FlowNote, + nodes: NodePosition[] +): NodePosition | undefined { + if (!groupNote.contained_node_ids?.length) { + return undefined + } + + const containedNodes = nodes.filter((node) => groupNote.contained_node_ids?.includes(node.id)) + + if (containedNodes.length === 0) { + return undefined + } + + return containedNodes.reduce((topMost, node) => + node.position.y < topMost.position.y ? node : topMost + ) +} + +/** + * Computes vertical spacing adjustments for nodes to accommodate group notes. + */ +export function computeGroupNoteSpacing( + nodes: NodePosition[], + notes: FlowNote[], + noteTextHeights: Record +): GroupNoteSpacingResult { + // Check cache first + if ( + computeGroupNoteSpacingCache && + deepEqual(nodes, computeGroupNoteSpacingCache[0]) && + deepEqual(notes, computeGroupNoteSpacingCache[1]) && + deepEqual(noteTextHeights, computeGroupNoteSpacingCache[2]) + ) { + return computeGroupNoteSpacingCache[3] + } + + // Filter to only group notes + const groupNotes = notes.filter((note) => note.type === 'group') + + if (groupNotes.length === 0) { + const result: GroupNoteSpacingResult = { + newNodePositions: Object.fromEntries(nodes.map((n) => [n.id, n.position])) + } + computeGroupNoteSpacingCache = [clone(nodes), clone(notes), clone(noteTextHeights), result] + return result + } + + // Map Y positions to required spacing + const ySpacingMap = new Map() + + // For each group note, determine the spacing needed at the topmost node's Y position + for (const groupNote of groupNotes) { + const topmostNode = findTopmostNodeInGroup(groupNote, nodes) + + if (topmostNode) { + const textHeight = getTextHeight(groupNote, noteTextHeights) + const requiredSpacing = textHeight + GROUP_NOTE_PADDING + + // If multiple group notes affect the same Y position, take the maximum spacing needed + const currentSpacing = ySpacingMap.get(topmostNode.position.y) || 0 + ySpacingMap.set(topmostNode.position.y, Math.max(currentSpacing, requiredSpacing)) + } + } + + // Sort nodes by Y position to apply cumulative spacing + const sortedNodes = [...nodes].sort((a, b) => a.position.y - b.position.y) + + // Apply cumulative spacing + let cumulativeOffset = 0 + let lastYPosition = -Infinity + const adjustedNodes = sortedNodes.map((node) => { + // When we encounter a new Y position, check if it needs additional spacing + if (node.position.y > lastYPosition) { + const spacingAtThisY = ySpacingMap.get(node.position.y) + if (spacingAtThisY !== undefined) { + cumulativeOffset += spacingAtThisY + } + lastYPosition = node.position.y + } + + return { + ...node, + position: { + ...node.position, + y: node.position.y + cumulativeOffset + } + } + }) + + const result: GroupNoteSpacingResult = { + newNodePositions: Object.fromEntries(adjustedNodes.map((n) => [n.id, n.position])) + } + + // Cache the result + computeGroupNoteSpacingCache = [clone(nodes), clone(notes), clone(noteTextHeights), result] + return result +} + +/** + * Clears the text height cache (useful for testing or when needed) + */ +export function clearTextHeightCache(): void { + textHeightCache = {} +} + +/** + * Clears the group note spacing cache (useful when nodes structure changes dramatically) + */ +export function clearGroupNoteSpacingCache(): void { + computeGroupNoteSpacingCache = undefined +} diff --git a/frontend/src/lib/components/graph/noteManager.svelte.ts b/frontend/src/lib/components/graph/noteManager.svelte.ts index 5f9b886b09..ed19e3ca03 100644 --- a/frontend/src/lib/components/graph/noteManager.svelte.ts +++ b/frontend/src/lib/components/graph/noteManager.svelte.ts @@ -2,9 +2,12 @@ import type { FlowNote } from '$lib/gen' import type { Node } from '@xyflow/svelte' import type { NoteColor } from './noteColors' import { calculateNodesBounds } from './util' +import { cacheTextHeight } from './groupNoteSpacing' -type NodeDep = { id: string; parentIds?: string[]; offset?: number } -type NodePos = { position: { x: number; y: number } } +export type NodePosition = { + id: string + position: { x: number; y: number } +} /** * Utility class for managing flow notes including regular and group notes @@ -136,42 +139,6 @@ export class NoteManager { } } - /** - * Helper function to determine if a node needs additional spacing above it for group notes. - * Returns the height needed above the node (text height + padding). - */ - getGroupNoteHeightForNode( - notes: FlowNote[], - nodeId: string, - layoutedNodes: (NodeDep & NodePos)[], - noteTextHeights: Record - ): number { - const PADDING = 20 // Fixed padding above and below the note text - - for (const note of notes) { - if (note.type === 'group' && note.contained_node_ids?.includes(nodeId)) { - // Find the topmost node in this group by Y position - const containedNodes = layoutedNodes.filter((node) => - note.contained_node_ids?.includes(node.id) - ) - - if (containedNodes.length > 0) { - const topmostNode = containedNodes.reduce((topMost, node) => - node.position.y < topMost.position.y ? node : topMost - ) - - // If this is the topmost node in the group, return the needed height - if (topmostNode.id === nodeId) { - // Use actual text height if available, otherwise default to 60 - const textHeight = noteTextHeights[note.id] || 60 - return textHeight + PADDING - } - } - } - } - return 0 - } - /** * Create common data object for note nodes */ @@ -210,6 +177,8 @@ export class NoteManager { }, onTextHeightChange: (textHeight: number) => { onTextHeightChange(note.id, textHeight) + // Cache the text height for improved performance + cacheTextHeight(note.id, note.text, textHeight) } } }