Add idle district lifecycle: decay then reap
The map only ever gained density: hubs were sized by lifetime-cumulative bytes (so they never shrank) and were never removed. Now each hub has a decaying activity level — quiet districts visibly shrink toward a dormant dot — and after a configurable idle timeout (~40s, NC_REAP_TICKS) the hub is demolished: faded out, removed with its road, and its per-org state pruned in the controller. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -39,7 +39,7 @@ final class BuildingNode: SKNode {
|
|||||||
private let core: SKShapeNode
|
private let core: SKShapeNode
|
||||||
private let title: SKLabelNode
|
private let title: SKLabelNode
|
||||||
private let subtitle: SKLabelNode
|
private let subtitle: SKLabelNode
|
||||||
private var cumulative: Double = 0
|
private var activity: Double = 0
|
||||||
|
|
||||||
private let spokeLayer = SKNode()
|
private let spokeLayer = SKNode()
|
||||||
private let endpointLayer = SKNode()
|
private let endpointLayer = SKNode()
|
||||||
@@ -106,13 +106,11 @@ final class BuildingNode: SKNode {
|
|||||||
|
|
||||||
func note(activeHosts: [String]) { knownHosts.formUnion(activeHosts) }
|
func note(activeHosts: [String]) { knownHosts.formUnion(activeHosts) }
|
||||||
|
|
||||||
/// React to a tick of traffic: grow toward a size set by total volume, and
|
/// React to a tick of traffic: bump the (decaying) activity level, grow
|
||||||
/// pulse so the eye catches the activity.
|
/// toward a size set by it, and pulse so the eye catches the activity.
|
||||||
func receive(bytes: Double) {
|
func receive(bytes: Double) {
|
||||||
cumulative += bytes
|
activity += bytes
|
||||||
let target = (0.30 + CGFloat(log10(cumulative + 1)) * 0.09).clamped(0.30, 1.05)
|
updateGlowSize()
|
||||||
glow.removeAction(forKey: "resize")
|
|
||||||
glow.run(.scale(to: target, duration: 0.6), withKey: "resize")
|
|
||||||
|
|
||||||
core.removeAction(forKey: "pulse")
|
core.removeAction(forKey: "pulse")
|
||||||
core.run(.sequence([
|
core.run(.sequence([
|
||||||
@@ -121,6 +119,23 @@ final class BuildingNode: SKNode {
|
|||||||
]), withKey: "pulse")
|
]), 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
|
// MARK: - Expansion
|
||||||
|
|
||||||
/// The capped, stably-ordered list of endpoints we actually draw.
|
/// The capped, stably-ordered list of endpoints we actually draw.
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ final class CityScene: SKScene {
|
|||||||
/// Called with a district key when a hub is clicked, or nil when empty space
|
/// Called with a district key when a hub is clicked, or nil when empty space
|
||||||
/// is clicked (deselect).
|
/// is clicked (deselect).
|
||||||
var onSelect: ((String?) -> Void)?
|
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 let selectionRing = SKShapeNode(circleOfRadius: 24)
|
||||||
private var dragDistance: CGFloat = 0
|
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
|
/// Render a tick of per-org traffic: ensure each district exists, decide
|
||||||
/// which ones are busy enough to fan out, then dispatch cars accordingly.
|
/// which ones are busy enough to fan out, then dispatch cars accordingly.
|
||||||
func apply(_ districts: [DistrictTraffic]) {
|
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.
|
// 1. Ensure hubs exist and learn their endpoints.
|
||||||
for t in districts {
|
for t in districts {
|
||||||
let hub = building(forKey: t.key, title: t.title, subtitle: t.subtitle)
|
let hub = building(forKey: t.key, title: t.title, subtitle: t.subtitle)
|
||||||
|
hub.idleTicks = 0
|
||||||
hub.note(activeHosts: t.endpoints.map(\.host))
|
hub.note(activeHosts: t.endpoints.map(\.host))
|
||||||
hub.receive(bytes: Double(t.inBytes + t.outBytes))
|
hub.receive(bytes: Double(t.inBytes + t.outBytes))
|
||||||
if let rtt = t.rttMs { repositionToLatency(key: t.key, rttMs: rtt) }
|
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 {
|
private func carCount(_ bytes: UInt64) -> Int {
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ final class TrafficController: ObservableObject {
|
|||||||
scene.onSelect = { [weak self] key in
|
scene.onSelect = { [weak self] key in
|
||||||
MainActor.assumeIsolated { self?.select(key) }
|
MainActor.assumeIsolated { self?.select(key) }
|
||||||
}
|
}
|
||||||
|
scene.onReap = { [weak self] key in
|
||||||
|
MainActor.assumeIsolated { self?.prune(key) }
|
||||||
|
}
|
||||||
if ProcessInfo.processInfo.environment["NC_DEMO"] != nil {
|
if ProcessInfo.processInfo.environment["NC_DEMO"] != nil {
|
||||||
startDemo()
|
startDemo()
|
||||||
return
|
return
|
||||||
@@ -175,6 +178,17 @@ final class TrafficController: ObservableObject {
|
|||||||
|
|
||||||
func deselect() { select(nil) }
|
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 {
|
private func buildInspection(_ key: String) -> OrgInspection {
|
||||||
let hosts = orgEndpoints[key] ?? []
|
let hosts = orgEndpoints[key] ?? []
|
||||||
var infos: [EndpointInfo] = []
|
var infos: [EndpointInfo] = []
|
||||||
|
|||||||
Reference in New Issue
Block a user