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

987 lines
34 KiB
Swift

import AppKit
import CoreText
import SwiftUI
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] = []
}
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 rows: [BoxRow] = []
var edges: [BoxEdge] = []
var presets: [BoxPreset] = []
}
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 build() -> BoxNode {
var n = node
n.children = children.map { $0.build() }
return n
}
}
func parseBoxDiagram(_ src: String) throws(DiagramError) -> BoxDiagram {
var rows: [(main: [NodeBuilder], aside: [NodeBuilder])] = []
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("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, 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")
}
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))
}
diagram.rows = rows.map { BoxRow(main: $0.main.map { $0.build() }, aside: $0.aside.map { $0.build() }) }
if ids.isEmpty {
throw DiagramError(reason: "empty diagram")
}
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
}
struct PlacedBox {
let node: BoxNode
let rect: CGRect
let depth: Int
let parent: String?
let header: CGRect
}
struct RoutedEdge {
let edge: BoxEdge
let points: [CGPoint]
let labelRect: CGRect?
}
struct BoxLayout: Identifiable {
let id = UUID()
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 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>
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)
}
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))
}
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) }
}
}
private let pad: CGFloat = 12
private let childGap: CGFloat = 14
private let boxGap: CGFloat = 20
private let rowGap: CGFloat = 48
private let columnGap: CGFloat = 80
private let margin: CGFloat = 20
@MainActor
private var titleFont: CTFont {
Fonts.ns(13, weight: 600) as CTFont
}
@MainActor
private var detailFont: CTFont {
Fonts.ns(12) as CTFont
}
@MainActor
private var edgeLabelFont: CTFont {
Fonts.ns(11) as CTFont
}
@MainActor
private func headerSize(_ n: BoxNode) -> CGSize {
let t = textSize(n.title, titleFont)
var w = t.width
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: w, height: h + (n.details.isEmpty ? 0 : 2))
}
@MainActor
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, +) + 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 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 { 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)
}
var ay = max(y, asideBottom + rowGap)
for n in r.aside {
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
}
bottom = max(bottom, y + height, asideBottom)
y += height + (r.main.isEmpty ? 0 : rowGap)
}
let width = (asideWidth > 0 ? asideX + asideWidth : margin + mainWidth) + margin
let size = CGSize(width: width, height: bottom + margin)
let index = Dictionary(uniqueKeysWithValues: boxes.enumerated().map { ($0.element.node.id, $0.offset) })
var router = Router(boxes: boxes, index: index, size: size)
let order = d.edges.indices.sorted { a, b in
router.span(d.edges[a]) < router.span(d.edges[b])
}
var points = [[CGPoint]](repeating: [], count: d.edges.count)
for i in order {
points[i] = try router.route(d.edges[i])
}
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, solid: solid, lines: others, CGRect(origin: .zero, size: size))
solid.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)
}
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 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))
} 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))
}
}
}
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 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
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]
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) -> [Bool] {
let open = Set(lineage(e.from) + lineage(e.to))
var grid = [Bool](repeating: false, count: cols * rows)
for b in boxes {
let c = cells(open.contains(b.node.id) ? 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
}
}
}
return grid
}
private func ports(_ id: String, _ grid: [Bool], outward: Bool) -> [Port] {
let r = boxes[index[id]!].rect
let c = cells(r)
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 {
return
}
out.append(Port(cell: y * cols + x, dir: outward ? dir : (dir + 2) % 4, border: border, cost: offset * 2))
}
let midX = (c.x0 + c.x1) / 2
let midY = (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))
}
}
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))
}
}
return out
}
mutating func route(_ e: BoxEdge) throws(DiagramError) -> [CGPoint] {
let grid = 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)
let goals = ports(e.to, grid, outward: false)
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 + (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] {
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()
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] {
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)
}
var pts = [startAt[path[0]]!.border]
pts += path.map { center($0 / 4) }
pts.append(goalAt[bestState / 4]!.border)
return simplify(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<BoxLayout, DiagramError>] = [:]
@MainActor
func cachedBoxLayout(_ source: String) -> Result<BoxLayout, DiagramError> {
let key = "\(FontSize.shared.body)\n\(source)"
if let hit = layoutCache[key] {
return hit
}
let result = Result(catching: { () throws(DiagramError) -> BoxLayout in try layoutBoxDiagram(parseBoxDiagram(source)) })
if layoutCache.count > 64 {
layoutCache = [:]
}
layoutCache[key] = result
return result
}
private 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: b.depth == 0 ? 8 : 6)
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
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)
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(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(Overlays.self) private var overlays
var body: some View {
switch cachedBoxLayout(source) {
case .success(let layout):
FitWidth(natural: layout.size) {
BoxCanvas(layout: layout)
}
.contentShape(Rectangle())
.onTapGesture {
overlays.diagram = layout
}
.help("Open full size")
.frame(maxWidth: .infinity, alignment: .leading)
case .failure(let error):
DiagramFailure(source: source, reason: error.reason)
}
}
}
struct DiagramViewer: View {
let layout: BoxLayout
@ViewState private var picked: String?
@ViewState private var preset: Int?
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 !layout.presets.isEmpty {
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))
}
}
}
ZoomingCanvas(layout: layout, focus: focus) { id in
preset = nil
picked = id == picked ? nil : id
}
}
.padding(12)
.background(Palette.raised, in: RoundedRectangle(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Palette.border))
.shadow(color: Palette.overlayShadow, radius: 14, y: 8)
}
}
private struct TappableCanvas: View {
let layout: BoxLayout
let focus: BoxFocus?
let tap: (String?) -> Void
var body: some View {
BoxCanvas(layout: layout, focus: focus)
.frame(width: layout.size.width, height: layout.size.height)
.contentShape(Rectangle())
.onTapGesture(coordinateSpace: .local) { p in
tap(layout.hit(p))
}
}
}
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
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: TappableCanvas(layout: layout, focus: focus, tap: tap))
host.frame = CGRect(origin: .zero, size: layout.size)
scroll.documentView = host
return scroll
}
func updateNSView(_ scroll: FittingScrollView, context: Context) {
(scroll.documentView as? NSHostingView<TappableCanvas>)?.rootView = TappableCanvas(layout: layout, focus: focus, tap: tap)
}
}