From 324c6f86525a86fa330cf3ad80cfc559757c3112 Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Fri, 23 Oct 2026 08:52:49 -0700 Subject: [PATCH] Tighten diagram layout: symmetry, consistent anchors, text padding --- mac/Sources/Byre/BoxDiagram.swift | 376 ++++++++++++++++++++++------- mac/Sources/Byre/DiagramHTML.swift | 10 +- 2 files changed, 297 insertions(+), 89 deletions(-) diff --git a/mac/Sources/Byre/BoxDiagram.swift b/mac/Sources/Byre/BoxDiagram.swift index a986f2d..ae30033 100644 --- a/mac/Sources/Byre/BoxDiagram.swift +++ b/mac/Sources/Byre/BoxDiagram.swift @@ -271,12 +271,17 @@ struct BoxFocus { } } -let pad: CGFloat = 12 -private let childGap: CGFloat = 14 -private let boxGap: CGFloat = 20 -private let rowGap: CGFloat = 48 +let padX: CGFloat = 16 +let padY: CGFloat = 13 +let titleGap: CGFloat = 4 +private let headGap: CGFloat = 24 +private let childGap: CGFloat = 16 +private let boxGap: CGFloat = 24 +private let rowGap: CGFloat = 52 private let columnGap: CGFloat = 80 private let margin: CGFloat = 20 +private let labelRoom: CGFloat = 28 +private let edgeGap: CGFloat = 36 @MainActor var titleFont: CTFont { @@ -303,23 +308,40 @@ private func headerSize(_ n: BoxNode) -> CGSize { w = max(w, s.width) h += s.height } - return CGSize(width: w, height: h + (n.details.isEmpty ? 0 : 2)) + return CGSize(width: ceil(w), height: ceil(h + (n.details.isEmpty ? 0 : titleGap))) } @MainActor private struct Sizer { - let labelGaps: [String: CGFloat] + let pairGaps: [String: CGFloat] init(_ d: BoxDiagram) { + var parents: [String: String] = [:] + func walk(_ n: BoxNode) { + for c in n.children { + parents[c.id] = n.id + walk(c) + } + } + d.rows.flatMap { $0.main + $0.aside }.forEach(walk) + func chain(_ id: String) -> [String] { + var out = [id] + while let p = parents[out.last!] { + out.append(p) + } + return out + } var gaps: [String: CGFloat] = [:] for e in d.edges { - guard let label = e.label else { - continue + let need = e.label.map { ceil(textSize($0, edgeLabelFont).width) + labelRoom } ?? edgeGap + for a in chain(e.from) { + for b in chain(e.to) where a != b && parents[a] == parents[b] { + let key = Sizer.pair(a, b) + gaps[key] = max(gaps[key] ?? 0, need) + } } - let key = Sizer.pair(e.from, e.to) - gaps[key] = max(gaps[key] ?? 0, textSize(label, edgeLabelFont).width + 16) } - labelGaps = gaps + pairGaps = gaps } static func pair(_ a: String, _ b: String) -> String { @@ -327,40 +349,54 @@ private struct Sizer { } 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) } + zip(nodes, nodes.dropFirst()).map { a, b in max(base, pairGaps[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) + return CGSize(width: head.width + padX * 2, height: head.height + padY * 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) + return CGSize(width: max(head.width, row) + padX * 2, height: padY + head.height + headGap + kids.map(\.height).max()! + padX) } 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 } + var room = width - gaps.reduce(0, +) + var fixed = Set() + while true { + let share = room / CGFloat(nodes.count - fixed.count) + let wide = natural.indices.filter { !fixed.contains($0) && natural[$0] > share } + if wide.isEmpty { + return natural.indices.map { fixed.contains($0) ? natural[$0] : share } + } + for i in wide { + fixed.insert(i) + room -= natural[i] + } + } + } + + func lay(_ nodes: [BoxNode], from x: CGFloat, width: CGFloat, gaps: [CGFloat], _ rect: (_ i: Int, _ x0: CGFloat, _ x1: CGFloat) -> Void) { + var x = x + for (i, w) in stretch(nodes, to: width, gaps: gaps).enumerated() { + rect(i, x.rounded(), (x + w).rounded()) + x += w + (i < gaps.count ? gaps[i] : 0) + } } 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) + let header = n.children.isEmpty ? rect : CGRect(x: rect.minX, y: rect.minY, width: min(rect.width, head.width + padX * 2), height: padY + 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) + let top = rect.minY + padY + head.height + headGap + lay(n.children, from: rect.minX + padX, width: rect.width - padX * 2, gaps: gaps(n.children, childGap)) { i, x0, x1 in + place(n.children[i], CGRect(x: x0, y: top, width: x1 - x0, height: rect.maxY - padX - top), depth + 1, n.id, &out) } } @@ -379,22 +415,22 @@ func layoutBoxDiagram(_ d: BoxDiagram) throws(DiagramError) -> BoxLayout { } let asideIDs = Set(d.rows.flatMap(\.aside).flatMap(ids)) let crossing = d.edges.filter { asideIDs.contains($0.from) != asideIDs.contains($0.to) }.compactMap(\.label) - let asideX = margin + mainWidth + crossing.map { textSize($0, edgeLabelFont).width + 16 }.reduce(columnGap, max) + let asideX = margin + mainWidth + crossing.map { ceil(textSize($0, edgeLabelFont).width) + labelRoom }.reduce(columnGap, max) var boxes: [PlacedBox] = [] var y = margin var asideBottom = margin - rowGap var bottom = margin for r in d.rows { let height = r.main.map { sizer.naturalSize($0).height }.max() ?? 0 - let gaps = sizer.gaps(r.main, boxGap) - var x = margin - 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) + sizer.lay(r.main, from: margin, width: mainWidth, gaps: sizer.gaps(r.main, boxGap)) { i, x0, x1 in + sizer.place(r.main[i], CGRect(x: x0, y: y, width: x1 - x0, height: height), 0, nil, &boxes) } var ay = max(y, asideBottom + rowGap) for n in r.aside { - let h = sizer.naturalSize(n).height + var h = sizer.naturalSize(n).height + if r.aside.count == 1 && ay == y { + h = max(h, height) + } sizer.place(n, CGRect(x: asideX, y: ay, width: asideWidth, height: h), 0, nil, &boxes) ay += h + rowGap asideBottom = ay - rowGap @@ -411,24 +447,133 @@ func layoutBoxDiagram(_ d: BoxDiagram) throws(DiagramError) -> BoxLayout { } var points = [[CGPoint]](repeating: [], count: d.edges.count) for i in order { - points[i] = try router.route(d.edges[i]) + points[i] = try router.route(d.edges[i], nil) + } + let pins = anchors(d.edges, points, boxes, index) + router = Router(boxes: boxes, index: index, size: size) + for i in order { + points[i] = try router.route(d.edges[i], pins[i]) } let headers = boxes.map(\.header) let outlines = boxes.map(\.rect) var labels: [CGRect] = [] 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() { + for (e, pts) in zip(d.edges, points) { 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, headers: headers, outlines: outlines, labels: labels, lines: others, CGRect(origin: .zero, size: size)) + let r = labelSpot(textSize(label, edgeLabelFont), pts, headers: headers, outlines: outlines, labels: labels, lines: lines.flatMap { $0 }, CGRect(x: 0, y: 0, width: size.width + margin, height: size.height + margin)) labels.append(r) labelRect = r } routed.append(RoutedEdge(edge: e, points: pts, labelRect: labelRect)) } - return BoxLayout(boxes: boxes, index: index, edges: routed, presets: d.presets, size: size) + let fit = CGSize(width: ceil(labels.map(\.maxX).reduce(size.width - margin, max) + margin), height: ceil(labels.map(\.maxY).reduce(size.height - margin, max) + margin)) + return BoxLayout(boxes: boxes, index: index, edges: routed, presets: d.presets, size: fit) +} + +private struct Anchor { + let side: Int + let at: CGFloat +} + +private let spread: CGFloat = 20 + +private func direction(_ a: CGPoint, _ b: CGPoint) -> Int { + if a.x == b.x { + return b.y > a.y ? 1 : 3 + } + return b.x > a.x ? 0 : 2 +} + +private func anchors(_ edges: [BoxEdge], _ points: [[CGPoint]], _ boxes: [PlacedBox], _ index: [String: Int]) -> [(from: Anchor, to: Anchor)] { + struct End { + let box: String + let side: Int + var at: CGFloat + let toward: CGFloat + var leader: Int? + } + func span(_ box: String, _ side: Int) -> (lo: CGFloat, hi: CGFloat, mid: CGFloat) { + let r = boxes[index[box]!].rect + return side % 2 == 1 ? (r.minX, r.maxX, r.midX) : (r.minY, r.maxY, r.midY) + } + func span(_ end: End) -> (lo: CGFloat, hi: CGFloat, mid: CGFloat) { + span(end.box, end.side) + } + func inside(_ v: CGFloat, _ s: (lo: CGFloat, hi: CGFloat, mid: CGFloat)) -> Bool { + let room = min(spread, (s.hi - s.lo) / 4) + return v > s.lo + room && v < s.hi - room + } + func lineage(_ id: String) -> [String] { + var out: [String] = [] + var p = boxes[index[id]!].parent + while let id = p { + out.append(id) + p = boxes[index[id]!].parent + } + return out + } + func clear(_ v: CGFloat, _ a: End, _ b: End) -> Bool { + let ra = boxes[index[a.box]!].rect + let rb = boxes[index[b.box]!].rect + let gap = a.side % 2 == 1 ? CGRect(x: v - 1, y: min(ra.maxY, rb.maxY), width: 2, height: max(ra.minY, rb.minY) - min(ra.maxY, rb.maxY)) : CGRect(x: min(ra.maxX, rb.maxX), y: v - 1, width: max(ra.minX, rb.minX) - min(ra.maxX, rb.maxX), height: 2) + guard gap.width > 0, gap.height > 0 else { + return false + } + let open = Set(lineage(a.box) + lineage(b.box)) + return !boxes.contains { box in + box.node.id != a.box && box.node.id != b.box && (open.contains(box.node.id) ? box.header : box.rect).intersects(gap) + } + } + var ends: [End] = [] + for (e, pts) in zip(edges, points) { + let i = ends.count + let sideA = direction(pts[0], pts[1]) + let sideB = direction(pts[pts.count - 1], pts[pts.count - 2]) + let sa = span(e.from, sideA) + let sb = span(e.to, sideB) + let first = pts[0] + let last = pts[pts.count - 1] + var a = End(box: e.from, side: sideA, at: sa.mid, toward: sideA % 2 == 1 ? first.x : first.y) + var b = End(box: e.to, side: sideB, at: sb.mid, toward: sideB % 2 == 1 ? last.x : last.y) + if sideA == (sideB + 2) % 4 { + let aHolds = inside(sb.mid, sa) + let bHolds = inside(sa.mid, sb) + if aHolds && (!bHolds || sb.hi - sb.lo < sa.hi - sa.lo) { + a.leader = i + 1 + } else if bHolds { + b.leader = i + } + } + ends += [a, b] + } + let groups = Dictionary(grouping: ends.indices, by: { "\(ends[$0].box) \(ends[$0].side)" }).values + func spreadOut(_ group: [Int]) { + let sorted = group.sorted { (ends[$0].at, ends[$0].toward) < (ends[$1].at, ends[$1].toward) } + guard zip(sorted, sorted.dropFirst()).contains(where: { ends[$1].at - ends[$0].at < spread }) else { + return + } + let s = span(ends[sorted[0]]) + for (k, i) in sorted.enumerated() { + ends[i].at = (s.lo + (s.hi - s.lo) * CGFloat(k + 1) / CGFloat(sorted.count + 1)).rounded() + ends[i].leader = nil + } + } + for g in groups { + spreadOut(g.filter { ends[$0].leader == nil }) + } + for i in ends.indices { + if let l = ends[i].leader, inside(ends[l].at, span(ends[i])), clear(ends[l].at, ends[i], ends[l]) { + ends[i].at = ends[l].at + } + } + for g in groups { + spreadOut(g) + } + return edges.indices.map { i in + (Anchor(side: ends[i * 2].side, at: ends[i * 2].at), Anchor(side: ends[i * 2 + 1].side, at: ends[i * 2 + 1].at)) + } } private func labelSpot(_ size: CGSize, _ pts: [CGPoint], headers: [CGRect], outlines: [CGRect], labels: [CGRect], lines: [CGRect], _ bounds: CGRect) -> CGRect { @@ -440,11 +585,11 @@ private func labelSpot(_ size: CGSize, _ pts: [CGPoint], headers: [CGRect], outl 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.count)) - candidates.append((CGRect(x: p.x - 6 - size.width, y: p.y - size.height / 2, width: size.width, height: size.height), candidates.count)) + candidates.append((CGRect(x: p.x + 8, y: p.y - size.height / 2, width: size.width, height: size.height), candidates.count)) + candidates.append((CGRect(x: p.x - 8 - 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.count)) - candidates.append((CGRect(x: p.x - size.width / 2, y: p.y + 3, width: size.width, height: size.height), candidates.count)) + candidates.append((CGRect(x: p.x - size.width / 2, y: p.y - 5 - size.height, width: size.width, height: size.height), candidates.count)) + candidates.append((CGRect(x: p.x - size.width / 2, y: p.y + 5, width: size.width, height: size.height), candidates.count)) } } } @@ -481,7 +626,8 @@ private let nearCost = 4 private let parallelCost = 80 private let laneCost = 40 private let laneWidth = 3 -private let crossCost = 15 +private let crossCost = 80 +private let pinCost = 200 private let dx = [1, 0, -1, 0] private let dy = [0, 1, 0, -1] @@ -529,10 +675,19 @@ private struct Router { return out } - private func blocked(_ e: BoxEdge) -> (grid: [Bool], upright: [Bool]) { + private func blocked(_ e: BoxEdge) -> (grid: [Bool], upright: [Bool], level: [Bool]) { let open = Set(lineage(e.from) + lineage(e.to)) var grid = [Bool](repeating: false, count: cols * rows) var upright = [Bool](repeating: false, count: cols * rows) + var level = [Bool](repeating: false, count: cols * rows) + func mark(_ r: CGRect, _ into: inout [Bool]) { + let band = cells(r) + for y in max(0, band.y0)...min(rows - 1, band.y1) { + for x in max(0, band.x0)...min(cols - 1, band.x1) { + into[y * cols + x] = true + } + } + } for b in boxes { let isOpen = open.contains(b.node.id) let c = cells(isOpen ? b.header : b.rect) @@ -542,48 +697,49 @@ private struct Router { } } if isOpen { - let band = cells(CGRect(x: b.rect.minX, y: b.rect.minY, width: b.rect.width, height: b.header.height)) - for y in max(0, band.y0)...min(rows - 1, band.y1) { - for x in max(0, band.x0)...min(cols - 1, band.x1) { - upright[y * cols + x] = true - } - } + let r = b.rect + mark(CGRect(x: r.minX, y: r.minY - cell * 2, width: r.width, height: b.header.height + cell * 2), &upright) + mark(CGRect(x: r.minX, y: r.maxY - cell, width: r.width, height: cell * 3), &upright) + mark(CGRect(x: r.minX - cell * 2, y: r.minY, width: cell * 3, height: r.height), &level) + mark(CGRect(x: r.maxX - cell, y: r.minY, width: cell * 3, height: r.height), &level) } } - return (grid, upright) + return (grid, upright, level) } - private func ports(_ id: String, _ grid: [Bool], outward: Bool) -> [Port] { + private func ports(_ id: String, _ grid: [Bool], outward: Bool, _ anchor: Anchor?) -> [Port] { let r = boxes[index[id]!].rect let c = cells(r) + let pinned = anchor.map { Int(floor($0.at / cell)) } var out: [Port] = [] func add(_ x: Int, _ y: Int, _ dir: Int, _ border: CGPoint, _ offset: Int) { - guard x >= 0, y >= 0, x < cols, y < rows, !grid[y * cols + x] else { + guard x >= 0, y >= 0, x < cols, y < rows, !grid[y * cols + x], anchor == nil || anchor!.side == dir else { return } - out.append(Port(cell: y * cols + x, dir: outward ? dir : (dir + 2) % 4, border: border, cost: offset * 2)) + out.append(Port(cell: y * cols + x, dir: outward ? dir : (dir + 2) % 4, border: border, cost: offset * (anchor == nil ? 2 : pinCost))) } - let midX = (c.x0 + c.x1) / 2 - let midY = (c.y0 + c.y1) / 2 + func at(_ i: Int) -> CGFloat { + i == pinned ? anchor!.at : (CGFloat(i) + 0.5) * cell + } + let midX = pinned ?? (c.x0 + c.x1) / 2 + let midY = pinned ?? (c.y0 + c.y1) / 2 if c.x1 - c.x0 >= 2 { for x in (c.x0 + 1)...(c.x1 - 1) { - let px = (CGFloat(x) + 0.5) * cell - add(x, c.y0 - 2, 3, CGPoint(x: px, y: r.minY), abs(x - midX)) - add(x, c.y1 + 2, 1, CGPoint(x: px, y: r.maxY), abs(x - midX)) + add(x, c.y0 - 2, 3, CGPoint(x: at(x), y: r.minY), abs(x - midX)) + add(x, c.y1 + 2, 1, CGPoint(x: at(x), y: r.maxY), abs(x - midX)) } } if c.y1 - c.y0 >= 2 { for y in (c.y0 + 1)...(c.y1 - 1) { - let py = (CGFloat(y) + 0.5) * cell - add(c.x0 - 2, y, 2, CGPoint(x: r.minX, y: py), abs(y - midY)) - add(c.x1 + 2, y, 0, CGPoint(x: r.maxX, y: py), abs(y - midY)) + add(c.x0 - 2, y, 2, CGPoint(x: r.minX, y: at(y)), abs(y - midY)) + add(c.x1 + 2, y, 0, CGPoint(x: r.maxX, y: at(y)), abs(y - midY)) } } return out } - mutating func route(_ e: BoxEdge) throws(DiagramError) -> [CGPoint] { - let (grid, upright) = blocked(e) + mutating func route(_ e: BoxEdge, _ pin: (from: Anchor, to: Anchor)?) throws(DiagramError) -> [CGPoint] { + let (grid, upright, level) = blocked(e) var near = [Bool](repeating: false, count: cols * rows) for i in grid.indices where grid[i] { let x = i % cols @@ -594,8 +750,8 @@ private struct Router { } } } - let starts = ports(e.from, grid, outward: true) - let goals = ports(e.to, grid, outward: false) + let starts = ports(e.from, grid, outward: true, pin?.from) + let goals = ports(e.to, grid, outward: false, pin?.to) guard !starts.isEmpty, !goals.isEmpty, e.from != e.to else { throw DiagramError(reason: "can't route \(e.from) -> \(e.to)") } @@ -639,7 +795,7 @@ private struct Router { let c = st / 4 let dir = st % 4 if let goal = goalAt[c] { - let total = g + (dir == goal.dir ? 0 : turnCost) + let total = g + goal.cost + (dir == goal.dir ? 0 : turnCost) if total < best { best = total bestState = st @@ -654,7 +810,7 @@ private struct Router { continue } let n = ny * cols + nx - if grid[n] || (nd % 2 == 0 && (upright[n] || upright[c])) { + if grid[n] || (nd % 2 == 0 && (upright[n] || upright[c])) || (nd % 2 == 1 && (level[n] || level[c])) { continue } var step = stepCost + (nd == dir ? 0 : turnCost) + (near[n] ? nearCost : 0) @@ -684,11 +840,56 @@ private struct Router { st = from[st] } path.reverse() - for (a, b) in zip(path, path.dropFirst()) { - let axis: UInt8 = (b % 4) % 2 == 0 ? 1 : 2 - used[a / 4] |= axis - used[b / 4] |= axis - for c in [a / 4, b / 4] { + func center(_ c: Int) -> CGPoint { + CGPoint(x: (CGFloat(c % cols) + 0.5) * cell, y: (CGFloat(c / cols) + 0.5) * cell) + } + let goal = goalAt[bestState / 4]! + var pts = [startAt[path[0]]!.border] + path.map { center($0 / 4) } + [goal.border] + let dirs = path.map { $0 % 4 } + [goal.dir] + func align(_ range: ClosedRange, to p: CGPoint, _ dir: Int) { + for i in range { + if dir % 2 == 1 { + pts[i].x = p.x + } else { + pts[i].y = p.y + } + } + } + let first = dirs.prefix { $0 == dirs[0] }.count + let last = dirs.reversed().prefix { $0 == dirs.last! }.count + if first == dirs.count { + align(0...(pts.count - 1), to: pts[0], dirs[0]) + } else { + align(0...first, to: pts[0], dirs[0]) + align((pts.count - 1 - last)...(pts.count - 1), to: pts[pts.count - 1], dirs.last!) + } + pts = simplify(pts) + func cellsAlong(_ a: CGPoint, _ b: CGPoint) -> [Int] { + let x0 = Int(floor(min(a.x, b.x) / cell)) + let x1 = Int(floor(max(a.x, b.x) / cell)) + let y0 = Int(floor(min(a.y, b.y) / cell)) + let y1 = Int(floor(max(a.y, b.y) / cell)) + return (max(0, y0)...min(rows - 1, y1)).flatMap { y in (max(0, x0)...min(cols - 1, x1)).map { y * cols + $0 } } + } + if pts.count == 4 { + let vertical = pts[0].x == pts[1].x + let mid = vertical ? (pts[0].y + pts[3].y) / 2 : (pts[0].x + pts[3].x) / 2 + let m = (floor(mid / cell) + 0.5) * cell + let b = vertical ? CGPoint(x: pts[1].x, y: m) : CGPoint(x: m, y: pts[1].y) + let c = vertical ? CGPoint(x: pts[2].x, y: m) : CGPoint(x: m, y: pts[2].y) + let stubs = cellsAlong(pts[1], b) + cellsAlong(pts[2], c) + let across = cellsAlong(b, c) + let stubAxis: UInt8 = vertical ? 2 : 1 + let acrossAxis: UInt8 = vertical ? 1 : 2 + if !stubs.contains(where: { grid[$0] || used[$0] & stubAxis != 0 || (vertical ? level[$0] : upright[$0]) }) && !across.contains(where: { grid[$0] || used[$0] & acrossAxis != 0 || (vertical ? upright[$0] : level[$0]) }) { + pts[1] = b + pts[2] = c + } + } + for (a, b) in zip(pts, pts.dropFirst()) { + let axis: UInt8 = a.y == b.y ? 1 : 2 + for c in cellsAlong(a, b) { + used[c] |= axis let x = c % cols let y = c / cols for k in 1...laneWidth { @@ -698,13 +899,7 @@ private struct Router { } } } - func center(_ c: Int) -> CGPoint { - CGPoint(x: (CGFloat(c % cols) + 0.5) * cell, y: (CGFloat(c / cols) + 0.5) * cell) - } - var pts = [startAt[path[0]]!.border] - pts += path.map { center($0 / 4) } - pts.append(goalAt[bestState / 4]!.border) - return simplify(pts) + return pts } } @@ -789,6 +984,19 @@ func cachedBoxLayout(_ source: String) -> Result { return result } +func shaft(_ pts: [CGPoint]) -> [CGPoint] { + guard pts.count >= 2 else { + return pts + } + let a = pts[pts.count - 2] + let b = pts[pts.count - 1] + let len = hypot(b.x - a.x, b.y - a.y) + let cut = min(5, len / 2) + var out = pts + out[out.count - 1] = CGPoint(x: b.x - (b.x - a.x) / len * cut, y: b.y - (b.y - a.y) / len * cut) + return out +} + func fill(_ b: PlacedBox) -> Color { switch b.node.tint { case .blue: Palette.accentTint @@ -823,11 +1031,11 @@ struct BoxCanvas: View { ctx.fill(path, with: .color(fill(b))) let lit = focus?.boxes.contains(b.node.id) == true ctx.stroke(path, with: .color(lit ? Palette.accent : Palette.border), lineWidth: lit ? 1.5 : 1) - var y = b.rect.minY + pad - ctx.draw(Text(b.node.title).font(Font(titleFont)).foregroundColor(Palette.text), at: CGPoint(x: b.rect.minX + pad, y: y), anchor: .topLeading) - y += titleLine + 2 + var y = b.rect.minY + padY + ctx.draw(Text(b.node.title).font(Font(titleFont)).foregroundColor(Palette.text), at: CGPoint(x: b.rect.minX + padX, y: y), anchor: .topLeading) + y += titleLine + titleGap for d in b.node.details { - ctx.draw(Text(d).font(Font(detailFont)).foregroundColor(Palette.muted), at: CGPoint(x: b.rect.minX + pad, y: y), anchor: .topLeading) + ctx.draw(Text(d).font(Font(detailFont)).foregroundColor(Palette.muted), at: CGPoint(x: b.rect.minX + padX, y: y), anchor: .topLeading) y += detailLine } } @@ -838,7 +1046,7 @@ struct BoxCanvas: View { ctx.opacity = 0.2 } let color = lit ? Palette.accent : Palette.muted - ctx.stroke(rounded(r.points), with: .color(color), style: StrokeStyle(lineWidth: lit ? 2 : 1.3, lineCap: .round, lineJoin: .round, dash: r.edge.dashed ? [5, 4] : [])) + ctx.stroke(rounded(shaft(r.points)), with: .color(color), style: StrokeStyle(lineWidth: lit ? 2 : 1.3, lineCap: .round, lineJoin: .round, dash: r.edge.dashed ? [5, 4] : [])) if r.points.count >= 2 { arrow(&ctx, r.points[r.points.count - 2], r.points[r.points.count - 1], color) } diff --git a/mac/Sources/Byre/DiagramHTML.swift b/mac/Sources/Byre/DiagramHTML.swift index 3ef9fd1..a6216a8 100644 --- a/mac/Sources/Byre/DiagramHTML.swift +++ b/mac/Sources/Byre/DiagramHTML.swift @@ -70,18 +70,18 @@ func diagramHTML(_ layout: BoxLayout) -> String { let f = fill(b) svg += "" svg += "" - var y = r.minY + pad - svg += "\(esc(b.node.title))" - y += titleLine + 2 + var y = r.minY + padY + svg += "\(esc(b.node.title))" + y += titleLine + titleGap for d in b.node.details { - svg += "\(esc(d))" + svg += "\(esc(d))" y += detailLine } svg += "" } for (i, e) in layout.edges.enumerated() { svg += "" - svg += "" + svg += "" if e.points.count >= 2 { svg += "" }