using Nonemm.Core; namespace Nonemm.Spotting; /// The stations on the band, in frequency order. A spot ages out after the /// timeout because a bandmap is a picture of the last hour or so, and a stale /// spot costs a move to an empty frequency. /// /// Age is counted from when the spot arrived, not from the time written in it. /// A node with a wrong clock, or one replaying its backlog, would otherwise /// empty the bandmap as fast as it filled it. /// /// Spots arrive on the cluster's thread and are read on the window's, so every /// method locks. The `Changed` event is raised outside the lock, on whichever /// thread made the change. public sealed class Bandmap { private readonly Dictionary spots = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary arrived = new(StringComparer.OrdinalIgnoreCase); private readonly Lock guard = new(); private readonly Func clock; /// The lists `On` and `All` hand out, kept until the next change. The /// windows ask on every refresh, a refresh can come many times a second /// while the spots change once a second, and each answer is a scan and a /// sort of every spot. private readonly Dictionary> onBand = []; private IReadOnlyList? everything; public Bandmap(TimeSpan? lifetime = null, Func? clock = null) { Lifetime = lifetime ?? TimeSpan.FromHours(1); this.clock = clock ?? (() => DateTime.UtcNow); } /// How long a spot stays on the map when `LifetimeFor` is not set. public TimeSpan Lifetime { get; set; } /// How long a given spot stays on the map, for a different time in each /// mode. N1MM asks for one number in minutes, on the telnet window's /// Filters tab. public Func? LifetimeFor { get; set; } public event EventHandler? Changed; public void Add(Spot spot) { string key = Key(spot); lock (guard) { spots[key] = spot; arrived[key] = clock(); Forget(); } Changed?.Invoke(this, EventArgs.Empty); } /// A batch of spots in one go, so a busy node redraws the windows once /// rather than once a spot. N1MM collects arriving spots the same way. public void AddRange(IEnumerable spots) { int added = 0; lock (guard) { foreach (Spot spot in spots) { string key = Key(spot); this.spots[key] = spot; arrived[key] = clock(); added++; } Forget(); } if (added > 0) { Changed?.Invoke(this, EventArgs.Empty); } } public void Remove(Callsign call, Band band) { string key = $"{call.Text}|{band.Name}"; bool removed; lock (guard) { arrived.Remove(key); removed = spots.Remove(key); Forget(); } if (removed) { Changed?.Invoke(this, EventArgs.Empty); } } public void DropOlderThan(DateTime nowUtc) { int dropped; lock (guard) { List stale = spots .Where(pair => nowUtc - arrived.GetValueOrDefault(pair.Key, nowUtc) > (LifetimeFor?.Invoke(pair.Value) ?? Lifetime)) .Select(pair => pair.Key) .ToList(); foreach (string key in stale) { spots.Remove(key); arrived.Remove(key); } dropped = stale.Count; if (dropped > 0) { Forget(); } } if (dropped > 0) { Changed?.Invoke(this, EventArgs.Empty); } } /// How long the spot has left before it drops off the map, or null for a /// spot that is not on it. public TimeSpan? Remaining(Spot spot) { string key = Key(spot); lock (guard) { return arrived.TryGetValue(key, out DateTime at) ? (LifetimeFor?.Invoke(spot) ?? Lifetime) - (clock() - at) : null; } } /// Called under the lock by everything that changes the spots. private void Forget() { onBand.Clear(); everything = null; } public IReadOnlyList On(Band band) { lock (guard) { if (!onBand.TryGetValue(band, out IReadOnlyList? found)) { found = spots.Values .Where(s => s.Band == band) .OrderBy(s => s.Frequency.Hertz) .ToList(); onBand[band] = found; } return found; } } public IReadOnlyList All() { lock (guard) { return everything ??= spots.Values.OrderBy(s => s.Frequency.Hertz).ToList(); } } /// The spot nearest the frequency, within the window either side. Used when /// the radio lands on a spot so the entry window can fill the call in. public Spot? Near(Frequency frequency, Frequency window) { lock (guard) { Spot? best = null; long bestDistance = long.MaxValue; // only the band the frequency is in is searched, which is a few // hundred spots rather than all of them IEnumerable candidates = Bands.ForFrequency(frequency) is { } band ? On(band) : spots.Values; foreach (Spot spot in candidates) { long distance = Math.Abs(spot.Frequency.Hertz - frequency.Hertz); if (distance <= window.Hertz && distance < bestDistance) { best = spot; bestDistance = distance; } } return best; } } /// One spot per station per band: a station that moves within the band keeps /// one entry rather than leaving a trail. private static string Key(Spot spot) => $"{spot.Call.Text}|{spot.Band?.Name}"; }