Initial commit: NetworkCity — network traffic as a neon city
A native macOS network monitor that renders live traffic as a top-down neon city: your Mac is downtown, remote orgs are glowing districts, and traffic is cars of light driving the roads. Architecture: - NetworkCityCore: swappable data layer behind a ConnectionSource protocol (nettop-backed today), plus pure/tested logic — traffic diffing, org classification (reverse-DNS + CIDR), traffic classes, hub-and-spoke expansion policy, label anti-overlap, and latency→distance layout. - NetworkCityApp: SwiftUI + SpriteKit city — auto-expanding districts, protocol-coloured cars, click-to-inspect panel, and latency-as-distance (districts glide to their measured RTT). - nettop-probe: CLI proof of the data layer. 44 tests passing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
import SpriteKit
|
||||
|
||||
/// A single endpoint (one server IP) belonging to an org — a small glowing
|
||||
/// satellite that appears when its district fans out.
|
||||
final class EndpointNode: SKNode {
|
||||
private let glow: SKSpriteNode
|
||||
private let core: SKShapeNode
|
||||
|
||||
override init() {
|
||||
glow = SKSpriteNode(texture: Textures.softCircle)
|
||||
core = SKShapeNode(circleOfRadius: 2.5)
|
||||
super.init()
|
||||
glow.color = Palette.building
|
||||
glow.colorBlendFactor = 1
|
||||
glow.blendMode = .add
|
||||
glow.setScale(0.12)
|
||||
glow.alpha = 0.7
|
||||
addChild(glow)
|
||||
core.fillColor = Palette.building
|
||||
core.strokeColor = .white
|
||||
core.lineWidth = 0.4
|
||||
addChild(core)
|
||||
}
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
func pulse() {
|
||||
core.removeAction(forKey: "pulse")
|
||||
core.run(.sequence([.scale(to: 1.8, duration: 0.12), .scale(to: 1.0, duration: 0.3)]), withKey: "pulse")
|
||||
glow.removeAction(forKey: "flare")
|
||||
glow.run(.sequence([.fadeAlpha(to: 1.0, duration: 0.12), .fadeAlpha(to: 0.7, duration: 0.4)]), withKey: "flare")
|
||||
}
|
||||
}
|
||||
|
||||
/// A destination *organization* rendered as a glowing district hub. When busy it
|
||||
/// fans out into its known endpoints (hub-and-spoke); when quiet it collapses
|
||||
/// back to a single labelled hub.
|
||||
final class BuildingNode: SKNode {
|
||||
private let glow: SKSpriteNode
|
||||
private let core: SKShapeNode
|
||||
private let title: SKLabelNode
|
||||
private let subtitle: SKLabelNode
|
||||
private var cumulative: Double = 0
|
||||
|
||||
private let spokeLayer = SKNode()
|
||||
private let endpointLayer = SKNode()
|
||||
private var endpointNodes: [String: EndpointNode] = [:]
|
||||
private var knownHosts: Set<String> = []
|
||||
private(set) var isExpanded = false
|
||||
|
||||
private let maxEndpoints = 20
|
||||
private let fanSpread = 75.0 * .pi / 180 // half-arc the spokes spread over
|
||||
private let spokeRadius: CGFloat = 82
|
||||
|
||||
init(title titleText: String, subtitle subtitleText: String) {
|
||||
glow = SKSpriteNode(texture: Textures.softCircle)
|
||||
core = SKShapeNode(circleOfRadius: 5)
|
||||
title = SKLabelNode(text: titleText)
|
||||
subtitle = SKLabelNode(text: subtitleText)
|
||||
super.init()
|
||||
|
||||
spokeLayer.zPosition = -1
|
||||
addChild(spokeLayer)
|
||||
addChild(endpointLayer)
|
||||
|
||||
glow.color = Palette.building
|
||||
glow.colorBlendFactor = 1
|
||||
glow.blendMode = .add
|
||||
glow.setScale(0.30)
|
||||
glow.alpha = 0.85
|
||||
addChild(glow)
|
||||
|
||||
core.fillColor = Palette.building
|
||||
core.strokeColor = .white
|
||||
core.lineWidth = 0.5
|
||||
core.glowWidth = 1
|
||||
addChild(core)
|
||||
|
||||
title.fontName = "Menlo-Bold"
|
||||
title.fontSize = 13
|
||||
title.fontColor = .white
|
||||
title.verticalAlignmentMode = .top
|
||||
title.position = CGPoint(x: 0, y: -12)
|
||||
addChild(title)
|
||||
|
||||
subtitle.fontName = "Menlo"
|
||||
subtitle.fontSize = 10
|
||||
subtitle.fontColor = Palette.building.withAlphaComponent(0.85)
|
||||
subtitle.verticalAlignmentMode = .top
|
||||
subtitle.position = CGPoint(x: 0, y: -27)
|
||||
addChild(subtitle)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
func update(subtitle text: String) {
|
||||
if subtitle.text != text { subtitle.text = text }
|
||||
}
|
||||
|
||||
var knownHostCount: Int { knownHosts.count }
|
||||
|
||||
/// The label box (title + subtitle) relative to this node's center, used for
|
||||
/// collision-aware placement so labels don't overlap their neighbours.
|
||||
var labelFootprint: CGRect {
|
||||
title.calculateAccumulatedFrame().union(subtitle.calculateAccumulatedFrame())
|
||||
}
|
||||
|
||||
func note(activeHosts: [String]) { knownHosts.formUnion(activeHosts) }
|
||||
|
||||
/// React to a tick of traffic: grow toward a size set by total volume, and
|
||||
/// pulse so the eye catches the activity.
|
||||
func receive(bytes: Double) {
|
||||
cumulative += bytes
|
||||
let target = (0.30 + CGFloat(log10(cumulative + 1)) * 0.09).clamped(0.30, 1.05)
|
||||
glow.removeAction(forKey: "resize")
|
||||
glow.run(.scale(to: target, duration: 0.6), withKey: "resize")
|
||||
|
||||
core.removeAction(forKey: "pulse")
|
||||
core.run(.sequence([
|
||||
.scale(to: 1.9, duration: 0.12),
|
||||
.scale(to: 1.0, duration: 0.35),
|
||||
]), withKey: "pulse")
|
||||
}
|
||||
|
||||
// MARK: - Expansion
|
||||
|
||||
/// The capped, stably-ordered list of endpoints we actually draw.
|
||||
private var renderedHosts: [String] { knownHosts.sorted().prefix(maxEndpoints).map { $0 } }
|
||||
|
||||
/// Local offset of an endpoint, fanned outward (away from downtown at origin).
|
||||
private func offset(forIndex i: Int, count: Int, host: String) -> CGPoint {
|
||||
let outward = atan2(position.y, position.x)
|
||||
let angle: CGFloat
|
||||
if count <= 1 {
|
||||
angle = outward
|
||||
} else {
|
||||
let t = CGFloat(i) / CGFloat(count - 1) // 0…1 across the arc
|
||||
angle = outward - fanSpread + 2 * fanSpread * t
|
||||
}
|
||||
let depth = spokeRadius + CGFloat(stableHash(host) % 4) * 11
|
||||
return CGPoint(x: cos(angle) * depth, y: sin(angle) * depth)
|
||||
}
|
||||
|
||||
/// Scene-space position of an endpoint (for routing cars along its spoke).
|
||||
func worldPosition(forEndpoint host: String) -> CGPoint {
|
||||
let hosts = renderedHosts
|
||||
guard let i = hosts.firstIndex(of: host) else { return position }
|
||||
let o = offset(forIndex: i, count: hosts.count, host: host)
|
||||
return CGPoint(x: position.x + o.x, y: position.y + o.y)
|
||||
}
|
||||
|
||||
func setExpanded(_ on: Bool) {
|
||||
if on {
|
||||
if !isExpanded {
|
||||
isExpanded = true
|
||||
if ProcessInfo.processInfo.environment["NC_DEBUG"] != nil {
|
||||
FileHandle.standardError.write(Data("[expand] \(title.text ?? "?") fanned out → \(knownHostCount) endpoints\n".utf8))
|
||||
}
|
||||
endpointLayer.alpha = 0
|
||||
spokeLayer.alpha = 0
|
||||
endpointLayer.run(.fadeIn(withDuration: 0.4))
|
||||
spokeLayer.run(.fadeAlpha(to: 0.5, duration: 0.4))
|
||||
}
|
||||
layoutEndpoints()
|
||||
} else if isExpanded {
|
||||
isExpanded = false
|
||||
endpointLayer.run(.fadeOut(withDuration: 0.35))
|
||||
spokeLayer.run(.sequence([.fadeOut(withDuration: 0.35), .run { [weak self] in
|
||||
self?.spokeLayer.removeAllChildren()
|
||||
self?.endpointLayer.removeAllChildren()
|
||||
self?.endpointNodes.removeAll()
|
||||
}]))
|
||||
}
|
||||
}
|
||||
|
||||
func pulseEndpoint(_ host: String) {
|
||||
endpointNodes[host]?.pulse()
|
||||
}
|
||||
|
||||
private func layoutEndpoints() {
|
||||
let hosts = renderedHosts
|
||||
spokeLayer.removeAllChildren()
|
||||
let spokes = CGMutablePath()
|
||||
|
||||
for (i, host) in hosts.enumerated() {
|
||||
let target = offset(forIndex: i, count: hosts.count, host: host)
|
||||
let node = endpointNodes[host] ?? {
|
||||
let n = EndpointNode()
|
||||
n.position = .zero
|
||||
n.alpha = 0
|
||||
n.run(.fadeIn(withDuration: 0.3))
|
||||
endpointLayer.addChild(n)
|
||||
endpointNodes[host] = n
|
||||
return n
|
||||
}()
|
||||
node.run(.move(to: target, duration: 0.3))
|
||||
spokes.move(to: .zero)
|
||||
spokes.addLine(to: target)
|
||||
}
|
||||
|
||||
// drop endpoints that fell out of the capped set
|
||||
for (host, node) in endpointNodes where !hosts.contains(host) {
|
||||
node.removeFromParent()
|
||||
endpointNodes[host] = nil
|
||||
}
|
||||
|
||||
let line = SKShapeNode(path: spokes)
|
||||
line.strokeColor = Palette.building.withAlphaComponent(0.4)
|
||||
line.lineWidth = 1
|
||||
spokeLayer.addChild(line)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
import SpriteKit
|
||||
import NetworkCityCore
|
||||
|
||||
/// Traffic of one class to/from one endpoint in a single tick.
|
||||
struct ClassFlow {
|
||||
let cls: TrafficClass
|
||||
let inBytes: UInt64
|
||||
let outBytes: UInt64
|
||||
}
|
||||
|
||||
/// One endpoint's traffic within a district for a single tick, split by class.
|
||||
struct EndpointTraffic {
|
||||
let host: String
|
||||
let flows: [ClassFlow]
|
||||
var inBytes: UInt64 { flows.reduce(0) { $0 + $1.inBytes } }
|
||||
var outBytes: UInt64 { flows.reduce(0) { $0 + $1.outBytes } }
|
||||
}
|
||||
|
||||
/// One org's traffic for a single tick, ready to render as a district.
|
||||
struct DistrictTraffic {
|
||||
let key: String
|
||||
let title: String
|
||||
var subtitle: String
|
||||
var inBytes: UInt64
|
||||
var outBytes: UInt64
|
||||
var endpoints: [EndpointTraffic]
|
||||
var rttMs: Double? = nil // measured round-trip time → distance from downtown
|
||||
}
|
||||
|
||||
/// The city. Downtown (your Mac) glows at the origin; every org is a district
|
||||
/// hub placed at a deterministic spot around it, connected by a road. Busy
|
||||
/// districts fan out into their endpoints (hub-and-spoke); cars of light flow
|
||||
/// the full path — cyan inbound for downloads, amber outbound for uploads.
|
||||
final class CityScene: SKScene {
|
||||
|
||||
private let cam = SKCameraNode()
|
||||
private let worldRadius: CGFloat = 540
|
||||
private var buildings: [String: BuildingNode] = [:]
|
||||
private var roads: [String: SKShapeNode] = [:]
|
||||
private var latencyRadius: [String: CGFloat] = [:]
|
||||
private var ringKey: String?
|
||||
private let roadLayer = SKNode()
|
||||
private let carLayer = SKNode()
|
||||
private let buildingLayer = SKNode()
|
||||
|
||||
// Auto-expand: an org's spokes stay open for a few ticks after it last
|
||||
// ranked among the busiest, giving a smooth tail instead of flicker.
|
||||
private let expansion = ExpansionPolicy()
|
||||
|
||||
/// Number of districts currently on the map (for the HUD).
|
||||
var buildingCount: Int { buildings.count }
|
||||
|
||||
/// Called with a district key when a hub is clicked, or nil when empty space
|
||||
/// is clicked (deselect).
|
||||
var onSelect: ((String?) -> Void)?
|
||||
private let selectionRing = SKShapeNode(circleOfRadius: 24)
|
||||
private var dragDistance: CGFloat = 0
|
||||
|
||||
override func didMove(to view: SKView) {
|
||||
backgroundColor = Palette.background
|
||||
scaleMode = .resizeFill
|
||||
anchorPoint = CGPoint(x: 0.5, y: 0.5)
|
||||
|
||||
drawGrid()
|
||||
addChild(roadLayer)
|
||||
addChild(carLayer)
|
||||
addChild(buildingLayer)
|
||||
addDowntown()
|
||||
|
||||
selectionRing.strokeColor = .white
|
||||
selectionRing.lineWidth = 2
|
||||
selectionRing.glowWidth = 4
|
||||
selectionRing.alpha = 0
|
||||
selectionRing.zPosition = 40
|
||||
addChild(selectionRing)
|
||||
|
||||
camera = cam
|
||||
cam.setScale(1.25)
|
||||
addChild(cam)
|
||||
}
|
||||
|
||||
// MARK: - Selection
|
||||
|
||||
/// Rings the selected district (or fades the ring out when nil).
|
||||
func highlight(key: String?) {
|
||||
ringKey = key
|
||||
selectionRing.removeAllActions()
|
||||
if let key, let building = buildings[key] {
|
||||
selectionRing.position = building.position
|
||||
selectionRing.alpha = 1
|
||||
selectionRing.run(.repeatForever(.sequence([
|
||||
.scale(to: 1.18, duration: 0.7), .scale(to: 1.0, duration: 0.7),
|
||||
])))
|
||||
} else {
|
||||
selectionRing.run(.fadeOut(withDuration: 0.2))
|
||||
}
|
||||
}
|
||||
|
||||
/// Nearest district hub within a (zoom-aware) radius of the point.
|
||||
private func districtKey(at point: CGPoint) -> String? {
|
||||
let radius = 30 * cam.xScale
|
||||
var best: (key: String, dist: CGFloat)?
|
||||
for (key, building) in buildings {
|
||||
let d = hypot(point.x - building.position.x, point.y - building.position.y)
|
||||
if d < radius, best == nil || d < best!.dist { best = (key, d) }
|
||||
}
|
||||
return best?.key
|
||||
}
|
||||
|
||||
// MARK: - Static scenery
|
||||
|
||||
private func drawGrid() {
|
||||
let grid = SKNode()
|
||||
let step: CGFloat = 80
|
||||
let extent: CGFloat = 1400
|
||||
let path = CGMutablePath()
|
||||
var x = -extent
|
||||
while x <= extent { path.move(to: CGPoint(x: x, y: -extent)); path.addLine(to: CGPoint(x: x, y: extent)); x += step }
|
||||
var y = -extent
|
||||
while y <= extent { path.move(to: CGPoint(x: -extent, y: y)); path.addLine(to: CGPoint(x: extent, y: y)); y += step }
|
||||
let line = SKShapeNode(path: path)
|
||||
line.strokeColor = Palette.grid.withAlphaComponent(0.18)
|
||||
line.lineWidth = 1
|
||||
grid.addChild(line)
|
||||
grid.zPosition = -100
|
||||
addChild(grid)
|
||||
}
|
||||
|
||||
private func addDowntown() {
|
||||
let glow = SKSpriteNode(texture: Textures.softCircle)
|
||||
glow.color = Palette.hub
|
||||
glow.colorBlendFactor = 1
|
||||
glow.blendMode = .add
|
||||
glow.setScale(1.1)
|
||||
glow.zPosition = -10
|
||||
glow.run(.repeatForever(.sequence([
|
||||
.fadeAlpha(to: 0.6, duration: 1.8),
|
||||
.fadeAlpha(to: 1.0, duration: 1.8),
|
||||
])))
|
||||
addChild(glow)
|
||||
|
||||
let core = SKShapeNode(circleOfRadius: 9)
|
||||
core.fillColor = Palette.hub
|
||||
core.strokeColor = .white
|
||||
core.glowWidth = 2
|
||||
addChild(core)
|
||||
|
||||
let label = SKLabelNode(text: "▸ this mac")
|
||||
label.fontName = "Menlo-Bold"
|
||||
label.fontSize = 13
|
||||
label.fontColor = Palette.hub
|
||||
label.verticalAlignmentMode = .top
|
||||
label.position = CGPoint(x: 0, y: -16)
|
||||
addChild(label)
|
||||
}
|
||||
|
||||
// MARK: - Live data
|
||||
|
||||
/// Render a tick of per-org traffic: ensure each district exists, decide
|
||||
/// which ones are busy enough to fan out, then dispatch cars accordingly.
|
||||
func apply(_ districts: [DistrictTraffic]) {
|
||||
// 1. Ensure hubs exist and learn their endpoints.
|
||||
for t in districts {
|
||||
let hub = building(forKey: t.key, title: t.title, subtitle: t.subtitle)
|
||||
hub.note(activeHosts: t.endpoints.map(\.host))
|
||||
hub.receive(bytes: Double(t.inBytes + t.outBytes))
|
||||
if let rtt = t.rttMs { repositionToLatency(key: t.key, rttMs: rtt) }
|
||||
}
|
||||
|
||||
// 2. Decide which districts fan out. Evaluate *every* known district
|
||||
// (not just this tick's active ones) so a TTL tail keeps idle hubs
|
||||
// open. A district needs ≥2 endpoints to have anything to expand.
|
||||
let bytesByKey = Dictionary(districts.map { ($0.key, $0.inBytes + $0.outBytes) },
|
||||
uniquingKeysWith: +)
|
||||
let entries = buildings.map { key, hub in
|
||||
ExpansionPolicy.District(key: key, bytes: bytesByKey[key] ?? 0, endpointCount: hub.knownHostCount)
|
||||
}
|
||||
let expanded = expansion.update(entries)
|
||||
|
||||
if ProcessInfo.processInfo.environment["NC_DEBUG"] != nil {
|
||||
let top = districts.sorted { ($0.inBytes + $0.outBytes) > ($1.inBytes + $1.outBytes) }.prefix(3)
|
||||
.map { "\($0.title)=\(($0.inBytes + $0.outBytes) / 1024)KB/ep\(buildings[$0.key]?.knownHostCount ?? 0)/\(expanded.contains($0.key) ? "EXP" : "—")" }
|
||||
.joined(separator: " ")
|
||||
var classKB: [TrafficClass: UInt64] = [:]
|
||||
for d in districts { for e in d.endpoints { for f in e.flows { classKB[f.cls, default: 0] += f.inBytes + f.outBytes } } }
|
||||
let mix = TrafficClass.allCases.compactMap { c in classKB[c].map { "\(c.rawValue):\($0 / 1024)KB" } }.joined(separator: " ")
|
||||
FileHandle.standardError.write(Data("[tick] \(top) | \(mix)\n".utf8))
|
||||
}
|
||||
|
||||
// 3. Apply expansion state to all hubs, then route this tick's cars.
|
||||
for (key, hub) in buildings { hub.setExpanded(expanded.contains(key)) }
|
||||
|
||||
for t in districts {
|
||||
guard let hub = buildings[t.key] else { continue }
|
||||
if expanded.contains(t.key) {
|
||||
// Per endpoint, per class: cars run the full Mac→hub→endpoint path.
|
||||
for e in t.endpoints {
|
||||
let dest = hub.worldPosition(forEndpoint: e.host)
|
||||
for f in e.flows {
|
||||
dispatchSpokeCars(carCount(f.inBytes), hub: hub.position, endpoint: dest, inbound: true, cls: f.cls, bytes: f.inBytes)
|
||||
dispatchSpokeCars(carCount(f.outBytes), hub: hub.position, endpoint: dest, inbound: false, cls: f.cls, bytes: f.outBytes)
|
||||
}
|
||||
if e.inBytes + e.outBytes > 0 { hub.pulseEndpoint(e.host) }
|
||||
}
|
||||
} else {
|
||||
// Collapsed: aggregate every endpoint's flows by class onto the hub.
|
||||
var byClass: [TrafficClass: (UInt64, UInt64)] = [:]
|
||||
for e in t.endpoints {
|
||||
for f in e.flows {
|
||||
let prior = byClass[f.cls] ?? (0, 0)
|
||||
byClass[f.cls] = (prior.0 + f.inBytes, prior.1 + f.outBytes)
|
||||
}
|
||||
}
|
||||
for (cls, bytes) in byClass {
|
||||
dispatchDirectCars(carCount(bytes.0), to: hub.position, inbound: true, cls: cls, bytes: bytes.0)
|
||||
dispatchDirectCars(carCount(bytes.1), to: hub.position, inbound: false, cls: cls, bytes: bytes.1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func carCount(_ bytes: UInt64) -> Int {
|
||||
guard bytes > 0 else { return 0 }
|
||||
return max(1, min(7, Int(Double(bytes) / 16_384))) // ~1 car / 16 KB, capped
|
||||
}
|
||||
|
||||
// MARK: - Buildings & roads
|
||||
|
||||
private func building(forKey key: String, title: String, subtitle: String) -> BuildingNode {
|
||||
if let existing = buildings[key] {
|
||||
existing.update(subtitle: subtitle)
|
||||
return existing
|
||||
}
|
||||
|
||||
let node = BuildingNode(title: title, subtitle: subtitle)
|
||||
node.position = placement(for: key, footprint: node.labelFootprint)
|
||||
node.alpha = 0
|
||||
node.run(.fadeIn(withDuration: 0.6))
|
||||
buildingLayer.addChild(node)
|
||||
buildings[key] = node
|
||||
|
||||
if ProcessInfo.processInfo.environment["NC_DEBUG"] != nil {
|
||||
let f = node.labelFootprint
|
||||
FileHandle.standardError.write(Data(String(format: "[place] %@ pos=(%.0f,%.0f) box=%.0fx%.0f\n",
|
||||
title, node.position.x, node.position.y, f.width, f.height).utf8))
|
||||
}
|
||||
|
||||
let path = CGMutablePath()
|
||||
path.move(to: .zero)
|
||||
path.addLine(to: node.position)
|
||||
let road = SKShapeNode(path: path)
|
||||
road.strokeColor = Palette.road.withAlphaComponent(0.55)
|
||||
road.lineWidth = 1.5
|
||||
road.zPosition = -20
|
||||
roadLayer.addChild(road)
|
||||
roads[key] = road
|
||||
|
||||
return node
|
||||
}
|
||||
|
||||
/// Glide a hub to the radius implied by its measured RTT, keeping its angle.
|
||||
/// The road (and selection ring, if attached) follow.
|
||||
private func repositionToLatency(key: String, rttMs: Double) {
|
||||
guard let building = buildings[key] else { return }
|
||||
let target = LatencyLayout.radius(forRTT: rttMs)
|
||||
if let current = latencyRadius[key], abs(current - target) < 18 { return }
|
||||
latencyRadius[key] = target
|
||||
|
||||
let angle = atan2(building.position.y, building.position.x)
|
||||
let dest = CGPoint(x: cos(angle) * Double(target), y: sin(angle) * Double(target))
|
||||
building.run(.move(to: dest, duration: 0.8))
|
||||
|
||||
if let road = roads[key] {
|
||||
let path = CGMutablePath()
|
||||
path.move(to: .zero)
|
||||
path.addLine(to: dest)
|
||||
road.path = path
|
||||
}
|
||||
if ringKey == key { selectionRing.run(.move(to: dest, duration: 0.8)) }
|
||||
}
|
||||
|
||||
/// Deterministic radial seed (angle + ring from the key's hash), then spiral
|
||||
/// out from there until the label box clears every existing district.
|
||||
private func placement(for key: String, footprint: CGRect) -> CGPoint {
|
||||
let h = stableHash(key)
|
||||
let baseAngle = Double(h % 3600) / 3600 * 2 * .pi
|
||||
let ring = Double((h >> 16) % 1000) / 1000
|
||||
let baseRadius = 165 + Double(ring) * Double(worldRadius - 165)
|
||||
|
||||
let existing = buildings.values.map {
|
||||
$0.labelFootprint.offsetBy(dx: $0.position.x, dy: $0.position.y)
|
||||
}
|
||||
return LayoutSolver.placeNonOverlapping(
|
||||
baseAngle: baseAngle, baseRadius: baseRadius,
|
||||
localRect: footprint, existing: existing, pad: 18
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Cars
|
||||
|
||||
private func dispatchDirectCars(_ count: Int, to dest: CGPoint, inbound: Bool, cls: TrafficClass, bytes: UInt64) {
|
||||
guard count > 0 else { return }
|
||||
let style = carStyle(cls, bytes: bytes)
|
||||
stagger(count) { [weak self] in
|
||||
guard let self else { return }
|
||||
let path = CGMutablePath()
|
||||
path.move(to: inbound ? dest : .zero)
|
||||
path.addLine(to: inbound ? .zero : dest)
|
||||
self.runCar(along: path, start: inbound ? dest : .zero, distance: hypot(dest.x, dest.y), style: style)
|
||||
}
|
||||
}
|
||||
|
||||
private func dispatchSpokeCars(_ count: Int, hub: CGPoint, endpoint: CGPoint, inbound: Bool, cls: TrafficClass, bytes: UInt64) {
|
||||
guard count > 0 else { return }
|
||||
let style = carStyle(cls, bytes: bytes)
|
||||
stagger(count) { [weak self] in
|
||||
guard let self else { return }
|
||||
let path = CGMutablePath()
|
||||
if inbound {
|
||||
path.move(to: endpoint); path.addLine(to: hub); path.addLine(to: .zero)
|
||||
} else {
|
||||
path.move(to: .zero); path.addLine(to: hub); path.addLine(to: endpoint)
|
||||
}
|
||||
let dist = hypot(hub.x, hub.y) + hypot(endpoint.x - hub.x, endpoint.y - hub.y)
|
||||
self.runCar(along: path, start: inbound ? endpoint : .zero, distance: dist, style: style)
|
||||
}
|
||||
}
|
||||
|
||||
/// Spreads `count` spawns across most of the interval so cars stream rather
|
||||
/// than appear all at once.
|
||||
private func stagger(_ count: Int, _ spawn: @escaping () -> Void) {
|
||||
guard count > 0 else { return }
|
||||
for i in 0..<count {
|
||||
let delay = Double(i) / Double(count) * 1.4
|
||||
run(.sequence([.wait(forDuration: delay), .run(spawn)]))
|
||||
}
|
||||
}
|
||||
|
||||
private func runCar(along path: CGPath, start: CGPoint, distance: CGFloat, style: CarStyle) {
|
||||
let car = SKSpriteNode(texture: Textures.softCircle)
|
||||
car.color = style.color
|
||||
car.colorBlendFactor = 1
|
||||
car.blendMode = .add
|
||||
car.xScale = style.scale * style.elongation
|
||||
car.yScale = style.scale
|
||||
car.position = start
|
||||
car.zPosition = 5
|
||||
carLayer.addChild(car)
|
||||
|
||||
let orient = style.elongation > 1.05 // streaks orient along their path
|
||||
let duration = Double((distance / style.speed).clamped(0.6, 2.8)) * Double.random(in: 0.85...1.15)
|
||||
car.run(.sequence([
|
||||
.group([
|
||||
.follow(path, asOffset: false, orientToPath: orient, duration: duration),
|
||||
.sequence([.fadeAlpha(to: 1.0, duration: duration * 0.15),
|
||||
.wait(forDuration: duration * 0.55),
|
||||
.fadeAlpha(to: 0.0, duration: duration * 0.3)]),
|
||||
]),
|
||||
.removeFromParent(),
|
||||
]))
|
||||
}
|
||||
|
||||
// MARK: - Pan, zoom & click (macOS)
|
||||
|
||||
override func scrollWheel(with event: NSEvent) {
|
||||
let factor = 1 - event.scrollingDeltaY * 0.006
|
||||
cam.setScale((cam.xScale * factor).clamped(0.45, 3.2))
|
||||
}
|
||||
|
||||
override func mouseDown(with event: NSEvent) {
|
||||
dragDistance = 0
|
||||
}
|
||||
|
||||
override func mouseDragged(with event: NSEvent) {
|
||||
dragDistance += abs(event.deltaX) + abs(event.deltaY)
|
||||
cam.position.x -= event.deltaX * cam.xScale
|
||||
cam.position.y += event.deltaY * cam.xScale
|
||||
}
|
||||
|
||||
override func mouseUp(with event: NSEvent) {
|
||||
// A near-stationary press is a click (select); a drag is a pan.
|
||||
guard dragDistance < 6, let view else { return }
|
||||
// Canonical, camera-correct window → view → scene conversion.
|
||||
let viewPoint = view.convert(event.locationInWindow, from: nil)
|
||||
onSelect?(districtKey(at: convertPoint(fromView: viewPoint)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import NetworkCityCore
|
||||
|
||||
/// One endpoint's live detail for the inspector panel.
|
||||
struct EndpointInfo: Identifiable {
|
||||
var id: String { host }
|
||||
let host: String
|
||||
let rdns: String?
|
||||
let downKBs: Double
|
||||
let upKBs: Double
|
||||
let topClass: TrafficClass
|
||||
let rttMs: Double?
|
||||
}
|
||||
|
||||
/// A live snapshot of one district, shown when its hub is clicked. Rebuilt each
|
||||
/// tick while selected so the panel updates in real time.
|
||||
struct OrgInspection: Identifiable {
|
||||
var id: String { key }
|
||||
let key: String
|
||||
let title: String
|
||||
let downKBs: Double
|
||||
let upKBs: Double
|
||||
let endpointCount: Int
|
||||
let endpoints: [EndpointInfo]
|
||||
let processes: [String]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
@main
|
||||
struct NetworkCityApp: App {
|
||||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate
|
||||
@StateObject private var controller = TrafficController()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup("NetworkCity") {
|
||||
CityView(controller: controller)
|
||||
.frame(minWidth: 900, minHeight: 620)
|
||||
.background(.black)
|
||||
}
|
||||
.windowStyle(.hiddenTitleBar)
|
||||
}
|
||||
}
|
||||
|
||||
/// Running as an SPM executable (no app bundle), so promote ourselves to a
|
||||
/// regular foreground app and grab focus on launch.
|
||||
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
NSApp.setActivationPolicy(.regular)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
}
|
||||
func applicationShouldTerminateAfterLastWindowClosed(_ app: NSApplication) -> Bool { true }
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import SwiftUI
|
||||
import NetworkCityCore
|
||||
|
||||
/// Owns the data feed, enriches it (real process names + reverse-DNS org
|
||||
/// grouping), and bridges per-district traffic to the scene on the main actor.
|
||||
@MainActor
|
||||
final class TrafficController: ObservableObject {
|
||||
let scene = CityScene(size: CGSize(width: 1200, height: 800))
|
||||
|
||||
@Published var downKBs: Double = 0
|
||||
@Published var upKBs: Double = 0
|
||||
@Published var districtCount: Int = 0
|
||||
@Published var live = false
|
||||
@Published var selection: OrgInspection?
|
||||
|
||||
private let source: ConnectionSource = NettopSource()
|
||||
private let differ = TrafficDiffer()
|
||||
private let namer = ProcessNamer()
|
||||
private let prober = LatencyProber()
|
||||
private let ownPID = Int32(ProcessInfo.processInfo.processIdentifier)
|
||||
|
||||
private var orgForHost: [String: Org] = [:] // resolved IP -> org
|
||||
private var pending: Set<String> = [] // resolutions in flight
|
||||
private var rttForHost: [String: Double] = [:] // measured RTT (ms)
|
||||
private var rttPending: Set<String> = []
|
||||
private var portForHost: [String: UInt16] = [:]
|
||||
private var task: Task<Void, Never>?
|
||||
|
||||
// Inspector state: enough per-org history to render the panel live.
|
||||
private var selectedKey: String?
|
||||
private var orgTitles: [String: String] = [:]
|
||||
private var orgEndpoints: [String: Set<String>] = [:] // all hosts ever seen per org
|
||||
private var orgProcesses: [String: Set<String>] = [:]
|
||||
private var lastEndpointBytes: [String: [String: [TrafficClass: (UInt64, UInt64)]]] = [:]
|
||||
private var lastInterval: TimeInterval = 2
|
||||
|
||||
func start() {
|
||||
guard task == nil else { return }
|
||||
scene.onSelect = { [weak self] key in
|
||||
MainActor.assumeIsolated { self?.select(key) }
|
||||
}
|
||||
if ProcessInfo.processInfo.environment["NC_DEMO"] != nil {
|
||||
startDemo()
|
||||
return
|
||||
}
|
||||
task = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
for await snap in source.snapshots(every: 2.0) {
|
||||
handle(snap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Offline demo: feeds a synthetic multi-endpoint district through the real
|
||||
/// scene path so the hub-and-spoke fan-out can be exercised without nettop.
|
||||
private func startDemo() {
|
||||
task = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
var tick = 0
|
||||
while !Task.isCancelled {
|
||||
tick += 1
|
||||
let classes: [TrafficClass] = [.https, .quic, .dns, .http, .other]
|
||||
let endpoints = (0..<8).map { i -> EndpointTraffic in
|
||||
let active = (i + tick) % 3 == 0
|
||||
let cls = classes[i % classes.count]
|
||||
let heavy = (cls == .https || cls == .quic) && i % 4 == 0
|
||||
return EndpointTraffic(host: "20.190.\(i).\(10 + i)", flows: [
|
||||
ClassFlow(cls: cls,
|
||||
inBytes: active ? (heavy ? 400_000 : 36_000) : 0,
|
||||
outBytes: active ? 12_000 : 0),
|
||||
])
|
||||
}
|
||||
let inB = endpoints.reduce(0) { $0 + $1.inBytes }
|
||||
let outB = endpoints.reduce(0) { $0 + $1.outBytes }
|
||||
scene.apply([DistrictTraffic(key: "onedrive", title: "OneDrive", subtitle: "OneDrive",
|
||||
inBytes: inB, outBytes: outB, endpoints: endpoints)])
|
||||
|
||||
// Keep the inspector maps live so the demo district is clickable.
|
||||
orgTitles["onedrive"] = "OneDrive"
|
||||
orgEndpoints["onedrive", default: []].formUnion(endpoints.map(\.host))
|
||||
orgProcesses["onedrive", default: []].insert("OneDrive")
|
||||
var bytesMap: [String: [TrafficClass: (UInt64, UInt64)]] = [:]
|
||||
for e in endpoints { for f in e.flows { bytesMap[e.host, default: [:]][f.cls] = (f.inBytes, f.outBytes) } }
|
||||
lastEndpointBytes["onedrive"] = bytesMap
|
||||
if let key = selectedKey { selection = buildInspection(key) }
|
||||
// Verification hook: auto-open the inspector in debug runs.
|
||||
if tick == 3, ProcessInfo.processInfo.environment["NC_DEBUG"] != nil { select("onedrive") }
|
||||
|
||||
downKBs = Double(inB) / 1024 / 2
|
||||
upKBs = Double(outB) / 1024 / 2
|
||||
districtCount = scene.buildingCount
|
||||
live = true
|
||||
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stop() { task?.cancel(); task = nil }
|
||||
|
||||
private func handle(_ snapshot: ConnectionSnapshot) {
|
||||
// Exclude our own traffic so we don't render our latency probes.
|
||||
let deltas = differ.ingest(snapshot).filter { $0.connection.isExternal && $0.connection.pid != ownPID }
|
||||
|
||||
// Group resolved hosts by org; kick off resolution for the rest. A
|
||||
// brand-new host shows up one tick late, which is fine — traffic is
|
||||
// continuous, and this avoids spawning an IP building we'd have to merge.
|
||||
var districts: [String: DistrictTraffic] = [:]
|
||||
// orgKey -> host -> class -> (in, out)
|
||||
var endpoints: [String: [String: [TrafficClass: (UInt64, UInt64)]]] = [:]
|
||||
for d in deltas {
|
||||
let host = d.connection.remote.host
|
||||
guard let org = orgForHost[host] else { resolve(host); continue }
|
||||
|
||||
let proc = namer.name(for: d.connection.pid) ?? d.connection.processName
|
||||
var district = districts[org.key]
|
||||
?? DistrictTraffic(key: org.key, title: org.name, subtitle: proc,
|
||||
inBytes: 0, outBytes: 0, endpoints: [])
|
||||
district.inBytes += d.bytesInDelta
|
||||
district.outBytes += d.bytesOutDelta
|
||||
district.subtitle = proc
|
||||
districts[org.key] = district
|
||||
|
||||
let cls = TrafficClass.classify(proto: d.connection.proto, port: d.connection.remote.port)
|
||||
var hosts = endpoints[org.key] ?? [:]
|
||||
var classes = hosts[host] ?? [:]
|
||||
let prior = classes[cls] ?? (0, 0)
|
||||
classes[cls] = (prior.0 + d.bytesInDelta, prior.1 + d.bytesOutDelta)
|
||||
hosts[host] = classes
|
||||
endpoints[org.key] = hosts
|
||||
|
||||
orgEndpoints[org.key, default: []].insert(host)
|
||||
if let p = d.connection.remote.port, p > 0 { portForHost[host] = UInt16(p) }
|
||||
probeIfNeeded(host)
|
||||
}
|
||||
let rendered = districts.map { key, d -> DistrictTraffic in
|
||||
var d = d
|
||||
d.endpoints = (endpoints[key] ?? [:]).map { host, classMap in
|
||||
EndpointTraffic(host: host, flows: classMap.map {
|
||||
ClassFlow(cls: $0.key, inBytes: $0.value.0, outBytes: $0.value.1)
|
||||
})
|
||||
}
|
||||
// Distance = closest measured edge of this org (the nearest server you're served from).
|
||||
d.rttMs = (orgEndpoints[key] ?? []).compactMap { rttForHost[$0] }.min()
|
||||
return d
|
||||
}
|
||||
scene.apply(rendered)
|
||||
|
||||
// Retain per-org detail for the inspector panel.
|
||||
lastInterval = deltas.first?.interval ?? 2
|
||||
lastEndpointBytes = endpoints
|
||||
for d in rendered {
|
||||
orgTitles[d.key] = d.title
|
||||
orgProcesses[d.key, default: []].insert(d.subtitle)
|
||||
}
|
||||
if let key = selectedKey { selection = buildInspection(key) }
|
||||
|
||||
// HUD totals reflect *all* external traffic, resolved or not, so the
|
||||
// throughput numbers stay honest while districts are still resolving.
|
||||
downKBs = deltas.reduce(0) { $0 + $1.inBytesPerSec } / 1024
|
||||
upKBs = deltas.reduce(0) { $0 + $1.outBytesPerSec } / 1024
|
||||
districtCount = scene.buildingCount
|
||||
live = true
|
||||
}
|
||||
|
||||
// MARK: - Inspector
|
||||
|
||||
func select(_ key: String?) {
|
||||
selectedKey = key
|
||||
scene.highlight(key: key)
|
||||
selection = key.flatMap(buildInspection)
|
||||
if let s = selection, ProcessInfo.processInfo.environment["NC_DEBUG"] != nil {
|
||||
FileHandle.standardError.write(Data("[inspect] \(s.title): \(s.endpointCount) pts ▼\(Int(s.downKBs)) ▲\(Int(s.upKBs)) KB/s top=\(s.endpoints.first?.host ?? "-")\n".utf8))
|
||||
}
|
||||
}
|
||||
|
||||
func deselect() { select(nil) }
|
||||
|
||||
private func buildInspection(_ key: String) -> OrgInspection {
|
||||
let hosts = orgEndpoints[key] ?? []
|
||||
var infos: [EndpointInfo] = []
|
||||
var totalDown = 0.0, totalUp = 0.0
|
||||
|
||||
for host in hosts {
|
||||
let classMap = lastEndpointBytes[key]?[host] ?? [:]
|
||||
var down = 0.0, up = 0.0
|
||||
var top: (cls: TrafficClass, bytes: UInt64)?
|
||||
for (cls, bytes) in classMap {
|
||||
down += Double(bytes.0) / lastInterval / 1024
|
||||
up += Double(bytes.1) / lastInterval / 1024
|
||||
let total = bytes.0 + bytes.1
|
||||
if top == nil || total > top!.bytes { top = (cls, total) }
|
||||
}
|
||||
totalDown += down
|
||||
totalUp += up
|
||||
infos.append(EndpointInfo(host: host, rdns: orgForHost[host]?.resolvedHost,
|
||||
downKBs: down, upKBs: up, topClass: top?.cls ?? .other,
|
||||
rttMs: rttForHost[host]))
|
||||
}
|
||||
infos.sort { ($0.downKBs + $0.upKBs) > ($1.downKBs + $1.upKBs) }
|
||||
|
||||
return OrgInspection(
|
||||
key: key, title: orgTitles[key] ?? key,
|
||||
downKBs: totalDown, upKBs: totalUp,
|
||||
endpointCount: hosts.count, endpoints: infos,
|
||||
processes: (orgProcesses[key] ?? []).sorted()
|
||||
)
|
||||
}
|
||||
|
||||
/// Measure RTT to a newly-seen endpoint once (cached), then store it so the
|
||||
/// next tick can glide its district to the right distance.
|
||||
private func probeIfNeeded(_ host: String) {
|
||||
guard rttForHost[host] == nil, !rttPending.contains(host),
|
||||
let port = portForHost[host] else { return }
|
||||
rttPending.insert(host)
|
||||
Task { [prober] in
|
||||
// Unreachable (firewalled) endpoints read as "far" rather than missing.
|
||||
let rtt = await prober.measure(host: host, port: port) ?? LatencyLayout.rttCeiling
|
||||
rttForHost[host] = rtt
|
||||
rttPending.remove(host)
|
||||
if ProcessInfo.processInfo.environment["NC_DEBUG"] != nil {
|
||||
FileHandle.standardError.write(Data("[rtt] \(host):\(port) → \(Int(rtt))ms\n".utf8))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a host's org off the main thread, then store it back on the main
|
||||
/// actor. Reverse DNS can block for seconds, so it runs detached.
|
||||
private func resolve(_ host: String) {
|
||||
guard orgForHost[host] == nil, pending.insert(host).inserted else { return }
|
||||
Task {
|
||||
let org = await Task.detached(priority: .utility) {
|
||||
let ptr = ReverseDNS.resolve(host)
|
||||
return OrgClassifier.classify(ip: host, ptr: ptr)
|
||||
}.value
|
||||
orgForHost[host] = org
|
||||
pending.remove(host)
|
||||
if ProcessInfo.processInfo.environment["NC_DEBUG"] != nil {
|
||||
FileHandle.standardError.write(Data("[org] \(host) → \(org.name) (\(org.resolvedHost ?? "no-ptr"))\n".utf8))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import SpriteKit
|
||||
import NetworkCityCore
|
||||
|
||||
/// How a car of light looks for a given traffic class + magnitude.
|
||||
struct CarStyle {
|
||||
let color: SKColor
|
||||
let scale: CGFloat
|
||||
let elongation: CGFloat // 1 = round dot; >1 = streak along motion
|
||||
let speed: CGFloat // points/sec
|
||||
}
|
||||
|
||||
/// Colour per traffic class — direction is read from motion, so hue is free to
|
||||
/// mean protocol.
|
||||
enum TrafficPalette {
|
||||
static func color(_ c: TrafficClass) -> SKColor {
|
||||
switch c {
|
||||
case .dns: return SKColor(red: 1.00, green: 0.82, blue: 0.25, alpha: 1) // gold
|
||||
case .https: return SKColor(red: 0.22, green: 0.88, blue: 1.00, alpha: 1) // cyan
|
||||
case .http: return SKColor(red: 1.00, green: 0.48, blue: 0.24, alpha: 1) // orange
|
||||
case .quic: return SKColor(red: 0.71, green: 0.48, blue: 1.00, alpha: 1) // violet
|
||||
case .other: return SKColor(red: 0.55, green: 0.60, blue: 0.72, alpha: 1) // slate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Translate a class + this-tick byte volume into a car's personality. Heavy
|
||||
/// flows become "freight": bigger, slower, stretched into a glowing comet.
|
||||
func carStyle(_ cls: TrafficClass, bytes: UInt64) -> CarStyle {
|
||||
let heavy = bytes > 196_608 // ~96 KB/s over a 2s tick
|
||||
var scale: CGFloat
|
||||
var elongation: CGFloat = 1
|
||||
var speed: CGFloat = 230
|
||||
|
||||
switch cls {
|
||||
case .dns: scale = 0.055; speed = 360 // tiny, fast sparks
|
||||
case .https: scale = 0.085
|
||||
case .http: scale = 0.085
|
||||
case .quic: scale = 0.085; elongation = 2.4; speed = 285 // streaks
|
||||
case .other: scale = 0.065
|
||||
}
|
||||
|
||||
if heavy {
|
||||
scale *= 1.7
|
||||
elongation = max(elongation, 3.0) // freight comet
|
||||
speed *= 0.7 // slow and heavy
|
||||
}
|
||||
return CarStyle(color: TrafficPalette.color(cls), scale: scale, elongation: elongation, speed: speed)
|
||||
}
|
||||
|
||||
/// Neon-night palette. Bright, saturated colors on near-black so additive
|
||||
/// blending reads as glow.
|
||||
enum Palette {
|
||||
static let background = SKColor(red: 0.039, green: 0.055, blue: 0.102, alpha: 1) // #0a0e1a
|
||||
static let grid = SKColor(red: 0.13, green: 0.20, blue: 0.38, alpha: 1)
|
||||
static let hub = SKColor(red: 1.00, green: 0.85, blue: 0.62, alpha: 1) // warm downtown
|
||||
static let building = SKColor(red: 0.50, green: 0.82, blue: 0.78, alpha: 1) // teal
|
||||
static let road = SKColor(red: 0.17, green: 0.26, blue: 0.46, alpha: 1)
|
||||
static let download = SKColor(red: 0.22, green: 0.88, blue: 1.00, alpha: 1) // cyan, inbound
|
||||
static let upload = SKColor(red: 1.00, green: 0.62, blue: 0.30, alpha: 1) // amber, outbound
|
||||
}
|
||||
|
||||
extension CGFloat {
|
||||
func clamped(_ lo: CGFloat, _ hi: CGFloat) -> CGFloat { Swift.min(Swift.max(self, lo), hi) }
|
||||
}
|
||||
|
||||
enum Textures {
|
||||
/// A white radial-gradient disc (opaque center → transparent edge). Tint it
|
||||
/// per-node with `colorBlendFactor` + `.add` blend mode to get a glow.
|
||||
// Immutable after creation and only read; safe to share across actors.
|
||||
nonisolated(unsafe) static let softCircle: SKTexture = makeSoftCircle(diameter: 128)
|
||||
|
||||
private static func makeSoftCircle(diameter: Int) -> SKTexture {
|
||||
let space = CGColorSpaceCreateDeviceRGB()
|
||||
let ctx = CGContext(
|
||||
data: nil, width: diameter, height: diameter,
|
||||
bitsPerComponent: 8, bytesPerRow: 0, space: space,
|
||||
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
||||
)!
|
||||
let colors = [
|
||||
SKColor.white.cgColor,
|
||||
SKColor.white.withAlphaComponent(0).cgColor,
|
||||
] as CFArray
|
||||
let gradient = CGGradient(colorsSpace: space, colors: colors, locations: [0, 1])!
|
||||
let center = CGPoint(x: diameter / 2, y: diameter / 2)
|
||||
ctx.drawRadialGradient(
|
||||
gradient, startCenter: center, startRadius: 0,
|
||||
endCenter: center, endRadius: CGFloat(diameter) / 2, options: []
|
||||
)
|
||||
return SKTexture(cgImage: ctx.makeImage()!)
|
||||
}
|
||||
}
|
||||
|
||||
/// Deterministic FNV-1a hash so a given host always lands in the same place,
|
||||
/// independent of Swift's per-process `Hasher` seed.
|
||||
func stableHash(_ s: String) -> UInt64 {
|
||||
var h: UInt64 = 1469598103934665603
|
||||
for byte in s.utf8 { h = (h ^ UInt64(byte)) &* 1099511628211 }
|
||||
return h
|
||||
}
|
||||
Reference in New Issue
Block a user