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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user