Files
byre/mac/Sources/Byre/BoxDiagram.swift
T

1752 lines
64 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import CoreText
import SwiftUI
import UniformTypeIdentifiers
enum BoxTint: String {
case plain, blue, green, amber, red
}
struct BoxNode {
let id: String
var title: String
var details: [String] = []
var tint = BoxTint.plain
var children: [BoxNode] = []
var opens = false
}
struct BoxRow {
var main: [BoxNode] = []
var aside: [BoxNode] = []
}
struct BoxEdge {
let from: String
let to: String
let label: String?
let dashed: Bool
}
struct BoxPreset {
let name: String
let ids: Set<String>
}
struct BoxDiagram {
var levels: [String: [BoxRow]] = [:]
var insides: [String] = []
var homes: [String: String] = [:]
var parents: [String: String] = [:]
var edges: [BoxEdge] = []
var presets: [BoxPreset] = []
func rep(_ id: String, at level: String) -> String? {
var id = id
while true {
if homes[id] == level || drawnUnder(id, level) {
return id
}
let h = homes[id]!
if h.isEmpty {
return nil
}
id = h
}
}
func drawnUnder(_ id: String, _ ancestor: String) -> Bool {
var p = parents[id]
while let up = p {
if up == ancestor {
return true
}
p = parents[up]
}
return false
}
func end(_ id: String, at level: String) -> (id: String, ghost: Bool, level: String) {
if let r = rep(id, at: level) {
return (r, false, level)
}
var up = homes[level]!
while true {
if let r = rep(id, at: up) {
return (r, true, up)
}
up = homes[up]!
}
}
}
private enum Token: Equatable {
case word(String)
case text(String)
}
private func tokenize(_ line: String) throws(DiagramError) -> [Token] {
var out: [Token] = []
var rest = Substring(line)
while true {
rest = rest.drop { $0 == " " || $0 == "\t" }
guard let c = rest.first else {
return out
}
if c == "\"" {
var text = ""
var i = rest.index(after: rest.startIndex)
var closed = false
while i < rest.endIndex {
let ch = rest[i]
if ch == "\\", rest.index(after: i) < rest.endIndex {
i = rest.index(after: i)
text.append(rest[i])
} else if ch == "\"" {
closed = true
break
} else {
text.append(ch)
}
i = rest.index(after: i)
}
guard closed else {
throw DiagramError(reason: "unclosed quote in \"\(line)\"")
}
out.append(.text(text))
rest = rest[rest.index(after: i)...]
continue
}
let word = rest.prefix { $0 != " " && $0 != "\t" && $0 != "\"" }
out.append(.word(String(word)))
rest = rest.dropFirst(word.count)
}
}
private let boxId = Re(#"^[A-Za-z0-9_-]+$"#)
private final class NodeBuilder {
var node: BoxNode
var children: [NodeBuilder] = []
init(_ node: BoxNode) {
self.node = node
}
}
func parseBoxDiagram(_ src: String) throws(DiagramError) -> BoxDiagram {
var sections: [String: [(main: [NodeBuilder], aside: [NodeBuilder])]] = ["": []]
var level = ""
var rows: [(main: [NodeBuilder], aside: [NodeBuilder])] {
get { sections[level]! }
set { sections[level] = newValue }
}
var stack: [(indent: Int, node: NodeBuilder)] = []
var ids = Set<String>()
var diagram = BoxDiagram()
for raw in src.components(separatedBy: "\n") {
let line = raw.replacingOccurrences(of: "\t", with: " ")
let body = trimmed(line)
if body.isEmpty || body.hasPrefix("#") {
continue
}
let indent = line.prefix { $0 == " " }.count
var toks = try tokenize(body)
if indent == 0 {
stack = []
if toks == [.word("row")] {
rows.append(([], []))
continue
}
if toks.first == .word("inside") {
guard toks.count == 2, case .word(let id) = toks[1] else {
throw DiagramError(reason: "inside takes one box id: \"\(body)\"")
}
guard sections[id] == nil else {
throw DiagramError(reason: "inside \(id) appears twice")
}
level = id
sections[id] = []
diagram.insides.append(id)
continue
}
if toks.first == .word("highlight") {
guard toks.count >= 3, case .text(let name) = toks[1] else {
throw DiagramError(reason: "a highlight needs a quoted name and box ids: \"\(body)\"")
}
var set = Set<String>()
for t in toks.dropFirst(2) {
guard case .word(let id) = t else {
throw DiagramError(reason: "can't parse \"\(body)\"")
}
set.insert(id)
}
diagram.presets.append(BoxPreset(name: name, ids: set))
continue
}
guard toks.count == 3 || toks.count == 4, case .word(let from) = toks[0], case .word(let arrow) = toks[1], case .word(let to) = toks[2], arrow == "->" || arrow == "..>" else {
throw DiagramError(reason: "can't parse \"\(body)\"; top-level lines are row, inside, highlight or an edge like a -> b \"label\"")
}
var label: String?
if toks.count == 4 {
guard case .text(let l) = toks[3] else {
throw DiagramError(reason: "an edge's label must be quoted: \"\(body)\"")
}
label = l
}
diagram.edges.append(BoxEdge(from: from, to: to, label: label, dashed: arrow == "..>"))
continue
}
guard !rows.isEmpty else {
throw DiagramError(reason: "boxes go under a row: \"\(body)\"")
}
let aside = toks.first == .word("aside")
if aside {
toks.removeFirst()
}
guard case .word(let id) = toks.first, boxId.test(id) else {
throw DiagramError(reason: "a box starts with its id: \"\(body)\"")
}
guard ids.insert(id).inserted else {
throw DiagramError(reason: "box \(id) is defined twice")
}
diagram.homes[id] = level
var node = BoxNode(id: id, title: id)
var texts: [String] = []
for t in toks.dropFirst() {
switch t {
case .text(let s):
texts.append(s)
case .word(let w):
guard let tint = BoxTint(rawValue: w) else {
throw DiagramError(reason: "\(w) isn't a color; use blue, green, amber, red or plain")
}
node.tint = tint
}
}
if let first = texts.first {
node.title = first
node.details = Array(texts.dropFirst())
}
let b = NodeBuilder(node)
while let last = stack.last, last.indent >= indent {
stack.removeLast()
}
if let parent = stack.last {
guard !aside else {
throw DiagramError(reason: "aside boxes sit directly under a row: \"\(body)\"")
}
parent.node.children.append(b)
} else if aside {
rows[rows.count - 1].aside.append(b)
} else {
rows[rows.count - 1].main.append(b)
}
stack.append((indent, b))
}
if ids.isEmpty {
throw DiagramError(reason: "empty diagram")
}
for (key, rows) in sections {
guard rows.contains(where: { !$0.main.isEmpty || !$0.aside.isEmpty }) else {
throw DiagramError(reason: key.isEmpty ? "the diagram has no boxes outside its inside sections" : "inside \(key) has no boxes")
}
}
for id in diagram.insides where !ids.contains(id) {
throw DiagramError(reason: "inside \(id) names no box")
}
for id in diagram.insides {
var seen: Set<String> = [id]
var up = diagram.homes[id]!
while !up.isEmpty {
guard seen.insert(up).inserted else {
throw DiagramError(reason: "inside \(id) ends up inside itself")
}
up = diagram.homes[up]!
}
}
let opens = Set(diagram.insides)
var previews: [String: [BoxNode]] = [:]
func build(_ b: NodeBuilder) -> BoxNode {
var n = b.node
n.opens = opens.contains(n.id)
for c in b.children {
diagram.parents[c.node.id] = n.id
n.children.append(build(c))
}
if n.opens && !n.children.isEmpty {
previews[n.id] = n.children
}
return n
}
for (key, rows) in sections {
diagram.levels[key] = rows.map { BoxRow(main: $0.main.map(build), aside: $0.aside.map(build)) }
}
for (id, kids) in previews {
diagram.levels[id] = [BoxRow(main: kids)] + diagram.levels[id]!
}
for e in diagram.edges {
for id in [e.from, e.to] where !ids.contains(id) {
throw DiagramError(reason: "edge \(e.from) -> \(e.to) names no box \(id)")
}
}
for p in diagram.presets {
for id in p.ids where !ids.contains(id) {
throw DiagramError(reason: "highlight \"\(p.name)\" names no box \(id)")
}
}
return diagram
}
enum BoxKind {
case box, frame, ghost
}
struct PlacedBox {
let node: BoxNode
var rect: CGRect
let depth: Int
var parent: String?
var header: CGRect
var kind = BoxKind.box
func moved(_ dx: CGFloat, _ dy: CGFloat) -> PlacedBox {
var b = self
b.rect = rect.offsetBy(dx: dx, dy: dy)
b.header = header.offsetBy(dx: dx, dy: dy)
return b
}
}
struct RoutedEdge {
let edge: BoxEdge
let points: [CGPoint]
let labelRect: CGRect?
}
struct DiagramLayout: Identifiable {
let id = UUID()
let levels: [String: BoxLayout]
let order: [String]
let homes: [String: String]
var root: BoxLayout {
levels[""]!
}
func trail(_ level: String) -> [String] {
var out = [level]
while let last = out.last, !last.isEmpty {
out.append(homes[last]!)
}
return out.reversed()
}
func title(_ level: String) -> String {
guard !level.isEmpty else {
return "Diagram"
}
let l = levels[homes[level]!]!
return l.boxes[l.index[level]!].node.title
}
}
struct BoxLayout {
let level: String
let boxes: [PlacedBox]
let index: [String: Int]
let edges: [RoutedEdge]
let presets: [BoxPreset]
let size: CGSize
func ancestors(_ 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 opener(at p: CGPoint) -> String? {
boxes.first { $0.kind == .box && $0.node.opens && markRect($0.rect).contains(p) }?.node.id
}
func hit(_ p: CGPoint) -> String? {
boxes.filter { $0.rect.contains(p) }.max { $0.depth < $1.depth }?.node.id
}
}
struct BoxFocus {
let boxes: Set<String>
let edges: Set<Int>
let outlined: Set<String>
static func box(_ id: String, _ layout: BoxLayout) -> BoxFocus {
var boxes: Set<String> = [id]
var edges = Set<Int>()
for (i, r) in layout.edges.enumerated() where r.edge.from == id || r.edge.to == id {
edges.insert(i)
boxes.insert(r.edge.from)
boxes.insert(r.edge.to)
}
return BoxFocus(boxes: boxes, edges: edges, outlined: [id])
}
static func preset(_ p: BoxPreset, _ layout: BoxLayout) -> BoxFocus {
let edges = layout.edges.indices.filter { p.ids.contains(layout.edges[$0].edge.from) && p.ids.contains(layout.edges[$0].edge.to) }
return BoxFocus(boxes: p.ids, edges: Set(edges), outlined: p.ids)
}
func dims(_ b: PlacedBox, _ layout: BoxLayout) -> Bool {
if boxes.contains(b.node.id) || layout.ancestors(b.node.id).contains(where: boxes.contains) {
return false
}
return !boxes.contains { layout.ancestors($0).contains(b.node.id) }
}
}
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 laneGap: CGFloat = 24
private let columnGap: CGFloat = 80
private let margin: CGFloat = 20
private let labelRoom: CGFloat = 28
private let edgeGap: CGFloat = 36
let markRoom: CGFloat = 20
@MainActor
var titleFont: CTFont {
Fonts.native(13, weight: 600) as CTFont
}
@MainActor
var detailFont: CTFont {
Fonts.native(12) as CTFont
}
@MainActor
var edgeLabelFont: CTFont {
Fonts.native(11) as CTFont
}
@MainActor
private func headerSize(_ n: BoxNode) -> CGSize {
let t = textSize(n.title, titleFont)
var w = t.width + (n.opens ? markRoom : 0)
var h = t.height
for d in n.details {
let s = textSize(d, detailFont)
w = max(w, s.width)
h += s.height
}
return CGSize(width: ceil(w), height: ceil(h + (n.details.isEmpty ? 0 : titleGap)))
}
@MainActor
private struct Sizer {
let pairGaps: [String: CGFloat]
init(_ rows: [BoxRow], _ edges: [BoxEdge], _ extra: [Int: CGFloat]) {
var parents: [String: String] = [:]
func walk(_ n: BoxNode) {
for c in n.children {
parents[c.id] = n.id
walk(c)
}
}
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 (i, e) in edges.enumerated() {
let need = (e.label.map { ceil(textSize($0, edgeLabelFont).width) + labelRoom } ?? edgeGap) + (extra[i] ?? 0)
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)
}
}
}
pairGaps = 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, 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 + 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) + 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 }
var room = width - gaps.reduce(0, +)
var fixed = Set<Int>()
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 + 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 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)
}
}
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) -> DiagramLayout {
var levels: [String: BoxLayout] = [:]
var order: [String] = []
func visit(_ level: String) throws(DiagramError) {
levels[level] = try layoutLevel(d, level, levels)
order.append(level)
for id in d.insides where d.homes[id] == level {
try visit(id)
}
}
try visit("")
return DiagramLayout(levels: levels, order: order, homes: d.homes)
}
private func projectEdges(_ d: BoxDiagram, _ level: String) -> (edges: [BoxEdge], ghosts: [(id: String, level: String)]) {
var keys: [String] = []
var groups: [String: [(edge: BoxEdge, exact: Bool)]] = [:]
var ghosts: [(id: String, level: String)] = []
for e in d.edges {
let a = e.from == level ? (id: level, ghost: false, level: level) : d.end(e.from, at: level)
let b = e.to == level ? (id: level, ghost: false, level: level) : d.end(e.to, at: level)
if a.ghost || b.ghost ? a.ghost == b.ghost || a.id == level || b.id == level : a.id == b.id {
continue
}
let exact = a.id == e.from && b.id == e.to
if !exact && (d.drawnUnder(a.id, b.id) || d.drawnUnder(b.id, a.id)) {
continue
}
for g in [a, b] where g.ghost && !ghosts.contains(where: { $0.id == g.id }) {
ghosts.append((g.id, g.level))
}
let key = "\(a.id) \(b.id) \(e.dashed)"
if groups[key] == nil {
keys.append(key)
}
groups[key, default: []].append((BoxEdge(from: a.id, to: b.id, label: e.label, dashed: e.dashed), exact))
}
let edges = keys.map { key in
let group = groups[key]!
var label = group.first(where: \.exact)?.edge.label
if !group.contains(where: \.exact) {
var seen: [String] = []
for l in group.compactMap(\.edge.label) where !seen.contains(l) {
seen.append(l)
}
label = seen.isEmpty ? nil : seen.joined(separator: " · ")
}
return BoxEdge(from: group[0].edge.from, to: group[0].edge.to, label: label, dashed: group[0].edge.dashed)
}
return (edges, ghosts)
}
@MainActor
private func layoutLevel(_ d: BoxDiagram, _ level: String, _ done: [String: BoxLayout]) throws(DiagramError) -> BoxLayout {
let (edges, ghosts) = projectEdges(d, level)
var extra: [Int: CGFloat] = [:]
while true {
var (boxes, size) = placeRows(d.levels[level]!, edges, extra)
if !level.isEmpty {
(boxes, size) = framed(d, level, boxes, size, ghosts, edges, done)
}
let visible = Set(boxes.filter { $0.kind != .frame }.map(\.node.id))
let presets = d.presets.compactMap { p -> BoxPreset? in
let ids = Set(p.ids.map { d.end($0, at: level).id }).intersection(visible)
return ids.contains { d.rep($0, at: level) == $0 } ? BoxPreset(name: p.name, ids: ids) : nil
}
let (layout, stuck) = try routeLevel(level, boxes, size, edges, presets)
if stuck.isEmpty || !extra.isEmpty {
return layout
}
extra = stuck
}
}
@MainActor
private func framed(_ d: BoxDiagram, _ level: String, _ inner: [PlacedBox], _ innerSize: CGSize, _ ghosts: [(id: String, level: String)], _ edges: [BoxEdge], _ done: [String: BoxLayout]) -> (boxes: [PlacedBox], size: CGSize) {
let up = done[d.homes[level]!]!
let node = BoxNode(id: level, title: up.boxes[up.index[level]!].node.title)
let head = headerSize(node)
let frame = CGRect(x: 0, y: 0, width: max(innerSize.width - margin * 2, head.width) + padX * 2, height: padY + head.height + headGap + innerSize.height - margin * 2 + padX)
let header = CGRect(x: 0, y: 0, width: head.width + padX * 2, height: padY + head.height)
var boxes = [PlacedBox(node: node, rect: frame, depth: -1, parent: nil, header: header, kind: .frame)]
for b in inner {
var m = b.moved(padX - margin, padY + head.height + headGap - margin)
m.parent = m.parent ?? level
boxes.append(m)
}
var sides: [[(key: CGFloat, node: BoxNode)]] = [[], [], [], []]
for g in ghosts {
let l = done[g.level]!
let gr = l.boxes[l.index[g.id]!].rect
let cr = l.boxes[l.index[d.rep(level, at: g.level)!]!].rect
let side = gr.maxY <= cr.minY ? 3 : gr.minY >= cr.maxY ? 1 : gr.midX < cr.midX ? 2 : 0
sides[side].append((side % 2 == 1 ? gr.midX : gr.midY, BoxNode(id: g.id, title: l.boxes[l.index[g.id]!].node.title)))
}
for (side, entries) in sides.enumerated() where !entries.isEmpty {
let nodes = entries.sorted { $0.key < $1.key }.map(\.node)
let sizes = nodes.map { n in
let h = headerSize(n)
return CGSize(width: h.width + padX * 2, height: h.height + padY * 2)
}
if side % 2 == 1 {
let total = sizes.map(\.width).reduce(0, +) + boxGap * CGFloat(nodes.count - 1)
let h = sizes.map(\.height).max()!
var x = (frame.midX - total / 2).rounded()
let y = side == 3 ? -rowGap - h : frame.maxY + rowGap
for (n, s) in zip(nodes, sizes) {
let r = CGRect(x: x, y: y, width: s.width, height: h)
boxes.append(PlacedBox(node: n, rect: r, depth: 0, parent: nil, header: r, kind: .ghost))
x += s.width + boxGap
}
} else {
let ids = Set(nodes.map(\.id))
let gap = edges.filter { ids.contains($0.from) || ids.contains($0.to) }.compactMap(\.label).map { ceil(textSize($0, edgeLabelFont).width) + labelRoom }.reduce(columnGap, max)
let total = sizes.map(\.height).reduce(0, +) + boxGap * CGFloat(nodes.count - 1)
let w = sizes.map(\.width).max()!
var y = (frame.midY - total / 2).rounded()
let x = side == 2 ? -gap - w : frame.maxX + gap
for (n, s) in zip(nodes, sizes) {
let r = CGRect(x: x, y: y, width: w, height: s.height)
boxes.append(PlacedBox(node: n, rect: r, depth: 0, parent: nil, header: r, kind: .ghost))
y += s.height + boxGap
}
}
}
let bounds = boxes.map(\.rect).reduce(frame) { $0.union($1) }
return (boxes.map { $0.moved(margin - bounds.minX, margin - bounds.minY) }, CGSize(width: bounds.width + margin * 2, height: bounds.height + margin * 2))
}
@MainActor
private func placeRows(_ rows: [BoxRow], _ edges: [BoxEdge], _ extra: [Int: CGFloat]) -> (boxes: [PlacedBox], size: CGSize) {
let sizer = Sizer(rows, edges, extra)
let mainWidth: CGFloat = rows.map(sizer.rowWidth).max() ?? 0
let asideWidth: CGFloat = rows.flatMap(\.aside).map { sizer.naturalSize($0).width }.max() ?? 0
func ids(_ n: BoxNode) -> [String] {
[n.id] + n.children.flatMap(ids)
}
let asideIDs = Set(rows.flatMap(\.aside).flatMap(ids))
let here = Set(rows.flatMap { $0.main + $0.aside }.flatMap(ids))
let crossing = edges.filter { here.contains($0.from) && here.contains($0.to) && asideIDs.contains($0.from) != asideIDs.contains($0.to) }.compactMap(\.label)
let asideX = margin + mainWidth + crossing.map { ceil(textSize($0, edgeLabelFont).width) + labelRoom }.reduce(columnGap, max)
var rowOf: [String: Int] = [:]
for (i, r) in rows.enumerated() {
for id in r.main.flatMap(ids) {
rowOf[id] = i
}
}
func gap(below i: Int) -> CGFloat {
let crossing = edges.indices.filter { k in
guard let a = rowOf[edges[k].from], let b = rowOf[edges[k].to] else {
return false
}
return min(a, b) <= i && max(a, b) > i
}
return max(rowGap, CGFloat(crossing.count + 1) * laneGap) + crossing.compactMap { extra[$0] }.reduce(0, max)
}
var boxes: [PlacedBox] = []
var y = margin
var asideBottom = margin - rowGap
var bottom = margin
for (i, r) in rows.enumerated() {
let height = r.main.map { sizer.naturalSize($0).height }.max() ?? 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 {
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
}
bottom = max(bottom, y + height, asideBottom)
y += height + (r.main.isEmpty ? 0 : gap(below: i))
}
let width = (asideWidth > 0 ? asideX + asideWidth : margin + mainWidth) + margin
return (boxes, CGSize(width: width, height: bottom + margin))
}
@MainActor
private func routeLevel(_ level: String, _ boxes: [PlacedBox], _ size: CGSize, _ edges: [BoxEdge], _ presets: [BoxPreset]) throws(DiagramError) -> (BoxLayout, [Int: CGFloat]) {
let index = Dictionary(uniqueKeysWithValues: boxes.enumerated().map { ($0.element.node.id, $0.offset) })
var router = Router(boxes: boxes, index: index, size: size)
let order = edges.indices.sorted { a, b in
router.span(edges[a]) < router.span(edges[b])
}
var points = [[CGPoint]](repeating: [], count: edges.count)
for i in order {
points[i] = try router.route(edges[i], nil)
}
let pins = anchors(edges, points, boxes, index)
router = Router(boxes: boxes, index: index, size: size)
for i in order {
points[i] = try router.route(edges[i], pins[i])
}
let headers = boxes.map(\.header) + boxes.filter { $0.kind == .box && $0.node.opens }.map { markRect($0.rect) }
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] = []
var stuck: [Int: CGFloat] = [:]
for (i, (e, pts)) in zip(edges, points).enumerated() {
var labelRect: CGRect?
if let label = e.label {
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
let crossed = lines.indices.filter { $0 != i && lines[$0].contains { $0.intersects(r) } }.count
if crossed > 0 {
stuck[i] = CGFloat(crossed) * laneGap
}
}
routed.append(RoutedEdge(edge: e, points: pts, labelRect: labelRect))
}
let dx = max(0, ceil(margin - labels.map(\.minX).reduce(margin, min)))
let dy = max(0, ceil(margin - labels.map(\.minY).reduce(margin, min)))
let fit = CGSize(width: ceil(labels.map(\.maxX).reduce(size.width - margin, max) + margin) + dx, height: ceil(labels.map(\.maxY).reduce(size.height - margin, max) + margin) + dy)
let shifted = routed.map { r in
RoutedEdge(edge: r.edge, points: r.points.map { CGPoint(x: $0.x + dx, y: $0.y + dy) }, labelRect: r.labelRect?.offsetBy(dx: dx, dy: dy))
}
return (BoxLayout(level: level, boxes: boxes.map { $0.moved(dx, dy) }, index: index, edges: shifted, presets: presets, size: fit), stuck)
}
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 {
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 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 + 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 - 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))
}
}
}
func area(_ a: CGRect, _ b: CGRect, _ clearance: CGFloat = 2) -> CGFloat {
let i = a.insetBy(dx: -clearance, dy: -clearance).intersection(b)
return i.isNull ? 0 : max(1, i.width * i.height)
}
func straddles(_ box: CGRect, _ c: CGRect) -> CGFloat {
let grown = c.insetBy(dx: -2, dy: -2)
if !grown.intersects(box) || box.contains(grown) {
return 0
}
return area(box, c)
}
func overlap(_ c: CGRect) -> CGFloat {
var covered = headers.reduce(0) { $0 + area($1, c) }
covered = outlines.reduce(covered) { $0 + straddles($1, c) }
covered = labels.reduce(covered) { $0 + area($1, c, 12) }
return covered * 100 + lines.reduce(0) { $0 + area($1, c) }
}
func outside(_ c: CGRect) -> CGFloat {
let inside = c.intersection(bounds)
return c.width * c.height - (inside.isNull ? 0 : inside.width * inside.height)
}
return candidates.min { a, b in
let oa = overlap(a.rect)
let ob = overlap(b.rect)
if oa != ob {
return oa < ob
}
let xa = outside(a.rect)
let xb = outside(b.rect)
return xa != xb ? xa < xb : a.order < b.order
}!.rect
}
private let cell: CGFloat = 4
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 = 80
private let pinCost = 200
private let dx = [1, 0, -1, 0]
private let dy = [0, 1, 0, -1]
private struct Port {
let cell: Int
let dir: Int
let border: CGPoint
let cost: Int
}
private struct Router {
let boxes: [PlacedBox]
let index: [String: Int]
let cols: Int
let rows: Int
var used: [UInt8]
var lanes: [UInt8]
init(boxes: [PlacedBox], index: [String: Int], size: CGSize) {
self.boxes = boxes
self.index = index
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 {
let a = boxes[index[e.from]!].rect
let b = boxes[index[e.to]!].rect
return abs(a.midX - b.midX) + abs(a.midY - b.midY)
}
private func cells(_ r: CGRect) -> (x0: Int, y0: Int, x1: Int, y1: Int) {
(Int(floor(r.minX / cell)), Int(floor(r.minY / cell)), Int(ceil(r.maxX / cell)) - 1, Int(ceil(r.maxY / cell)) - 1)
}
private 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
}
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)
for y in max(0, c.y0 - 1)...min(rows - 1, c.y1 + 1) {
for x in max(0, c.x0 - 1)...min(cols - 1, c.x1 + 1) {
grid[y * cols + x] = true
}
}
if isOpen {
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)
}
}
let toAncestors = Set(lineage(e.to))
if let outer = lineage(e.from).first(where: toAncestors.contains) {
let c = cells(boxes[index[outer]!].rect)
for y in 0..<rows {
for x in 0..<cols where x <= c.x0 || x >= c.x1 || y <= c.y0 || y >= c.y1 {
grid[y * cols + x] = true
}
}
}
return (grid, upright, level)
}
private func ports(_ id: String, _ grid: [Bool], outward: Bool, inside: 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], anchor == nil || anchor!.side == dir else {
return
}
out.append(Port(cell: y * cols + x, dir: outward ? dir : (dir + 2) % 4, border: border, cost: offset * (anchor == nil ? 2 : pinCost)))
}
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
let step = inside ? -2 : 2
if c.x1 - c.x0 >= 2 {
for x in (c.x0 + 1)...(c.x1 - 1) {
add(x, c.y0 - step, inside ? 1 : 3, CGPoint(x: at(x), y: r.minY), abs(x - midX))
add(x, c.y1 + step, inside ? 3 : 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) {
add(c.x0 - step, y, inside ? 0 : 2, CGPoint(x: r.minX, y: at(y)), abs(y - midY))
add(c.x1 + step, y, inside ? 2 : 0, CGPoint(x: r.maxX, y: at(y)), abs(y - midY))
}
}
return out
}
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
let y = i / cols
for yy in max(0, y - 2)...min(rows - 1, y + 2) {
for xx in max(0, x - 2)...min(cols - 1, x + 2) {
near[yy * cols + xx] = true
}
}
}
let starts = ports(e.from, grid, outward: true, inside: lineage(e.to).contains(e.from), pin?.from)
let goals = ports(e.to, grid, outward: false, inside: lineage(e.from).contains(e.to), pin?.to)
guard !starts.isEmpty, !goals.isEmpty, e.from != e.to else {
throw DiagramError(reason: "can't route \(e.from) -> \(e.to)")
}
var goalAt: [Int: Port] = [:]
for g in goals {
goalAt[g.cell] = g
}
let gx0 = goals.map { $0.cell % cols }.min()!
let gx1 = goals.map { $0.cell % cols }.max()!
let gy0 = goals.map { $0.cell / cols }.min()!
let gy1 = goals.map { $0.cell / cols }.max()!
func h(_ c: Int) -> Int {
let x = c % cols
let y = c / cols
return stepCost * (max(0, gx0 - x, x - gx1) + max(0, gy0 - y, y - gy1))
}
let states = cols * rows * 4
var cost = [Int](repeating: .max, count: states)
var from = [Int](repeating: -1, count: states)
var heap = Heap()
var startAt: [Int: Port] = [:]
for s in starts {
let st = s.cell * 4 + s.dir
if s.cost < cost[st] {
cost[st] = s.cost
startAt[st] = s
heap.push(s.cost + h(s.cell), st)
}
}
var best = Int.max
var bestState = -1
while let top = heap.pop() {
let (f, st) = top
if f >= best {
break
}
let g = cost[st]
if f > g + h(st / 4) {
continue
}
let c = st / 4
let dir = st % 4
if let goal = goalAt[c] {
let total = g + goal.cost + (dir == goal.dir ? 0 : turnCost)
if total < best {
best = total
bestState = st
}
}
let x = c % cols
let y = c / cols
for nd in 0..<4 where nd != (dir + 2) % 4 {
let nx = x + dx[nd]
let ny = y + dy[nd]
guard nx >= 0, ny >= 0, nx < cols, ny < rows else {
continue
}
let n = ny * cols + nx
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)
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
}
let ns = n * 4 + nd
if g + step < cost[ns] {
cost[ns] = g + step
from[ns] = st
heap.push(g + step + h(n), ns)
}
}
}
guard bestState >= 0 else {
throw DiagramError(reason: "can't route \(e.from) -> \(e.to)")
}
var path: [Int] = []
var st = bestState
while st >= 0 {
path.append(st)
st = from[st]
}
path.reverse()
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<Int>, 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 {
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
}
}
}
}
return pts
}
}
private func simplify(_ pts: [CGPoint]) -> [CGPoint] {
var out: [CGPoint] = []
for p in pts {
if let last = out.last, last == p {
continue
}
if out.count >= 2 {
let a = out[out.count - 2]
let b = out[out.count - 1]
if (a.x == b.x && b.x == p.x) || (a.y == b.y && b.y == p.y) {
out[out.count - 1] = p
continue
}
}
out.append(p)
}
return out
}
private struct Heap {
private var items: [(Int, Int)] = []
mutating func push(_ key: Int, _ value: Int) {
items.append((key, value))
var i = items.count - 1
while i > 0 {
let p = (i - 1) / 2
if items[p].0 <= items[i].0 {
break
}
items.swapAt(p, i)
i = p
}
}
mutating func pop() -> (Int, Int)? {
guard let top = items.first else {
return nil
}
let last = items.removeLast()
if !items.isEmpty {
items[0] = last
var i = 0
while true {
let l = i * 2 + 1
let r = l + 1
var m = i
if l < items.count && items[l].0 < items[m].0 {
m = l
}
if r < items.count && items[r].0 < items[m].0 {
m = r
}
if m == i {
break
}
items.swapAt(m, i)
i = m
}
}
return top
}
}
@MainActor
private var layoutCache: [String: Result<DiagramLayout, DiagramError>] = [:]
@MainActor
func cachedBoxLayout(_ source: String) -> Result<DiagramLayout, DiagramError> {
let key = "\(FontSize.shared.body)\n\(source)"
if let hit = layoutCache[key] {
return hit
}
let result = Result(catching: { () throws(DiagramError) -> DiagramLayout in try layoutBoxDiagram(parseBoxDiagram(source)) })
if layoutCache.count > 64 {
layoutCache = [:]
}
layoutCache[key] = 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
}
let ghostDash: [CGFloat] = [3, 3]
func corner(_ b: PlacedBox) -> CGFloat {
b.kind == .frame ? 10 : b.depth == 0 ? 8 : 6
}
func markRect(_ r: CGRect) -> CGRect {
CGRect(x: r.maxX - 34, y: r.minY + 2, width: 32, height: 32)
}
func openMark(_ r: CGRect) -> Path {
let s: CGFloat = 8
let x = r.maxX - 12 - s - 3
let y = r.minY + 12
var p = Path()
p.addRoundedRect(in: CGRect(x: x + 3, y: y, width: s, height: s), cornerSize: CGSize(width: 2, height: 2))
p.move(to: CGPoint(x: x, y: y + 3))
p.addLine(to: CGPoint(x: x, y: y + 3 + s))
p.addLine(to: CGPoint(x: x + s, y: y + 3 + s))
return p
}
func fill(_ b: PlacedBox) -> Color {
switch b.node.tint {
case .blue: Palette.accentTint
case .green: Palette.okTint
case .amber: Palette.warnTint
case .red: Palette.dangerTint
case .plain: b.depth == 0 ? Palette.surface : Palette.raised
}
}
struct BoxCanvas: View {
let layout: BoxLayout
var focus: BoxFocus?
var body: some View {
Canvas { context, size in
let scale = layout.size.width > 0 ? size.width / layout.size.width : 1
context.scaleBy(x: scale, y: scale)
draw(&context)
}
}
private func draw(_ context: inout GraphicsContext) {
let titleLine = textSize("X", titleFont).height
let detailLine = textSize("X", detailFont).height
for b in layout.boxes {
var ctx = context
if let focus, focus.dims(b, layout) {
ctx.opacity = 0.3
}
let path = Path(roundedRect: b.rect, cornerRadius: corner(b))
let lit = focus?.outlined.contains(b.node.id) == true
let stroke = lit ? Palette.accent : b.kind == .ghost ? Palette.muted : Palette.border
if b.kind == .box {
ctx.fill(path, with: .color(fill(b)))
}
ctx.stroke(path, with: .color(stroke), style: StrokeStyle(lineWidth: lit ? 1.5 : 1, dash: b.kind == .ghost ? ghostDash : []))
if b.node.opens {
ctx.stroke(openMark(b.rect), with: .color(Palette.muted), lineWidth: 1.2)
}
var y = b.rect.minY + padY
ctx.draw(Text(b.node.title).font(Font(titleFont)).foregroundColor(b.kind == .ghost ? Palette.muted : 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 + padX, y: y), anchor: .topLeading)
y += detailLine
}
}
for (i, r) in layout.edges.enumerated() {
var ctx = context
let lit = focus?.edges.contains(i) == true
if focus != nil && !lit {
ctx.opacity = 0.2
}
let color = lit ? Palette.accent : Palette.muted
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)
}
if let label = r.edge.label, let rect = r.labelRect {
ctx.draw(Text(label).font(Font(edgeLabelFont)).foregroundColor(lit ? Palette.accent : Palette.muted), at: CGPoint(x: rect.minX, y: rect.minY), anchor: .topLeading)
}
}
}
private func rounded(_ pts: [CGPoint]) -> Path {
var p = Path()
guard let first = pts.first else {
return p
}
p.move(to: first)
if pts.count > 2 {
for i in 1..<(pts.count - 1) {
let a = pts[i - 1]
let b = pts[i]
let c = pts[i + 1]
let r = min(6, hypot(b.x - a.x, b.y - a.y) / 2, hypot(c.x - b.x, c.y - b.y) / 2)
p.addArc(tangent1End: b, tangent2End: c, radius: r)
}
}
p.addLine(to: pts[pts.count - 1])
return p
}
private func arrow(_ context: inout GraphicsContext, _ from: CGPoint, _ to: CGPoint, _ color: Color) {
let angle = atan2(to.y - from.y, to.x - from.x)
let size: CGFloat = 8
var head = Path()
head.move(to: to)
head.addLine(to: CGPoint(x: to.x - size * cos(angle - .pi / 7), y: to.y - size * sin(angle - .pi / 7)))
head.addLine(to: CGPoint(x: to.x - size * cos(angle + .pi / 7), y: to.y - size * sin(angle + .pi / 7)))
head.closeSubpath()
context.fill(head, with: .color(color))
}
}
struct BoxDiagramView: View {
let source: String
@Environment(Store.self) private var store
@Environment(\.inTask) private var inTask
var body: some View {
switch cachedBoxLayout(source) {
case .success(let diagram):
FitWidth(natural: diagram.root.size) {
GeometryReader { g in
BoxCanvas(layout: diagram.root)
.contentShape(Rectangle())
.onTapGesture(coordinateSpace: .local) { p in
let s = diagram.root.size.width / max(1, g.size.width)
let level = diagram.root.opener(at: CGPoint(x: p.x * s, y: p.y * s)) ?? ""
store.openSheet(.diagram(diagram, level), fromTask: inTask)
}
}
}
.help("Open full size")
.diagramMenu(diagram.root)
.frame(maxWidth: .infinity, alignment: .leading)
case .failure(let error):
DiagramFailure(source: source, reason: error.reason)
}
}
}
struct DiagramViewer: View {
let diagram: DiagramLayout
@ViewState private var level: String
@ViewState private var picked: String?
@ViewState private var preset: Int?
@ViewState private var exporting: DiagramHTMLFile?
init(diagram: DiagramLayout, level: String) {
self.diagram = diagram
_level = ViewState(initialValue: level)
}
private var layout: BoxLayout {
diagram.levels[level]!
}
private var opening: String? {
guard let picked, let i = layout.index[picked], layout.boxes[i].kind == .box, layout.boxes[i].node.opens else {
return nil
}
return picked
}
private func go(_ to: String, select: String?) {
level = to
preset = nil
picked = select
}
private func tap(_ id: String?) {
preset = nil
guard let id, let i = layout.index[id], layout.boxes[i].kind != .frame else {
picked = nil
return
}
if layout.boxes[i].kind == .ghost {
go(diagram.homes[id]!, select: id)
return
}
picked = id == picked ? nil : id
}
private var focus: BoxFocus? {
if let picked {
return .box(picked, layout)
}
if let preset {
return .preset(layout.presets[preset], layout)
}
return nil
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
if !level.isEmpty {
HStack(spacing: 6) {
let trail = diagram.trail(level)
ForEach(Array(trail.enumerated()), id: \.offset) { i, l in
if i > 0 {
Text("›").foregroundStyle(Palette.muted)
}
if l == level {
Text(diagram.title(l)).font(Fonts.font(13, weight: 700))
} else {
Button(diagram.title(l)) {
go(l, select: trail[i + 1])
}
.buttonStyle(.plain)
.font(Fonts.font(13, weight: 700))
.foregroundStyle(Palette.accent)
.help("Back to \(diagram.title(l))")
}
}
}
}
HStack(spacing: 6) {
ForEach(Array(layout.presets.enumerated()), id: \.offset) { i, p in
Button(p.name) {
picked = nil
preset = preset == i ? nil : i
}
.buttonStyle(WebButtonStyle(primary: preset == i))
}
Spacer(minLength: 0)
if let opening {
Button("Open \(diagram.title(opening)) ›") {
go(opening, select: nil)
}
.buttonStyle(WebButtonStyle(primary: true))
.help("Show the diagram inside this box")
}
Button("Export HTML") {
exporting = DiagramHTMLFile(html: diagramHTML(diagram))
}
.buttonStyle(WebButtonStyle())
.help("Save the diagram as a web page that zooms and highlights like this view")
}
.fileExporter(isPresented: Binding { exporting != nil } set: { if !$0 { exporting = nil } }, document: exporting, contentType: .html, defaultFilename: "diagram.html") { _ in
exporting = nil
}
ZoomingCanvas(layout: layout, focus: focus, tap: tap) { id in
go(id, select: nil)
}
.id(level)
}
}
}
private struct SizedCanvas: View {
let layout: BoxLayout
let focus: BoxFocus?
var body: some View {
BoxCanvas(layout: layout, focus: focus)
.frame(width: layout.size.width, height: layout.size.height)
.diagramMenu(layout)
}
}
private func click(_ layout: BoxLayout, _ p: CGPoint, tap: (String?) -> Void, open: (String) -> Void) {
if let id = layout.opener(at: p) {
open(id)
} else {
tap(layout.hit(p))
}
}
extension View {
func diagramMenu(_ layout: BoxLayout) -> some View {
contextMenu {
Button("Copy image") {
if let image = diagramCGImage(layout) {
copyToClipboard(platformImage(image))
}
}
}
}
}
#if os(macOS)
private final class CenteringClipView: NSClipView {
override func constrainBoundsRect(_ proposedBounds: NSRect) -> NSRect {
var r = super.constrainBoundsRect(proposedBounds)
guard let doc = documentView else {
return r
}
if r.width > doc.frame.width {
r.origin.x = (doc.frame.width - r.width) / 2
}
if r.height > doc.frame.height {
r.origin.y = (doc.frame.height - r.height) / 2
}
return r
}
}
private final class FittingScrollView: NSScrollView {
var fitted = false
override func layout() {
super.layout()
guard !fitted, let doc = documentView, bounds.width > 0, bounds.height > 0, doc.frame.width > 0 else {
return
}
fitted = true
magnification = min(2, bounds.width / doc.frame.width, bounds.height / doc.frame.height)
}
}
private struct ZoomingCanvas: NSViewRepresentable {
let layout: BoxLayout
let focus: BoxFocus?
let tap: (String?) -> Void
let open: (String) -> Void
@MainActor
final class Coordinator: NSObject {
var canvas: ZoomingCanvas
init(_ canvas: ZoomingCanvas) {
self.canvas = canvas
}
@objc func clicked(_ g: NSClickGestureRecognizer) {
guard let view = g.view else {
return
}
click(canvas.layout, g.location(in: view), tap: canvas.tap, open: canvas.open)
}
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeNSView(context: Context) -> FittingScrollView {
let scroll = FittingScrollView()
scroll.contentView = CenteringClipView()
scroll.drawsBackground = false
scroll.hasVerticalScroller = true
scroll.hasHorizontalScroller = true
scroll.autohidesScrollers = true
scroll.allowsMagnification = true
scroll.minMagnification = 0.25
scroll.maxMagnification = 6
let host = NSHostingView(rootView: SizedCanvas(layout: layout, focus: focus))
host.frame = CGRect(origin: .zero, size: layout.size)
host.addGestureRecognizer(NSClickGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.clicked)))
scroll.documentView = host
return scroll
}
func updateNSView(_ scroll: FittingScrollView, context: Context) {
context.coordinator.canvas = self
(scroll.documentView as? NSHostingView<SizedCanvas>)?.rootView = SizedCanvas(layout: layout, focus: focus)
}
}
#else
private struct TappableCanvas: View {
let layout: BoxLayout
let focus: BoxFocus?
let tap: (String?) -> Void
let open: (String) -> Void
var body: some View {
SizedCanvas(layout: layout, focus: focus)
.contentShape(Rectangle())
.onTapGesture(coordinateSpace: .local) { p in
click(layout, p, tap: tap, open: open)
}
}
}
private final class FittingScrollView: UIScrollView, UIScrollViewDelegate {
let host: UIHostingController<TappableCanvas>
private var fitted = false
init(_ canvas: TappableCanvas, size: CGSize) {
host = UIHostingController(rootView: canvas)
super.init(frame: .zero)
host.view.backgroundColor = .clear
host.view.frame = CGRect(origin: .zero, size: size)
addSubview(host.view)
contentSize = size
delegate = self
minimumZoomScale = 0.25
maximumZoomScale = 6
}
required init?(coder: NSCoder) {
fatalError("init(coder:) is not supported")
}
override func layoutSubviews() {
super.layoutSubviews()
if !fitted, bounds.width > 0, bounds.height > 0, host.view.bounds.width > 0 {
fitted = true
zoomScale = min(2, bounds.width / host.view.bounds.width, bounds.height / host.view.bounds.height)
}
center()
}
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
host.view
}
func scrollViewDidZoom(_ scrollView: UIScrollView) {
center()
}
private func center() {
let x = max(0, (bounds.width - contentSize.width) / 2)
let y = max(0, (bounds.height - contentSize.height) / 2)
contentInset = UIEdgeInsets(top: y, left: x, bottom: y, right: x)
}
}
private struct ZoomingCanvas: UIViewRepresentable {
let layout: BoxLayout
let focus: BoxFocus?
let tap: (String?) -> Void
let open: (String) -> Void
func makeUIView(context: Context) -> FittingScrollView {
FittingScrollView(TappableCanvas(layout: layout, focus: focus, tap: tap, open: open), size: layout.size)
}
func updateUIView(_ scroll: FittingScrollView, context: Context) {
scroll.host.rootView = TappableCanvas(layout: layout, focus: focus, tap: tap, open: open)
}
}
#endif