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 targetPos: [String: CGPoint] = [:] // where each hub is heading 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)? /// Called when a district is demolished, so the controller can prune its state. var onReap: ((String) -> Void)? /// Demolish a district after this many idle ticks (~2s each). Env-tunable for testing. private let reapAfterTicks = Int(ProcessInfo.processInfo.environment["NC_REAP_TICKS"] ?? "") ?? 20 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]) { // 0. Age every hub: decay its glow and count idle ticks. Active hubs // reset below; dead ones get reaped at the end of the tick. for (_, hub) in buildings { hub.decay() hub.idleTicks += 1 } // 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.idleTicks = 0 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) } } } // 4. Demolish districts that have been idle too long. for key in buildings.keys where (buildings[key]?.idleTicks ?? 0) > reapAfterTicks { reap(key) } } /// Fade out and remove a dead district, clearing all of its scene state. private func reap(_ key: String) { buildings[key]?.run(.sequence([.fadeOut(withDuration: 0.7), .removeFromParent()])) roads[key]?.run(.sequence([.fadeOut(withDuration: 0.7), .removeFromParent()])) buildings[key] = nil roads[key] = nil latencyRadius[key] = nil targetPos[key] = nil if ringKey == key { highlight(key: nil); onSelect?(nil) } onReap?(key) if ProcessInfo.processInfo.environment["NC_DEBUG"] != nil { FileHandle.standardError.write(Data("[reap] \(key)\n".utf8)) } } 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) targetPos[key] = node.position 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 // Keep distance = latency, but rotate/nudge the (arbitrary) angle to // clear other labels — otherwise gliding hubs land on top of each other. let angle = atan2(building.position.y, building.position.x) let others = buildings.compactMap { (k, b) -> CGRect? in guard k != key else { return nil } let p = targetPos[k] ?? b.position return b.labelFootprint.offsetBy(dx: p.x, dy: p.y) } let dest = LayoutSolver.placeNonOverlapping( baseAngle: angle, baseRadius: Double(target), localRect: building.labelFootprint, existing: others, pad: 18 ) targetPos[key] = dest 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.map { (k, b) in let p = targetPos[k] ?? b.position return b.labelFootprint.offsetBy(dx: p.x, dy: p.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.. 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))) } }