1752 lines
64 KiB
Swift
1752 lines
64 KiB
Swift
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
|