Files
networkcity/Sources/NetworkCityApp/CityView.swift
T

201 lines
8.0 KiB
Swift
Raw Normal View History

import SwiftUI
import SpriteKit
import NetworkCityCore
/// The SpriteKit city with a glassy stats HUD floated on top.
struct CityView: View {
@ObservedObject var controller: TrafficController
var body: some View {
ZStack(alignment: .topLeading) {
SpriteView(scene: controller.scene, options: [.ignoresSiblingOrder])
.ignoresSafeArea()
hud
.padding(14)
if let inspection = controller.selection {
inspector(inspection)
.padding(14)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing)
.transition(.move(edge: .trailing).combined(with: .opacity))
}
}
.animation(.easeOut(duration: 0.18), value: controller.selection?.id)
.onAppear { controller.start() }
}
// MARK: - Inspector panel
static func color(_ c: TrafficClass) -> Color {
switch c {
case .dns: return Color(red: 1.00, green: 0.82, blue: 0.25)
case .https: return Color(red: 0.22, green: 0.88, blue: 1.00)
case .http: return Color(red: 1.00, green: 0.48, blue: 0.24)
case .quic: return Color(red: 0.71, green: 0.48, blue: 1.00)
case .other: return Color(red: 0.55, green: 0.60, blue: 0.72)
}
}
private func inspector(_ insp: OrgInspection) -> some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text(insp.title)
.font(.system(size: 16, weight: .bold, design: .monospaced))
.foregroundStyle(.white)
Spacer(minLength: 12)
Button { controller.deselect() } label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.white.opacity(0.5))
}
.buttonStyle(.plain)
}
HStack(spacing: 16) {
Text("\(fmt(insp.downKBs))").foregroundStyle(.cyan)
Text("\(fmt(insp.upKBs))").foregroundStyle(.orange)
Spacer(minLength: 4)
Text("\(insp.endpointCount) pts").foregroundStyle(.white.opacity(0.6))
}
.font(.system(size: 12, weight: .semibold, design: .monospaced))
if !insp.processes.isEmpty {
Text(insp.processes.joined(separator: ", "))
.font(.system(size: 10, design: .monospaced))
.foregroundStyle(.white.opacity(0.55))
.lineLimit(2)
}
Divider().overlay(.white.opacity(0.15))
Text("ENDPOINTS").font(.system(size: 9, weight: .bold, design: .monospaced))
.foregroundStyle(.white.opacity(0.4))
ScrollView {
VStack(alignment: .leading, spacing: 7) {
ForEach(insp.endpoints) { ep in endpointRow(ep) }
}
}
.frame(maxHeight: 320)
}
.padding(14)
.frame(width: 290)
.background(.black.opacity(0.62), in: RoundedRectangle(cornerRadius: 12))
.overlay(RoundedRectangle(cornerRadius: 12).stroke(.white.opacity(0.1)))
}
private func endpointRow(_ ep: EndpointInfo) -> some View {
HStack(alignment: .top, spacing: 8) {
Circle().fill(Self.color(ep.topClass)).frame(width: 7, height: 7)
.shadow(color: Self.color(ep.topClass), radius: 3)
.padding(.top, 3)
VStack(alignment: .leading, spacing: 1) {
Text(ep.host)
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(.white.opacity(0.9))
if let rdns = ep.rdns {
Text(rdns)
.font(.system(size: 9, design: .monospaced))
.foregroundStyle(.white.opacity(0.45))
.lineLimit(1).truncationMode(.middle)
}
if let rtt = ep.rttMs {
Text("\(Int(rtt)) ms")
.font(.system(size: 9, design: .monospaced))
.foregroundStyle(.white.opacity(0.5))
}
}
Spacer(minLength: 6)
VStack(alignment: .trailing, spacing: 1) {
if ep.downKBs >= 0.1 { Text("\(fmt(ep.downKBs))").foregroundStyle(.cyan.opacity(0.85)) }
if ep.upKBs >= 0.1 { Text("\(fmt(ep.upKBs))").foregroundStyle(.orange.opacity(0.85)) }
if ep.downKBs < 0.1 && ep.upKBs < 0.1 { Text("idle").foregroundStyle(.white.opacity(0.3)) }
}
.font(.system(size: 9, weight: .semibold, design: .monospaced))
}
}
private var hud: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
Circle()
.fill(controller.live ? Color.green : Color.orange)
.frame(width: 8, height: 8)
Text("NETWORKCITY")
.font(.system(size: 14, weight: .bold, design: .monospaced))
.foregroundStyle(.white.opacity(0.9))
}
Divider().frame(width: 168).overlay(.white.opacity(0.15))
stat("▼ down", controller.downKBs, .cyan)
stat("▲ up", controller.upKBs, .orange)
stat(label: "districts", value: "\(controller.districtCount)")
Divider().frame(width: 168).overlay(.white.opacity(0.15))
legend
Text(controller.live ? "live · color = protocol · motion = direction"
: "warming up…")
.font(.system(size: 10, design: .monospaced))
.foregroundStyle(.white.opacity(0.4))
.padding(.top, 2)
}
.padding(12)
.background(.black.opacity(0.45), in: RoundedRectangle(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(.white.opacity(0.08)))
}
private static let legendItems: [(String, Color)] = [
("DNS", Color(red: 1.00, green: 0.82, blue: 0.25)),
("HTTPS", Color(red: 0.22, green: 0.88, blue: 1.00)),
("HTTP", Color(red: 1.00, green: 0.48, blue: 0.24)),
("QUIC", Color(red: 0.71, green: 0.48, blue: 1.00)),
("Other", Color(red: 0.55, green: 0.60, blue: 0.72)),
]
private var legend: some View {
VStack(alignment: .leading, spacing: 4) {
ForEach(Self.legendItems, id: \.0) { name, color in
HStack(spacing: 8) {
Circle().fill(color).frame(width: 7, height: 7)
.shadow(color: color, radius: 3)
Text(name)
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(.white.opacity(0.8))
}
}
}
.frame(width: 168, alignment: .leading)
}
private func stat(_ label: String, _ kbs: Double, _ color: Color) -> some View {
HStack {
Text(label)
.font(.system(size: 12, design: .monospaced))
.foregroundStyle(color.opacity(0.9))
Spacer(minLength: 16)
Text(format(kbs))
.font(.system(size: 12, weight: .semibold, design: .monospaced))
.foregroundStyle(.white)
}
.frame(width: 168)
}
private func stat(label: String, value: String) -> some View {
HStack {
Text(label).font(.system(size: 12, design: .monospaced)).foregroundStyle(.white.opacity(0.7))
Spacer(minLength: 16)
Text(value).font(.system(size: 12, weight: .semibold, design: .monospaced)).foregroundStyle(.white)
}
.frame(width: 168)
}
private func format(_ kbs: Double) -> String {
kbs >= 1024 ? String(format: "%.1f MB/s", kbs / 1024) : String(format: "%.0f KB/s", kbs)
}
/// Compact rate for the dense inspector rows.
private func fmt(_ kbs: Double) -> String {
kbs >= 1024 ? String(format: "%.1fM", kbs / 1024) : String(format: "%.0fK", kbs)
}
}