import AppKit import CoreText import SwiftUI enum NodeShape { case box, rounded, diamond, circle } enum EdgeStyle { case solid, dotted, thick } enum FlowDirection { case lr, rl, td, bt } struct NodeColors { var fill: Color? var stroke: Color? var text: Color? mutating func merge(_ other: NodeColors) { fill = other.fill ?? fill stroke = other.stroke ?? stroke text = other.text ?? text } } struct FlowNode { let id: String var label: String var shape: NodeShape = .box var classes: [String] = [] var style = NodeColors() } struct FlowEdge { let from: String let to: String let label: String? let style: EdgeStyle let arrow: Bool } struct Flowchart { var direction: FlowDirection = .td var order: [String] = [] var nodes: [String: FlowNode] = [:] var edges: [FlowEdge] = [] var classDefs: [String: NodeColors] = [:] mutating func node(_ id: String, label: String? = nil, shape: NodeShape? = nil) { if nodes[id] == nil { order.append(id) nodes[id] = FlowNode(id: id, label: id) } if let label { nodes[id]!.label = label } if let shape { nodes[id]!.shape = shape } } func colors(_ id: String) -> NodeColors { let n = nodes[id]! var c = NodeColors() for cls in n.classes { if let d = classDefs[cls] { c.merge(d) } } c.merge(n.style) return c } } struct DiagramError: Error { let reason: String } private let header = Re(#"^(flowchart|graph)(?:\s+(LR|RL|TD|TB|BT))?$"#, caseless: true) private let nodeId = Re(#"^[A-Za-z0-9_]+"#) private let plainEdge = Re(#"^(-{2,}>|-{3,}|-\.+->|-\.+-|={2,}>|={3,})(?:\|([^|]*)\|)?"#) private let dashLabel = Re(#"^--\s*([^->].*?)\s*(-{2,}>|-{3,})"#) private let dotLabel = Re(#"^-\.\s*(.*?)\s*(\.+->|\.+-)"#) private let thickLabel = Re(#"^==\s*([^=>].*?)\s*(={2,}>|={3,})"#) private let classDefLine = Re(#"^classDef\s+([A-Za-z0-9_,]+)\s+(.+)$"#) private let classLine = Re(#"^class\s+([A-Za-z0-9_,\s]+?)\s+([A-Za-z0-9_]+)$"#) private let styleLine = Re(#"^style\s+([A-Za-z0-9_]+)\s+(.+)$"#) private let hexColor = Re(#"^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$"#) private let shapes: [(open: String, close: String, shape: NodeShape)] = [ ("((", "))", .circle), ("([", "])", .rounded), ("[[", "]]", .box), ("[(", ")]", .rounded), ("{{", "}}", .diamond), ("[", "]", .box), ("(", ")", .rounded), ("{", "}", .diamond), ] func parseFlowchart(_ src: String) throws(DiagramError) -> Flowchart { var lines: [String] = [] for raw in src.components(separatedBy: "\n") { let line = trimmed(raw) if line.isEmpty || line.hasPrefix("%%") { continue } for part in line.split(separator: ";") { let s = trimmed(String(part)) if !s.isEmpty { lines.append(s) } } } guard let first = lines.first else { throw DiagramError(reason: "empty diagram") } guard let h = header.exec(first) else { let kind = first.split(separator: " ").first.map(String.init) ?? first throw DiagramError(reason: "\(kind) diagrams aren't drawn here, only flowcharts") } var chart = Flowchart() switch h[2]?.uppercased() { case "LR": chart.direction = .lr case "RL": chart.direction = .rl case "BT": chart.direction = .bt default: chart.direction = .td } for line in lines.dropFirst() { if line.hasPrefix("linkStyle ") { continue } if let m = classDefLine.exec(line) { let colors = parseStyle(m[2]!) for name in m[1]!.split(separator: ",") { chart.classDefs[String(name)] = colors } continue } if let m = classLine.exec(line) { for id in m[1]!.split(whereSeparator: { $0 == "," || $0 == " " }) { chart.node(String(id)) chart.nodes[String(id)]!.classes.append(m[2]!) } continue } if let m = styleLine.exec(line) { chart.node(m[1]!) chart.nodes[m[1]!]!.style.merge(parseStyle(m[2]!)) continue } try parseChain(line, &chart) } return chart } private func parseStyle(_ text: String) -> NodeColors { var c = NodeColors() for prop in text.split(separator: ",") { let kv = prop.split(separator: ":", maxSplits: 1).map { trimmed(String($0)) } guard kv.count == 2, let color = parseColor(kv[1]) else { continue } switch kv[0] { case "fill": c.fill = color case "stroke": c.stroke = color case "color": c.text = color default: break } } return c } private func parseColor(_ text: String) -> Color? { guard let m = hexColor.exec(text) else { return nil } var hex = m[1]! if hex.count == 3 { hex = hex.map { "\($0)\($0)" }.joined() } let v = UInt32(hex, radix: 16)! return Color(.sRGB, red: Double(v >> 16 & 0xff) / 255, green: Double(v >> 8 & 0xff) / 255, blue: Double(v & 0xff) / 255) } private func unquote(_ s: String) -> String { var t = trimmed(s) if t.count >= 2 && t.hasPrefix("\"") && t.hasSuffix("\"") { t = String(t.dropFirst().dropLast()) } return t.replacingOccurrences(of: "
", with: "\n").replacingOccurrences(of: "
", with: "\n").replacingOccurrences(of: "
", with: "\n") } private func parseChain(_ line: String, _ chart: inout Flowchart) throws(DiagramError) { var rest = Substring(line) let fail = DiagramError(reason: "can't parse \"\(line)\"") func skipSpaces() { rest = rest.drop { $0 == " " || $0 == "\t" } } func node() throws(DiagramError) -> String { skipSpaces() guard let m = nodeId.match(String(rest)) else { throw fail } let id = m.groups[0]! rest = rest.dropFirst(id.count) var label: String? var shape: NodeShape? for s in shapes where rest.hasPrefix(s.open) { let inner = rest.dropFirst(s.open.count) var after: Substring? if inner.first == "\"", let q = inner.dropFirst().firstIndex(of: "\"") { let tail = inner[inner.index(after: q)...] if tail.hasPrefix(s.close) { label = unquote(String(inner[...q])) after = tail.dropFirst(s.close.count) } } if after == nil, let r = inner.range(of: s.close) { label = unquote(String(inner[.. (label: String?, style: EdgeStyle, arrow: Bool) { skipSpaces() let s = String(rest) if let m = plainEdge.match(s) { rest = rest.dropFirst(s.distance(from: s.startIndex, to: m.range.upperBound)) let arrow = m.groups[1]! return (m.groups[2].map(unquote), arrow.hasPrefix("=") ? .thick : arrow.contains(".") ? .dotted : .solid, arrow.hasSuffix(">")) } for (re, style) in [(dashLabel, EdgeStyle.solid), (dotLabel, .dotted), (thickLabel, .thick)] { if let m = re.match(s) { rest = rest.dropFirst(s.distance(from: s.startIndex, to: m.range.upperBound)) return (unquote(m.groups[1]!), style, m.groups[2]!.hasSuffix(">")) } } throw fail } var from = try node() while true { skipSpaces() if rest.isEmpty { return } let e = try edge() let to = try node() chart.edges.append(FlowEdge(from: from, to: to, label: e.label?.isEmpty == true ? nil : e.label, style: e.style, arrow: e.arrow)) from = to } } struct PlacedNode { let node: FlowNode let center: CGPoint let size: CGSize let colors: NodeColors } struct DiagramLayout { let nodes: [String: PlacedNode] let edges: [FlowEdge] let size: CGSize } nonisolated(unsafe) private let labelFont = Fonts.ct(13) nonisolated(unsafe) private let edgeFont = Fonts.ct(12) func textSize(_ text: String, _ font: CTFont) -> CGSize { let lines = text.components(separatedBy: "\n") var width: CGFloat = 0 for l in lines { let line = CTLineCreateWithAttributedString(NSAttributedString(string: l, attributes: [.font: font as NSFont])) width = max(width, CGFloat(CTLineGetTypographicBounds(line, nil, nil, nil))) } let lineHeight = CTFontGetAscent(font) + CTFontGetDescent(font) + CTFontGetLeading(font) + 3 return CGSize(width: ceil(width), height: ceil(lineHeight * CGFloat(lines.count))) } func layoutFlowchart(_ chart: Flowchart) -> DiagramLayout { let ids = chart.order var succ: [String: [String]] = [:] for e in chart.edges where e.from != e.to { succ[e.from, default: []].append(e.to) } var state: [String: Int] = [:] var dag: [String: [String]] = [:] func visit(_ n: String) { state[n] = 1 for m in succ[n] ?? [] { if state[m] == 1 { dag[m, default: []].append(n) continue } dag[n, default: []].append(m) if state[m] == nil { visit(m) } } state[n] = 2 } for n in ids where state[n] == nil { visit(n) } var indegree: [String: Int] = [:] for (_, ms) in dag { for m in ms { indegree[m, default: 0] += 1 } } var layer: [String: Int] = [:] var queue = ids.filter { (indegree[$0] ?? 0) == 0 } for n in queue { layer[n] = 0 } var qi = 0 while qi < queue.count { let n = queue[qi] qi += 1 for m in dag[n] ?? [] { layer[m] = max(layer[m] ?? 0, layer[n]! + 1) indegree[m]! -= 1 if indegree[m] == 0 { queue.append(m) } } } let depth = (layer.values.max() ?? 0) + 1 var layers = Array(repeating: [String](), count: depth) for n in ids { layers[layer[n] ?? 0].append(n) } var preds: [String: [String]] = [:] for (n, ms) in dag { for m in ms { preds[m, default: []].append(n) } } func position(_ l: Int) -> [String: Double] { Dictionary(uniqueKeysWithValues: layers[l].enumerated().map { ($0.element, Double($0.offset)) }) } func reorder(_ l: Int, _ ref: Int, _ links: [String: [String]]) { let pos = position(ref) let current = position(l) layers[l].sort { a, b in func bary(_ n: String) -> Double { let ps = (links[n] ?? []).compactMap { pos[$0] } return ps.isEmpty ? current[n]! : ps.reduce(0, +) / Double(ps.count) } let (x, y) = (bary(a), bary(b)) return x == y ? current[a]! < current[b]! : x < y } } for _ in 0..<4 { for l in layers.indices.dropFirst() { reorder(l, l - 1, preds) } for l in layers.indices.dropLast().reversed() { reorder(l, l + 1, dag) } } var sizes: [String: CGSize] = [:] for n in ids { let node = chart.nodes[n]! let t = textSize(node.label, labelFont) switch node.shape { case .box, .rounded: sizes[n] = CGSize(width: t.width + 30, height: t.height + 18) case .diamond: sizes[n] = CGSize(width: (t.width * 0.75 + 14) * 2, height: (t.height * 1.5 + 8) * 2) case .circle: let d = (t.width * t.width + t.height * t.height).squareRoot() + 16 sizes[n] = CGSize(width: d, height: d) } } let horizontal = chart.direction == .lr || chart.direction == .rl let rankGap: CGFloat = horizontal ? 70 : 56 let nodeGap: CGFloat = horizontal ? 24 : 32 func along(_ s: CGSize) -> CGFloat { horizontal ? s.width : s.height } func across(_ s: CGSize) -> CGFloat { horizontal ? s.height : s.width } let spans = layers.map { l in l.map { across(sizes[$0]!) }.reduce(0, +) + nodeGap * CGFloat(max(0, l.count - 1)) } let widest = spans.max() ?? 0 var centers: [String: CGPoint] = [:] var rank: CGFloat = 0 for (l, nodes) in layers.enumerated() { let thick = nodes.map { along(sizes[$0]!) }.max() ?? 0 var offset = (widest - spans[l]) / 2 for n in nodes { let a = across(sizes[n]!) let c1 = rank + thick / 2 let c2 = offset + a / 2 centers[n] = horizontal ? CGPoint(x: c1, y: c2) : CGPoint(x: c2, y: c1) offset += a + nodeGap } rank += thick + rankGap } let length = max(0, rank - rankGap) let margin: CGFloat = 8 let total = horizontal ? CGSize(width: length, height: widest) : CGSize(width: widest, height: length) var placed: [String: PlacedNode] = [:] for n in ids { var c = centers[n]! if chart.direction == .rl { c.x = total.width - c.x } if chart.direction == .bt { c.y = total.height - c.y } placed[n] = PlacedNode(node: chart.nodes[n]!, center: CGPoint(x: c.x + margin, y: c.y + margin), size: sizes[n]!, colors: chart.colors(n)) } return DiagramLayout(nodes: placed, edges: chart.edges, size: CGSize(width: total.width + margin * 2, height: total.height + margin * 2)) } private func boundary(_ n: PlacedNode, toward p: CGPoint) -> CGPoint { let dx = p.x - n.center.x let dy = p.y - n.center.y if dx == 0 && dy == 0 { return n.center } let a = n.size.width / 2 let b = n.size.height / 2 let t: CGFloat switch n.node.shape { case .box, .rounded: t = min(dx == 0 ? .infinity : a / abs(dx), dy == 0 ? .infinity : b / abs(dy)) case .diamond: t = 1 / (abs(dx) / a + abs(dy) / b) case .circle: t = a / (dx * dx + dy * dy).squareRoot() } return CGPoint(x: n.center.x + dx * t, y: n.center.y + dy * t) } private func shapePath(_ n: PlacedNode) -> Path { let r = CGRect(x: n.center.x - n.size.width / 2, y: n.center.y - n.size.height / 2, width: n.size.width, height: n.size.height) switch n.node.shape { case .box: return Path(roundedRect: r, cornerRadius: 3) case .rounded: return Path(roundedRect: r, cornerRadius: min(r.height / 2, 14)) case .circle: return Path(ellipseIn: r) case .diamond: var p = Path() p.move(to: CGPoint(x: r.midX, y: r.minY)) p.addLine(to: CGPoint(x: r.maxX, y: r.midY)) p.addLine(to: CGPoint(x: r.midX, y: r.maxY)) p.addLine(to: CGPoint(x: r.minX, y: r.midY)) p.closeSubpath() return p } } struct FlowchartCanvas: View { let layout: DiagramLayout var body: some View { FitWidth(natural: layout.size) { 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) { for e in layout.edges { guard let a = layout.nodes[e.from], let b = layout.nodes[e.to] else { continue } let stroke = StrokeStyle(lineWidth: e.style == .thick ? 3 : 1.5, lineCap: .round, dash: e.style == .dotted ? [3, 4] : []) var mid: CGPoint if e.from == e.to { let top = CGPoint(x: a.center.x, y: a.center.y - a.size.height / 2) var loop = Path() loop.move(to: CGPoint(x: top.x - 8, y: top.y)) loop.addCurve(to: CGPoint(x: top.x + 8, y: top.y), control1: CGPoint(x: top.x - 24, y: top.y - 30), control2: CGPoint(x: top.x + 24, y: top.y - 30)) context.stroke(loop, with: .color(Palette.muted), style: stroke) if e.arrow { arrowHead(&context, from: CGPoint(x: top.x + 20, y: top.y - 20), to: CGPoint(x: top.x + 8, y: top.y)) } mid = CGPoint(x: top.x, y: top.y - 26) } else { let start = boundary(a, toward: b.center) let end = boundary(b, toward: a.center) var line = Path() line.move(to: start) line.addLine(to: end) context.stroke(line, with: .color(Palette.muted), style: stroke) if e.arrow { arrowHead(&context, from: start, to: end) } mid = CGPoint(x: (start.x + end.x) / 2, y: (start.y + end.y) / 2) } if let label = e.label { let size = textSize(label, edgeFont) let box = CGRect(x: mid.x - size.width / 2 - 4, y: mid.y - size.height / 2 - 2, width: size.width + 8, height: size.height + 4) context.fill(Path(roundedRect: box, cornerRadius: 3), with: .color(Palette.bg)) context.draw(Text(label).font(Font(edgeFont)).foregroundColor(Palette.text), at: mid, anchor: .center) } } for (_, n) in layout.nodes { let path = shapePath(n) context.fill(path, with: .color(n.colors.fill ?? Palette.surface)) context.stroke(path, with: .color(n.colors.stroke ?? Palette.border), lineWidth: 1) for (i, line) in n.node.label.components(separatedBy: "\n").enumerated() { let count = CGFloat(n.node.label.components(separatedBy: "\n").count) let lineHeight = textSize("X", labelFont).height let y = n.center.y + (CGFloat(i) - (count - 1) / 2) * lineHeight context.draw(Text(line).font(Font(labelFont)).foregroundColor(n.colors.text ?? Palette.text), at: CGPoint(x: n.center.x, y: y), anchor: .center) } } } private func arrowHead(_ context: inout GraphicsContext, from: CGPoint, to: CGPoint) { let angle = atan2(to.y - from.y, to.x - from.x) let size: CGFloat = 9 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(Palette.muted)) } } struct DiagramView: View { let source: String var body: some View { switch Result(catching: { () throws(DiagramError) -> Flowchart in try parseFlowchart(source) }) { case .success(let chart): FlowchartCanvas(layout: layoutFlowchart(chart)) .frame(maxWidth: .infinity, alignment: .leading) case .failure(let error): VStack(alignment: .leading, spacing: 4) { Text(verbatim: source) .textSelection(.enabled) .padding(.horizontal, 8) .padding(.vertical, 6) .frame(maxWidth: .infinity, alignment: .leading) .background(Palette.surface, in: RoundedRectangle(cornerRadius: 6)) Text(verbatim: "diagram not rendered: \(error.reason)") .foregroundStyle(Palette.muted) } } } }