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 = [] 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) } }