diff --git a/CHANGELOG.md b/CHANGELOG.md index dfa04cedc..48aa4aeef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Connections can now have more than one tag. Assign several tags in the connection form, and filter the welcome list by tag with Match Any or Match All. (#744) - Elasticsearch support. Connect to Elasticsearch 7.x and 8.x, browse indices, run Query DSL requests in a console, and edit documents in the data grid. Install from Settings > Plugins. (#1529) +### Changed + +- The ER diagram now arranges tables in a compact layout that fills the canvas in both directions, keeps tables linked by foreign keys together, and tints each group of connected tables with its own header color. (#1755) + ### Fixed - Raw filters in the data grid now apply on document and key-value databases; the typed text was being dropped before it reached the driver. (#1529) diff --git a/TablePro/Models/ERDiagram/ERClusterAnalyzer.swift b/TablePro/Models/ERDiagram/ERClusterAnalyzer.swift new file mode 100644 index 000000000..fc7dd9d0d --- /dev/null +++ b/TablePro/Models/ERDiagram/ERClusterAnalyzer.swift @@ -0,0 +1,72 @@ +import Foundation + +enum ERClusterAnalyzer { + static func assignClusters( + nodes: [ERTableNode], + edges: [EREdge], + nodeIndex: [String: UUID] + ) -> [UUID: Int] { + guard !nodes.isEmpty else { return [:] } + + var parent: [UUID: UUID] = [:] + var rank: [UUID: Int] = [:] + for node in nodes { + parent[node.id] = node.id + rank[node.id] = 0 + } + + func find(_ start: UUID) -> UUID { + var root = start + while let next = parent[root], next != root { root = next } + var current = start + while let next = parent[current], next != root { + parent[current] = root + current = next + } + return root + } + + func union(_ lhs: UUID, _ rhs: UUID) { + let rootLhs = find(lhs) + let rootRhs = find(rhs) + guard rootLhs != rootRhs else { return } + let rankLhs = rank[rootLhs] ?? 0 + let rankRhs = rank[rootRhs] ?? 0 + if rankLhs < rankRhs { + parent[rootLhs] = rootRhs + } else if rankLhs > rankRhs { + parent[rootRhs] = rootLhs + } else { + parent[rootRhs] = rootLhs + rank[rootLhs] = rankLhs + 1 + } + } + + for edge in edges { + guard let from = nodeIndex[edge.fromTable], + let to = nodeIndex[edge.toTable], + from != to + else { continue } + union(from, to) + } + + var members: [UUID: [UUID]] = [:] + for node in nodes { + members[find(node.id), default: []].append(node.id) + } + + let nameById = Dictionary(uniqueKeysWithValues: nodes.map { ($0.id, $0.tableName) }) + let multiNodeComponents = members.values.filter { $0.count >= 2 } + let ordered = multiNodeComponents.sorted { lhs, rhs in + let lhsKey = lhs.compactMap { nameById[$0] }.min() ?? "" + let rhsKey = rhs.compactMap { nameById[$0] }.min() ?? "" + return lhsKey < rhsKey + } + + var result: [UUID: Int] = [:] + for (index, component) in ordered.enumerated() { + for member in component { result[member] = index } + } + return result + } +} diff --git a/TablePro/Models/ERDiagram/ERDiagramLayout.swift b/TablePro/Models/ERDiagram/ERDiagramLayout.swift index 33a46a7ff..732808f04 100644 --- a/TablePro/Models/ERDiagram/ERDiagramLayout.swift +++ b/TablePro/Models/ERDiagram/ERDiagramLayout.swift @@ -2,8 +2,9 @@ import AppKit import Foundation import os -/// Sugiyama-style layered layout for ER diagrams. -/// Produces node center positions from a graph of tables and FK edges. +/// Component-aware compact layout for ER diagrams. +/// Detects connected components, places each with a force-directed pass, then packs +/// the component blocks into the 2D plane so the diagram fills both axes. enum ERDiagramLayout { private static let logger = Logger(subsystem: "com.TablePro", category: "ERDiagramLayout") @@ -16,274 +17,394 @@ enum ERDiagramLayout { static var nodeWidth: CGFloat { 220 * typeScale } static let horizontalGap: CGFloat = 60 static let verticalGap: CGFloat = 40 + static let blockGap: CGFloat = 80 static var headerHeight: CGFloat { 36 * typeScale } static var columnRowHeight: CGFloat { 22 * typeScale } - static func compute( - graph: ERDiagramGraph - ) -> [UUID: CGPoint] { + private struct Block { + let positions: [UUID: CGPoint] + let size: CGSize + } + + static func compute(graph: ERDiagramGraph) -> [UUID: CGPoint] { guard !graph.nodes.isEmpty else { return [:] } - let adjacency = buildAdjacency(graph: graph) - let dagEdges = breakCycles(adjacency: adjacency, nodeIds: graph.nodes.map(\.id)) - let layers = assignLayers(dagEdges: dagEdges, nodeIds: graph.nodes.map(\.id), graph: graph) - let orderedLayers = minimizeCrossings(layers: layers, dagEdges: dagEdges) - return assignCoordinates(orderedLayers: orderedLayers, graph: graph) + let sizes = nodeSizes(graph: graph) + let adjacency = undirectedAdjacency(graph: graph) + + var componentGroups: [Int: [UUID]] = [:] + var singletons: [UUID] = [] + for node in graph.nodes.sorted(by: { $0.tableName < $1.tableName }) { + if let clusterId = node.clusterId { + componentGroups[clusterId, default: []].append(node.id) + } else { + singletons.append(node.id) + } + } + + var blocks: [Block] = [] + for clusterId in componentGroups.keys.sorted() { + let members = componentGroups[clusterId] ?? [] + let local = forceDirected(members: members, adjacency: adjacency, sizes: sizes) + blocks.append(makeBlock(centers: local, members: members, sizes: sizes)) + } + if !singletons.isEmpty { + blocks.append(gridBlock(members: singletons, sizes: sizes)) + } + + let placements = packBlocks(blocks.map(\.size)) + return composeCenters(blocks: blocks, placements: placements, sizes: sizes) } static func estimateHeight(columnCount: Int) -> CGFloat { headerHeight + CGFloat(max(columnCount, 1)) * columnRowHeight } - // MARK: - Adjacency + // MARK: - Graph Derivations - private static func buildAdjacency(graph: ERDiagramGraph) -> [UUID: [UUID]] { - var adj: [UUID: [UUID]] = [:] - for node in graph.nodes { - adj[node.id] = [] - } + private static func nodeSizes(graph: ERDiagramGraph) -> [UUID: CGSize] { + Dictionary(uniqueKeysWithValues: graph.nodes.map { node in + (node.id, CGSize(width: nodeWidth, height: estimateHeight(columnCount: node.displayColumns.count))) + }) + } + + private static func undirectedAdjacency(graph: ERDiagramGraph) -> [UUID: [UUID]] { + var adjacency: [UUID: [UUID]] = [:] for edge in graph.edges { - guard let fromId = graph.nodeIndex[edge.fromTable], - let toId = graph.nodeIndex[edge.toTable] + guard let from = graph.nodeIndex[edge.fromTable], + let to = graph.nodeIndex[edge.toTable], + from != to else { continue } - // FK owner → referenced table (child → parent in ER terms) - adj[fromId, default: []].append(toId) + adjacency[from, default: []].append(to) + adjacency[to, default: []].append(from) } - return adj + return adjacency } - // MARK: - Cycle Breaking (DFS) - - private static func breakCycles(adjacency: [UUID: [UUID]], nodeIds: [UUID]) -> [UUID: [UUID]] { - var visited: Set = [] - var onStack: Set = [] - var dag = adjacency - var backEdges: [(UUID, UUID)] = [] - - for startNode in nodeIds where !visited.contains(startNode) { - // Iterative DFS using explicit stack - // Each entry: (node, neighborIndex) - var stack: [(node: UUID, idx: Int)] = [(startNode, 0)] - visited.insert(startNode) - onStack.insert(startNode) - - while !stack.isEmpty { - let (node, idx) = stack[stack.count - 1] - let neighbors = adjacency[node] ?? [] - - if idx < neighbors.count { - stack[stack.count - 1].idx += 1 - let neighbor = neighbors[idx] - if onStack.contains(neighbor) { - backEdges.append((node, neighbor)) - } else if !visited.contains(neighbor) { - visited.insert(neighbor) - onStack.insert(neighbor) - stack.append((neighbor, 0)) - } - } else { - onStack.remove(node) - stack.removeLast() - } + // MARK: - Force-Directed Component Layout + + private static func forceDirected( + members: [UUID], + adjacency: [UUID: [UUID]], + sizes: [UUID: CGSize] + ) -> [UUID: CGPoint] { + guard let first = members.first else { return [:] } + guard members.count > 1 else { return [first: .zero] } + + let count = members.count + let spacing = idealDistance(members: members, sizes: sizes) + let edges = uniqueEdges(members: members, adjacency: adjacency) + var degree: [UUID: Int] = [:] + for (source, target) in edges { + degree[source, default: 0] += 1 + degree[target, default: 0] += 1 + } + var positions = circularInit(members: members, idealDistance: spacing) + let iterations = max(60, min(300, 2_000 / count)) + var temperature = spacing * 2 + + for _ in 0.. [UUID: CGPoint] { + guard members.count > 2 else { return positions } + var centerX: CGFloat = 0 + var centerY: CGFloat = 0 + for id in members { + centerX += positions[id]?.x ?? 0 + centerY += positions[id]?.y ?? 0 + } + centerX /= CGFloat(members.count) + centerY /= CGFloat(members.count) + + var sxx: CGFloat = 0 + var syy: CGFloat = 0 + var sxy: CGFloat = 0 + for id in members { + guard let position = positions[id] else { continue } + let dx = position.x - centerX + let dy = position.y - centerY + sxx += dx * dx + syy += dy * dy + sxy += dx * dy } - return dag + let theta = 0.5 * atan2(2 * sxy, sxx - syy) + let cosT = cos(-theta) + let sinT = sin(-theta) + var rotated: [UUID: CGPoint] = [:] + for id in members { + guard let position = positions[id] else { continue } + let dx = position.x - centerX + let dy = position.y - centerY + rotated[id] = CGPoint(x: centerX + dx * cosT - dy * sinT, y: centerY + dx * sinT + dy * cosT) + } + return rotated } - // MARK: - Layer Assignment (Longest Path) - - private static func assignLayers( - dagEdges: [UUID: [UUID]], - nodeIds: [UUID], - graph: ERDiagramGraph - ) -> [[UUID]] { - // Build reverse adjacency (incoming edges) - var inDegree: [UUID: Int] = [:] - for id in nodeIds { inDegree[id] = 0 } - for (_, neighbors) in dagEdges { - for n in neighbors { inDegree[n, default: 0] += 1 } + private static func uniqueEdges(members: [UUID], adjacency: [UUID: [UUID]]) -> [(UUID, UUID)] { + let memberSet = Set(members) + var seen: Set = [] + var edges: [(UUID, UUID)] = [] + for source in members { + for target in adjacency[source] ?? [] where memberSet.contains(target) { + let key = source.uuidString < target.uuidString + ? source.uuidString + target.uuidString + : target.uuidString + source.uuidString + guard !seen.contains(key) else { continue } + seen.insert(key) + edges.append((source, target)) + } } + return edges + } - // Topological sort via Kahn's algorithm - var queue = nodeIds.filter { (inDegree[$0] ?? 0) == 0 } - var layerAssignment: [UUID: Int] = [:] - for id in queue { layerAssignment[id] = 0 } - - var idx = 0 - while idx < queue.count { - let node = queue[idx] - idx += 1 - let currentLayer = layerAssignment[node] ?? 0 - for neighbor in dagEdges[node] ?? [] { - let newLayer = currentLayer + 1 - if newLayer > (layerAssignment[neighbor] ?? 0) { - layerAssignment[neighbor] = newLayer - } - inDegree[neighbor] = (inDegree[neighbor] ?? 1) - 1 - if inDegree[neighbor] == 0 { - queue.append(neighbor) + private static func forceStep( + members: [UUID], + edges: [(UUID, UUID)], + degree: [UUID: Int], + positions: [UUID: CGPoint], + idealDistance: CGFloat + ) -> [UUID: CGVector] { + var displacement: [UUID: CGVector] = [:] + for id in members { displacement[id] = .zero } + + var centerX: CGFloat = 0 + var centerY: CGFloat = 0 + for id in members { + centerX += positions[id]?.x ?? 0 + centerY += positions[id]?.y ?? 0 + } + centerX /= CGFloat(members.count) + centerY /= CGFloat(members.count) + + let count = members.count + for i in 0.. [[UUID]] { - guard layers.count > 1 else { return layers } + private static func idealDistance(members: [UUID], sizes: [UUID: CGSize]) -> CGFloat { + let count = CGFloat(max(members.count, 1)) + let avgWidth = members.reduce(0) { $0 + (sizes[$1]?.width ?? nodeWidth) } / count + let avgHeight = members.reduce(0) { $0 + (sizes[$1]?.height ?? headerHeight) } / count + return (avgWidth + avgHeight) * 0.8 + horizontalGap + } - var reverseEdges: [UUID: [UUID]] = [:] - for (from, neighbors) in dagEdges { - for to in neighbors { - reverseEdges[to, default: []].append(from) - } + private static func circularInit(members: [UUID], idealDistance: CGFloat) -> [UUID: CGPoint] { + let count = members.count + let radius = idealDistance * CGFloat(count) / (2 * .pi) + idealDistance + var positions: [UUID: CGPoint] = [:] + for (index, id) in members.enumerated() { + let angle = 2 * CGFloat.pi * CGFloat(index) / CGFloat(count) + positions[id] = CGPoint(x: radius * cos(angle), y: radius * sin(angle)) } + return positions + } - var result = layers - let sweepCount = min(layers.count * 2, 8) - - for sweep in 0.. 0, overlapY > 0 else { continue } + moved = true + if overlapX < overlapY { + let shift = overlapX / 2 * (posLhs.x >= posRhs.x ? 1 : -1) + positions[lhs]?.x += shift + positions[rhs]?.x -= shift + } else { + let shift = overlapY / 2 * (posLhs.y >= posRhs.y ? 1 : -1) + positions[lhs]?.y += shift + positions[rhs]?.y -= shift } - result[layerIdx].sort { (barycenters[$0] ?? .infinity) < (barycenters[$1] ?? .infinity) } } } + if !moved { break } } - - return result } - // MARK: - Coordinate Assignment (top-to-bottom, center-aligned) + // MARK: - Blocks + + private static func makeBlock( + centers: [UUID: CGPoint], + members: [UUID], + sizes: [UUID: CGSize] + ) -> Block { + var minX = CGFloat.greatestFiniteMagnitude + var minY = CGFloat.greatestFiniteMagnitude + var maxX = -CGFloat.greatestFiniteMagnitude + var maxY = -CGFloat.greatestFiniteMagnitude + for id in members { + let center = centers[id] ?? .zero + let size = sizes[id] ?? CGSize(width: nodeWidth, height: estimateHeight(columnCount: 1)) + minX = min(minX, center.x - size.width / 2) + minY = min(minY, center.y - size.height / 2) + maxX = max(maxX, center.x + size.width / 2) + maxY = max(maxY, center.y + size.height / 2) + } - private static func assignCoordinates( - orderedLayers: [[UUID]], - graph: ERDiagramGraph - ) -> [UUID: CGPoint] { var positions: [UUID: CGPoint] = [:] - let nodeById: [UUID: ERTableNode] = Dictionary( - uniqueKeysWithValues: graph.nodes.map { ($0.id, $0) } - ) - let nodeColumnCounts: [UUID: Int] = nodeById.mapValues(\.displayColumns.count) - - // Separate connected and isolated layers - let allConnected = Set(graph.edges.flatMap { [$0.fromTable, $0.toTable] }) - var connectedLayers: [[UUID]] = [] - var isolatedNodes: [UUID] = [] - - for layer in orderedLayers { - var connected: [UUID] = [] - for nodeId in layer { - let tableName = nodeById[nodeId]?.tableName ?? "" - if allConnected.contains(tableName) { - connected.append(nodeId) - } else { - isolatedNodes.append(nodeId) - } - } - if !connected.isEmpty { - connectedLayers.append(connected) - } + for id in members { + let center = centers[id] ?? .zero + let size = sizes[id] ?? CGSize(width: nodeWidth, height: estimateHeight(columnCount: 1)) + positions[id] = CGPoint(x: center.x - size.width / 2 - minX, y: center.y - size.height / 2 - minY) } + return Block(positions: positions, size: CGSize(width: maxX - minX, height: maxY - minY)) + } - // Top-to-bottom: y = layer row, x = position within layer (center-aligned) - let padding: CGFloat = 40 - var currentY: CGFloat = padding - let totalConnectedNodes = connectedLayers.reduce(0) { $0 + $1.count } - - for layer in connectedLayers { - let layerWidth = CGFloat(layer.count) * nodeWidth + CGFloat(max(layer.count - 1, 0)) * horizontalGap - var currentX = padding + (nodeWidth / 2) - var maxHeight: CGFloat = 0 - - // Center the layer horizontally - let totalWidth = max(layerWidth, CGFloat(totalConnectedNodes) * (nodeWidth + horizontalGap)) - let layerOffset = (totalWidth - layerWidth) / 2 - currentX += layerOffset - - for nodeId in layer { - let colCount = nodeColumnCounts[nodeId] ?? 1 - let height = estimateHeight(columnCount: colCount) - - positions[nodeId] = CGPoint(x: currentX, y: currentY + height / 2) - currentX += nodeWidth + horizontalGap - maxHeight = max(maxHeight, height) + private static func gridBlock(members: [UUID], sizes: [UUID: CGSize]) -> Block { + let columns = max(1, Int(ceil(sqrt(Double(members.count))))) + var positions: [UUID: CGPoint] = [:] + var currentX: CGFloat = 0 + var currentY: CGFloat = 0 + var rowHeight: CGFloat = 0 + var column = 0 + + for id in members { + let size = sizes[id] ?? CGSize(width: nodeWidth, height: estimateHeight(columnCount: 1)) + positions[id] = CGPoint(x: currentX, y: currentY) + currentX += size.width + horizontalGap + rowHeight = max(rowHeight, size.height) + column += 1 + if column >= columns { + column = 0 + currentX = 0 + currentY += rowHeight + verticalGap + rowHeight = 0 } - - currentY += maxHeight + verticalGap } - // Place isolated tables in a grid below the connected layers - if !isolatedNodes.isEmpty { - currentY += verticalGap - let gridColumns = max(Int(sqrt(Double(isolatedNodes.count))), 3) - var col = 0 - var rowMaxHeight: CGFloat = 0 - - for nodeId in isolatedNodes { - let colCount = nodeColumnCounts[nodeId] ?? 1 - let height = estimateHeight(columnCount: colCount) - let x = padding + nodeWidth / 2 + CGFloat(col) * (nodeWidth + horizontalGap) - - positions[nodeId] = CGPoint(x: x, y: currentY + height / 2) - rowMaxHeight = max(rowMaxHeight, height) - - col += 1 - if col >= gridColumns { - col = 0 - currentY += rowMaxHeight + verticalGap - rowMaxHeight = 0 - } + let width = members.map { (positions[$0]?.x ?? 0) + (sizes[$0]?.width ?? nodeWidth) }.max() ?? 0 + let height = members.map { (positions[$0]?.y ?? 0) + (sizes[$0]?.height ?? headerHeight) }.max() ?? 0 + return Block(positions: positions, size: CGSize(width: width, height: height)) + } + + private static func packBlocks(_ blockSizes: [CGSize]) -> [Int: CGPoint] { + guard !blockSizes.isEmpty else { return [:] } + + let totalArea = blockSizes.reduce(0) { $0 + $1.width * $1.height } + let widest = blockSizes.map(\.width).max() ?? 0 + let targetWidth = max(widest, sqrt(totalArea * 1.6)) + let order = blockSizes.indices.sorted { blockSizes[$0].height > blockSizes[$1].height } + + var placements: [Int: CGPoint] = [:] + var currentX: CGFloat = 0 + var currentY: CGFloat = 0 + var shelfHeight: CGFloat = 0 + for index in order { + let size = blockSizes[index] + if currentX > 0, currentX + size.width > targetWidth { + currentX = 0 + currentY += shelfHeight + blockGap + shelfHeight = 0 } + placements[index] = CGPoint(x: currentX, y: currentY) + currentX += size.width + blockGap + shelfHeight = max(shelfHeight, size.height) } + return placements + } - return positions + private static func composeCenters( + blocks: [Block], + placements: [Int: CGPoint], + sizes: [UUID: CGSize] + ) -> [UUID: CGPoint] { + let padding: CGFloat = 40 + var result: [UUID: CGPoint] = [:] + for (index, block) in blocks.enumerated() { + let origin = placements[index] ?? .zero + for (id, topLeft) in block.positions { + let size = sizes[id] ?? CGSize(width: nodeWidth, height: estimateHeight(columnCount: 1)) + result[id] = CGPoint( + x: padding + origin.x + topLeft.x + size.width / 2, + y: padding + origin.y + topLeft.y + size.height / 2 + ) + } + } + return result } } diff --git a/TablePro/Models/ERDiagram/ERDiagramModels.swift b/TablePro/Models/ERDiagram/ERDiagramModels.swift index 9881ee224..3d3929dd6 100644 --- a/TablePro/Models/ERDiagram/ERDiagramModels.swift +++ b/TablePro/Models/ERDiagram/ERDiagramModels.swift @@ -8,6 +8,7 @@ struct ERTableNode: Identifiable, Sendable { let tableName: String let columns: [ERColumnDisplay] var displayColumns: [ERColumnDisplay] + var clusterId: Int? } struct ERColumnDisplay: Identifiable, Sendable { @@ -81,7 +82,8 @@ enum ERDiagramGraphBuilder { id: id, tableName: tableName, columns: displayColumns, - displayColumns: displayColumns + displayColumns: displayColumns, + clusterId: nil )) } @@ -108,7 +110,14 @@ enum ERDiagramGraphBuilder { } } - return ERDiagramGraph(nodes: nodes, edges: edges, nodeIndex: nodeIndex) + let clusters = ERClusterAnalyzer.assignClusters(nodes: nodes, edges: edges, nodeIndex: nodeIndex) + let clusteredNodes = nodes.map { node -> ERTableNode in + var updated = node + updated.clusterId = clusters[node.id] + return updated + } + + return ERDiagramGraph(nodes: clusteredNodes, edges: edges, nodeIndex: nodeIndex) } private static func stableId(for name: String) -> UUID { diff --git a/TablePro/Views/ERDiagram/ERClusterPalette.swift b/TablePro/Views/ERDiagram/ERClusterPalette.swift new file mode 100644 index 000000000..8b74df298 --- /dev/null +++ b/TablePro/Views/ERDiagram/ERClusterPalette.swift @@ -0,0 +1,12 @@ +import SwiftUI + +enum ERClusterPalette { + static let colors: [Color] = [ + .blue, .green, .orange, .purple, .pink, .teal, .indigo, .red, .mint, .brown, .cyan, .yellow + ] + + static func color(for clusterId: Int?) -> Color? { + guard let clusterId, clusterId >= 0 else { return nil } + return colors[clusterId % colors.count] + } +} diff --git a/TablePro/Views/ERDiagram/ERDiagramNodeRenderer.swift b/TablePro/Views/ERDiagram/ERDiagramNodeRenderer.swift index f8fb0ed88..f95484671 100644 --- a/TablePro/Views/ERDiagram/ERDiagramNodeRenderer.swift +++ b/TablePro/Views/ERDiagram/ERDiagramNodeRenderer.swift @@ -35,7 +35,8 @@ enum ERDiagramNodeRenderer { context: inout GraphicsContext, node: ERTableNode, rect: CGRect, - isSelected: Bool + isSelected: Bool, + clusterColor: Color? ) { let scale = ERDiagramLayout.typeScale let cornerRadius: CGFloat = 6 @@ -55,7 +56,8 @@ enum ERDiagramNodeRenderer { cornerRadii: RectangleCornerRadii(topLeading: cornerRadius, topTrailing: cornerRadius) ) } - context.fill(headerPath, with: .color(Color.accentColor.opacity(0.15))) + let headerTint = clusterColor ?? Color.accentColor + context.fill(headerPath, with: .color(headerTint.opacity(clusterColor == nil ? 0.15 : 0.22))) let displayName = (node.tableName as NSString).length > maxTableNameChars ? String(node.tableName.prefix(maxTableNameChars)) + "\u{2026}" diff --git a/TablePro/Views/ERDiagram/ERDiagramView.swift b/TablePro/Views/ERDiagram/ERDiagramView.swift index 20b784f0e..5893c176c 100644 --- a/TablePro/Views/ERDiagram/ERDiagramView.swift +++ b/TablePro/Views/ERDiagram/ERDiagramView.swift @@ -5,6 +5,7 @@ import UniformTypeIdentifiers struct ERDiagramView: View { @Bindable var viewModel: ERDiagramViewModel + @Environment(\.accessibilityDifferentiateWithoutColor) private var differentiateWithoutColor @State private var selectedNodeId: UUID? @State private var scrollMonitor: Any? @State private var currentCursor: NSCursor? @@ -71,6 +72,7 @@ struct ERDiagramView: View { let selectedId = selectedNodeId let mag = viewModel.magnification let offset = viewModel.canvasOffset + let clusterColors = nodeClusterColors(nodes: nodes) Canvas { context, _ in context.translateBy(x: offset.x, y: offset.y) @@ -89,7 +91,8 @@ struct ERDiagramView: View { context: &context, node: node, rect: rect, - isSelected: selectedId == node.id + isSelected: selectedId == node.id, + clusterColor: clusterColors[node.id] ) } } @@ -163,6 +166,19 @@ struct ERDiagramView: View { } } + // MARK: - Cluster Colors + + private func nodeClusterColors(nodes: [ERTableNode]) -> [UUID: Color] { + guard !differentiateWithoutColor else { return [:] } + var colors: [UUID: Color] = [:] + for node in nodes { + if let color = ERClusterPalette.color(for: node.clusterId) { + colors[node.id] = color + } + } + return colors + } + // MARK: - Hit Testing private func nodeAt(point: CGPoint) -> UUID? { @@ -228,6 +244,7 @@ struct ERDiagramView: View { let nodes = viewModel.graph.nodes let edges = viewModel.graph.edges let nodeIndex = viewModel.graph.nodeIndex + let clusterColors = nodeClusterColors(nodes: nodes) let padding: CGFloat = 40 let bounds = nodeRects.values.reduce(CGRect.null) { $0.union($1) } @@ -246,7 +263,13 @@ struct ERDiagramView: View { ) for node in nodes { guard let rect = nodeRects[node.id] else { continue } - ERDiagramNodeRenderer.drawNode(context: &context, node: node, rect: rect, isSelected: false) + ERDiagramNodeRenderer.drawNode( + context: &context, + node: node, + rect: rect, + isSelected: false, + clusterColor: clusterColors[node.id] + ) } } .frame(width: exportWidth, height: exportHeight) diff --git a/TableProTests/Models/ERDiagram/ERClusterAnalyzerTests.swift b/TableProTests/Models/ERDiagram/ERClusterAnalyzerTests.swift new file mode 100644 index 000000000..b3b28574c --- /dev/null +++ b/TableProTests/Models/ERDiagram/ERClusterAnalyzerTests.swift @@ -0,0 +1,115 @@ +// +// ERClusterAnalyzerTests.swift +// TableProTests +// +// Tests connected-component cluster assignment for the ER diagram. +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("ER cluster analyzer") +struct ERClusterAnalyzerTests { + private func node(_ name: String) -> ERTableNode { + ERTableNode(id: UUID(), tableName: name, columns: [], displayColumns: [], clusterId: nil) + } + + private func makeGraph( + tables: [String], + foreignKeys: [(from: String, to: String)] + ) -> (nodes: [ERTableNode], edges: [EREdge], index: [String: UUID]) { + let nodes = tables.map(node) + let index = Dictionary(uniqueKeysWithValues: nodes.map { ($0.tableName, $0.id) }) + let edges = foreignKeys.enumerated().map { offset, fk in + EREdge( + id: UUID(), + fkName: "fk_\(offset)", + fromTable: fk.from, + fromColumn: "ref_id", + toTable: fk.to, + toColumn: "id", + cardinality: .manyToOne + ) + } + return (nodes, edges, index) + } + + private func clusterId(of name: String, clusters: [UUID: Int], nodes: [ERTableNode]) -> Int? { + nodes.first { $0.tableName == name }.flatMap { clusters[$0.id] } + } + + @Test("Two separate components get distinct cluster ids ordered by name") + func twoComponents() { + let graph = makeGraph(tables: ["a", "b", "c", "d"], foreignKeys: [("a", "b"), ("c", "d")]) + let clusters = ERClusterAnalyzer.assignClusters(nodes: graph.nodes, edges: graph.edges, nodeIndex: graph.index) + + #expect(clusterId(of: "a", clusters: clusters, nodes: graph.nodes) == 0) + #expect(clusterId(of: "b", clusters: clusters, nodes: graph.nodes) == 0) + #expect(clusterId(of: "c", clusters: clusters, nodes: graph.nodes) == 1) + #expect(clusterId(of: "d", clusters: clusters, nodes: graph.nodes) == 1) + } + + @Test("A chain forms a single cluster") + func chain() { + let graph = makeGraph(tables: ["a", "b", "c"], foreignKeys: [("a", "b"), ("b", "c")]) + let clusters = ERClusterAnalyzer.assignClusters(nodes: graph.nodes, edges: graph.edges, nodeIndex: graph.index) + + #expect(["a", "b", "c"].allSatisfy { clusterId(of: $0, clusters: clusters, nodes: graph.nodes) == 0 }) + } + + @Test("A star forms a single cluster") + func star() { + let graph = makeGraph( + tables: ["hub", "x", "y", "z"], + foreignKeys: [("x", "hub"), ("y", "hub"), ("z", "hub")] + ) + let clusters = ERClusterAnalyzer.assignClusters(nodes: graph.nodes, edges: graph.edges, nodeIndex: graph.index) + + #expect(["hub", "x", "y", "z"].allSatisfy { clusterId(of: $0, clusters: clusters, nodes: graph.nodes) == 0 }) + } + + @Test("Tables with no foreign keys stay uncolored") + func isolatedTables() { + let graph = makeGraph(tables: ["a", "b", "c"], foreignKeys: []) + let clusters = ERClusterAnalyzer.assignClusters(nodes: graph.nodes, edges: graph.edges, nodeIndex: graph.index) + + #expect(clusters.isEmpty) + } + + @Test("A self-referencing table stays a singleton") + func selfReference() { + let graph = makeGraph(tables: ["employee"], foreignKeys: [("employee", "employee")]) + let clusters = ERClusterAnalyzer.assignClusters(nodes: graph.nodes, edges: graph.edges, nodeIndex: graph.index) + + #expect(clusterId(of: "employee", clusters: clusters, nodes: graph.nodes) == nil) + } + + @Test("A connected pair and an isolated table coexist") + func mixed() { + let graph = makeGraph(tables: ["a", "b", "loner"], foreignKeys: [("a", "b")]) + let clusters = ERClusterAnalyzer.assignClusters(nodes: graph.nodes, edges: graph.edges, nodeIndex: graph.index) + + #expect(clusterId(of: "a", clusters: clusters, nodes: graph.nodes) == 0) + #expect(clusterId(of: "b", clusters: clusters, nodes: graph.nodes) == 0) + #expect(clusterId(of: "loner", clusters: clusters, nodes: graph.nodes) == nil) + } + + @Test("Assignment is deterministic across runs") + func deterministic() { + let graph = makeGraph( + tables: ["a", "b", "c", "d", "e"], + foreignKeys: [("a", "b"), ("c", "d"), ("d", "e")] + ) + let first = ERClusterAnalyzer.assignClusters(nodes: graph.nodes, edges: graph.edges, nodeIndex: graph.index) + let second = ERClusterAnalyzer.assignClusters(nodes: graph.nodes, edges: graph.edges, nodeIndex: graph.index) + + #expect(first == second) + } + + @Test("An empty graph yields no clusters") + func empty() { + let clusters = ERClusterAnalyzer.assignClusters(nodes: [], edges: [], nodeIndex: [:]) + #expect(clusters.isEmpty) + } +} diff --git a/TableProTests/Models/ERDiagram/ERDiagramLayoutTests.swift b/TableProTests/Models/ERDiagram/ERDiagramLayoutTests.swift new file mode 100644 index 000000000..70c0b0156 --- /dev/null +++ b/TableProTests/Models/ERDiagram/ERDiagramLayoutTests.swift @@ -0,0 +1,152 @@ +// +// ERDiagramLayoutTests.swift +// TableProTests +// +// Tests the component-aware compact layout used by the ER diagram. +// + +import CoreGraphics +import Foundation +@testable import TablePro +import Testing + +@Suite("ER diagram layout") +struct ERDiagramLayoutTests { + private func column(_ name: String) -> ERColumnDisplay { + ERColumnDisplay(id: name, name: name, dataType: "int", isPrimaryKey: false, isForeignKey: false, isNullable: true) + } + + private func makeGraph( + tables: [String], + columnsPerTable: Int = 3, + foreignKeys: [(from: String, to: String)] = [] + ) -> ERDiagramGraph { + let nodes = tables.map { name -> ERTableNode in + let cols = (0.. ERTableNode in + var updated = node + updated.clusterId = clusters[node.id] + return updated + } + return ERDiagramGraph(nodes: clustered, edges: edges, nodeIndex: index) + } + + private func rect(for node: ERTableNode, at center: CGPoint) -> CGRect { + let height = ERDiagramLayout.estimateHeight(columnCount: node.displayColumns.count) + return CGRect( + x: center.x - ERDiagramLayout.nodeWidth / 2, + y: center.y - height / 2, + width: ERDiagramLayout.nodeWidth, + height: height + ) + } + + @Test("An empty graph produces no positions") + func empty() { + let layout = ERDiagramLayout.compute(graph: .empty) + #expect(layout.isEmpty) + } + + @Test("Every node receives a position") + func everyNodePositioned() { + let graph = makeGraph( + tables: ["a", "b", "c", "d", "e"], + foreignKeys: [("a", "b"), ("b", "c"), ("d", "e")] + ) + let layout = ERDiagramLayout.compute(graph: graph) + #expect(Set(layout.keys) == Set(graph.nodes.map(\.id))) + } + + @Test("Layout is deterministic across runs") + func deterministic() { + let graph = makeGraph( + tables: ["orders", "items", "users", "tags", "logs"], + foreignKeys: [("items", "orders"), ("orders", "users"), ("tags", "users")] + ) + let first = ERDiagramLayout.compute(graph: graph) + let second = ERDiagramLayout.compute(graph: graph) + #expect(first == second) + } + + @Test("No two table boxes overlap") + func noOverlap() { + let graph = makeGraph( + tables: ["a", "b", "c", "m", "n", "p", "q"], + foreignKeys: [("a", "b"), ("b", "c"), ("m", "n")] + ) + let layout = ERDiagramLayout.compute(graph: graph) + let rects = graph.nodes.compactMap { node in layout[node.id].map { rect(for: node, at: $0) } } + + for i in 0.. CGRect { + graph.nodes + .filter { names.contains($0.tableName) } + .compactMap { node in layout[node.id].map { rect(for: node, at: $0) } } + .reduce(CGRect.null) { $0.union($1) } + } + + #expect(!bounds(["a", "b"]).intersects(bounds(["c", "d"]))) + } + + @Test("Isolated tables fill horizontal space instead of stacking vertically") + func isolatedTablesUseWidth() { + let graph = makeGraph(tables: (0..<9).map { "t\($0)" }) + let layout = ERDiagramLayout.compute(graph: graph) + let bounds = graph.nodes + .compactMap { node in layout[node.id].map { rect(for: node, at: $0) } } + .reduce(CGRect.null) { $0.union($1) } + + #expect(bounds.width > ERDiagramLayout.nodeWidth * 2) + } + + @Test("A single table is positioned") + func singleTable() { + let graph = makeGraph(tables: ["solo"]) + let layout = ERDiagramLayout.compute(graph: graph) + #expect(layout.count == 1) + } + + @Test("A long foreign-key chain does not stack into a tall narrow column") + func longChainStaysCompact() { + let tables = (0..<10).map { "t\($0)" } + let chainFks = (0..<9).map { (from: "t\($0)", to: "t\($0 + 1)") } + let graph = makeGraph(tables: tables, foreignKeys: chainFks) + let layout = ERDiagramLayout.compute(graph: graph) + let bounds = graph.nodes + .compactMap { node in layout[node.id].map { rect(for: node, at: $0) } } + .reduce(CGRect.null) { $0.union($1) } + + let aspect = bounds.width / bounds.height + #expect(aspect > 0.7) + #expect(aspect < 4.0) + } +} diff --git a/docs/features/er-diagram.mdx b/docs/features/er-diagram.mdx index b4239f376..4d69ac31f 100644 --- a/docs/features/er-diagram.mdx +++ b/docs/features/er-diagram.mdx @@ -22,11 +22,13 @@ View all tables and foreign key relationships in your schema as an interactive d ## Layout -Tables are arranged automatically using a layered layout algorithm. Tables with foreign keys (child tables) are placed above the tables they reference (parent tables). Tables with no relationships are placed in a grid below. +Tables are arranged automatically so the diagram fills the canvas in both directions instead of stacking into one tall column. TablePro finds groups of tables connected by foreign keys, places each group together, then packs the groups across the available width. Tables with no relationships sit in a grid at the bottom. + +Each group of connected tables gets its own header color, so you can tell related tables apart at a glance in a large schema. Single tables and tables without foreign keys stay uncolored. When the system **Differentiate Without Color** accessibility setting is on, the color tint is dropped and grouping is shown by position alone. Each table node shows: -- **Header**: table name with icon +- **Header**: table name with icon, tinted by its relationship group - **Columns**: name and data type, with badges for primary keys and foreign keys - **Edges**: lines connecting FK columns to their referenced tables