Fix diagram label collisions, arrow tangles and inline size

This commit is contained in:
Ian Gulliver
2026-10-21 15:33:43 -07:00
parent f445e1cac3
commit 56285b1402
2 changed files with 152 additions and 67 deletions
+85 -40
View File
@@ -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 {
private struct Sizer {
let labelGaps: [String: CGFloat]
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
}
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, +) + childGap * CGFloat(kids.count - 1)
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)
}
@MainActor
private func stretch(_ nodes: [BoxNode], to width: CGFloat, gap: CGFloat) -> [CGFloat] {
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 - gap * CGFloat(nodes.count - 1)
let extra = width - sum - gaps.reduce(0, +)
return natural.map { $0 + extra * $0 / sum }
}
@MainActor
private func place(_ n: BoxNode, _ rect: CGRect, _ depth: Int, _ parent: String?, _ out: inout [PlacedBox]) {
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))
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 widths = stretch(n.children, to: rect.width - pad * 2, gap: childGap)
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 (c, w) in zip(n.children, widths) {
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 + childGap
x += w + (i < gaps.count ? gaps[i] : 0)
}
}
@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))
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 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)
}
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)
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 { overlap($0) < overlap($1) }!
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)
+45 -5
View File
@@ -586,13 +586,34 @@ struct TrailingBubble: Layout {
}
}
struct FitWidth: Layout {
struct FitWidth<Content: View>: 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)))
}
}