Add nested diagrams with drill-down and cross-edge arrows

This commit is contained in:
Ian Gulliver
2026-10-23 09:57:58 -07:00
parent 2bc462ada6
commit 68e0cc3063
5 changed files with 592 additions and 109 deletions
+35 -3
View File
@@ -30,8 +30,40 @@ highlight "Event loop" store server coord
- `highlight "Name" id id ...` names a set of boxes to light up together, with the arrows between them.
- Lines starting with `#` are comments. A mistake shows the source with the reason it wasn't drawn.
Keep each box to a title and one or two short detail lines, and draw only the arrows the reader needs; a picture with every dependency in it reads worse than prose.
## Nested diagrams
Check a diagram before you send it: `render_diagram` takes the block's body and returns a PNG of what the app draws, or the reason it can't. Look for crossing or overlapping arrows and labels sitting on top of each other, and adjust the rows, `aside` or arrows until it reads cleanly.
When a box hides a whole diagram of its own, give it an `inside` section instead of children. A top-level line `inside <id>` starts that box's diagram; the `row`s, arrows and highlights after it belong to it, up to the next `inside`. The box gets a mark, and in the full-size view selecting it shows an **Open** button; a breadcrumb leads back up.
To hand a diagram over as a file, `export_diagram` writes it as one self-contained HTML page with the app's exact layout and its full-size view's interactivity (click to highlight, highlight buttons, zoom and pan, light and dark mode); show it with `show_page`. The full-size view in the app has the same export as its Export HTML button.
```diagram
row
app "Byre.app"
row
server "internal/server" blue
aside store "internal/store" green
row
manager "agents.Manager" amber
app -> server
server -> manager "start · message"
store ..> coord "poke"
workers -> store "snapshots"
inside manager
row
coord "coordinator" "level-triggered"
live "live sessions"
row
workers "workers" "push · snapshot"
coord -> workers "start"
live -> workers "turn end"
```
- Ids are unique across the whole block, so an arrow or highlight anywhere can name a box at any level. Write each arrow once, between the boxes it really joins.
- Each level draws an arrow at the deepest boxes it can show: at the top, `store ..> coord` ends on `manager`; inside `manager`, `store` shows as a dashed ghost outside the frame, on the side where it sits above, and the arrow crosses the frame to `coord`. Clicking a ghost goes back up to it. Arrows that end up with the same ends merge into one.
- An `inside` can name a box from another `inside`, to any depth.
- A box can have both indented children and an `inside`: the children show inline as a preview, and when it's opened they form its diagram's first row, above the `inside` rows.
Keep each box to a title and one or two short detail lines, and draw only the arrows the reader needs; a picture with every dependency in it reads worse than prose. When a level grows past that, move a cluster into an `inside`.
Check a diagram before you send it: `render_diagram` takes the block's body and returns a PNG of what the app draws, every level stacked under its breadcrumb, or the reason it can't. Look for crossing or overlapping arrows and labels sitting on top of each other, and adjust the rows, `aside` or arrows until it reads cleanly.
To hand a diagram over as a file, `export_diagram` writes it as one self-contained HTML page with the app's exact layout and its full-size view's interactivity (click to highlight, highlight buttons, opening nested diagrams, zoom and pan, light and dark mode); show it with `show_page`. The full-size view in the app has the same export as its Export HTML button.
+434 -67
View File
@@ -12,6 +12,7 @@ struct BoxNode {
var details: [String] = []
var tint = BoxTint.plain
var children: [BoxNode] = []
var opens = false
}
struct BoxRow {
@@ -32,9 +33,50 @@ struct BoxPreset {
}
struct BoxDiagram {
var rows: [BoxRow] = []
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 {
@@ -89,16 +131,15 @@ private final class 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 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()
@@ -116,6 +157,18 @@ func parseBoxDiagram(_ src: String) throws(DiagramError) -> BoxDiagram {
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)\"")
@@ -131,7 +184,7 @@ func parseBoxDiagram(_ src: String) throws(DiagramError) -> BoxDiagram {
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\"")
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 {
@@ -156,6 +209,7 @@ func parseBoxDiagram(_ src: String) throws(DiagramError) -> BoxDiagram {
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() {
@@ -189,10 +243,47 @@ func parseBoxDiagram(_ src: String) throws(DiagramError) -> BoxDiagram {
}
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 (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)")
@@ -206,12 +297,24 @@ func parseBoxDiagram(_ src: String) throws(DiagramError) -> BoxDiagram {
return diagram
}
enum BoxKind {
case box, frame, ghost
}
struct PlacedBox {
let node: BoxNode
let rect: CGRect
var rect: CGRect
let depth: Int
let parent: String?
let header: CGRect
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 {
@@ -220,8 +323,35 @@ struct RoutedEdge {
let labelRect: CGRect?
}
struct BoxLayout: Identifiable {
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]
@@ -238,6 +368,10 @@ struct BoxLayout: Identifiable {
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
}
@@ -282,6 +416,7 @@ 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 {
@@ -301,7 +436,7 @@ var edgeLabelFont: CTFont {
@MainActor
private func headerSize(_ n: BoxNode) -> CGSize {
let t = textSize(n.title, titleFont)
var w = t.width
var w = t.width + (n.opens ? markRoom : 0)
var h = t.height
for d in n.details {
let s = textSize(d, detailFont)
@@ -315,7 +450,7 @@ private func headerSize(_ n: BoxNode) -> CGSize {
private struct Sizer {
let pairGaps: [String: CGFloat]
init(_ d: BoxDiagram) {
init(_ rows: [BoxRow], _ edges: [BoxEdge]) {
var parents: [String: String] = [:]
func walk(_ n: BoxNode) {
for c in n.children {
@@ -323,7 +458,7 @@ private struct Sizer {
walk(c)
}
}
d.rows.flatMap { $0.main + $0.aside }.forEach(walk)
rows.flatMap { $0.main + $0.aside }.forEach(walk)
func chain(_ id: String) -> [String] {
var out = [id]
while let p = parents[out.last!] {
@@ -332,7 +467,7 @@ private struct Sizer {
return out
}
var gaps: [String: CGFloat] = [:]
for e in d.edges {
for e in edges {
let need = e.label.map { ceil(textSize($0, edgeLabelFont).width) + labelRoom } ?? edgeGap
for a in chain(e.from) {
for b in chain(e.to) where a != b && parents[a] == parents[b] {
@@ -406,21 +541,145 @@ private struct Sizer {
}
@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
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 (boxes, size) = placeRows(d.levels[level]!, edges)
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
}
return try routeLevel(level, boxes, size, edges, presets)
}
@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]) -> (boxes: [PlacedBox], size: CGSize) {
let sizer = Sizer(rows, edges)
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(d.rows.flatMap(\.aside).flatMap(ids))
let crossing = d.edges.filter { asideIDs.contains($0.from) != asideIDs.contains($0.to) }.compactMap(\.label)
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 boxes: [PlacedBox] = []
var y = margin
var asideBottom = margin - rowGap
var bottom = margin
for r in d.rows {
for r in rows {
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)
@@ -439,27 +698,31 @@ func layoutBoxDiagram(_ d: BoxDiagram) throws(DiagramError) -> BoxLayout {
y += height + (r.main.isEmpty ? 0 : rowGap)
}
let width = (asideWidth > 0 ? asideX + asideWidth : margin + mainWidth) + margin
let size = CGSize(width: width, height: bottom + 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 {
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])
let order = edges.indices.sorted { a, b in
router.span(edges[a]) < router.span(edges[b])
}
var points = [[CGPoint]](repeating: [], count: d.edges.count)
var points = [[CGPoint]](repeating: [], count: edges.count)
for i in order {
points[i] = try router.route(d.edges[i], nil)
points[i] = try router.route(edges[i], nil)
}
let pins = anchors(d.edges, points, boxes, index)
let pins = anchors(edges, points, boxes, index)
router = Router(boxes: boxes, index: index, size: size)
for i in order {
points[i] = try router.route(d.edges[i], pins[i])
points[i] = try router.route(edges[i], pins[i])
}
let headers = boxes.map(\.header)
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] = []
for (e, pts) in zip(d.edges, points) {
for (e, pts) in zip(edges, points) {
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))
@@ -469,7 +732,7 @@ func layoutBoxDiagram(_ d: BoxDiagram) throws(DiagramError) -> BoxLayout {
routed.append(RoutedEdge(edge: e, points: pts, labelRect: labelRect))
}
let fit = CGSize(width: ceil(labels.map(\.maxX).reduce(size.width - margin, max) + margin), height: ceil(labels.map(\.maxY).reduce(size.height - margin, max) + margin))
return BoxLayout(boxes: boxes, index: index, edges: routed, presets: d.presets, size: fit)
return BoxLayout(level: level, boxes: boxes, index: index, edges: routed, presets: presets, size: fit)
}
private struct Anchor {
@@ -707,7 +970,7 @@ private struct Router {
return (grid, upright, level)
}
private func ports(_ id: String, _ grid: [Bool], outward: Bool, _ anchor: Anchor?) -> [Port] {
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)) }
@@ -723,16 +986,17 @@ private struct Router {
}
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 - 2, 3, CGPoint(x: at(x), y: r.minY), abs(x - midX))
add(x, c.y1 + 2, 1, CGPoint(x: at(x), y: r.maxY), abs(x - midX))
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 - 2, y, 2, CGPoint(x: r.minX, y: at(y)), abs(y - midY))
add(c.x1 + 2, y, 0, CGPoint(x: r.maxX, y: at(y)), abs(y - midY))
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
@@ -750,8 +1014,8 @@ private struct Router {
}
}
}
let starts = ports(e.from, grid, outward: true, pin?.from)
let goals = ports(e.to, grid, outward: false, pin?.to)
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)")
}
@@ -968,15 +1232,15 @@ private struct Heap {
}
@MainActor
private var layoutCache: [String: Result<BoxLayout, DiagramError>] = [:]
private var layoutCache: [String: Result<DiagramLayout, DiagramError>] = [:]
@MainActor
func cachedBoxLayout(_ source: String) -> Result<BoxLayout, DiagramError> {
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) -> BoxLayout in try layoutBoxDiagram(parseBoxDiagram(source)) })
let result = Result(catching: { () throws(DiagramError) -> DiagramLayout in try layoutBoxDiagram(parseBoxDiagram(source)) })
if layoutCache.count > 64 {
layoutCache = [:]
}
@@ -997,6 +1261,28 @@ func shaft(_ pts: [CGPoint]) -> [CGPoint] {
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
@@ -1027,12 +1313,18 @@ struct BoxCanvas: View {
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 path = Path(roundedRect: b.rect, cornerRadius: corner(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)
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(Palette.text), at: CGPoint(x: b.rect.minX + padX, y: y), anchor: .topLeading)
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)
@@ -1094,16 +1386,20 @@ struct BoxDiagramView: View {
var body: some View {
switch cachedBoxLayout(source) {
case .success(let layout):
FitWidth(natural: layout.size) {
BoxCanvas(layout: layout)
}
.contentShape(Rectangle())
.onTapGesture {
store.openSheet(.diagram(layout), fromTask: inTask)
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(layout)
.diagramMenu(diagram.root)
.frame(maxWidth: .infinity, alignment: .leading)
case .failure(let error):
DiagramFailure(source: source, reason: error.reason)
@@ -1112,11 +1408,47 @@ struct BoxDiagramView: View {
}
struct DiagramViewer: View {
let layout: BoxLayout
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)
@@ -1129,6 +1461,27 @@ struct DiagramViewer: View {
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) {
@@ -1138,8 +1491,15 @@ struct DiagramViewer: View {
.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(layout))
exporting = DiagramHTMLFile(html: diagramHTML(diagram))
}
.buttonStyle(WebButtonStyle())
.help("Save the diagram as a web page that zooms and highlights like this view")
@@ -1147,10 +1507,10 @@ struct DiagramViewer: 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) { id in
preset = nil
picked = id == picked ? nil : id
ZoomingCanvas(layout: layout, focus: focus, tap: tap) { id in
go(id, select: nil)
}
.id(level)
}
}
}
@@ -1159,13 +1519,18 @@ private struct TappableCanvas: View {
let layout: BoxLayout
let focus: BoxFocus?
let tap: (String?) -> Void
let open: (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))
if let id = layout.opener(at: p) {
open(id)
} else {
tap(layout.hit(p))
}
}
.diagramMenu(layout)
}
@@ -1217,6 +1582,7 @@ private struct ZoomingCanvas: NSViewRepresentable {
let layout: BoxLayout
let focus: BoxFocus?
let tap: (String?) -> Void
let open: (String) -> Void
func makeNSView(context: Context) -> FittingScrollView {
let scroll = FittingScrollView()
@@ -1228,14 +1594,14 @@ private struct ZoomingCanvas: NSViewRepresentable {
scroll.allowsMagnification = true
scroll.minMagnification = 0.25
scroll.maxMagnification = 6
let host = NSHostingView(rootView: TappableCanvas(layout: layout, focus: focus, tap: tap))
let host = NSHostingView(rootView: TappableCanvas(layout: layout, focus: focus, tap: tap, open: open))
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)
(scroll.documentView as? NSHostingView<TappableCanvas>)?.rootView = TappableCanvas(layout: layout, focus: focus, tap: tap, open: open)
}
}
#else
@@ -1287,13 +1653,14 @@ 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), size: layout.size)
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)
scroll.host.rootView = TappableCanvas(layout: layout, focus: focus, tap: tap, open: open)
}
}
#endif
+94 -28
View File
@@ -49,6 +49,25 @@ private func edgePath(_ pts: [CGPoint]) -> String {
return d + "L\(num(last.x)) \(num(last.y))"
}
private func svgPath(_ p: Path) -> String {
var d = ""
p.forEach { e in
switch e {
case .move(let to):
d += "M\(num(to.x)) \(num(to.y))"
case .line(let to):
d += "L\(num(to.x)) \(num(to.y))"
case .quadCurve(let to, let c):
d += "Q\(num(c.x)) \(num(c.y)) \(num(to.x)) \(num(to.y))"
case .curve(let to, let c1, let c2):
d += "C\(num(c1.x)) \(num(c1.y)) \(num(c2.x)) \(num(c2.y)) \(num(to.x)) \(num(to.y))"
case .closeSubpath:
d += "Z"
}
}
return d
}
private func arrowHead(_ from: CGPoint, _ to: CGPoint) -> String {
let angle = atan2(to.y - from.y, to.x - from.x)
let size: CGFloat = 8
@@ -58,18 +77,24 @@ private func arrowHead(_ from: CGPoint, _ to: CGPoint) -> String {
}
@MainActor
func diagramHTML(_ layout: BoxLayout) -> String {
let colors: [(String, Color)] = [("bg", Palette.bg), ("border", Palette.border), ("text", Palette.text), ("muted", Palette.muted), ("accent", Palette.accent)]
let light = colors.map { "--\($0.0):\(hex($0.1, .light))" }.joined(separator: ";")
let dark = colors.map { "--\($0.0):\(hex($0.1, .dark))" }.joined(separator: ";")
private func levelSVG(_ layout: BoxLayout) -> String {
let titleLine = textSize("X", titleFont).height
let detailLine = textSize("X", detailFont).height
var svg = ""
var svg = "<g class=\"level\" data-k=\"\(esc(layout.level))\">"
for b in layout.boxes {
let r = b.rect
let f = fill(b)
svg += "<g class=\"box\" data-id=\"\(esc(b.node.id))\" style=\"--l:\(hex(f, .light));--d:\(hex(f, .dark))\">"
svg += "<rect x=\"\(num(r.minX))\" y=\"\(num(r.minY))\" width=\"\(num(r.width))\" height=\"\(num(r.height))\" rx=\"\(b.depth == 0 ? 8 : 6)\"/>"
let kind = switch b.kind {
case .box: ""
case .frame: " frame"
case .ghost: " ghost"
}
svg += "<g class=\"box\(kind)\" data-id=\"\(esc(b.node.id))\" style=\"--l:\(hex(f, .light));--d:\(hex(f, .dark))\">"
svg += "<rect x=\"\(num(r.minX))\" y=\"\(num(r.minY))\" width=\"\(num(r.width))\" height=\"\(num(r.height))\" rx=\"\(num(corner(b)))\"/>"
if b.node.opens {
let m = markRect(r)
svg += "<g class=\"mark\"><rect x=\"\(num(m.minX))\" y=\"\(num(m.minY))\" width=\"\(num(m.width))\" height=\"\(num(m.height))\"/><path d=\"\(svgPath(openMark(r)))\"/></g>"
}
var y = r.minY + padY
svg += "<text class=\"title\" x=\"\(num(r.minX + padX))\" y=\"\(num(y + CTFontGetAscent(titleFont)))\">\(esc(b.node.title))</text>"
y += titleLine + titleGap
@@ -90,14 +115,28 @@ func diagramHTML(_ layout: BoxLayout) -> String {
}
svg += "</g>"
}
let data: [String: Any] = [
"boxes": layout.boxes.map { ["id": $0.node.id, "parent": $0.parent.map { $0 as Any } ?? NSNull()] },
"edges": layout.edges.map { ["from": $0.edge.from, "to": $0.edge.to] },
"presets": layout.presets.map { ["name": $0.name, "ids": Array($0.ids)] },
"width": layout.size.width,
"height": layout.size.height,
]
let buttons = layout.presets.enumerated().map { "<button data-p=\"\($0.offset)\">\(esc($0.element.name))</button>" }.joined()
return svg + "</g>"
}
@MainActor
func diagramHTML(_ diagram: DiagramLayout) -> String {
let colors: [(String, Color)] = [("bg", Palette.bg), ("border", Palette.border), ("text", Palette.text), ("muted", Palette.muted), ("accent", Palette.accent)]
let light = colors.map { "--\($0.0):\(hex($0.1, .light))" }.joined(separator: ";")
let dark = colors.map { "--\($0.0):\(hex($0.1, .dark))" }.joined(separator: ";")
let svg = diagram.order.map { levelSVG(diagram.levels[$0]!) }.joined()
var levels: [String: Any] = [:]
for (key, layout) in diagram.levels {
let kinds: [BoxKind: String] = [.box: "box", .frame: "frame", .ghost: "ghost"]
levels[key] = [
"title": diagram.title(key),
"boxes": layout.boxes.map { ["id": $0.node.id, "parent": $0.parent.map { $0 as Any } ?? NSNull(), "kind": kinds[$0.kind]!, "opens": $0.node.opens] },
"edges": layout.edges.map { ["from": $0.edge.from, "to": $0.edge.to] },
"presets": layout.presets.map { ["name": $0.name, "ids": Array($0.ids)] },
"width": layout.size.width,
"height": layout.size.height,
]
}
let data: [String: Any] = ["levels": levels, "homes": diagram.homes]
return """
<!doctype html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
@@ -109,15 +148,26 @@ func diagramHTML(_ layout: BoxLayout) -> String {
@media (prefers-color-scheme:dark){:root{\(dark)}.box{--l:var(--d)!important}}
html,body{margin:0;height:100%;background:var(--bg);color:var(--text);font-family:Atkinson,-apple-system,sans-serif}
body{display:flex;flex-direction:column}
nav{display:flex;flex-wrap:wrap;gap:6px;padding:12px 12px 0}
nav{display:flex;flex-wrap:wrap;align-items:center;gap:6px;padding:12px 12px 0;font:700 13px Atkinson,-apple-system,sans-serif}
nav:empty{display:none}
nav .sep{color:var(--muted)}
nav .grow{flex:1}
nav a{color:var(--accent);cursor:pointer}
button{font:600 13px Atkinson,-apple-system,sans-serif;color:var(--text);background:transparent;border:1px solid var(--border);border-radius:6px;padding:4px 10px;cursor:pointer}
button.on{color:var(--bg);background:var(--accent);border-color:var(--accent)}
svg{flex:1;width:100%;min-height:0;touch-action:none;cursor:grab;user-select:none;-webkit-user-select:none}
.level{display:none}
.level.shown{display:inline}
.box rect{fill:var(--l);stroke:var(--border);stroke-width:1}
.box.frame rect{fill:none}
.box.ghost rect{fill:none;stroke:var(--muted);stroke-dasharray:3 3}
.box.ghost .title{fill:var(--muted)}
.box.lit rect{stroke:var(--accent);stroke-width:1.5}
.box.dim{opacity:.3}
.box{cursor:pointer}
.box.frame{cursor:inherit}
.box .mark rect{fill:transparent;stroke:none}
.mark path{fill:none;stroke:var(--muted);stroke-width:1.2}
.title{font-size:\(num(CTFontGetSize(titleFont)))px;font-weight:600;fill:var(--text)}
.detail{font-size:\(num(CTFontGetSize(detailFont)))px;fill:var(--muted)}
.edge{pointer-events:none}
@@ -128,29 +178,42 @@ func diagramHTML(_ layout: BoxLayout) -> String {
.edge.lit polygon,.edge.lit text{fill:var(--accent)}
.edge.faint{opacity:.2}
</style></head><body>
<nav>\(buttons)</nav>
<nav id="trail"></nav>
<nav id="bar"></nav>
<svg xmlns="http://www.w3.org/2000/svg"><g id="world">\(svg)</g></svg>
<script>
const D=\(json(data));
const svg=document.querySelector("svg"),world=document.getElementById("world");
const parent={};D.boxes.forEach(b=>parent[b.id]=b.parent);
let level="",picked=null,preset=null,L,parent,kind;
const anc=id=>{const o=[];for(let p=parent[id];p!=null;p=parent[p])o.push(p);return o};
let picked=null,preset=null;
const shown=()=>[...document.querySelectorAll(".level")].find(g=>g.dataset.k===level);
const trail=()=>{const o=[level];while(o[o.length-1]!=="")o.push(D.homes[o[o.length-1]]);return o.reverse()};
const el=(tag,cls,text,click)=>{const e=document.createElement(tag);if(cls)e.className=cls;e.textContent=text;if(click)e.onclick=click;return e};
function go(to,sel){level=to;picked=sel;preset=null;L=D.levels[level];parent={};kind={};
L.boxes.forEach(b=>{parent[b.id]=b.parent;kind[b.id]=b.kind});
document.querySelectorAll(".level").forEach(g=>g.classList.toggle("shown",g.dataset.k===level));
const t=document.getElementById("trail");t.replaceChildren();
if(level!==""){const ls=trail();ls.forEach((l,i)=>{if(i>0)t.append(el("span","sep","›"));
t.append(l===level?el("span","",D.levels[l].title):el("a","",D.levels[l].title,()=>go(l,ls[i+1])))})}
render();fit()}
function render(){
let boxes=null,edges=new Set();
if(picked!=null){boxes=new Set([picked]);D.edges.forEach((e,i)=>{if(e.from==picked||e.to==picked){edges.add(i);boxes.add(e.from);boxes.add(e.to)}})}
else if(preset!=null){const ids=D.presets[preset].ids;boxes=new Set(ids);D.edges.forEach((e,i)=>{if(boxes.has(e.from)&&boxes.has(e.to))edges.add(i)})}
document.querySelectorAll(".box").forEach(g=>{const id=g.dataset.id;
if(picked!=null){boxes=new Set([picked]);L.edges.forEach((e,i)=>{if(e.from==picked||e.to==picked){edges.add(i);boxes.add(e.from);boxes.add(e.to)}})}
else if(preset!=null){const ids=L.presets[preset].ids;boxes=new Set(ids);L.edges.forEach((e,i)=>{if(boxes.has(e.from)&&boxes.has(e.to))edges.add(i)})}
const g=shown();
g.querySelectorAll(".box").forEach(g=>{const id=g.dataset.id;
g.classList.toggle("lit",!!boxes&&boxes.has(id));
const dim=!!boxes&&!boxes.has(id)&&!anc(id).some(a=>boxes.has(a))&&![...boxes].some(b=>anc(b).includes(id));
g.classList.toggle("dim",dim)});
document.querySelectorAll(".edge").forEach(g=>{const i=+g.dataset.i;g.classList.toggle("lit",edges.has(i));g.classList.toggle("faint",!!boxes&&!edges.has(i))});
document.querySelectorAll("nav button").forEach(b=>b.classList.toggle("on",+b.dataset.p===preset));
g.querySelectorAll(".edge").forEach(g=>{const i=+g.dataset.i;g.classList.toggle("lit",edges.has(i));g.classList.toggle("faint",!!boxes&&!edges.has(i))});
const bar=document.getElementById("bar");bar.replaceChildren();
L.presets.forEach((p,i)=>{const b=el("button",i===preset?"on":"",p.name,()=>{picked=null;preset=preset===i?null:i;render()});bar.append(b)});
const opening=picked!=null&&L.boxes.find(b=>b.id===picked&&b.kind==="box"&&b.opens);
if(opening){bar.append(el("span","grow",""));bar.append(el("button","on","Open "+D.levels[picked].title+" ›",()=>go(picked,null)))}
}
document.querySelectorAll("nav button").forEach(b=>b.onclick=()=>{const p=+b.dataset.p;picked=null;preset=preset===p?null:p;render()});
let s=1,tx=0,ty=0;
const apply=()=>world.setAttribute("transform",`translate(${tx} ${ty}) scale(${s})`);
function fit(){const r=svg.getBoundingClientRect();s=Math.min(2,r.width/D.width,r.height/D.height);tx=(r.width-D.width*s)/2;ty=(r.height-D.height*s)/2;apply()}
function fit(){const r=svg.getBoundingClientRect();s=Math.min(2,r.width/L.width,r.height/L.height);tx=(r.width-L.width*s)/2;ty=(r.height-L.height*s)/2;apply()}
function zoom(f,cx,cy){const n=Math.min(6,Math.max(.25,s*f));tx=cx-(cx-tx)*n/s;ty=cy-(cy-ty)*n/s;s=n;apply()}
const local=e=>{const r=svg.getBoundingClientRect();return[e.clientX-r.left,e.clientY-r.top]};
svg.addEventListener("wheel",e=>{e.preventDefault();if(e.ctrlKey){const[x,y]=local(e);zoom(Math.exp(-e.deltaY*.01),x,y)}else{tx-=e.deltaX;ty-=e.deltaY;apply()}},{passive:false});
@@ -163,10 +226,13 @@ func diagramHTML(_ layout: BoxLayout) -> String {
const up=e=>{ptrs.delete(e.pointerId);pinch=0};
svg.addEventListener("pointerup",e=>{const tap=ptrs.size==1&&moved<4;up(e);if(!tap)return;
const g=document.elementsFromPoint(e.clientX,e.clientY).find(el=>el.closest&&el.closest(".box"));
const id=g?g.closest(".box").dataset.id:null;preset=null;picked=id==picked?null:id;render()});
const id=g?g.closest(".box").dataset.id:null;preset=null;
if(id!=null&&kind[id]==="ghost"){go(D.homes[id],id);return}
if(id!=null&&kind[id]==="box"&&g.closest(".mark")){go(id,null);return}
picked=id==null||kind[id]==="frame"||id==picked?null:id;render()});
svg.addEventListener("pointercancel",up);
addEventListener("resize",fit);
fit();
go("",null);
</script></body></html>
"""
}
+25 -7
View File
@@ -5,19 +5,33 @@ import SwiftUI
func renderDiagram(html: Bool) -> Int32 {
FontSize.stored = false
let source = String(decoding: FileHandle.standardInput.readDataToEndOfFile(), as: UTF8.self)
let layout: BoxLayout
let diagram: DiagramLayout
switch cachedBoxLayout(source) {
case .success(let l):
layout = l
case .success(let d):
diagram = d
case .failure(let error):
FileHandle.standardError.write(Data(error.reason.utf8))
return 1
}
if html {
FileHandle.standardOutput.write(Data(diagramHTML(layout).utf8))
FileHandle.standardOutput.write(Data(diagramHTML(diagram).utf8))
return 0
}
guard let png = diagramCGImage(layout).flatMap({ NSBitmapImageRep(cgImage: $0).representation(using: .png, properties: [:]) }) else {
let content = VStack(alignment: .leading, spacing: 24) {
ForEach(diagram.order, id: \.self) { level in
VStack(alignment: .leading, spacing: 8) {
if diagram.order.count > 1 {
Text(diagram.trail(level).map(diagram.title).joined(separator: " › "))
.font(Font(titleFont))
.foregroundStyle(Palette.text)
}
let layout = diagram.levels[level]!
BoxCanvas(layout: layout)
.frame(width: layout.size.width, height: layout.size.height)
}
}
}
guard let png = diagramCGImage(content).flatMap({ NSBitmapImageRep(cgImage: $0).representation(using: .png, properties: [:]) }) else {
FileHandle.standardError.write(Data("diagram didn't render".utf8))
return 1
}
@@ -28,8 +42,12 @@ func renderDiagram(html: Bool) -> Int32 {
@MainActor
func diagramCGImage(_ layout: BoxLayout) -> CGImage? {
let content = BoxCanvas(layout: layout)
.frame(width: layout.size.width, height: layout.size.height)
diagramCGImage(BoxCanvas(layout: layout).frame(width: layout.size.width, height: layout.size.height))
}
@MainActor
func diagramCGImage(_ view: some View) -> CGImage? {
let content = view
.padding(16)
.background(Palette.bg)
.environment(\.colorScheme, .light)
+4 -4
View File
@@ -3,7 +3,7 @@ import SwiftUI
enum SheetView {
case tab(TaskTab)
case image(PlatformImage)
case diagram(BoxLayout)
case diagram(DiagramLayout, String)
}
struct Sheet {
@@ -155,9 +155,9 @@ struct SheetContent: View {
.padding(16)
}
.defaultScrollAnchor(.center)
case .diagram(let layout):
DiagramViewer(layout: layout)
.id(layout.id)
case .diagram(let diagram, let level):
DiagramViewer(diagram: diagram, level: level)
.id("\(diagram.id) \(level)")
.padding(12)
}
}