diff --git a/mac/Sources/Byre/Diagram.swift b/mac/Sources/Byre/Diagram.swift index 46f3fdb..0efb716 100644 --- a/mac/Sources/Byre/Diagram.swift +++ b/mac/Sources/Byre/Diagram.swift @@ -32,6 +32,7 @@ struct FlowNode { var shape: NodeShape = .box var classes: [String] = [] var style = NodeColors() + var members: [FlowNode] = [] } struct FlowEdge { @@ -269,8 +270,12 @@ struct PlacedNode { let center: CGPoint let size: CGSize let colors: NodeColors + var members: [PlacedNode] = [] } +private let groupPad: CGFloat = 10 +private let memberGap: CGFloat = 10 + struct DiagramLayout { let nodes: [String: PlacedNode] let edges: [FlowEdge] @@ -385,21 +390,6 @@ func layoutFlowchart(_ chart: Flowchart) -> DiagramLayout { } } - var sizes: [String: CGSize] = [:] - for n in ids { - let node = chart.nodes[n]! - let t = textSize(node.label, labelFont) - switch node.shape { - case .box, .rounded: - sizes[n] = CGSize(width: t.width + 30, height: t.height + 18) - case .diamond: - sizes[n] = CGSize(width: (t.width * 0.75 + 14) * 2, height: (t.height * 1.5 + 8) * 2) - case .circle: - let d = (t.width * t.width + t.height * t.height).squareRoot() + 16 - sizes[n] = CGSize(width: d, height: d) - } - } - let horizontal = chart.direction == .lr || chart.direction == .rl let rankGap: CGFloat = horizontal ? 70 : 56 let nodeGap: CGFloat = horizontal ? 24 : 32 @@ -409,6 +399,47 @@ func layoutFlowchart(_ chart: Flowchart) -> DiagramLayout { func across(_ s: CGSize) -> CGFloat { horizontal ? s.height : s.width } + + func nodeSize(_ node: FlowNode) -> CGSize { + let t = textSize(node.label, labelFont) + if !node.members.isEmpty { + let ms = node.members.map(nodeSize) + let band = groupPad + t.height + groupPad + let gaps = memberGap * CGFloat(ms.count - 1) + let inner = horizontal + ? CGSize(width: ms.map(\.width).max()!, height: ms.map(\.height).reduce(0, +) + gaps) + : CGSize(width: ms.map(\.width).reduce(0, +) + gaps, height: ms.map(\.height).max()!) + return CGSize(width: max(t.width + 30, inner.width + groupPad * 2), height: band + inner.height + groupPad) + } + switch node.shape { + case .box, .rounded: + return CGSize(width: t.width + 30, height: t.height + 18) + case .diamond: + return CGSize(width: (t.width * 0.75 + 14) * 2, height: (t.height * 1.5 + 8) * 2) + case .circle: + let d = (t.width * t.width + t.height * t.height).squareRoot() + 16 + return CGSize(width: d, height: d) + } + } + func placeMembers(_ group: FlowNode, _ center: CGPoint, _ size: CGSize) -> [PlacedNode] { + let ms = group.members.map(nodeSize) + let top = center.y - size.height / 2 + groupPad + textSize(group.label, labelFont).height + groupPad + let gaps = memberGap * CGFloat(ms.count - 1) + var offset = horizontal ? top : center.x - (ms.map(\.width).reduce(0, +) + gaps) / 2 + let rowHeight = ms.map(\.height).max() ?? 0 + return zip(group.members, ms).map { m, s in + let c = horizontal + ? CGPoint(x: center.x, y: offset + s.height / 2) + : CGPoint(x: offset + s.width / 2, y: top + rowHeight / 2) + offset += (horizontal ? s.height : s.width) + memberGap + return PlacedNode(node: m, center: c, size: s, colors: m.style) + } + } + + var sizes: [String: CGSize] = [:] + for n in ids { + sizes[n] = nodeSize(chart.nodes[n]!) + } let spans = layers.map { l in l.map { across(sizes[$0]!) }.reduce(0, +) + nodeGap * CGFloat(max(0, l.count - 1)) } let widest = spans.max() ?? 0 var centers: [String: CGPoint] = [:] @@ -437,7 +468,9 @@ func layoutFlowchart(_ chart: Flowchart) -> DiagramLayout { if chart.direction == .bt { c.y = total.height - c.y } - placed[n] = PlacedNode(node: chart.nodes[n]!, center: CGPoint(x: c.x + margin, y: c.y + margin), size: sizes[n]!, colors: chart.colors(n)) + let center = CGPoint(x: c.x + margin, y: c.y + margin) + let node = chart.nodes[n]! + placed[n] = PlacedNode(node: node, center: center, size: sizes[n]!, colors: chart.colors(n), members: placeMembers(node, center, sizes[n]!)) } return DiagramLayout(nodes: placed, edges: chart.edges, size: CGSize(width: total.width + margin * 2, height: total.height + margin * 2)) } @@ -532,18 +565,26 @@ struct FlowchartCanvas: View { } } for (_, n) in layout.nodes { - let path = shapePath(n) - context.fill(path, with: .color(n.colors.fill ?? Palette.surface)) - context.stroke(path, with: .color(n.colors.stroke ?? Palette.border), lineWidth: 1) - for (i, line) in n.node.label.components(separatedBy: "\n").enumerated() { - let count = CGFloat(n.node.label.components(separatedBy: "\n").count) - let lineHeight = textSize("X", labelFont).height - let y = n.center.y + (CGFloat(i) - (count - 1) / 2) * lineHeight - context.draw(Text(line).font(Font(labelFont)).foregroundColor(n.colors.text ?? Palette.text), at: CGPoint(x: n.center.x, y: y), anchor: .center) + drawNode(&context, n) + for m in n.members { + drawNode(&context, m) } } } + private func drawNode(_ context: inout GraphicsContext, _ n: PlacedNode) { + let path = shapePath(n) + context.fill(path, with: .color(n.colors.fill ?? Palette.surface)) + context.stroke(path, with: .color(n.colors.stroke ?? Palette.border), lineWidth: 1) + let lines = n.node.label.components(separatedBy: "\n") + let lineHeight = textSize("X", labelFont).height + let middle = n.members.isEmpty ? n.center.y : n.center.y - n.size.height / 2 + groupPad + lineHeight * CGFloat(lines.count) / 2 + for (i, line) in lines.enumerated() { + let y = middle + (CGFloat(i) - CGFloat(lines.count - 1) / 2) * lineHeight + context.draw(Text(line).font(Font(labelFont)).foregroundColor(n.colors.text ?? Palette.text), at: CGPoint(x: n.center.x, y: y), anchor: .center) + } + } + private func arrowHead(_ context: inout GraphicsContext, from: CGPoint, to: CGPoint) { let angle = atan2(to.y - from.y, to.x - from.x) let size: CGFloat = 9 diff --git a/mac/Sources/Byre/Workflow.swift b/mac/Sources/Byre/Workflow.swift index c744a2e..c7de36c 100644 --- a/mac/Sources/Byre/Workflow.swift +++ b/mac/Sources/Byre/Workflow.swift @@ -15,12 +15,19 @@ private let stateColors: [RunStage.StatePayload: NodeColors] = [ .discarded: NodeColors(fill: Palette.dangerTint, stroke: Palette.danger, text: Palette.text), ] +private func stageSteps(_ steps: [WorkflowStep]) -> [WorkflowStep] { + steps.flatMap { s in s.order.map(stageSteps) ?? s.parallel.map(stageSteps) ?? [s] } +} + private func stageGraph(_ run: WorkflowRun) -> Flowchart { var chart = Flowchart(direction: .td) + let steps = Dictionary(stageSteps(run.definition.steps).map { ($0.stage ?? "", $0) }, uniquingKeysWith: { a, _ in a }) for (i, s) in run.stages.enumerated() { let id = "s\(i)" + let colors = stateColors[s.state]! chart.node(id, label: s.name, shape: .box) - chart.nodes[id]!.style = stateColors[s.state]! + chart.nodes[id]!.style = colors + chart.nodes[id]!.members = teamNodes(id, steps[s.name]?.agents, stroke: colors.stroke) } let ids = Dictionary(run.stages.enumerated().map { ($0.element.name, "s\($0.offset)") }, uniquingKeysWith: { a, _ in a }) for s in run.stages { diff --git a/mac/Sources/Byre/WorkflowEditor.swift b/mac/Sources/Byre/WorkflowEditor.swift index dbb6b65..d47094c 100644 --- a/mac/Sources/Byre/WorkflowEditor.swift +++ b/mac/Sources/Byre/WorkflowEditor.swift @@ -92,6 +92,12 @@ private func newStage(_ root: StepNode, _ model: String) -> StepNode { return s } +func teamNodes(_ id: String, _ agents: [TeamMember]?, stroke: Color?) -> [FlowNode] { + (agents ?? []).enumerated().map { i, a in + FlowNode(id: "\(id)m\(i)", label: "\(a.name.isEmpty ? " " : a.name)\n\(a.model)", style: NodeColors(fill: Palette.bg, stroke: stroke, text: Palette.text)) + } +} + @MainActor private func stepGraph(_ root: StepNode) -> Flowchart { var chart = Flowchart(direction: .td) @@ -104,6 +110,7 @@ private func stepGraph(_ root: StepNode) -> Flowchart { case .stage: let id = "n\(chart.order.count)" chart.node(id, label: node.name.isEmpty ? " " : node.name, shape: .box) + chart.nodes[id]!.members = teamNodes(id, node.agents, stroke: nil) for b in before { chart.edges.append(FlowEdge(from: b, to: id, label: nil, style: .solid, arrow: true)) }