From 56285b1402133402aec2661fb38533d0267cb142 Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Wed, 21 Oct 2026 15:33:43 -0700 Subject: [PATCH] Fix diagram label collisions, arrow tangles and inline size --- mac/Sources/Byre/BoxDiagram.swift | 169 +++++++++++++++++++----------- mac/Sources/Byre/Format.swift | 50 ++++++++- 2 files changed, 152 insertions(+), 67 deletions(-) diff --git a/mac/Sources/Byre/BoxDiagram.swift b/mac/Sources/Byre/BoxDiagram.swift index 31e0853..36188f9 100644 --- a/mac/Sources/Byre/BoxDiagram.swift +++ b/mac/Sources/Byre/BoxDiagram.swift @@ -274,7 +274,7 @@ struct BoxFocus { private let pad: CGFloat = 12 private let childGap: CGFloat = 14 private let boxGap: CGFloat = 20 -private let rowGap: CGFloat = 36 +private let rowGap: CGFloat = 48 private let columnGap: CGFloat = 80 private let margin: CGFloat = 20 @@ -307,67 +307,90 @@ private func headerSize(_ n: BoxNode) -> CGSize { } @MainActor -private func naturalSize(_ n: BoxNode) -> CGSize { - let head = headerSize(n) - if n.children.isEmpty { - return CGSize(width: head.width + pad * 2, height: head.height + pad * 2) - } - let kids = n.children.map(naturalSize) - let row = kids.map(\.width).reduce(0, +) + childGap * CGFloat(kids.count - 1) - return CGSize(width: max(head.width, row) + pad * 2, height: pad + head.height + pad + kids.map(\.height).max()! + pad) -} +private struct Sizer { + let labelGaps: [String: CGFloat] -@MainActor -private func stretch(_ nodes: [BoxNode], to width: CGFloat, gap: CGFloat) -> [CGFloat] { - let natural = nodes.map { naturalSize($0).width } - let sum = natural.reduce(0, +) - let extra = width - sum - gap * CGFloat(nodes.count - 1) - return natural.map { $0 + extra * $0 / sum } -} - -@MainActor -private func place(_ n: BoxNode, _ rect: CGRect, _ depth: Int, _ parent: String?, _ out: inout [PlacedBox]) { - let head = headerSize(n) - let header = CGRect(x: rect.minX, y: rect.minY, width: rect.width, height: pad + head.height + (n.children.isEmpty ? pad : 0)) - out.append(PlacedBox(node: n, rect: rect, depth: depth, parent: parent, header: header)) - if n.children.isEmpty { - return + init(_ d: BoxDiagram) { + var gaps: [String: CGFloat] = [:] + for e in d.edges { + guard let label = e.label else { + continue + } + let key = Sizer.pair(e.from, e.to) + gaps[key] = max(gaps[key] ?? 0, textSize(label, edgeLabelFont).width + 16) + } + labelGaps = gaps } - let widths = stretch(n.children, to: rect.width - pad * 2, gap: childGap) - let top = rect.minY + pad + head.height + pad - var x = rect.minX + pad - for (c, w) in zip(n.children, widths) { - place(c, CGRect(x: x, y: top, width: w, height: rect.maxY - pad - top), depth + 1, n.id, &out) - x += w + childGap - } -} -@MainActor -private func rowWidth(_ r: BoxRow) -> CGFloat { - let boxes: CGFloat = r.main.map { naturalSize($0).width }.reduce(0, +) - return boxes + boxGap * CGFloat(max(0, r.main.count - 1)) + static func pair(_ a: String, _ b: String) -> String { + a < b ? "\(a) \(b)" : "\(b) \(a)" + } + + func gaps(_ nodes: [BoxNode], _ base: CGFloat) -> [CGFloat] { + zip(nodes, nodes.dropFirst()).map { a, b in max(base, labelGaps[Sizer.pair(a.id, b.id)] ?? 0) } + } + + func naturalSize(_ n: BoxNode) -> CGSize { + let head = headerSize(n) + if n.children.isEmpty { + return CGSize(width: head.width + pad * 2, height: head.height + pad * 2) + } + let kids = n.children.map(naturalSize) + let row = kids.map(\.width).reduce(0, +) + gaps(n.children, childGap).reduce(0, +) + return CGSize(width: max(head.width, row) + pad * 2, height: pad + head.height + pad + kids.map(\.height).max()! + pad) + } + + func stretch(_ nodes: [BoxNode], to width: CGFloat, gaps: [CGFloat]) -> [CGFloat] { + let natural = nodes.map { naturalSize($0).width } + let sum = natural.reduce(0, +) + let extra = width - sum - gaps.reduce(0, +) + return natural.map { $0 + extra * $0 / sum } + } + + func place(_ n: BoxNode, _ rect: CGRect, _ depth: Int, _ parent: String?, _ out: inout [PlacedBox]) { + let head = headerSize(n) + let header = n.children.isEmpty ? rect : CGRect(x: rect.minX, y: rect.minY, width: min(rect.width, head.width + pad * 2), height: pad + head.height) + out.append(PlacedBox(node: n, rect: rect, depth: depth, parent: parent, header: header)) + if n.children.isEmpty { + return + } + let gaps = gaps(n.children, childGap) + let widths = stretch(n.children, to: rect.width - pad * 2, gaps: gaps) + let top = rect.minY + pad + head.height + pad + var x = rect.minX + pad + for (i, (c, w)) in zip(n.children, widths).enumerated() { + place(c, CGRect(x: x, y: top, width: w, height: rect.maxY - pad - top), depth + 1, n.id, &out) + x += w + (i < gaps.count ? gaps[i] : 0) + } + } + + func rowWidth(_ r: BoxRow) -> CGFloat { + r.main.map { naturalSize($0).width }.reduce(0, +) + gaps(r.main, boxGap).reduce(0, +) + } } @MainActor func layoutBoxDiagram(_ d: BoxDiagram) throws(DiagramError) -> BoxLayout { - let mainWidth: CGFloat = d.rows.map(rowWidth).max() ?? 0 - let asideWidth: CGFloat = d.rows.flatMap(\.aside).map { naturalSize($0).width }.max() ?? 0 + let sizer = Sizer(d) + let mainWidth: CGFloat = d.rows.map(sizer.rowWidth).max() ?? 0 + let asideWidth: CGFloat = d.rows.flatMap(\.aside).map { sizer.naturalSize($0).width }.max() ?? 0 let asideX = margin + mainWidth + columnGap var boxes: [PlacedBox] = [] var y = margin var asideBottom = margin - rowGap var bottom = margin for r in d.rows { - let height = r.main.map { naturalSize($0).height }.max() ?? 0 + let height = r.main.map { sizer.naturalSize($0).height }.max() ?? 0 + let gaps = sizer.gaps(r.main, boxGap) var x = margin - for (n, w) in zip(r.main, stretch(r.main, to: mainWidth, gap: boxGap)) { - place(n, CGRect(x: x, y: y, width: w, height: height), 0, nil, &boxes) - x += w + boxGap + for (i, (n, w)) in zip(r.main, sizer.stretch(r.main, to: mainWidth, gaps: gaps)).enumerated() { + sizer.place(n, CGRect(x: x, y: y, width: w, height: height), 0, nil, &boxes) + x += w + (i < gaps.count ? gaps[i] : 0) } var ay = max(y, asideBottom + rowGap) for n in r.aside { - let h = naturalSize(n).height - place(n, CGRect(x: asideX, y: ay, width: asideWidth, height: h), 0, nil, &boxes) + let h = sizer.naturalSize(n).height + sizer.place(n, CGRect(x: asideX, y: ay, width: asideWidth, height: h), 0, nil, &boxes) ay += h + rowGap asideBottom = ay - rowGap } @@ -385,15 +408,15 @@ func layoutBoxDiagram(_ d: BoxDiagram) throws(DiagramError) -> BoxLayout { for i in order { points[i] = try router.route(d.edges[i]) } - var taken: [CGRect] = boxes.map { $0.node.children.isEmpty ? $0.rect : $0.header } + var solid: [CGRect] = boxes.map(\.header) let lines = points.map { pts in zip(pts, pts.dropFirst()).map { a, b in CGRect(x: min(a.x, b.x), y: min(a.y, b.y), width: abs(a.x - b.x), height: abs(a.y - b.y)).insetBy(dx: -1, dy: -1) } } var routed: [RoutedEdge] = [] for (i, (e, pts)) in zip(d.edges, points).enumerated() { var labelRect: CGRect? if let label = e.label { let others = lines.enumerated().filter { $0.offset != i }.flatMap(\.element) - let r = labelSpot(textSize(label, edgeLabelFont), pts, taken + others, CGRect(origin: .zero, size: size)) - taken.append(r) + let r = labelSpot(textSize(label, edgeLabelFont), pts, solid: solid, lines: others, CGRect(origin: .zero, size: size)) + solid.append(r) labelRect = r } routed.append(RoutedEdge(edge: e, points: pts, labelRect: labelRect)) @@ -401,31 +424,38 @@ func layoutBoxDiagram(_ d: BoxDiagram) throws(DiagramError) -> BoxLayout { return BoxLayout(boxes: boxes, index: index, edges: routed, presets: d.presets, size: size) } -private func labelSpot(_ size: CGSize, _ pts: [CGPoint], _ taken: [CGRect], _ bounds: CGRect) -> CGRect { - var candidates: [CGRect] = [] +private func labelSpot(_ size: CGSize, _ pts: [CGPoint], solid: [CGRect], lines: [CGRect], _ bounds: CGRect) -> CGRect { + var candidates: [(rect: CGRect, order: Int)] = [] let segs = zip(pts, pts.dropFirst()).sorted { a, b in abs(a.0.x - a.1.x) + abs(a.0.y - a.1.y) > abs(b.0.x - b.1.x) + abs(b.0.y - b.1.y) } for (a, b) in segs { - for t in [0.5, 0.3, 0.7, 0.15, 0.85] { + for t in stride(from: 0.5, through: 0.95, by: 0.05).flatMap({ $0 == 0.5 ? [$0] : [$0, 1 - $0] }) { let p = CGPoint(x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t) if a.x == b.x { - candidates.append(CGRect(x: p.x + 6, y: p.y - size.height / 2, width: size.width, height: size.height)) - candidates.append(CGRect(x: p.x - 6 - size.width, y: p.y - size.height / 2, width: size.width, height: size.height)) + candidates.append((CGRect(x: p.x + 6, y: p.y - size.height / 2, width: size.width, height: size.height), candidates.count)) + candidates.append((CGRect(x: p.x - 6 - size.width, y: p.y - size.height / 2, width: size.width, height: size.height), candidates.count)) } else { - candidates.append(CGRect(x: p.x - size.width / 2, y: p.y - 3 - size.height, width: size.width, height: size.height)) - candidates.append(CGRect(x: p.x - size.width / 2, y: p.y + 3, width: size.width, height: size.height)) + candidates.append((CGRect(x: p.x - size.width / 2, y: p.y - 3 - size.height, width: size.width, height: size.height), candidates.count)) + candidates.append((CGRect(x: p.x - size.width / 2, y: p.y + 3, width: size.width, height: size.height), candidates.count)) } } } - func overlap(_ c: CGRect) -> CGFloat { - let outside = c.width * c.height - c.intersection(bounds).width * c.intersection(bounds).height - return taken.reduce(outside) { sum, t in - let i = t.insetBy(dx: -2, dy: -2).intersection(c) - return i.isNull ? sum : sum + max(1, i.width * i.height) - } + func area(_ a: CGRect, _ b: CGRect) -> CGFloat { + let i = a.insetBy(dx: -2, dy: -2).intersection(b) + return i.isNull ? 0 : max(1, i.width * i.height) } - return candidates.min { overlap($0) < overlap($1) }! + func overlap(_ c: CGRect) -> CGFloat { + let inside = c.intersection(bounds) + let outside = c.width * c.height - (inside.isNull ? 0 : inside.width * inside.height) + let covered = solid.reduce(outside) { $0 + area($1, c) } + return covered * 100 + lines.reduce(0) { $0 + area($1, c) } + } + return candidates.min { a, b in + let oa = overlap(a.rect) + let ob = overlap(b.rect) + return oa != ob ? oa < ob : a.order < b.order + }!.rect } private let cell: CGFloat = 4 @@ -433,6 +463,8 @@ private let stepCost = 10 private let turnCost = 60 private let nearCost = 4 private let parallelCost = 80 +private let laneCost = 40 +private let laneWidth = 3 private let crossCost = 15 private let dx = [1, 0, -1, 0] private let dy = [0, 1, 0, -1] @@ -450,6 +482,7 @@ private struct Router { let cols: Int let rows: Int var used: [UInt8] + var lanes: [UInt8] init(boxes: [PlacedBox], index: [String: Int], size: CGSize) { self.boxes = boxes @@ -457,6 +490,7 @@ private struct Router { cols = Int(ceil(size.width / cell)) rows = Int(ceil(size.height / cell)) used = [UInt8](repeating: 0, count: cols * rows) + lanes = [UInt8](repeating: 0, count: cols * rows) } func span(_ e: BoxEdge) -> CGFloat { @@ -601,6 +635,8 @@ private struct Router { let axis: UInt8 = nd % 2 == 0 ? 1 : 2 if used[n] & axis != 0 { step += parallelCost + } else if lanes[n] & axis != 0 { + step += laneCost } else if used[n] != 0 { step += crossCost } @@ -626,6 +662,15 @@ private struct Router { let axis: UInt8 = (b % 4) % 2 == 0 ? 1 : 2 used[a / 4] |= axis used[b / 4] |= axis + for c in [a / 4, b / 4] { + let x = c % cols + let y = c / cols + for k in 1...laneWidth { + for (nx, ny) in axis == 1 ? [(x, y - k), (x, y + k)] : [(x - k, y), (x + k, y)] where nx >= 0 && ny >= 0 && nx < cols && ny < rows { + lanes[ny * cols + nx] |= axis + } + } + } } func center(_ c: Int) -> CGPoint { CGPoint(x: (CGFloat(c % cols) + 0.5) * cell, y: (CGFloat(c / cols) + 0.5) * cell) diff --git a/mac/Sources/Byre/Format.swift b/mac/Sources/Byre/Format.swift index 9fea5ea..0a940a8 100644 --- a/mac/Sources/Byre/Format.swift +++ b/mac/Sources/Byre/Format.swift @@ -586,13 +586,34 @@ struct TrailingBubble: Layout { } } -struct FitWidth: Layout { +struct FitWidth: View { + let natural: CGSize + @ViewBuilder let content: Content + + var body: some View { + ScrollFit(natural: natural) { + ScrollView(.horizontal) { + ScaleToHeight(natural: natural) { + content + } + } + .scrollBounceBehavior(.basedOnSize, axes: .horizontal) + } + } +} + +private let minDiagramScale: CGFloat = 0.75 + +private struct ScrollFit: Layout { let natural: CGSize private func scaled(_ width: CGFloat?) -> CGSize { let w = width ?? natural.width - let scale = natural.width > 0 ? min(1, w / natural.width) : 1 - return CGSize(width: natural.width * scale, height: natural.height * scale) + guard natural.width > 0 else { + return natural + } + let scale = max(minDiagramScale, min(1, w / natural.width)) + return CGSize(width: min(w, natural.width * scale), height: natural.height * scale) } func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { @@ -600,8 +621,27 @@ struct FitWidth: Layout { } func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { - let size = scaled(bounds.width) - subviews[0].place(at: bounds.origin, proposal: ProposedViewSize(size)) + subviews[0].place(at: bounds.origin, proposal: ProposedViewSize(scaled(bounds.width))) + } +} + +private struct ScaleToHeight: Layout { + let natural: CGSize + + private func scaled(_ height: CGFloat?) -> CGSize { + guard let height, natural.height > 0 else { + return natural + } + let scale = height / natural.height + return CGSize(width: natural.width * scale, height: height) + } + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + scaled(proposal.height) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + subviews[0].place(at: bounds.origin, proposal: ProposedViewSize(scaled(bounds.height))) } }