From c0e6077387bfa085e3e8b51b64956ea40d8c7f92 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 23 Jun 2026 01:23:04 +0700 Subject: [PATCH 1/6] feat(er-diagram): lay tables out in compact 2D clusters with color --- CHANGELOG.md | 4 + .../Models/ERDiagram/ERClusterAnalyzer.swift | 73 +++ .../Models/ERDiagram/ERDiagramLayout.swift | 493 ++++++++++-------- .../Models/ERDiagram/ERDiagramModels.swift | 13 +- .../Views/ERDiagram/ERClusterPalette.swift | 12 + .../ERDiagram/ERDiagramNodeRenderer.swift | 6 +- TablePro/Views/ERDiagram/ERDiagramView.swift | 27 +- .../ERDiagram/ERClusterAnalyzerTests.swift | 115 ++++ .../ERDiagram/ERDiagramLayoutTests.swift | 136 +++++ docs/features/er-diagram.mdx | 6 +- 10 files changed, 655 insertions(+), 230 deletions(-) create mode 100644 TablePro/Models/ERDiagram/ERClusterAnalyzer.swift create mode 100644 TablePro/Views/ERDiagram/ERClusterPalette.swift create mode 100644 TableProTests/Models/ERDiagram/ERClusterAnalyzerTests.swift create mode 100644 TableProTests/Models/ERDiagram/ERDiagramLayoutTests.swift 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..ec52b14c1 --- /dev/null +++ b/TablePro/Models/ERDiagram/ERClusterAnalyzer.swift @@ -0,0 +1,73 @@ +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 node in nodes { result[node.id] = -1 } + 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..f7e8cbabb 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,322 @@ 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 node.clusterId >= 0 { + componentGroups[node.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 - for (from, to) in backEdges { - dag[from]?.removeAll { $0 == to } + 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 memberSet = Set(members) + let spacing = idealDistance(members: members, sizes: sizes) + var positions = circularInit(members: members, idealDistance: spacing) + let iterations = max(60, min(300, 2_000 / count)) + var temperature = spacing * 2 + + for _ in 0.. [[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 } - } - - // 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], + memberSet: Set, + adjacency: [UUID: [UUID]], + positions: [UUID: CGPoint], + idealDistance: CGFloat + ) -> [UUID: CGVector] { + var displacement: [UUID: CGVector] = [:] + for id in members { displacement[id] = .zero } + + let count = members.count + for i in 0.. = [] + 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) + guard let posSource = positions[source], let posTarget = positions[target] else { continue } + let dx = posSource.x - posTarget.x + let dy = posSource.y - posTarget.y + let distance = max(hypot(dx, dy), 0.01) + let attraction = distance * distance / idealDistance + let unitX = dx / distance + let unitY = dy / distance + displacement[source]?.dx -= unitX * attraction + displacement[source]?.dy -= unitY * attraction + displacement[target]?.dx += unitX * attraction + displacement[target]?.dy += unitY * attraction + } } - let maxLayer = layers.keys.max() ?? 0 - return (0...maxLayer).map { layers[$0] ?? [] } + return displacement } - // MARK: - Crossing Minimization (Barycentric) - - private static func minimizeCrossings(layers: [[UUID]], dagEdges: [UUID: [UUID]]) -> [[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..018271088 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: -1 )) } @@ -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] ?? -1 + 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..b87fee85a --- /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(forCluster clusterId: Int) -> Color? { + guard 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..c5789caad 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(forCluster: 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..03a03a371 --- /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: -1) + } + + 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.values.allSatisfy { $0 == -1 }) + } + + @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) == -1) + } + + @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) == -1) + } + + @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..52fbb54f1 --- /dev/null +++ b/TableProTests/Models/ERDiagram/ERDiagramLayoutTests.swift @@ -0,0 +1,136 @@ +// +// ERDiagramLayoutTests.swift +// TableProTests +// +// Tests the component-aware compact layout used by the ER diagram. +// + +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] ?? -1 + 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) + } +} 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 From 1614e2bee209fb47f0d64781fb941b89ef8c55ac Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 23 Jun 2026 01:28:16 +0700 Subject: [PATCH 2/6] refactor(er-diagram): precompute force edges and model clusterId as optional --- .../Models/ERDiagram/ERClusterAnalyzer.swift | 1 - .../Models/ERDiagram/ERDiagramLayout.swift | 64 +++++++++++-------- .../Models/ERDiagram/ERDiagramModels.swift | 6 +- .../Views/ERDiagram/ERClusterPalette.swift | 4 +- TablePro/Views/ERDiagram/ERDiagramView.swift | 2 +- .../ERDiagram/ERClusterAnalyzerTests.swift | 8 +-- .../ERDiagram/ERDiagramLayoutTests.swift | 4 +- 7 files changed, 48 insertions(+), 41 deletions(-) diff --git a/TablePro/Models/ERDiagram/ERClusterAnalyzer.swift b/TablePro/Models/ERDiagram/ERClusterAnalyzer.swift index ec52b14c1..fc7dd9d0d 100644 --- a/TablePro/Models/ERDiagram/ERClusterAnalyzer.swift +++ b/TablePro/Models/ERDiagram/ERClusterAnalyzer.swift @@ -64,7 +64,6 @@ enum ERClusterAnalyzer { } var result: [UUID: Int] = [:] - for node in nodes { result[node.id] = -1 } for (index, component) in ordered.enumerated() { for member in component { result[member] = index } } diff --git a/TablePro/Models/ERDiagram/ERDiagramLayout.swift b/TablePro/Models/ERDiagram/ERDiagramLayout.swift index f7e8cbabb..7ab25b7e4 100644 --- a/TablePro/Models/ERDiagram/ERDiagramLayout.swift +++ b/TablePro/Models/ERDiagram/ERDiagramLayout.swift @@ -35,8 +35,8 @@ enum ERDiagramLayout { var componentGroups: [Int: [UUID]] = [:] var singletons: [UUID] = [] for node in graph.nodes.sorted(by: { $0.tableName < $1.tableName }) { - if node.clusterId >= 0 { - componentGroups[node.clusterId, default: []].append(node.id) + if let clusterId = node.clusterId { + componentGroups[clusterId, default: []].append(node.id) } else { singletons.append(node.id) } @@ -92,8 +92,8 @@ enum ERDiagramLayout { guard members.count > 1 else { return [first: .zero] } let count = members.count - let memberSet = Set(members) let spacing = idealDistance(members: members, sizes: sizes) + let edges = uniqueEdges(members: members, adjacency: adjacency) var positions = circularInit(members: members, idealDistance: spacing) let iterations = max(60, min(300, 2_000 / count)) var temperature = spacing * 2 @@ -101,8 +101,7 @@ enum ERDiagramLayout { for _ in 0.. [(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 + } + private static func forceStep( members: [UUID], - memberSet: Set, - adjacency: [UUID: [UUID]], + edges: [(UUID, UUID)], positions: [UUID: CGPoint], idealDistance: CGFloat ) -> [UUID: CGVector] { @@ -157,26 +172,18 @@ enum ERDiagramLayout { } } - var seen: Set = [] - 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) - guard let posSource = positions[source], let posTarget = positions[target] else { continue } - let dx = posSource.x - posTarget.x - let dy = posSource.y - posTarget.y - let distance = max(hypot(dx, dy), 0.01) - let attraction = distance * distance / idealDistance - let unitX = dx / distance - let unitY = dy / distance - displacement[source]?.dx -= unitX * attraction - displacement[source]?.dy -= unitY * attraction - displacement[target]?.dx += unitX * attraction - displacement[target]?.dy += unitY * attraction - } + for (source, target) in edges { + guard let posSource = positions[source], let posTarget = positions[target] else { continue } + let dx = posSource.x - posTarget.x + let dy = posSource.y - posTarget.y + let distance = max(hypot(dx, dy), 0.01) + let attraction = distance * distance / idealDistance + let unitX = dx / distance + let unitY = dy / distance + displacement[source]?.dx -= unitX * attraction + displacement[source]?.dy -= unitY * attraction + displacement[target]?.dx += unitX * attraction + displacement[target]?.dy += unitY * attraction } return displacement @@ -207,7 +214,8 @@ enum ERDiagramLayout { ) { let count = members.count let padding = horizontalGap * 0.5 - for _ in 0..<20 { + let passes = max(20, min(count, 60)) + for _ in 0.. ERTableNode in var updated = node - updated.clusterId = clusters[node.id] ?? -1 + updated.clusterId = clusters[node.id] return updated } diff --git a/TablePro/Views/ERDiagram/ERClusterPalette.swift b/TablePro/Views/ERDiagram/ERClusterPalette.swift index b87fee85a..8b74df298 100644 --- a/TablePro/Views/ERDiagram/ERClusterPalette.swift +++ b/TablePro/Views/ERDiagram/ERClusterPalette.swift @@ -5,8 +5,8 @@ enum ERClusterPalette { .blue, .green, .orange, .purple, .pink, .teal, .indigo, .red, .mint, .brown, .cyan, .yellow ] - static func color(forCluster clusterId: Int) -> Color? { - guard clusterId >= 0 else { return nil } + 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/ERDiagramView.swift b/TablePro/Views/ERDiagram/ERDiagramView.swift index c5789caad..5893c176c 100644 --- a/TablePro/Views/ERDiagram/ERDiagramView.swift +++ b/TablePro/Views/ERDiagram/ERDiagramView.swift @@ -172,7 +172,7 @@ struct ERDiagramView: View { guard !differentiateWithoutColor else { return [:] } var colors: [UUID: Color] = [:] for node in nodes { - if let color = ERClusterPalette.color(forCluster: node.clusterId) { + if let color = ERClusterPalette.color(for: node.clusterId) { colors[node.id] = color } } diff --git a/TableProTests/Models/ERDiagram/ERClusterAnalyzerTests.swift b/TableProTests/Models/ERDiagram/ERClusterAnalyzerTests.swift index 03a03a371..b3b28574c 100644 --- a/TableProTests/Models/ERDiagram/ERClusterAnalyzerTests.swift +++ b/TableProTests/Models/ERDiagram/ERClusterAnalyzerTests.swift @@ -12,7 +12,7 @@ import Testing @Suite("ER cluster analyzer") struct ERClusterAnalyzerTests { private func node(_ name: String) -> ERTableNode { - ERTableNode(id: UUID(), tableName: name, columns: [], displayColumns: [], clusterId: -1) + ERTableNode(id: UUID(), tableName: name, columns: [], displayColumns: [], clusterId: nil) } private func makeGraph( @@ -74,7 +74,7 @@ struct ERClusterAnalyzerTests { let graph = makeGraph(tables: ["a", "b", "c"], foreignKeys: []) let clusters = ERClusterAnalyzer.assignClusters(nodes: graph.nodes, edges: graph.edges, nodeIndex: graph.index) - #expect(clusters.values.allSatisfy { $0 == -1 }) + #expect(clusters.isEmpty) } @Test("A self-referencing table stays a singleton") @@ -82,7 +82,7 @@ struct ERClusterAnalyzerTests { 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) == -1) + #expect(clusterId(of: "employee", clusters: clusters, nodes: graph.nodes) == nil) } @Test("A connected pair and an isolated table coexist") @@ -92,7 +92,7 @@ struct ERClusterAnalyzerTests { #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) == -1) + #expect(clusterId(of: "loner", clusters: clusters, nodes: graph.nodes) == nil) } @Test("Assignment is deterministic across runs") diff --git a/TableProTests/Models/ERDiagram/ERDiagramLayoutTests.swift b/TableProTests/Models/ERDiagram/ERDiagramLayoutTests.swift index 52fbb54f1..3eec905a9 100644 --- a/TableProTests/Models/ERDiagram/ERDiagramLayoutTests.swift +++ b/TableProTests/Models/ERDiagram/ERDiagramLayoutTests.swift @@ -22,7 +22,7 @@ struct ERDiagramLayoutTests { ) -> ERDiagramGraph { let nodes = tables.map { name -> ERTableNode in let cols = (0.. ERTableNode in var updated = node - updated.clusterId = clusters[node.id] ?? -1 + updated.clusterId = clusters[node.id] return updated } return ERDiagramGraph(nodes: clustered, edges: edges, nodeIndex: index) From 5d0dd1a3da21f1a35c77db422b72d777ac3902c4 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 23 Jun 2026 02:08:09 +0700 Subject: [PATCH 3/6] fix(er-diagram): fold connected components so the diagram fills width --- .../Models/ERDiagram/ERDiagramLayout.swift | 64 +++++++++++++++++++ .../ERDiagram/ERDiagramLayoutTests.swift | 15 +++++ 2 files changed, 79 insertions(+) diff --git a/TablePro/Models/ERDiagram/ERDiagramLayout.swift b/TablePro/Models/ERDiagram/ERDiagramLayout.swift index 7ab25b7e4..d570fa8ce 100644 --- a/TablePro/Models/ERDiagram/ERDiagramLayout.swift +++ b/TablePro/Models/ERDiagram/ERDiagramLayout.swift @@ -94,6 +94,11 @@ enum ERDiagramLayout { 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 @@ -102,6 +107,7 @@ enum ERDiagramLayout { let displacement = forceStep( members: members, edges: edges, + degree: degree, positions: positions, idealDistance: spacing ) @@ -117,10 +123,47 @@ enum ERDiagramLayout { temperature = max(temperature * 0.95, spacing * 0.05) } + positions = rotateToLandscape(members: members, positions: positions) removeOverlaps(members: members, positions: &positions, sizes: sizes) return positions } + private static func rotateToLandscape(members: [UUID], positions: [UUID: CGPoint]) -> [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 + } + + 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 + } + private static func uniqueEdges(members: [UUID], adjacency: [UUID: [UUID]]) -> [(UUID, UUID)] { let memberSet = Set(members) var seen: Set = [] @@ -141,12 +184,22 @@ enum ERDiagramLayout { 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.. 0.7) + #expect(aspect < 4.0) + } } From 4734d6491a35cdd7ff32315a6334d025893bd1e1 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 23 Jun 2026 02:16:08 +0700 Subject: [PATCH 4/6] fix(er-diagram): compact component layout to reduce whitespace --- TablePro/Models/ERDiagram/ERDiagramLayout.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TablePro/Models/ERDiagram/ERDiagramLayout.swift b/TablePro/Models/ERDiagram/ERDiagramLayout.swift index d570fa8ce..39078a97a 100644 --- a/TablePro/Models/ERDiagram/ERDiagramLayout.swift +++ b/TablePro/Models/ERDiagram/ERDiagramLayout.swift @@ -239,7 +239,7 @@ enum ERDiagramLayout { displacement[target]?.dy += unitY * attraction } - let gravity: CGFloat = 0.16 + let gravity: CGFloat = 0.34 for id in members { guard let position = positions[id] else { continue } let dx = centerX - position.x From 1976643ee52df753a02677ad9af31dd6927ad6f4 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 23 Jun 2026 02:17:19 +0700 Subject: [PATCH 5/6] fix(er-diagram): soften component packing density --- TablePro/Models/ERDiagram/ERDiagramLayout.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TablePro/Models/ERDiagram/ERDiagramLayout.swift b/TablePro/Models/ERDiagram/ERDiagramLayout.swift index 39078a97a..732808f04 100644 --- a/TablePro/Models/ERDiagram/ERDiagramLayout.swift +++ b/TablePro/Models/ERDiagram/ERDiagramLayout.swift @@ -239,7 +239,7 @@ enum ERDiagramLayout { displacement[target]?.dy += unitY * attraction } - let gravity: CGFloat = 0.34 + let gravity: CGFloat = 0.28 for id in members { guard let position = positions[id] else { continue } let dx = centerX - position.x From 141ed77bc52a428d6a3ffcfad653657c65a309c2 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 23 Jun 2026 11:00:53 +0700 Subject: [PATCH 6/6] test(er-diagram): import CoreGraphics for CGRect geometry in layout tests --- TableProTests/Models/ERDiagram/ERDiagramLayoutTests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/TableProTests/Models/ERDiagram/ERDiagramLayoutTests.swift b/TableProTests/Models/ERDiagram/ERDiagramLayoutTests.swift index d85f92d3c..70c0b0156 100644 --- a/TableProTests/Models/ERDiagram/ERDiagramLayoutTests.swift +++ b/TableProTests/Models/ERDiagram/ERDiagramLayoutTests.swift @@ -5,6 +5,7 @@ // Tests the component-aware compact layout used by the ER diagram. // +import CoreGraphics import Foundation @testable import TablePro import Testing