02f48ddd56
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>
227 lines
8.0 KiB
Swift
227 lines
8.0 KiB
Swift
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 activity: 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: bump the (decaying) activity level, grow
|
|
/// toward a size set by it, and pulse so the eye catches the activity.
|
|
func receive(bytes: Double) {
|
|
activity += bytes
|
|
updateGlowSize()
|
|
|
|
core.removeAction(forKey: "pulse")
|
|
core.run(.sequence([
|
|
.scale(to: 1.9, duration: 0.12),
|
|
.scale(to: 1.0, duration: 0.35),
|
|
]), 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.
|
|
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)
|
|
}
|
|
}
|