diff --git a/Sources/NetworkCityApp/BuildingNode.swift b/Sources/NetworkCityApp/BuildingNode.swift index 3b0f9d8..d0493d0 100644 --- a/Sources/NetworkCityApp/BuildingNode.swift +++ b/Sources/NetworkCityApp/BuildingNode.swift @@ -39,7 +39,7 @@ final class BuildingNode: SKNode { private let core: SKShapeNode private let title: SKLabelNode private let subtitle: SKLabelNode - private var cumulative: Double = 0 + private var activity: Double = 0 private let spokeLayer = SKNode() private let endpointLayer = SKNode() @@ -106,13 +106,11 @@ final class BuildingNode: SKNode { 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. + /// React to a tick of traffic: bump the (decaying) activity level, grow + /// toward a size set by it, 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") + activity += bytes + updateGlowSize() core.removeAction(forKey: "pulse") core.run(.sequence([ @@ -121,6 +119,23 @@ final class BuildingNode: SKNode { ]), withKey: "pulse") } + /// Called every tick. Activity decays so quiet hubs visibly shrink toward + /// dormant instead of staying bright forever. + func decay(factor: Double = 0.55) { + guard activity > 0 else { return } + activity *= factor + updateGlowSize() + } + + /// Ticks since this hub last carried traffic — used to reap dead districts. + var idleTicks = 0 + + private func updateGlowSize() { + let target = (0.20 + CGFloat(log10(activity + 1)) * 0.085).clamped(0.20, 1.05) + glow.removeAction(forKey: "resize") + glow.run(.scale(to: target, duration: 0.6), withKey: "resize") + } + // MARK: - Expansion /// The capped, stably-ordered list of endpoints we actually draw. diff --git a/Sources/NetworkCityApp/CityScene.swift b/Sources/NetworkCityApp/CityScene.swift index 2bece15..c21134f 100644 --- a/Sources/NetworkCityApp/CityScene.swift +++ b/Sources/NetworkCityApp/CityScene.swift @@ -54,6 +54,10 @@ final class CityScene: SKScene { /// 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 @@ -160,9 +164,17 @@ final class CityScene: SKScene { /// 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) } @@ -218,6 +230,26 @@ final class CityScene: SKScene { } } } + + // 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 { diff --git a/Sources/NetworkCityApp/TrafficController.swift b/Sources/NetworkCityApp/TrafficController.swift index 19f0d1f..3118174 100644 --- a/Sources/NetworkCityApp/TrafficController.swift +++ b/Sources/NetworkCityApp/TrafficController.swift @@ -39,6 +39,9 @@ final class TrafficController: ObservableObject { scene.onSelect = { [weak self] key in MainActor.assumeIsolated { self?.select(key) } } + scene.onReap = { [weak self] key in + MainActor.assumeIsolated { self?.prune(key) } + } if ProcessInfo.processInfo.environment["NC_DEMO"] != nil { startDemo() return @@ -175,6 +178,17 @@ final class TrafficController: ObservableObject { func deselect() { select(nil) } + /// A district was demolished — drop its rendering/inspection state. The + /// resolution + RTT caches are kept (small, bounded by distinct hosts, and + /// save rework if the district comes back). + private func prune(_ key: String) { + orgEndpoints[key] = nil + orgProcesses[key] = nil + orgTitles[key] = nil + lastEndpointBytes[key] = nil + if selectedKey == key { selectedKey = nil; selection = nil } + } + private func buildInspection(_ key: String) -> OrgInspection { let hosts = orgEndpoints[key] ?? [] var infos: [EndpointInfo] = []