From 8ffe63f54eb3369e0d05679e30afb68418925341 Mon Sep 17 00:00:00 2001 From: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com> Date: Fri, 19 Sep 2025 21:52:57 +0100 Subject: [PATCH] Layoutstore Minimap calculation (#5547) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This pull request refactors the minimap rendering system to use a unified, extensible data source abstraction for all minimap operations. By introducing a data source interface and factory, the minimap can now seamlessly support multiple sources of node layout (such as the `LayoutStore` or the underlying `LiteGraph`), improving maintainability and future extensibility. Rendering logic and change detection throughout the minimap have been updated to use this new abstraction, resulting in cleaner code and easier support for new data models. **Core architecture improvements:** * Introduced a new `IMinimapDataSource` interface and related data types (`MinimapNodeData`, `MinimapLinkData`, `MinimapGroupData`) to standardize node, link, and group data for minimap rendering. * Added an abstract base class `AbstractMinimapDataSource` that provides shared logic for bounds and group/link extraction, and implemented two concrete data sources: `LiteGraphDataSource` (for classic graph data) and `LayoutStoreDataSource` (for layout store data). [[1]](diffhunk://#diff-ea46218fc9ffced84168a5ff975e4a30e43f7bf134ee8f02ed2eae66efbb729dR1-R95) [[2]](diffhunk://#diff-9a6b7c6be25b4dbeb358fea18f3a21e78797058ccc86c818ed1e5f69c7355273R1-R30) [[3]](diffhunk://#diff-f200ba9495a03157198abff808ed6c3761746071404a52adbad98f6a9d01249bR1-R42) * Created a `MinimapDataSourceFactory` that selects the appropriate data source based on the presence of layout store data, enabling seamless switching between data models. **Minimap rendering and logic refactoring:** * Updated all minimap rendering functions (`renderGroups`, `renderNodes`, `renderConnections`) and the main `renderMinimapToCanvas` entry point to use the unified data source interface, significantly simplifying the rendering code and decoupling it from the underlying graph structure. [[1]](diffhunk://#diff-3670f99330b2e24aca3cffeeac6600adf8abadd6dd585f596d60fde1dd093121L1-R11) [[2]](diffhunk://#diff-3670f99330b2e24aca3cffeeac6600adf8abadd6dd585f596d60fde1dd093121R33-R75) [[3]](diffhunk://#diff-3670f99330b2e24aca3cffeeac6600adf8abadd6dd585f596d60fde1dd093121L66-R124) [[4]](diffhunk://#diff-3670f99330b2e24aca3cffeeac6600adf8abadd6dd585f596d60fde1dd093121L134-R161) [[5]](diffhunk://#diff-3670f99330b2e24aca3cffeeac6600adf8abadd6dd585f596d60fde1dd093121L153-R187) [[6]](diffhunk://#diff-3670f99330b2e24aca3cffeeac6600adf8abadd6dd585f596d60fde1dd093121L187-L188) [[7]](diffhunk://#diff-3670f99330b2e24aca3cffeeac6600adf8abadd6dd585f596d60fde1dd093121R227-R231) [[8]](diffhunk://#diff-3670f99330b2e24aca3cffeeac6600adf8abadd6dd585f596d60fde1dd093121L230-R248) * Refactored minimap viewport and graph change detection logic to use the data source abstraction for bounds, node, and link change detection, and to respond to layout store version changes. [[1]](diffhunk://#diff-d92e448dee5e30782a66b9e66d8c8b05626dffd0b2ff1032f2612b9a9b9c51f6L2-R10) [[2]](diffhunk://#diff-d92e448dee5e30782a66b9e66d8c8b05626dffd0b2ff1032f2612b9a9b9c51f6R33-R35) [[3]](diffhunk://#diff-d92e448dee5e30782a66b9e66d8c8b05626dffd0b2ff1032f2612b9a9b9c51f6L99-R141) [[4]](diffhunk://#diff-d92e448dee5e30782a66b9e66d8c8b05626dffd0b2ff1032f2612b9a9b9c51f6R157-R160) [[5]](diffhunk://#diff-338d14c67dabffaf6f68fbf09b16e8d67bead2b9df340e46601b2fbd57331521L8-R11) [[6]](diffhunk://#diff-338d14c67dabffaf6f68fbf09b16e8d67bead2b9df340e46601b2fbd57331521L56-R64) These changes make the minimap codebase more modular and robust, and lay the groundwork for supporting additional node layout strategies in the future. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-5547-Layoutstore-Minimap-calculation-26e6d73d3650813e9457c051dff41ca1) by [Unito](https://www.unito.io) --- .../minimap/composables/useMinimapGraph.ts | 48 +++-- .../minimap/composables/useMinimapViewport.ts | 16 +- .../minimap/data/AbstractMinimapDataSource.ts | 95 ++++++++++ .../minimap/data/LayoutStoreDataSource.ts | 42 +++++ .../minimap/data/LiteGraphDataSource.ts | 30 +++ .../minimap/data/MinimapDataSourceFactory.ts | 22 +++ .../minimap/minimapCanvasRenderer.ts | 146 ++++++++------- src/renderer/extensions/minimap/types.ts | 48 +++++ .../tests/minimap/MinimapDataSource.test.ts | 174 ++++++++++++++++++ 9 files changed, 529 insertions(+), 92 deletions(-) create mode 100644 src/renderer/extensions/minimap/data/AbstractMinimapDataSource.ts create mode 100644 src/renderer/extensions/minimap/data/LayoutStoreDataSource.ts create mode 100644 src/renderer/extensions/minimap/data/LiteGraphDataSource.ts create mode 100644 src/renderer/extensions/minimap/data/MinimapDataSourceFactory.ts create mode 100644 tests-ui/tests/minimap/MinimapDataSource.test.ts diff --git a/src/renderer/extensions/minimap/composables/useMinimapGraph.ts b/src/renderer/extensions/minimap/composables/useMinimapGraph.ts index 4f1c0dd8e..c5bf9aa6c 100644 --- a/src/renderer/extensions/minimap/composables/useMinimapGraph.ts +++ b/src/renderer/extensions/minimap/composables/useMinimapGraph.ts @@ -1,11 +1,13 @@ import { useThrottleFn } from '@vueuse/core' -import { ref } from 'vue' +import { ref, watch } from 'vue' import type { Ref } from 'vue' import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph' import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema' +import { layoutStore } from '@/renderer/core/layout/store/layoutStore' import { api } from '@/scripts/api' +import { MinimapDataSourceFactory } from '../data/MinimapDataSourceFactory' import type { UpdateFlags } from '../types' interface GraphCallbacks { @@ -28,6 +30,9 @@ export function useMinimapGraph( viewport: false }) + // Track LayoutStore version for change detection + const layoutStoreVersion = layoutStore.getVersion() + // Map to store original callbacks per graph ID const originalCallbacksMap = new Map() @@ -96,28 +101,30 @@ export function useMinimapGraph( let positionChanged = false let connectionChanged = false - if (g._nodes.length !== lastNodeCount.value) { + // Use unified data source for change detection + const dataSource = MinimapDataSourceFactory.create(g) + + // Check for node count changes + const currentNodeCount = dataSource.getNodeCount() + if (currentNodeCount !== lastNodeCount.value) { structureChanged = true - lastNodeCount.value = g._nodes.length + lastNodeCount.value = currentNodeCount } - for (const node of g._nodes) { - const key = node.id - const currentState = `${node.pos[0]},${node.pos[1]},${node.size[0]},${node.size[1]}` + // Check for node position/size changes + const nodes = dataSource.getNodes() + for (const node of nodes) { + const nodeId = node.id + const currentState = `${node.x},${node.y},${node.width},${node.height}` - if (nodeStatesCache.get(key) !== currentState) { + if (nodeStatesCache.get(nodeId) !== currentState) { positionChanged = true - nodeStatesCache.set(key, currentState) + nodeStatesCache.set(nodeId, currentState) } } - const currentLinks = JSON.stringify(g.links || {}) - if (currentLinks !== linksCache.value) { - connectionChanged = true - linksCache.value = currentLinks - } - - const currentNodeIds = new Set(g._nodes.map((n: LGraphNode) => n.id)) + // Clean up removed nodes from cache + const currentNodeIds = new Set(nodes.map((n) => n.id)) for (const [nodeId] of nodeStatesCache) { if (!currentNodeIds.has(nodeId)) { nodeStatesCache.delete(nodeId) @@ -125,6 +132,13 @@ export function useMinimapGraph( } } + // TODO: update when Layoutstore tracks links + const currentLinks = JSON.stringify(g.links || {}) + if (currentLinks !== linksCache.value) { + connectionChanged = true + linksCache.value = currentLinks + } + if (structureChanged || positionChanged) { updateFlags.value.bounds = true updateFlags.value.nodes = true @@ -140,6 +154,10 @@ export function useMinimapGraph( const init = () => { setupEventListeners() api.addEventListener('graphChanged', handleGraphChangedThrottled) + + watch(layoutStoreVersion, () => { + void handleGraphChangedThrottled() + }) } const destroy = () => { diff --git a/src/renderer/extensions/minimap/composables/useMinimapViewport.ts b/src/renderer/extensions/minimap/composables/useMinimapViewport.ts index da67e5a7c..6f947a307 100644 --- a/src/renderer/extensions/minimap/composables/useMinimapViewport.ts +++ b/src/renderer/extensions/minimap/composables/useMinimapViewport.ts @@ -5,9 +5,9 @@ import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformS import type { LGraph } from '@/lib/litegraph/src/litegraph' import { calculateMinimapScale, - calculateNodeBounds, enforceMinimumBounds } from '@/renderer/core/spatial/boundsCalculator' +import { MinimapDataSourceFactory } from '@/renderer/extensions/minimap/data/MinimapDataSourceFactory' import type { MinimapBounds, MinimapCanvas, ViewportTransform } from '../types' @@ -53,17 +53,15 @@ export function useMinimapViewport( } const calculateGraphBounds = (): MinimapBounds => { - const g = graph.value - if (!g || !g._nodes || g._nodes.length === 0) { + // Use unified data source + const dataSource = MinimapDataSourceFactory.create(graph.value) + + if (!dataSource.hasData()) { return { minX: 0, minY: 0, maxX: 100, maxY: 100, width: 100, height: 100 } } - const bounds = calculateNodeBounds(g._nodes) - if (!bounds) { - return { minX: 0, minY: 0, maxX: 100, maxY: 100, width: 100, height: 100 } - } - - return enforceMinimumBounds(bounds) + const sourceBounds = dataSource.getBounds() + return enforceMinimumBounds(sourceBounds) } const calculateScale = () => { diff --git a/src/renderer/extensions/minimap/data/AbstractMinimapDataSource.ts b/src/renderer/extensions/minimap/data/AbstractMinimapDataSource.ts new file mode 100644 index 000000000..4aae340b4 --- /dev/null +++ b/src/renderer/extensions/minimap/data/AbstractMinimapDataSource.ts @@ -0,0 +1,95 @@ +import type { LGraph } from '@/lib/litegraph/src/litegraph' +import { calculateNodeBounds } from '@/renderer/core/spatial/boundsCalculator' + +import type { + IMinimapDataSource, + MinimapBounds, + MinimapGroupData, + MinimapLinkData, + MinimapNodeData +} from '../types' + +/** + * Abstract base class for minimap data sources + * Provides common functionality and shared implementation + */ +export abstract class AbstractMinimapDataSource implements IMinimapDataSource { + constructor(protected graph: LGraph | null) {} + + // Abstract methods that must be implemented by subclasses + abstract getNodes(): MinimapNodeData[] + abstract getNodeCount(): number + abstract hasData(): boolean + + // Shared implementation using calculateNodeBounds + getBounds(): MinimapBounds { + const nodes = this.getNodes() + if (nodes.length === 0) { + return { minX: 0, minY: 0, maxX: 100, maxY: 100, width: 100, height: 100 } + } + + // Convert MinimapNodeData to the format expected by calculateNodeBounds + const compatibleNodes = nodes.map((node) => ({ + pos: [node.x, node.y], + size: [node.width, node.height] + })) + + const bounds = calculateNodeBounds(compatibleNodes) + if (!bounds) { + return { minX: 0, minY: 0, maxX: 100, maxY: 100, width: 100, height: 100 } + } + + return bounds + } + + // Shared implementation for groups + getGroups(): MinimapGroupData[] { + if (!this.graph?._groups) return [] + return this.graph._groups.map((group) => ({ + x: group.pos[0], + y: group.pos[1], + width: group.size[0], + height: group.size[1], + color: group.color + })) + } + + // TODO: update when Layoutstore supports links + getLinks(): MinimapLinkData[] { + if (!this.graph) return [] + return this.extractLinksFromGraph(this.graph) + } + + protected extractLinksFromGraph(graph: LGraph): MinimapLinkData[] { + const links: MinimapLinkData[] = [] + const nodeMap = new Map(this.getNodes().map((n) => [n.id, n])) + + for (const node of graph._nodes) { + if (!node.outputs) continue + + const sourceNodeData = nodeMap.get(String(node.id)) + if (!sourceNodeData) continue + + for (const output of node.outputs) { + if (!output.links) continue + + for (const linkId of output.links) { + const link = graph.links[linkId] + if (!link) continue + + const targetNodeData = nodeMap.get(String(link.target_id)) + if (!targetNodeData) continue + + links.push({ + sourceNode: sourceNodeData, + targetNode: targetNodeData, + sourceSlot: link.origin_slot, + targetSlot: link.target_slot + }) + } + } + } + + return links + } +} diff --git a/src/renderer/extensions/minimap/data/LayoutStoreDataSource.ts b/src/renderer/extensions/minimap/data/LayoutStoreDataSource.ts new file mode 100644 index 000000000..c0daf7030 --- /dev/null +++ b/src/renderer/extensions/minimap/data/LayoutStoreDataSource.ts @@ -0,0 +1,42 @@ +import { layoutStore } from '@/renderer/core/layout/store/layoutStore' + +import type { MinimapNodeData } from '../types' +import { AbstractMinimapDataSource } from './AbstractMinimapDataSource' + +/** + * Layout Store data source implementation + */ +export class LayoutStoreDataSource extends AbstractMinimapDataSource { + getNodes(): MinimapNodeData[] { + const allNodes = layoutStore.getAllNodes().value + if (allNodes.size === 0) return [] + + const nodes: MinimapNodeData[] = [] + + for (const [nodeId, layout] of allNodes) { + // Find corresponding LiteGraph node for additional properties + const graphNode = this.graph?._nodes?.find((n) => String(n.id) === nodeId) + + nodes.push({ + id: nodeId, + x: layout.position.x, + y: layout.position.y, + width: layout.size.width, + height: layout.size.height, + bgcolor: graphNode?.bgcolor, + mode: graphNode?.mode, + hasErrors: graphNode?.has_errors + }) + } + + return nodes + } + + getNodeCount(): number { + return layoutStore.getAllNodes().value.size + } + + hasData(): boolean { + return this.getNodeCount() > 0 + } +} diff --git a/src/renderer/extensions/minimap/data/LiteGraphDataSource.ts b/src/renderer/extensions/minimap/data/LiteGraphDataSource.ts new file mode 100644 index 000000000..8e1048e75 --- /dev/null +++ b/src/renderer/extensions/minimap/data/LiteGraphDataSource.ts @@ -0,0 +1,30 @@ +import type { MinimapNodeData } from '../types' +import { AbstractMinimapDataSource } from './AbstractMinimapDataSource' + +/** + * LiteGraph data source implementation + */ +export class LiteGraphDataSource extends AbstractMinimapDataSource { + getNodes(): MinimapNodeData[] { + if (!this.graph?._nodes) return [] + + return this.graph._nodes.map((node) => ({ + id: String(node.id), + x: node.pos[0], + y: node.pos[1], + width: node.size[0], + height: node.size[1], + bgcolor: node.bgcolor, + mode: node.mode, + hasErrors: node.has_errors + })) + } + + getNodeCount(): number { + return this.graph?._nodes?.length ?? 0 + } + + hasData(): boolean { + return this.getNodeCount() > 0 + } +} diff --git a/src/renderer/extensions/minimap/data/MinimapDataSourceFactory.ts b/src/renderer/extensions/minimap/data/MinimapDataSourceFactory.ts new file mode 100644 index 000000000..49b15ed9e --- /dev/null +++ b/src/renderer/extensions/minimap/data/MinimapDataSourceFactory.ts @@ -0,0 +1,22 @@ +import type { LGraph } from '@/lib/litegraph/src/litegraph' +import { layoutStore } from '@/renderer/core/layout/store/layoutStore' + +import type { IMinimapDataSource } from '../types' +import { LayoutStoreDataSource } from './LayoutStoreDataSource' +import { LiteGraphDataSource } from './LiteGraphDataSource' + +/** + * Factory for creating the appropriate data source + */ +export class MinimapDataSourceFactory { + static create(graph: LGraph | null): IMinimapDataSource { + // Check if LayoutStore has data + const layoutStoreHasData = layoutStore.getAllNodes().value.size > 0 + + if (layoutStoreHasData) { + return new LayoutStoreDataSource(graph) + } + + return new LiteGraphDataSource(graph) + } +} diff --git a/src/renderer/extensions/minimap/minimapCanvasRenderer.ts b/src/renderer/extensions/minimap/minimapCanvasRenderer.ts index 2e0790ca9..3e547ce68 100644 --- a/src/renderer/extensions/minimap/minimapCanvasRenderer.ts +++ b/src/renderer/extensions/minimap/minimapCanvasRenderer.ts @@ -3,7 +3,12 @@ import { LGraphEventMode } from '@/lib/litegraph/src/litegraph' import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore' import { adjustColor } from '@/utils/colorUtil' -import type { MinimapRenderContext } from './types' +import { MinimapDataSourceFactory } from './data/MinimapDataSourceFactory' +import type { + IMinimapDataSource, + MinimapNodeData, + MinimapRenderContext +} from './types' /** * Get theme-aware colors for the minimap @@ -25,24 +30,49 @@ function getMinimapColors() { } } +/** + * Get node color based on settings and node properties (Single Responsibility) + */ +function getNodeColor( + node: MinimapNodeData, + settings: MinimapRenderContext['settings'], + colors: ReturnType +): string { + if (settings.renderBypass && node.mode === LGraphEventMode.BYPASS) { + return colors.bypassColor + } + + if (settings.nodeColors) { + if (node.bgcolor) { + return colors.isLightTheme + ? adjustColor(node.bgcolor, { lightness: 0.5 }) + : node.bgcolor + } + return colors.nodeColorDefault + } + + return colors.nodeColor +} + /** * Render groups on the minimap */ function renderGroups( ctx: CanvasRenderingContext2D, - graph: LGraph, + dataSource: IMinimapDataSource, offsetX: number, offsetY: number, context: MinimapRenderContext, colors: ReturnType ) { - if (!graph._groups || graph._groups.length === 0) return + const groups = dataSource.getGroups() + if (groups.length === 0) return - for (const group of graph._groups) { - const x = (group.pos[0] - context.bounds.minX) * context.scale + offsetX - const y = (group.pos[1] - context.bounds.minY) * context.scale + offsetY - const w = group.size[0] * context.scale - const h = group.size[1] * context.scale + for (const group of groups) { + const x = (group.x - context.bounds.minX) * context.scale + offsetX + const y = (group.y - context.bounds.minY) * context.scale + offsetY + const w = group.width * context.scale + const h = group.height * context.scale let color = colors.groupColor @@ -64,45 +94,34 @@ function renderGroups( */ function renderNodes( ctx: CanvasRenderingContext2D, - graph: LGraph, + dataSource: IMinimapDataSource, offsetX: number, offsetY: number, context: MinimapRenderContext, colors: ReturnType ) { - if (!graph._nodes || graph._nodes.length === 0) return + const nodes = dataSource.getNodes() + if (nodes.length === 0) return - // Group nodes by color for batch rendering + // Group nodes by color for batch rendering (performance optimization) const nodesByColor = new Map< string, Array<{ x: number; y: number; w: number; h: number; hasErrors?: boolean }> >() - for (const node of graph._nodes) { - const x = (node.pos[0] - context.bounds.minX) * context.scale + offsetX - const y = (node.pos[1] - context.bounds.minY) * context.scale + offsetY - const w = node.size[0] * context.scale - const h = node.size[1] * context.scale + for (const node of nodes) { + const x = (node.x - context.bounds.minX) * context.scale + offsetX + const y = (node.y - context.bounds.minY) * context.scale + offsetY + const w = node.width * context.scale + const h = node.height * context.scale - let color = colors.nodeColor - - if (context.settings.renderBypass && node.mode === LGraphEventMode.BYPASS) { - color = colors.bypassColor - } else if (context.settings.nodeColors) { - color = colors.nodeColorDefault - - if (node.bgcolor) { - color = colors.isLightTheme - ? adjustColor(node.bgcolor, { lightness: 0.5 }) - : node.bgcolor - } - } + const color = getNodeColor(node, context.settings, colors) if (!nodesByColor.has(color)) { nodesByColor.set(color, []) } - nodesByColor.get(color)!.push({ x, y, w, h, hasErrors: node.has_errors }) + nodesByColor.get(color)!.push({ x, y, w, h, hasErrors: node.hasErrors }) } // Batch render nodes by color @@ -132,13 +151,14 @@ function renderNodes( */ function renderConnections( ctx: CanvasRenderingContext2D, - graph: LGraph, + dataSource: IMinimapDataSource, offsetX: number, offsetY: number, context: MinimapRenderContext, colors: ReturnType ) { - if (!graph || !graph._nodes) return + const links = dataSource.getLinks() + if (links.length === 0) return ctx.strokeStyle = colors.linkColor ctx.lineWidth = 0.3 @@ -151,41 +171,28 @@ function renderConnections( y2: number }> = [] - for (const node of graph._nodes) { - if (!node.outputs) continue + for (const link of links) { + const x1 = + (link.sourceNode.x - context.bounds.minX) * context.scale + offsetX + const y1 = + (link.sourceNode.y - context.bounds.minY) * context.scale + offsetY + const x2 = + (link.targetNode.x - context.bounds.minX) * context.scale + offsetX + const y2 = + (link.targetNode.y - context.bounds.minY) * context.scale + offsetY - const x1 = (node.pos[0] - context.bounds.minX) * context.scale + offsetX - const y1 = (node.pos[1] - context.bounds.minY) * context.scale + offsetY + const outputX = x1 + link.sourceNode.width * context.scale + const outputY = y1 + link.sourceNode.height * context.scale * 0.2 + const inputX = x2 + const inputY = y2 + link.targetNode.height * context.scale * 0.2 - for (const output of node.outputs) { - if (!output.links) continue + // Draw connection line + ctx.beginPath() + ctx.moveTo(outputX, outputY) + ctx.lineTo(inputX, inputY) + ctx.stroke() - for (const linkId of output.links) { - const link = graph.links[linkId] - if (!link) continue - - const targetNode = graph.getNodeById(link.target_id) - if (!targetNode) continue - - const x2 = - (targetNode.pos[0] - context.bounds.minX) * context.scale + offsetX - const y2 = - (targetNode.pos[1] - context.bounds.minY) * context.scale + offsetY - - const outputX = x1 + node.size[0] * context.scale - const outputY = y1 + node.size[1] * context.scale * 0.2 - const inputX = x2 - const inputY = y2 + targetNode.size[1] * context.scale * 0.2 - - // Draw connection line - ctx.beginPath() - ctx.moveTo(outputX, outputY) - ctx.lineTo(inputX, inputY) - ctx.stroke() - - connections.push({ x1: outputX, y1: outputY, x2: inputX, y2: inputY }) - } - } + connections.push({ x1: outputX, y1: outputY, x2: inputX, y2: inputY }) } // Render connection slots on top @@ -217,8 +224,11 @@ export function renderMinimapToCanvas( // Clear canvas ctx.clearRect(0, 0, context.width, context.height) + // Create unified data source (Dependency Inversion) + const dataSource = MinimapDataSourceFactory.create(graph) + // Fast path for empty graph - if (!graph || !graph._nodes || graph._nodes.length === 0) { + if (!dataSource.hasData()) { return } @@ -228,12 +238,12 @@ export function renderMinimapToCanvas( // Render in correct order: groups -> links -> nodes if (context.settings.showGroups) { - renderGroups(ctx, graph, offsetX, offsetY, context, colors) + renderGroups(ctx, dataSource, offsetX, offsetY, context, colors) } if (context.settings.showLinks) { - renderConnections(ctx, graph, offsetX, offsetY, context, colors) + renderConnections(ctx, dataSource, offsetX, offsetY, context, colors) } - renderNodes(ctx, graph, offsetX, offsetY, context, colors) + renderNodes(ctx, dataSource, offsetX, offsetY, context, colors) } diff --git a/src/renderer/extensions/minimap/types.ts b/src/renderer/extensions/minimap/types.ts index fbea21c83..b458718ea 100644 --- a/src/renderer/extensions/minimap/types.ts +++ b/src/renderer/extensions/minimap/types.ts @@ -2,6 +2,7 @@ * Minimap-specific type definitions */ import type { LGraph } from '@/lib/litegraph/src/litegraph' +import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema' /** * Minimal interface for what the minimap needs from the canvas @@ -66,3 +67,50 @@ export type MinimapSettingsKey = | 'Comfy.Minimap.ShowGroups' | 'Comfy.Minimap.RenderBypassState' | 'Comfy.Minimap.RenderErrorState' + +/** + * Node data required for minimap rendering + */ +export interface MinimapNodeData { + id: NodeId + x: number + y: number + width: number + height: number + bgcolor?: string + mode?: number + hasErrors?: boolean +} + +/** + * Link data required for minimap rendering + */ +export interface MinimapLinkData { + sourceNode: MinimapNodeData + targetNode: MinimapNodeData + sourceSlot: number + targetSlot: number +} + +/** + * Group data required for minimap rendering + */ +export interface MinimapGroupData { + x: number + y: number + width: number + height: number + color?: string +} + +/** + * Interface for minimap data sources (Dependency Inversion Principle) + */ +export interface IMinimapDataSource { + getNodes(): MinimapNodeData[] + getLinks(): MinimapLinkData[] + getGroups(): MinimapGroupData[] + getBounds(): MinimapBounds + getNodeCount(): number + hasData(): boolean +} diff --git a/tests-ui/tests/minimap/MinimapDataSource.test.ts b/tests-ui/tests/minimap/MinimapDataSource.test.ts new file mode 100644 index 000000000..eff6ba771 --- /dev/null +++ b/tests-ui/tests/minimap/MinimapDataSource.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it, vi } from 'vitest' +import { type ComputedRef, computed } from 'vue' + +import type { NodeId } from '@/lib/litegraph/src/LGraphNode' +import type { LGraph, LGraphNode, LLink } from '@/lib/litegraph/src/litegraph' +import { layoutStore } from '@/renderer/core/layout/store/layoutStore' +import type { NodeLayout } from '@/renderer/core/layout/types' +import { MinimapDataSourceFactory } from '@/renderer/extensions/minimap/data/MinimapDataSourceFactory' + +// Mock layoutStore +vi.mock('@/renderer/core/layout/store/layoutStore', () => ({ + layoutStore: { + getAllNodes: vi.fn() + } +})) + +// Helper to create mock links that satisfy LGraph['links'] type +function createMockLinks(): LGraph['links'] { + const map = new Map() + return Object.assign(map, {}) as LGraph['links'] +} + +describe('MinimapDataSource', () => { + describe('MinimapDataSourceFactory', () => { + it('should create LayoutStoreDataSource when LayoutStore has data', () => { + // Arrange + const mockNodes = new Map([ + [ + 'node1', + { + id: 'node1', + position: { x: 0, y: 0 }, + size: { width: 100, height: 50 }, + zIndex: 0, + visible: true, + bounds: { x: 0, y: 0, width: 100, height: 50 } + } + ] + ]) + + // Create a computed ref that returns the map + const computedNodes: ComputedRef> = + computed(() => mockNodes) + vi.mocked(layoutStore.getAllNodes).mockReturnValue(computedNodes) + + const mockGraph: Pick = { + _nodes: [], + _groups: [], + links: createMockLinks() + } + + // Act + const dataSource = MinimapDataSourceFactory.create(mockGraph as LGraph) + + // Assert + expect(dataSource).toBeDefined() + expect(dataSource.hasData()).toBe(true) + expect(dataSource.getNodeCount()).toBe(1) + }) + + it('should create LiteGraphDataSource when LayoutStore is empty', () => { + // Arrange + const emptyMap = new Map() + const computedEmpty: ComputedRef> = + computed(() => emptyMap) + vi.mocked(layoutStore.getAllNodes).mockReturnValue(computedEmpty) + + const mockNode: Pick< + LGraphNode, + 'id' | 'pos' | 'size' | 'bgcolor' | 'mode' | 'has_errors' | 'outputs' + > = { + id: 'node1' as NodeId, + pos: [0, 0], + size: [100, 50], + bgcolor: '#fff', + mode: 0, + has_errors: false, + outputs: [] + } + + const mockGraph: Pick = { + _nodes: [mockNode as LGraphNode], + _groups: [], + links: createMockLinks() + } + + // Act + const dataSource = MinimapDataSourceFactory.create(mockGraph as LGraph) + + // Assert + expect(dataSource).toBeDefined() + expect(dataSource.hasData()).toBe(true) + expect(dataSource.getNodeCount()).toBe(1) + + const nodes = dataSource.getNodes() + expect(nodes).toHaveLength(1) + expect(nodes[0]).toMatchObject({ + id: 'node1', + x: 0, + y: 0, + width: 100, + height: 50 + }) + }) + + it('should handle empty graph correctly', () => { + // Arrange + const emptyMap = new Map() + const computedEmpty: ComputedRef> = + computed(() => emptyMap) + vi.mocked(layoutStore.getAllNodes).mockReturnValue(computedEmpty) + + const mockGraph: Pick = { + _nodes: [], + _groups: [], + links: createMockLinks() + } + + // Act + const dataSource = MinimapDataSourceFactory.create(mockGraph as LGraph) + + // Assert + expect(dataSource.hasData()).toBe(false) + expect(dataSource.getNodeCount()).toBe(0) + expect(dataSource.getNodes()).toEqual([]) + expect(dataSource.getLinks()).toEqual([]) + expect(dataSource.getGroups()).toEqual([]) + }) + }) + + describe('Bounds calculation', () => { + it('should calculate correct bounds from nodes', () => { + // Arrange + const emptyMap = new Map() + const computedEmpty: ComputedRef> = + computed(() => emptyMap) + vi.mocked(layoutStore.getAllNodes).mockReturnValue(computedEmpty) + + const mockNode1: Pick = { + id: 'node1' as NodeId, + pos: [0, 0], + size: [100, 50], + outputs: [] + } + + const mockNode2: Pick = { + id: 'node2' as NodeId, + pos: [200, 100], + size: [150, 75], + outputs: [] + } + + const mockGraph: Pick = { + _nodes: [mockNode1 as LGraphNode, mockNode2 as LGraphNode], + _groups: [], + links: createMockLinks() + } + + // Act + const dataSource = MinimapDataSourceFactory.create(mockGraph as LGraph) + const bounds = dataSource.getBounds() + + // Assert + expect(bounds).toEqual({ + minX: 0, + minY: 0, + maxX: 350, + maxY: 175, + width: 350, + height: 175 + }) + }) + }) +})