mirror of
https://github.com/exo-explore/exo.git
synced 2025-12-23 14:17:58 -05:00
173 lines
6.1 KiB
Swift
173 lines
6.1 KiB
Swift
import SwiftUI
|
|
|
|
struct TopologyMiniView: View {
|
|
let topology: TopologyViewModel
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("Topology")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
GeometryReader { geo in
|
|
ZStack {
|
|
connectionLines(in: geo.size)
|
|
let positions = positionedNodes(in: geo.size)
|
|
ForEach(Array(positions.enumerated()), id: \.element.node.id) { _, positioned in
|
|
NodeGlyphView(node: positioned.node, isCurrent: positioned.isCurrent)
|
|
.position(positioned.point)
|
|
}
|
|
}
|
|
}
|
|
.frame(height: heightForNodes())
|
|
}
|
|
}
|
|
|
|
private func positionedNodes(in size: CGSize) -> [PositionedNode] {
|
|
let nodes = orderedNodesForLayout()
|
|
guard !nodes.isEmpty else { return [] }
|
|
var result: [PositionedNode] = []
|
|
let glyphHeight: CGFloat = 70
|
|
let rootPoint = CGPoint(x: size.width / 2, y: glyphHeight / 2 + 10)
|
|
result.append(
|
|
PositionedNode(
|
|
node: nodes[0],
|
|
point: rootPoint,
|
|
isCurrent: nodes[0].id == topology.currentNodeId
|
|
)
|
|
)
|
|
guard nodes.count > 1 else { return result }
|
|
let childCount = nodes.count - 1
|
|
// Larger radius to reduce overlap when several nodes exist
|
|
let minDimension = min(size.width, size.height)
|
|
let radius = max(120, minDimension * 0.42)
|
|
let startAngle = Double.pi * 0.75
|
|
let endAngle = Double.pi * 0.25
|
|
let step = childCount == 1 ? 0 : (startAngle - endAngle) / Double(childCount - 1)
|
|
for (index, node) in nodes.dropFirst().enumerated() {
|
|
let angle = startAngle - step * Double(index)
|
|
let x = size.width / 2 + radius * CGFloat(cos(angle))
|
|
let y = rootPoint.y + radius * CGFloat(sin(angle)) + glyphHeight / 2
|
|
result.append(
|
|
PositionedNode(
|
|
node: node,
|
|
point: CGPoint(x: x, y: y),
|
|
isCurrent: node.id == topology.currentNodeId
|
|
)
|
|
)
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func orderedNodesForLayout() -> [NodeViewModel] {
|
|
guard let currentId = topology.currentNodeId else {
|
|
return topology.nodes
|
|
}
|
|
guard let currentIndex = topology.nodes.firstIndex(where: { $0.id == currentId }) else {
|
|
return topology.nodes
|
|
}
|
|
if currentIndex == 0 {
|
|
return topology.nodes
|
|
}
|
|
var reordered = topology.nodes
|
|
let current = reordered.remove(at: currentIndex)
|
|
reordered.insert(current, at: 0)
|
|
return reordered
|
|
}
|
|
|
|
private func connectionLines(in size: CGSize) -> some View {
|
|
let positions = positionedNodes(in: size)
|
|
let positionById = Dictionary(uniqueKeysWithValues: positions.map { ($0.node.id, $0.point) })
|
|
return Canvas { context, _ in
|
|
guard !topology.edges.isEmpty else { return }
|
|
let nodeRadius: CGFloat = 32
|
|
let arrowLength: CGFloat = 10
|
|
let arrowSpread: CGFloat = .pi / 7
|
|
for edge in topology.edges {
|
|
guard let start = positionById[edge.sourceId], let end = positionById[edge.targetId] else { continue }
|
|
let dx = end.x - start.x
|
|
let dy = end.y - start.y
|
|
let distance = max(CGFloat(hypot(dx, dy)), 1)
|
|
let ux = dx / distance
|
|
let uy = dy / distance
|
|
let adjustedStart = CGPoint(x: start.x + ux * nodeRadius, y: start.y + uy * nodeRadius)
|
|
let adjustedEnd = CGPoint(x: end.x - ux * nodeRadius, y: end.y - uy * nodeRadius)
|
|
|
|
var linePath = Path()
|
|
linePath.move(to: adjustedStart)
|
|
linePath.addLine(to: adjustedEnd)
|
|
context.stroke(
|
|
linePath,
|
|
with: .color(.secondary.opacity(0.3)),
|
|
style: StrokeStyle(lineWidth: 1, dash: [4, 4])
|
|
)
|
|
|
|
let angle = atan2(uy, ux)
|
|
let tip = adjustedEnd
|
|
let leftWing = CGPoint(
|
|
x: tip.x - arrowLength * cos(angle - arrowSpread),
|
|
y: tip.y - arrowLength * sin(angle - arrowSpread)
|
|
)
|
|
let rightWing = CGPoint(
|
|
x: tip.x - arrowLength * cos(angle + arrowSpread),
|
|
y: tip.y - arrowLength * sin(angle + arrowSpread)
|
|
)
|
|
var arrowPath = Path()
|
|
arrowPath.move(to: tip)
|
|
arrowPath.addLine(to: leftWing)
|
|
arrowPath.move(to: tip)
|
|
arrowPath.addLine(to: rightWing)
|
|
context.stroke(
|
|
arrowPath,
|
|
with: .color(.secondary.opacity(0.5)),
|
|
style: StrokeStyle(lineWidth: 1)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func heightForNodes() -> CGFloat {
|
|
switch topology.nodes.count {
|
|
case 0...1:
|
|
return 130
|
|
case 2...3:
|
|
return 200
|
|
default:
|
|
return 240
|
|
}
|
|
}
|
|
|
|
private struct PositionedNode {
|
|
let node: NodeViewModel
|
|
let point: CGPoint
|
|
let isCurrent: Bool
|
|
}
|
|
}
|
|
|
|
private struct NodeGlyphView: View {
|
|
let node: NodeViewModel
|
|
let isCurrent: Bool
|
|
|
|
var body: some View {
|
|
VStack(spacing: 2) {
|
|
Image(systemName: node.deviceIconName)
|
|
.font(.subheadline)
|
|
Text(node.friendlyName)
|
|
.font(.caption2)
|
|
.lineLimit(1)
|
|
.foregroundColor(isCurrent ? Color(nsColor: .systemBlue) : .primary)
|
|
Text(node.memoryLabel)
|
|
.font(.caption2)
|
|
HStack(spacing: 3) {
|
|
Text(node.gpuUsageLabel)
|
|
Text(node.temperatureLabel)
|
|
}
|
|
.foregroundColor(.secondary)
|
|
.font(.caption2)
|
|
}
|
|
.padding(.vertical, 3)
|
|
.frame(width: 95)
|
|
}
|
|
}
|
|
|
|
|