2026-06-13 08:43:32 -07:00
|
|
|
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] = [:]
|
2026-06-14 10:46:53 -07:00
|
|
|
private var targetPos: [String: CGPoint] = [:] // where each hub is heading
|
2026-06-13 08:43:32 -07:00
|
|
|
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)?
|
|
|
|
|
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]) {
|
|
|
|
|
// 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.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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)
|
2026-06-14 10:46:53 -07:00
|
|
|
targetPos[key] = node.position
|
2026-06-13 08:43:32 -07:00
|
|
|
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
|
|
|
|
|
|
2026-06-14 10:46:53 -07:00
|
|
|
// Keep distance = latency, but rotate/nudge the (arbitrary) angle to
|
|
|
|
|
// clear other labels — otherwise gliding hubs land on top of each other.
|
2026-06-13 08:43:32 -07:00
|
|
|
let angle = atan2(building.position.y, building.position.x)
|
2026-06-14 10:46:53 -07:00
|
|
|
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
|
2026-06-13 08:43:32 -07:00
|
|
|
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)
|
|
|
|
|
|
2026-06-14 10:46:53 -07:00
|
|
|
let existing = buildings.map { (k, b) in
|
|
|
|
|
let p = targetPos[k] ?? b.position
|
|
|
|
|
return b.labelFootprint.offsetBy(dx: p.x, dy: p.y)
|
2026-06-13 08:43:32 -07:00
|
|
|
}
|
|
|
|
|
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..<count {
|
|
|
|
|
let delay = Double(i) / Double(count) * 1.4
|
|
|
|
|
run(.sequence([.wait(forDuration: delay), .run(spawn)]))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func runCar(along path: CGPath, start: CGPoint, distance: CGFloat, style: CarStyle) {
|
|
|
|
|
let car = SKSpriteNode(texture: Textures.softCircle)
|
|
|
|
|
car.color = style.color
|
|
|
|
|
car.colorBlendFactor = 1
|
|
|
|
|
car.blendMode = .add
|
|
|
|
|
car.xScale = style.scale * style.elongation
|
|
|
|
|
car.yScale = style.scale
|
|
|
|
|
car.position = start
|
|
|
|
|
car.zPosition = 5
|
|
|
|
|
carLayer.addChild(car)
|
|
|
|
|
|
|
|
|
|
let orient = style.elongation > 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)))
|
|
|
|
|
}
|
|
|
|
|
}
|