diff --git a/docs/design/ais-zoom-gated-subscription.md b/docs/design/ais-zoom-gated-subscription.md new file mode 100644 index 0000000..cadd567 --- /dev/null +++ b/docs/design/ais-zoom-gated-subscription.md @@ -0,0 +1,200 @@ +# AIS zoom-gated subscription + +> Status: implemented
+> Specs touched: [PR-D3 AIS overlay (#142)](https://github.com/philliphoff/EncDotNet.S100/pull/142), [PR-D4 dynamic-source pick (#147)](https://github.com/philliphoff/EncDotNet.S100/pull/147)
+> Companion docs: [AIS dynamic feature source](ais-source.md), [Dynamic feature sources](dynamic-feature-source.md) + +## Problem + +PR-D3 wired the AIS overlay end-to-end. With the overlay enabled and an +aisstream.io API key in scope, the source subscribes **immediately on +viewer startup** with whatever bbox the persisted `InitialArea` +contains — which, for a fresh user, is `null` (i.e. the world). That +behaviour has three problems: + +1. **Visual.** A globe-wide AIS feed paints thousands of vessel + symbols on the cold-start map, almost none of which correspond to + anywhere the user is looking. +2. **Quota.** aisstream.io is a free service; pulling the world feed + when you only care about, say, the English Channel wastes their + bandwidth and ours. +3. **Performance.** First-frame render time scales with the live + vessel count. + +The existing `AisDynamicFeatureSource.UpdateArea(BoundingBox?)` and +`AisStreamIoSubscription.TryUpdateArea` have been plumbed since PR-D3 +but **are not called from the viewer today**. We want the AIS +subscription to (a) not start until the visible map is meaningfully +smaller than "the world", and (b) thereafter track the live viewport +so the wire bbox always matches what the user can see. + +## Decisions + +### Q1. Activation criterion — zoom level or viewport area? + +**Decision: viewport bounding-box span in degrees.** + +aisstream.io's spatial filter *is* a lat/lon bounding box, so a +"trip when both lat-span and lon-span are ≤ T" rule maps 1:1 to "the +filter we'd send wouldn't be insane." We use both spans (logical AND) +rather than area because area collapses an extremely-wide-and-thin +viewport into the same number as a square one, and the wide-and-thin +case still produces a globe-spanning longitude filter. + +Default threshold: **50°**. That excludes the cold-start global view +(typically 360° × 170° on a fresh viewer) and admits any reasonable +regional view (e.g. North Sea ~10° × 6°, Mediterranean ~40° × 14°). + +### Q2. Should the gate also drive the area filter? + +**Decision: yes — the gate trip seeds the subscription with the live +viewport bbox, and subsequent viewport changes call `UpdateArea` +(debounced 250 ms).** + +Subscribing to "the world" the moment the user zooms past the +threshold would just kick the original problem one click down the +road. Now that we have a viewport listener for the gate, it's +essentially free to keep feeding it into the existing +`AisDynamicFeatureSource.UpdateArea` plumbing. + +The 250 ms debounce mirrors `DynamicSourceOverlayHost`'s +`_coalesceWindow`. It keeps a single pan or zoom from triggering +hundreds of resubscribes against aisstream.io. + +### Q3. Where the setting lives + +**Decision: `AisOverlaySettings.ActivationViewportSpanDegrees`, +nullable `double`, default `50.0`.** + +A single number is easy to explain in a tooltip and easy to test. +Degrees is the right unit because that's what the wire format +already takes. A `null` value means "no gate" (subscribe immediately +— the legacy PR-D3 behaviour) for backward compatibility. + +Validation: any value `<= 0` is normalised to `null` by the settings +view-model so users can't accidentally configure a gate that never +opens. + +### Q4. UI + +**Decision: numeric input + tooltip + helper text in the AIS section +of the Settings dialog. Defer the layer-stack "waiting" indicator.** + +New `Resources/Strings.resx` keys: + +| Key | Purpose | +|---|---| +| `Settings_AisActivationSpan` | Field label | +| `Settings_AisActivationSpanTooltip` | Hover help | +| `Settings_AisActivationSpanHint` | Sub-label clarifying default + null semantics | + +A tiny "waiting to activate — zoom in" indicator on the layer-stack +row would be a nice UX bonus, but the row currently doesn't surface +`DynamicSourceMetadata.Description` at all; threading that through is +a separate change. Defer. + +### Q5. Lifecycle — start/stop or always-allocated? + +**Decision: deferred decorator wrapping `IDynamicFeatureSource`.** + +``` +DeferredAisFeatureSource (viewer-side) + │ while gate closed: + │ CurrentFeatures = [] + │ Changed not raised + │ no subscription, no driver, no socket + │ + │ on first viewport with both spans <= T: + │ factory(seedBbox) -> AisDynamicFeatureSource + │ hook inner.Changed -> forward + │ + │ on every subsequent viewport (debounced): + │ inner.UpdateArea(bbox) + │ + └─ Dispose: dispose inner if activated; cancel debounce timer +``` + +This is the least-invasive option. The real `AisDynamicFeatureSource` +and the aisstream.io driver stay completely unaware of the gate; the +decorator is a thin viewer-side wrapper that lives next to +`AisOverlayServiceCollectionExtensions`. + +### Q6. What do we do once the gate has tripped? + +**Decision: one-shot activation. Once the source is constructed, it +stays constructed for the lifetime of the viewer process — even if +the user zooms back out.** + +Tearing down on zoom-out has three drawbacks: (a) more chatty against +aisstream.io's connection limits, (b) confuses users who wonder why +their vessels disappeared, and (c) the corner cases (does the +threshold use hysteresis? what if the user is mid-pan?) are non-trivial +for a marginal benefit. Document the one-shot behaviour explicitly so +no one is surprised. + +Each viewer launch starts gated again — there is no persisted "this +session has activated" flag. + +## Architecture + +### New types (viewer-side) + +| Type | Purpose | +|---|---| +| `MapViewportSnapshot` (record) | Immutable lat/lon bbox of the current viewport. | +| `IMapViewportNotifier` (interface) | Publishes `event ViewportChanged` and exposes `Current`. | +| `MapViewportNotifier` (class) | Singleton DI registration. Hooks `Mapsui.Navigator.ViewportChanged`, projects EPSG:3857 → EPSG:4326 via `SphericalMercator.ToLonLat`, and re-fires. | +| `DeferredAisFeatureSource` (class) | The decorator described above. Implements `IDynamicFeatureSource` + `IAsyncDisposable`. | + +### Modified types + +| Type | Change | +|---|---| +| `AisOverlaySettings` | New `double? ActivationViewportSpanDegrees`, default 50.0. | +| `AisOverlayServiceCollectionExtensions.BuildSource` | When the threshold is non-null, returns `new DeferredAisFeatureSource(threshold, () => realSource, viewportNotifier)`. | +| `App.axaml.cs` | Registers `MapViewportNotifier` as a singleton. | +| `MainWindow.axaml.cs` | Once the `Mapsui.Navigator` exists, calls `notifier.Bind(navigator)` so the notifier starts publishing. | +| `SettingsViewModel` | New `AisActivationViewportSpanDegrees` property + persistence. | +| `SettingsView.axaml` | New labelled numeric input + tooltip + hint. | +| `Resources/Strings.resx` + `.cs` | Three new keys. | + +### Threading + +- `Mapsui.Navigator.ViewportChanged` fires on the UI thread. +- `MapViewportNotifier.ViewportChanged` therefore also fires on the UI + thread (it's a thin re-publisher). +- `DeferredAisFeatureSource.OnViewportChanged` runs on the UI thread. + Activation does in-process work only (constructor + + `messageSource.Subscribe`); the driver's WebSocket connect happens + lazily inside the driver itself. +- The inner `AisDynamicFeatureSource.Changed` events arrive on the + websocket reader thread — we forward them verbatim. The downstream + `DynamicSourceOverlayHost` already marshals to UI. +- The debounce timer uses `CancellationTokenSource` + `Task.Delay`; + the trailing call lands on a worker thread but only invokes + `inner.UpdateArea`, which the driver makes thread-safe via its own + internal lock. + +Activation uses a cheap mutex (`object _activationLock`) plus a +`volatile` field for the inner reference. `volatile`-read on the hot +path keeps `CurrentFeatures` allocation-free. + +### Test surface + +| Project | What it covers | +|---|---| +| `AisOverlaySettingsTests` | Round-trip the new property; default = 50.0; null deserialises cleanly. | +| `DeferredAisFeatureSourceTests` | Gate closed → empty features, no `Changed`. Above threshold → still gated. ≤ threshold → factory called once with seed bbox. Subsequent viewports → debounced `UpdateArea`. Activation is one-shot (zoom-out doesn't deactivate). `DisposeAsync` disposes inner. Uses fake notifier + spy factory + fake `IDynamicFeatureSource`. | +| `MapViewportNotifierTests` | EPSG:3857 → EPSG:4326 projection round-trips reasonably; `Current` is `null` until bound. | +| `SettingsViewModelTests` | Set/get round-trip; ≤ 0 → null; getter falls back to default when settings absent. | +| `AisOverlayFactoryTests` | Already exercises Disabled / real source paths; new test covers the deferred-wrapper path. | + +## Out of scope + +- Tearing down on zoom-out (Q6 — one-shot). +- Layer-stack panel "waiting to activate" indicator (Q4 — defer). +- Generalising the gate to other dynamic sources (the decorator + stays AIS-named; if a second source ever needs the same trick we + can lift the abstraction then). +- Persisting "AIS already activated this session" across viewer + restarts. diff --git a/docs/toc.yml b/docs/toc.yml index bc447dd..3acd984 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -16,5 +16,7 @@ href: design/ais-source.md - name: Dynamic source pick href: design/dynamic-source-pick.md + - name: AIS zoom-gated subscription + href: design/ais-zoom-gated-subscription.md - name: S-98 interoperability href: design/s98-interoperability.md diff --git a/src/EncDotNet.S100.DynamicSources.Ais/README.md b/src/EncDotNet.S100.DynamicSources.Ais/README.md index 2bbc3d1..1701432 100644 --- a/src/EncDotNet.S100.DynamicSources.Ais/README.md +++ b/src/EncDotNet.S100.DynamicSources.Ais/README.md @@ -45,4 +45,7 @@ The source advertises `RendererKey = "vessel.ais"`. Register a matching - `docs/design/ais-source.md` — full design notes. - `docs/design/dynamic-feature-source.md` — base abstractions. +- `docs/design/ais-zoom-gated-subscription.md` — viewer-side gate + that defers the first subscription until the visible viewport + has shrunk below a configurable span. - `EncDotNet.S100.DynamicSources.Ais.Drivers.AisStreamIo` — production driver. diff --git a/src/EncDotNet.S100.Viewer/AisOverlaySettings.cs b/src/EncDotNet.S100.Viewer/AisOverlaySettings.cs index 451484d..d5a5d11 100644 --- a/src/EncDotNet.S100.Viewer/AisOverlaySettings.cs +++ b/src/EncDotNet.S100.Viewer/AisOverlaySettings.cs @@ -56,6 +56,32 @@ internal sealed class AisOverlaySettings /// initial subscribe before the user has panned the map. /// public AisOverlayBoundingBox? InitialArea { get; set; } + + /// + /// Maximum viewport span — in degrees of latitude AND longitude — + /// at which the AIS subscription is allowed to start. While the + /// visible viewport's lat-span or lon-span is wider than this, the + /// overlay stays inactive and no traffic is fetched from + /// aisstream.io. Once both spans drop to or below this threshold + /// the subscription is created with the live viewport bbox and + /// stays active for the rest of the viewer session. + /// + /// + /// + /// Default 50.0 — wide enough to admit any realistic + /// regional view (e.g. North Sea ~10° × 6°, Mediterranean + /// ~40° × 14°) while excluding the cold-start global view + /// (typically 360° × 170° on a fresh viewer). + /// + /// + /// disables the gate entirely — the + /// subscription starts immediately on viewer launch, matching + /// pre-PR behaviour. Values <= 0 are normalised to + /// by the settings view-model so users + /// cannot configure a gate that never opens. + /// + /// + public double? ActivationViewportSpanDegrees { get; set; } = 50.0; } /// diff --git a/src/EncDotNet.S100.Viewer/App.axaml.cs b/src/EncDotNet.S100.Viewer/App.axaml.cs index 351c6d0..faa98b1 100644 --- a/src/EncDotNet.S100.Viewer/App.axaml.cs +++ b/src/EncDotNet.S100.Viewer/App.axaml.cs @@ -285,6 +285,14 @@ private static IServiceProvider ConfigureServices() services.AddSingleton(sp => sp.GetRequiredService()); + // Map-viewport notifier (singleton). Inert until MainWindow + // calls Bind(navigator) once the MapControl exists. Used by + // the AIS overlay's zoom-gated decorator (see + // docs/design/ais-zoom-gated-subscription.md). + services.AddSingleton(); + services.AddSingleton(sp => + sp.GetRequiredService()); + // PR-D? upgraded own-ship symbology: register OwnShipRenderer // under the "ownship" key so DynamicSourceOverlayHost resolves // it for the own-ship source (RendererKey = "ownship"). diff --git a/src/EncDotNet.S100.Viewer/MainWindow.axaml.cs b/src/EncDotNet.S100.Viewer/MainWindow.axaml.cs index 06eb800..61adfe1 100644 --- a/src/EncDotNet.S100.Viewer/MainWindow.axaml.cs +++ b/src/EncDotNet.S100.Viewer/MainWindow.axaml.cs @@ -221,6 +221,18 @@ internal MainWindow( mapForBackColor.BackColor = new Mapsui.Styles.Color(170, 211, 223); } + // Bind the map-viewport notifier as early as possible so the + // AIS overlay's zoom-gated decorator (resolved below via + // GetServices) can read the current + // viewport synchronously in its constructor. See + // docs/design/ais-zoom-gated-subscription.md. + if (MapControl.Map?.Navigator is { } notifierNav) + { + App.Services.GetRequiredService< + EncDotNet.S100.Viewer.Services.MapViewportNotifier>() + .Bind(notifierNav); + } + // PR-D2: dynamic-source overlay host. Registered *after* the // basemap so MapsuiMapHost's ComputeOverlayInsertIndex places // the overlay above the OSM tile layer rather than at index 0 diff --git a/src/EncDotNet.S100.Viewer/README.md b/src/EncDotNet.S100.Viewer/README.md index e61888b..3f91469 100644 --- a/src/EncDotNet.S100.Viewer/README.md +++ b/src/EncDotNet.S100.Viewer/README.md @@ -250,6 +250,23 @@ crosshair. The hit-test radius is 12 device pixels (matches the AIS pictogram outer disc). See [`docs/design/dynamic-source-pick.md`](../../docs/design/dynamic-source-pick.md). +### AIS zoom-gated subscription + +The AIS overlay is **gated by viewport span** at viewer startup: +the aisstream.io subscription is not opened until the visible +viewport's lat-span and lon-span have both fallen to or below a +configurable threshold (default `50°`). On a fresh launch the +camera looks at the whole world, so the gate is closed and no +features stream — once the user zooms in the gate trips, the +subscription opens with the live viewport bounding box, and +subsequent pans / zooms keep the bbox in sync via debounced +`UpdateArea` calls. Activation is one-shot: the subscription +stays alive for the rest of the session even if the user zooms +back out. Set the threshold (or clear it for the legacy +"subscribe immediately" behaviour) under **Settings → AIS +overlay**. See +[`docs/design/ais-zoom-gated-subscription.md`](../../docs/design/ais-zoom-gated-subscription.md). + ## Optional MCP server The viewer can optionally host a Model Context Protocol server diff --git a/src/EncDotNet.S100.Viewer/Resources/Strings.cs b/src/EncDotNet.S100.Viewer/Resources/Strings.cs index d9c14d8..a28a189 100644 --- a/src/EncDotNet.S100.Viewer/Resources/Strings.cs +++ b/src/EncDotNet.S100.Viewer/Resources/Strings.cs @@ -425,6 +425,9 @@ private static string Get(string name) => public static string Settings_AisApiKeyTooltip => Get(nameof(Settings_AisApiKeyTooltip)); public static string Settings_AisApiKey_EnvVarHint => Get(nameof(Settings_AisApiKey_EnvVarHint)); public static string Settings_AisApiKey_EnvVarPresent => Get(nameof(Settings_AisApiKey_EnvVarPresent)); + public static string Settings_AisActivationSpan => Get(nameof(Settings_AisActivationSpan)); + public static string Settings_AisActivationSpanTooltip => Get(nameof(Settings_AisActivationSpanTooltip)); + public static string Settings_AisActivationSpanHint => Get(nameof(Settings_AisActivationSpanHint)); // PR-D4: Dynamic-source pick report. public static string PickReport_DynamicSection => Get(nameof(PickReport_DynamicSection)); diff --git a/src/EncDotNet.S100.Viewer/Resources/Strings.resx b/src/EncDotNet.S100.Viewer/Resources/Strings.resx index 3e7b5ab..1caaaef 100644 --- a/src/EncDotNet.S100.Viewer/Resources/Strings.resx +++ b/src/EncDotNet.S100.Viewer/Resources/Strings.resx @@ -1126,6 +1126,15 @@ Open an S-101, S-124, S-125, S-201, or S-421 dataset to see display controls her Environment variable {0} is set; it will be used. {0} = environment variable name. + + Activate at viewport span (degrees) + + + The AIS subscription will not start until the visible map's latitude and longitude spans are both at or below this value (in degrees). The default of 50° excludes the global cold-start view but admits any reasonable regional view. Leave blank to disable the gate and subscribe immediately on launch. + + + Default 50°. Leave blank to subscribe immediately on launch (legacy behaviour). Once activated, the subscription stays active for the rest of the viewer session. + diff --git a/src/EncDotNet.S100.Viewer/Services/DynamicSources/Ais/AisOverlayServiceCollectionExtensions.cs b/src/EncDotNet.S100.Viewer/Services/DynamicSources/Ais/AisOverlayServiceCollectionExtensions.cs index be91a6f..a64a39f 100644 --- a/src/EncDotNet.S100.Viewer/Services/DynamicSources/Ais/AisOverlayServiceCollectionExtensions.cs +++ b/src/EncDotNet.S100.Viewer/Services/DynamicSources/Ais/AisOverlayServiceCollectionExtensions.cs @@ -2,6 +2,7 @@ using EncDotNet.S100.DynamicSources.Ais; using EncDotNet.S100.DynamicSources.Ais.Drivers.AisStreamIo; using EncDotNet.S100.Pipelines; +using EncDotNet.S100.Viewer.Services; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -28,7 +29,8 @@ public static IServiceCollection AddAisOverlay(this IServiceCollection services) { var settings = sp.GetRequiredService(); var loggerFactory = sp.GetService(); - return BuildSource(settings.AisOverlay, loggerFactory); + var notifier = sp.GetService(); + return BuildSource(settings.AisOverlay, loggerFactory, notifier); }); return services; @@ -36,13 +38,17 @@ public static IServiceCollection AddAisOverlay(this IServiceCollection services) /// /// Internal factory exposed for tests: builds either a real - /// AisDynamicFeatureSource or the + /// AisDynamicFeatureSource, a + /// wrapping one (when + /// + /// is non-null and a viewport notifier is available), or the /// sentinel based on /// and the environment. /// internal static IDynamicFeatureSource BuildSource( AisOverlaySettings? overlaySettings, - ILoggerFactory? loggerFactory) + ILoggerFactory? loggerFactory, + IMapViewportNotifier? viewportNotifier = null) { if (overlaySettings is null || !overlaySettings.Enabled) return new DisabledAisFeatureSource(); @@ -56,19 +62,33 @@ internal static IDynamicFeatureSource BuildSource( if (string.IsNullOrWhiteSpace(apiKey)) return new DisabledAisFeatureSource(); - var driver = new AisStreamIoMessageSource( - new AisStreamIoOptions { ApiKey = apiKey }, - loggerFactory); + var seedBox = ToBoundingBox(overlaySettings.InitialArea); - var request = new AisSubscriptionRequest + AisDynamicFeatureSource BuildReal(BoundingBox? area) => new( + id: "ais", + messageSource: new AisStreamIoMessageSource( + new AisStreamIoOptions { ApiKey = apiKey }, + loggerFactory), + request: new AisSubscriptionRequest { Area = area }); + + // Zoom-gated activation: when the user has configured a span + // threshold AND we have a viewport notifier wired, defer + // construction of the real source until the visible viewport + // shrinks below the threshold. See + // docs/design/ais-zoom-gated-subscription.md. + if (overlaySettings.ActivationViewportSpanDegrees is { } spanDegrees + && spanDegrees > 0 + && viewportNotifier is not null) { - Area = ToBoundingBox(overlaySettings.InitialArea), - }; + return new DeferredAisFeatureSource( + id: "ais", + activationSpanDegrees: spanDegrees, + factory: bbox => BuildReal(bbox), + notifier: viewportNotifier, + logger: loggerFactory?.CreateLogger()); + } - return new AisDynamicFeatureSource( - id: "ais", - messageSource: driver, - request: request); + return BuildReal(seedBox); } private static BoundingBox? ToBoundingBox(AisOverlayBoundingBox? box) diff --git a/src/EncDotNet.S100.Viewer/Services/DynamicSources/Ais/DeferredAisFeatureSource.cs b/src/EncDotNet.S100.Viewer/Services/DynamicSources/Ais/DeferredAisFeatureSource.cs new file mode 100644 index 0000000..968ee2b --- /dev/null +++ b/src/EncDotNet.S100.Viewer/Services/DynamicSources/Ais/DeferredAisFeatureSource.cs @@ -0,0 +1,302 @@ +using EncDotNet.S100.DynamicSources; +using EncDotNet.S100.DynamicSources.Ais; +using EncDotNet.S100.Pipelines; +using EncDotNet.S100.Viewer.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace EncDotNet.S100.Viewer.Services.DynamicSources.Ais; + +/// +/// Viewer-side decorator that gates the construction of the real +/// on the visible viewport +/// shrinking below a configured span (in degrees of latitude AND +/// longitude). While the gate is closed the source is silently +/// inactive: is empty, +/// never fires, and no aisstream.io traffic +/// is fetched. +/// +/// +/// +/// Once both spans of a viewport snapshot drop to or below +/// activationSpanDegrees, the decorator constructs the inner +/// source via the supplied factory (passing the live viewport bbox +/// as the seed) and forwards every subsequent +/// event verbatim. Activation is one-shot: zooming back out +/// after activation does not deactivate the source — the +/// subscription stays alive for the rest of the viewer session. +/// +/// +/// After activation, every viewport change triggers a debounced +/// (trailing-edge) call to +/// so the wire bbox tracks what the user can see. The default +/// debounce window is 250 ms — matching DynamicSourceOverlayHost +/// — which keeps a single pan or zoom from triggering hundreds of +/// re-subscribes. +/// +/// +/// See docs/design/ais-zoom-gated-subscription.md for the +/// full rationale (Q1–Q6). +/// +/// +internal sealed class DeferredAisFeatureSource : IDynamicFeatureSource, IAsyncDisposable +{ + /// + /// Default debounce window for the post-activation + /// UpdateArea stream. Mirrors + /// DynamicSourceOverlayHost._coalesceWindow. + /// + public static readonly TimeSpan DefaultUpdateAreaDebounce = TimeSpan.FromMilliseconds(250); + + private readonly double _activationSpanDegrees; + private readonly Func _factory; + private readonly IMapViewportNotifier _notifier; + private readonly TimeSpan _debounce; + private readonly ILogger _logger; + private readonly object _activationLock = new(); + private volatile AisDynamicFeatureSource? _inner; + private CancellationTokenSource? _debounceCts; + private BoundingBox? _latestBbox; + private bool _disposed; + + /// + /// Constructs a new gated AIS source. + /// + /// Stable instance id (e.g. "ais"). + /// + /// Maximum lat-span AND lon-span (degrees) at which the inner + /// source is allowed to start. Must be positive. + /// + /// + /// Lazy constructor for the real + /// . Called at most once, + /// the first time the gate opens. The argument is the live + /// viewport bbox at the moment of activation, suitable for use + /// as the seed AisSubscriptionRequest.Area. + /// + /// Map-viewport publisher. + /// + /// Debounce window for post-activation UpdateArea calls. + /// uses + /// ; pass + /// in tests to disable debouncing. + /// + /// Optional logger. + public DeferredAisFeatureSource( + string id, + double activationSpanDegrees, + Func factory, + IMapViewportNotifier notifier, + TimeSpan? debounce = null, + ILogger? logger = null) + { + ArgumentException.ThrowIfNullOrEmpty(id); + ArgumentNullException.ThrowIfNull(factory); + ArgumentNullException.ThrowIfNull(notifier); + if (!(activationSpanDegrees > 0)) + { + throw new ArgumentOutOfRangeException( + nameof(activationSpanDegrees), + activationSpanDegrees, + "Activation span must be a positive number of degrees."); + } + + Id = id; + _activationSpanDegrees = activationSpanDegrees; + _factory = factory; + _notifier = notifier; + _debounce = debounce ?? DefaultUpdateAreaDebounce; + _logger = logger ?? NullLogger.Instance; + + Metadata = new DynamicSourceMetadata + { + DisplayName = "AIS targets", + RendererKey = "vessel.ais", + }; + + _notifier.ViewportChanged += OnViewportChanged; + + // If the notifier has already seen a viewport (e.g. binding + // happened before this decorator was constructed), evaluate + // the gate immediately so we don't have to wait for the next + // user interaction to (de)activate. + if (_notifier.Current is { } seed) + { + Evaluate(seed); + } + } + + /// + public string Id { get; } + + /// + public DynamicSourceMetadata Metadata { get; } + + /// + public IReadOnlyList CurrentFeatures => + _inner?.CurrentFeatures ?? Array.Empty(); + + /// + public event EventHandler? Changed; + + /// + /// once the gate has opened and the inner + /// source has been constructed. Used by tests; not part of the + /// public contract. + /// + internal bool IsActivated => _inner is not null; + + /// + /// The seed bounding box passed to the factory at activation, + /// or if not yet activated. Test hook. + /// + internal BoundingBox? ActivationBoundingBox { get; private set; } + + private void OnViewportChanged(object? sender, MapViewportSnapshot snapshot) + { + if (_disposed) return; + Evaluate(snapshot); + } + + private void Evaluate(MapViewportSnapshot snapshot) + { + var bbox = ToBoundingBox(snapshot); + if (_inner is null) + { + // Gate is still closed: only act if both spans satisfy + // the threshold. We deliberately use <= so a viewport + // exactly equal to the threshold trips the gate. + if (snapshot.LatitudeSpanDegrees <= _activationSpanDegrees + && snapshot.LongitudeSpanDegrees <= _activationSpanDegrees) + { + Activate(bbox); + } + return; + } + + // Already activated — feed viewport changes to UpdateArea. + ScheduleUpdateArea(bbox); + } + + private void Activate(BoundingBox seedBbox) + { + AisDynamicFeatureSource? built = null; + lock (_activationLock) + { + if (_inner is not null) return; // Lost the race. + if (_disposed) return; + + try + { + built = _factory(seedBbox); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Deferred AIS source factory threw during activation."); + return; + } + + ActivationBoundingBox = seedBbox; + built.Changed += OnInnerChanged; + _inner = built; + } + + // Surface the freshly-activated state to consumers — even an + // empty CurrentFeatures snapshot is meaningful because layer + // visibility, picks, etc. now have a real source to query. + Changed?.Invoke(this, new DynamicFeaturesChanged + { + Kind = DynamicSourceChangeKind.Reset, + ChangedIds = Array.Empty(), + }); + } + + private void OnInnerChanged(object? sender, DynamicFeaturesChanged e) + { + if (_disposed) return; + Changed?.Invoke(this, e); + } + + private void ScheduleUpdateArea(BoundingBox bbox) + { + // Capture the latest bbox under a lock so the trailing + // continuation always sees the most recent value, even if + // multiple events landed while a previous Task.Delay was + // pending. + CancellationTokenSource newCts; + lock (_activationLock) + { + if (_disposed || _inner is null) return; + _latestBbox = bbox; + + if (_debounce <= TimeSpan.Zero) + { + _inner.UpdateArea(bbox); + return; + } + + _debounceCts?.Cancel(); + _debounceCts?.Dispose(); + newCts = new CancellationTokenSource(); + _debounceCts = newCts; + } + + _ = Task.Delay(_debounce, newCts.Token).ContinueWith(t => + { + if (t.IsCanceled) return; + AisDynamicFeatureSource? inner; + BoundingBox? pending; + lock (_activationLock) + { + inner = _inner; + pending = _latestBbox; + if (ReferenceEquals(_debounceCts, newCts)) + { + _debounceCts = null; + } + } + if (inner is null || pending is null || _disposed) return; + try + { + inner.UpdateArea(pending); + } + catch (Exception ex) + { + _logger.LogWarning( + ex, + "Deferred AIS source UpdateArea threw post-activation."); + } + }, TaskScheduler.Default); + } + + private static BoundingBox ToBoundingBox(MapViewportSnapshot snapshot) => new( + southLatitude: snapshot.MinLatitude, + westLongitude: snapshot.MinLongitude, + northLatitude: snapshot.MaxLatitude, + eastLongitude: snapshot.MaxLongitude); + + /// + public async ValueTask DisposeAsync() + { + AisDynamicFeatureSource? inner; + lock (_activationLock) + { + if (_disposed) return; + _disposed = true; + _notifier.ViewportChanged -= OnViewportChanged; + _debounceCts?.Cancel(); + _debounceCts?.Dispose(); + _debounceCts = null; + inner = _inner; + _inner = null; + } + + if (inner is not null) + { + inner.Changed -= OnInnerChanged; + await inner.DisposeAsync().ConfigureAwait(false); + } + } +} diff --git a/src/EncDotNet.S100.Viewer/Services/IMapViewportNotifier.cs b/src/EncDotNet.S100.Viewer/Services/IMapViewportNotifier.cs new file mode 100644 index 0000000..17705f2 --- /dev/null +++ b/src/EncDotNet.S100.Viewer/Services/IMapViewportNotifier.cs @@ -0,0 +1,39 @@ +namespace EncDotNet.S100.Viewer.Services; + +/// +/// Publishes lat/lon bounding boxes of the map's current viewport. +/// Implemented as a singleton in DI so feature-source code can +/// subscribe to viewport changes without taking a dependency on +/// Mapsui types or on the construction order of MapControl. +/// +/// +/// +/// The viewer's concrete implementation +/// () is constructed eagerly at app +/// startup and starts publishing once MainWindow binds it to +/// the live Mapsui.Navigator. Subscribers registered before +/// Bind simply see no events until then; this matches the +/// "gate is closed by default" semantics of +/// . +/// +/// +/// Threading: fires on whatever thread +/// raised the underlying Mapsui event — typically the UI thread. +/// Subscribers that need to do heavy work should marshal off the UI +/// thread themselves. +/// +/// +internal interface IMapViewportNotifier +{ + /// + /// Most recent viewport snapshot, or if + /// the notifier hasn't been bound to a navigator yet. + /// + MapViewportSnapshot? Current { get; } + + /// + /// Raised whenever the viewport changes. The + /// payload is the new snapshot in EPSG:4326. + /// + event EventHandler? ViewportChanged; +} diff --git a/src/EncDotNet.S100.Viewer/Services/MapViewportNotifier.cs b/src/EncDotNet.S100.Viewer/Services/MapViewportNotifier.cs new file mode 100644 index 0000000..db79471 --- /dev/null +++ b/src/EncDotNet.S100.Viewer/Services/MapViewportNotifier.cs @@ -0,0 +1,129 @@ +using Mapsui; +using Mapsui.Projections; + +namespace EncDotNet.S100.Viewer.Services; + +/// +/// Concrete backed by a Mapsui +/// Navigator. Translates the navigator's native EPSG:3857 +/// viewport rectangle into a lat/lon +/// on every ViewportChanged event. +/// +/// +/// +/// The notifier is constructed at DI registration time but is inert +/// until is called with a live navigator. This +/// mirrors the existing DynamicFeatureSourceRegistryAccessor +/// pattern: the singleton exists in the container before MainWindow +/// constructs the MapControl, but only starts emitting once it has +/// the navigator to listen to. +/// +/// +/// Re-binding to a different navigator (or the same navigator after +/// a re-init) is safe — the notifier detaches from the previous one +/// before subscribing to the new one. +/// +/// +internal sealed class MapViewportNotifier : IMapViewportNotifier, IDisposable +{ + private readonly object _lock = new(); + private Navigator? _navigator; + private Navigator.ViewportChangedEventHandler? _handler; + private MapViewportSnapshot? _current; + + /// + public MapViewportSnapshot? Current + { + get + { + lock (_lock) return _current; + } + } + + /// + public event EventHandler? ViewportChanged; + + /// + /// Subscribes to 's viewport-change + /// events. Detaches from any previously-bound navigator first. + /// Pushes the navigator's current viewport synchronously so + /// subscribers see an initial snapshot without waiting for the + /// next user interaction. + /// + public void Bind(Navigator navigator) + { + ArgumentNullException.ThrowIfNull(navigator); + + lock (_lock) + { + DetachLocked(); + _navigator = navigator; + _handler = (_, _) => HandleChange(navigator); + navigator.ViewportChanged += _handler; + } + + HandleChange(navigator); + } + + private void HandleChange(Navigator navigator) + { + var snapshot = TryProject(navigator.Viewport); + if (snapshot is null) return; + + lock (_lock) _current = snapshot; + ViewportChanged?.Invoke(this, snapshot); + } + + /// + /// Builds a lat/lon snapshot from a Mapsui EPSG:3857 viewport. + /// Returns if the viewport hasn't been + /// laid out yet (zero width or height) — those events fire + /// during early window construction and carry no useful data. + /// + internal static MapViewportSnapshot? TryProject(Viewport viewport) + { + if (viewport.Width <= 0 || viewport.Height <= 0) + return null; + + var halfW = viewport.Width * viewport.Resolution / 2.0; + var halfH = viewport.Height * viewport.Resolution / 2.0; + + var minX = viewport.CenterX - halfW; + var maxX = viewport.CenterX + halfW; + var minY = viewport.CenterY - halfH; + var maxY = viewport.CenterY + halfH; + + // SphericalMercator.ToLonLat clamps Y to the projection's + // valid range (±20037508.34) and returns lat/lon in degrees. + var (minLon, minLat) = SphericalMercator.ToLonLat(minX, minY); + var (maxLon, maxLat) = SphericalMercator.ToLonLat(maxX, maxY); + + return new MapViewportSnapshot + { + MinLatitude = minLat, + MinLongitude = minLon, + MaxLatitude = maxLat, + MaxLongitude = maxLon, + }; + } + + /// + public void Dispose() + { + lock (_lock) + { + DetachLocked(); + _current = null; + } + } + + private void DetachLocked() + { + if (_navigator is { } nav && _handler is { } h) + { + nav.ViewportChanged -= h; + } + _navigator = null; + _handler = null; + } +} diff --git a/src/EncDotNet.S100.Viewer/Services/MapViewportSnapshot.cs b/src/EncDotNet.S100.Viewer/Services/MapViewportSnapshot.cs new file mode 100644 index 0000000..d49388a --- /dev/null +++ b/src/EncDotNet.S100.Viewer/Services/MapViewportSnapshot.cs @@ -0,0 +1,41 @@ +namespace EncDotNet.S100.Viewer.Services; + +/// +/// Immutable lat/lon (EPSG:4326) bounding box of the map's current +/// viewport. Published by on every +/// viewport change so subscribers can reason about the visible area +/// in geographic coordinates without depending on Mapsui types. +/// +/// +/// The viewer projects Mapsui's native EPSG:3857 (Web Mercator) +/// viewport via SphericalMercator.ToLonLat when constructing a +/// snapshot. Latitudes are therefore clamped to roughly +/// ±85.0511° by the projection; longitudes can fall outside +/// [-180, +180] when the user pans across the antimeridian +/// (Mapsui doesn't wrap by default), which is intentional — the +/// downstream gate sees a wide span and stays closed. +/// +internal sealed record MapViewportSnapshot +{ + /// South edge of the visible viewport, in decimal + /// degrees (EPSG:4326). + public required double MinLatitude { get; init; } + + /// West edge of the visible viewport, in decimal + /// degrees (EPSG:4326). + public required double MinLongitude { get; init; } + + /// North edge of the visible viewport, in decimal + /// degrees (EPSG:4326). + public required double MaxLatitude { get; init; } + + /// East edge of the visible viewport, in decimal + /// degrees (EPSG:4326). + public required double MaxLongitude { get; init; } + + /// Latitude span (north - south) in degrees. + public double LatitudeSpanDegrees => MaxLatitude - MinLatitude; + + /// Longitude span (east - west) in degrees. + public double LongitudeSpanDegrees => MaxLongitude - MinLongitude; +} diff --git a/src/EncDotNet.S100.Viewer/ViewModels/SettingsViewModel.cs b/src/EncDotNet.S100.Viewer/ViewModels/SettingsViewModel.cs index 48a8a12..bb0df1f 100644 --- a/src/EncDotNet.S100.Viewer/ViewModels/SettingsViewModel.cs +++ b/src/EncDotNet.S100.Viewer/ViewModels/SettingsViewModel.cs @@ -541,6 +541,7 @@ public SettingsViewModel(ViewerSettings settings) var ais = settings.AisOverlay ?? new AisOverlaySettings(); _aisEnabled = ais.Enabled; _aisApiKey = ais.ApiKey; + _aisActivationViewportSpanDegrees = ais.ActivationViewportSpanDegrees; } /// @@ -773,4 +774,29 @@ public string AisApiKeyHint : string.Format(CultureInfo.CurrentCulture, Strings.Settings_AisApiKey_EnvVarPresent, envVar); } } + + private double? _aisActivationViewportSpanDegrees; + /// + /// Activation threshold (in degrees of latitude AND longitude) + /// for the AIS subscription. While the visible viewport's + /// lat-span or lon-span is wider than this, no traffic is fetched + /// from aisstream.io. disables the gate + /// entirely (subscribe immediately on viewer launch). Values + /// <= 0 are normalised to so + /// users can't accidentally configure a gate that never opens. + /// + public double? AisActivationViewportSpanDegrees + { + get => _aisActivationViewportSpanDegrees; + set + { + var normalised = value is { } v && v > 0 ? value : null; + if (SetProperty(ref _aisActivationViewportSpanDegrees, normalised)) + { + EnsureAisOverlaySettings(); + _settings.AisOverlay!.ActivationViewportSpanDegrees = normalised; + _settings.Save(); + } + } + } } \ No newline at end of file diff --git a/src/EncDotNet.S100.Viewer/Views/SettingsView.axaml b/src/EncDotNet.S100.Viewer/Views/SettingsView.axaml index 5d779e5..5662a78 100644 --- a/src/EncDotNet.S100.Viewer/Views/SettingsView.axaml +++ b/src/EncDotNet.S100.Viewer/Views/SettingsView.axaml @@ -383,6 +383,23 @@ Opacity="0.6" TextWrapping="Wrap" /> + + + + + diff --git a/tests/EncDotNet.S100.Viewer.Tests/AisOverlaySettingsTests.cs b/tests/EncDotNet.S100.Viewer.Tests/AisOverlaySettingsTests.cs new file mode 100644 index 0000000..dd72821 --- /dev/null +++ b/tests/EncDotNet.S100.Viewer.Tests/AisOverlaySettingsTests.cs @@ -0,0 +1,80 @@ +using System.IO; +using EncDotNet.S100.Viewer; +using Xunit; + +namespace EncDotNet.S100.Viewer.Tests; + +/// +/// PR-AIS-zoom-gated: round-trip the +/// +/// property through JSON persistence. +/// +public class AisOverlaySettingsTests +{ + [Fact] + public void Default_ActivationViewportSpanDegrees_is_50() + { + var settings = new AisOverlaySettings(); + Assert.Equal(50.0, settings.ActivationViewportSpanDegrees); + } + + [Fact] + public void Default_can_be_cleared_to_null() + { + var settings = new AisOverlaySettings { ActivationViewportSpanDegrees = null }; + Assert.Null(settings.ActivationViewportSpanDegrees); + } + + [Fact] + public void RoundTrips_through_settings_json() + { + var path = Path.Combine(Path.GetTempPath(), $"viewer-settings-{Path.GetRandomFileName()}.json"); + try + { + var s = new ViewerSettings + { + SettingsFilePath = path, + AisOverlay = new AisOverlaySettings + { + Enabled = true, + ActivationViewportSpanDegrees = 12.5, + }, + }; + s.Save(); + + var loaded = ViewerSettings.Load(path); + Assert.NotNull(loaded.AisOverlay); + Assert.Equal(12.5, loaded.AisOverlay!.ActivationViewportSpanDegrees); + } + finally + { + if (File.Exists(path)) File.Delete(path); + } + } + + [Fact] + public void Null_round_trips_as_null() + { + var path = Path.Combine(Path.GetTempPath(), $"viewer-settings-{Path.GetRandomFileName()}.json"); + try + { + var s = new ViewerSettings + { + SettingsFilePath = path, + AisOverlay = new AisOverlaySettings + { + Enabled = true, + ActivationViewportSpanDegrees = null, + }, + }; + s.Save(); + + var loaded = ViewerSettings.Load(path); + Assert.Null(loaded.AisOverlay!.ActivationViewportSpanDegrees); + } + finally + { + if (File.Exists(path)) File.Delete(path); + } + } +} diff --git a/tests/EncDotNet.S100.Viewer.Tests/DynamicSources/AisOverlayFactoryTests.cs b/tests/EncDotNet.S100.Viewer.Tests/DynamicSources/AisOverlayFactoryTests.cs index 3ae0fbf..aa102bc 100644 --- a/tests/EncDotNet.S100.Viewer.Tests/DynamicSources/AisOverlayFactoryTests.cs +++ b/tests/EncDotNet.S100.Viewer.Tests/DynamicSources/AisOverlayFactoryTests.cs @@ -1,4 +1,5 @@ using EncDotNet.S100.Viewer; +using EncDotNet.S100.Viewer.Services; using EncDotNet.S100.Viewer.Services.DynamicSources.Ais; namespace EncDotNet.S100.Viewer.Tests.DynamicSources; @@ -129,4 +130,84 @@ public void Returns_disabled_when_apikey_blank_and_env_unset() loggerFactory: null); Assert.IsType(src); } + + [Fact] + public async Task Returns_deferred_wrapper_when_activation_span_set_and_notifier_provided() + { + var notifier = new FactoryFakeViewportNotifier(); + var src = AisOverlayServiceCollectionExtensions.BuildSource( + new AisOverlaySettings + { + Enabled = true, + ApiKey = "settings-key", + ActivationViewportSpanDegrees = 25.0, + }, + loggerFactory: null, + viewportNotifier: notifier); + try + { + Assert.IsType(src); + } + finally + { + if (src is IAsyncDisposable d) await d.DisposeAsync(); + } + } + + [Fact] + public async Task Returns_real_source_when_activation_span_null() + { + var notifier = new FactoryFakeViewportNotifier(); + var src = AisOverlayServiceCollectionExtensions.BuildSource( + new AisOverlaySettings + { + Enabled = true, + ApiKey = "settings-key", + ActivationViewportSpanDegrees = null, + }, + loggerFactory: null, + viewportNotifier: notifier); + try + { + Assert.IsNotType(src); + Assert.IsNotType(src); + } + finally + { + if (src is IAsyncDisposable d) await d.DisposeAsync(); + } + } + + [Fact] + public async Task Returns_real_source_when_notifier_unavailable_even_with_span_set() + { + var src = AisOverlayServiceCollectionExtensions.BuildSource( + new AisOverlaySettings + { + Enabled = true, + ApiKey = "settings-key", + ActivationViewportSpanDegrees = 25.0, + }, + loggerFactory: null, + viewportNotifier: null); + try + { + Assert.IsNotType(src); + Assert.IsNotType(src); + } + finally + { + if (src is IAsyncDisposable d) await d.DisposeAsync(); + } + } + + private sealed class FactoryFakeViewportNotifier : IMapViewportNotifier + { + public MapViewportSnapshot? Current => null; + public event EventHandler? ViewportChanged + { + add { } + remove { } + } + } } diff --git a/tests/EncDotNet.S100.Viewer.Tests/DynamicSources/DeferredAisFeatureSourceTests.cs b/tests/EncDotNet.S100.Viewer.Tests/DynamicSources/DeferredAisFeatureSourceTests.cs new file mode 100644 index 0000000..b31fd3a --- /dev/null +++ b/tests/EncDotNet.S100.Viewer.Tests/DynamicSources/DeferredAisFeatureSourceTests.cs @@ -0,0 +1,343 @@ +using EncDotNet.S100.DynamicSources; +using EncDotNet.S100.DynamicSources.Ais; +using EncDotNet.S100.Pipelines; +using EncDotNet.S100.Viewer.Services; +using EncDotNet.S100.Viewer.Services.DynamicSources.Ais; +using Xunit; + +namespace EncDotNet.S100.Viewer.Tests.DynamicSources; + +public class DeferredAisFeatureSourceTests +{ + private const double Threshold = 50.0; + + [Fact] + public void Stays_inactive_when_viewport_above_threshold() + { + var notifier = new FakeMapViewportNotifier(); + var spy = new SpyFactory(); + using var src = new TestableDeferredSource(spy.Build, notifier); + + notifier.Publish(SnapshotWithSpan(60, 60)); + + Assert.False(src.Decorator.IsActivated); + Assert.Empty(src.Decorator.CurrentFeatures); + Assert.Equal(0, spy.Calls); + } + + [Fact] + public void Stays_inactive_when_only_one_span_meets_threshold() + { + var notifier = new FakeMapViewportNotifier(); + var spy = new SpyFactory(); + using var src = new TestableDeferredSource(spy.Build, notifier); + + // Lat span 5° satisfies; lon span 360° (global) does not. + notifier.Publish(SnapshotWithSpan(5, 360)); + + Assert.False(src.Decorator.IsActivated); + Assert.Equal(0, spy.Calls); + } + + [Fact] + public void Activates_when_both_spans_at_or_below_threshold() + { + var notifier = new FakeMapViewportNotifier(); + var spy = new SpyFactory(); + using var src = new TestableDeferredSource(spy.Build, notifier); + + notifier.Publish(SnapshotWithSpan(10, 10)); + + Assert.True(src.Decorator.IsActivated); + Assert.Equal(1, spy.Calls); + Assert.NotNull(src.Decorator.ActivationBoundingBox); + var seed = src.Decorator.ActivationBoundingBox!; + Assert.Equal(-5.0, seed.SouthLatitude, 6); + Assert.Equal(5.0, seed.NorthLatitude, 6); + Assert.Equal(-5.0, seed.WestLongitude, 6); + Assert.Equal(5.0, seed.EastLongitude, 6); + } + + [Fact] + public void Activates_at_exact_threshold() + { + var notifier = new FakeMapViewportNotifier(); + var spy = new SpyFactory(); + using var src = new TestableDeferredSource(spy.Build, notifier); + + notifier.Publish(SnapshotWithSpan(Threshold, Threshold)); + + Assert.True(src.Decorator.IsActivated); + } + + [Fact] + public void Activates_only_once_even_after_repeated_qualifying_viewports() + { + var notifier = new FakeMapViewportNotifier(); + var spy = new SpyFactory(); + using var src = new TestableDeferredSource(spy.Build, notifier); + + notifier.Publish(SnapshotWithSpan(10, 10)); + notifier.Publish(SnapshotWithSpan(8, 8)); + notifier.Publish(SnapshotWithSpan(60, 60)); // zoomed back out + notifier.Publish(SnapshotWithSpan(5, 5)); // back in + + Assert.Equal(1, spy.Calls); + } + + [Fact] + public void Forwards_inner_changed_events_after_activation() + { + var notifier = new FakeMapViewportNotifier(); + var spy = new SpyFactory(); + using var src = new TestableDeferredSource(spy.Build, notifier); + + var raised = new List(); + src.Decorator.Changed += (_, e) => raised.Add(e); + + notifier.Publish(SnapshotWithSpan(10, 10)); + Assert.Single(raised); // initial Reset on activation. + + // Drive the inner source's underlying message stream. + spy.LastMessageSource!.Subscriptions[^1].EmitPosition(new AisPositionReport + { + Mmsi = 123456789, + Timestamp = DateTimeOffset.UtcNow, + Latitude = 0, + Longitude = 0, + }); + + Assert.True(raised.Count >= 2); + Assert.Equal(DynamicSourceChangeKind.Added, raised[^1].Kind); + } + + [Fact] + public void Calls_UpdateArea_on_subsequent_viewport_changes_after_activation() + { + var notifier = new FakeMapViewportNotifier(); + var spy = new SpyFactory(); + // debounce = Zero: synchronous UpdateArea propagation for tests. + using var src = new TestableDeferredSource(spy.Build, notifier, debounce: TimeSpan.Zero); + + notifier.Publish(SnapshotWithSpan(10, 10)); + var fakeSub = spy.LastMessageSource!.Subscriptions[^1]; + + Assert.Empty(fakeSub.AreaUpdates); // seed went via constructor, not UpdateArea. + + notifier.Publish(SnapshotWithSpan(4, 4)); + notifier.Publish(SnapshotWithSpan(2, 2)); + + Assert.Equal(2, fakeSub.AreaUpdates.Count); + var last = fakeSub.AreaUpdates[^1]!; + Assert.Equal(-1.0, last.SouthLatitude, 6); + Assert.Equal(1.0, last.NorthLatitude, 6); + } + + [Fact] + public async Task Debounces_UpdateArea_to_a_single_call() + { + var notifier = new FakeMapViewportNotifier(); + var spy = new SpyFactory(); + using var src = new TestableDeferredSource(spy.Build, notifier, debounce: TimeSpan.FromMilliseconds(40)); + + notifier.Publish(SnapshotWithSpan(10, 10)); + var fakeSub = spy.LastMessageSource!.Subscriptions[^1]; + + // Burst a handful of viewport changes faster than the debounce. + for (int i = 0; i < 5; i++) + { + notifier.Publish(SnapshotWithSpan(8 - i * 0.5, 8 - i * 0.5)); + } + + // Wait long enough for the trailing call to fire. + await Task.Delay(200); + + Assert.Single(fakeSub.AreaUpdates); + } + + [Fact] + public async Task Dispose_disposes_inner_source() + { + var notifier = new FakeMapViewportNotifier(); + var spy = new SpyFactory(); + var src = new TestableDeferredSource(spy.Build, notifier, debounce: TimeSpan.Zero); + + notifier.Publish(SnapshotWithSpan(10, 10)); + var fakeSub = spy.LastMessageSource!.Subscriptions[^1]; + + await src.Decorator.DisposeAsync(); + Assert.True(fakeSub.Disposed); + } + + [Fact] + public async Task Dispose_when_never_activated_is_a_noop() + { + var notifier = new FakeMapViewportNotifier(); + var spy = new SpyFactory(); + var src = new TestableDeferredSource(spy.Build, notifier); + + await src.Decorator.DisposeAsync(); + Assert.Equal(0, spy.Calls); + } + + [Fact] + public void Reads_initial_snapshot_from_notifier_at_construction() + { + var notifier = new FakeMapViewportNotifier(); + notifier.Publish(SnapshotWithSpan(10, 10)); // populate Current first. + + var spy = new SpyFactory(); + using var src = new TestableDeferredSource(spy.Build, notifier); + + // Without subscribing-to-event-then-publish, the gate should + // still trip because the decorator reads notifier.Current in + // its constructor. + Assert.True(src.Decorator.IsActivated); + } + + [Fact] + public void Throws_on_non_positive_threshold() + { + var notifier = new FakeMapViewportNotifier(); + Assert.Throws(() => + new DeferredAisFeatureSource( + id: "ais", + activationSpanDegrees: 0, + factory: _ => throw new InvalidOperationException(), + notifier: notifier)); + } + + private static MapViewportSnapshot SnapshotWithSpan(double latSpan, double lonSpan) => + new() + { + MinLatitude = -latSpan / 2, + MaxLatitude = latSpan / 2, + MinLongitude = -lonSpan / 2, + MaxLongitude = lonSpan / 2, + }; + + /// Fake viewport publisher. + private sealed class FakeMapViewportNotifier : IMapViewportNotifier + { + public MapViewportSnapshot? Current { get; private set; } + public event EventHandler? ViewportChanged; + + public void Publish(MapViewportSnapshot snapshot) + { + Current = snapshot; + ViewportChanged?.Invoke(this, snapshot); + } + } + + /// + /// Records every factory invocation and stashes the message + /// source backing the latest + /// so tests can drive position reports through the fake driver. + /// + private sealed class SpyFactory + { + public int Calls { get; private set; } + public BoundingBox? LastBbox { get; private set; } + public FakeAisMessageSource? LastMessageSource { get; private set; } + + public AisDynamicFeatureSource Build(BoundingBox bbox) + { + Calls++; + LastBbox = bbox; + LastMessageSource = new FakeAisMessageSource(); + return new AisDynamicFeatureSource( + id: "ais", + messageSource: LastMessageSource, + request: new AisSubscriptionRequest { Area = bbox }); + } + } + + /// + /// Disposable wrapper that owns a . + /// + private sealed class TestableDeferredSource : IDisposable + { + public DeferredAisFeatureSource Decorator { get; } + + public TestableDeferredSource( + Func factory, + IMapViewportNotifier notifier, + TimeSpan? debounce = null) + { + Decorator = new DeferredAisFeatureSource( + id: "ais", + activationSpanDegrees: Threshold, + factory: factory, + notifier: notifier, + debounce: debounce); + } + + public void Dispose() => Decorator.DisposeAsync().AsTask().GetAwaiter().GetResult(); + } +} + +/// +/// In-memory for these tests. We +/// duplicate this from the AIS test project (where the equivalent is +/// internal) so we don't have to expose internals across +/// assemblies just for this PR. +/// +internal sealed class FakeAisMessageSource : IAisMessageSource +{ + public AisSourceMetadata Metadata { get; } = new() + { + DisplayName = "Fake AIS source", + Description = "Test fake.", + }; + + public List Subscriptions { get; } = new(); + + public IAisSubscription Subscribe(AisSubscriptionRequest request) + { + var sub = new FakeAisSubscription(request); + Subscriptions.Add(sub); + return sub; + } +} + +internal sealed class FakeAisSubscription : IAisSubscription +{ + private AisSubscriptionRequest _active; + + public FakeAisSubscription(AisSubscriptionRequest request) + { + _active = request; + } + + public AisSubscriptionRequest ActiveRequest => _active; + public bool Disposed { get; private set; } + public bool SupportsAreaUpdate { get; set; } = true; + public List AreaUpdates { get; } = new(); + + public event EventHandler? PositionReportReceived; + public event EventHandler? StaticVoyageDataReceived; + public event EventHandler? TargetLost; + + public bool TryUpdateArea(BoundingBox? area) + { + if (!SupportsAreaUpdate) return false; + AreaUpdates.Add(area); + _active = _active with { Area = area }; + return true; + } + + public void EmitPosition(AisPositionReport report) + => PositionReportReceived?.Invoke(this, report); + + public void EmitStatic(AisStaticVoyageData data) + => StaticVoyageDataReceived?.Invoke(this, data); + + public void EmitTargetLost(AisTargetLost lost) + => TargetLost?.Invoke(this, lost); + + public ValueTask DisposeAsync() + { + Disposed = true; + return ValueTask.CompletedTask; + } +} diff --git a/tests/EncDotNet.S100.Viewer.Tests/MapViewportNotifierTests.cs b/tests/EncDotNet.S100.Viewer.Tests/MapViewportNotifierTests.cs new file mode 100644 index 0000000..c9d5b7b --- /dev/null +++ b/tests/EncDotNet.S100.Viewer.Tests/MapViewportNotifierTests.cs @@ -0,0 +1,68 @@ +using EncDotNet.S100.Viewer.Services; +using Mapsui; +using Mapsui.Projections; +using Xunit; + +namespace EncDotNet.S100.Viewer.Tests; + +public class MapViewportNotifierTests +{ + [Fact] + public void TryProject_returns_null_when_viewport_unsized() + { + var vp = new Viewport(centerX: 0, centerY: 0, resolution: 1, rotation: 0, width: 0, height: 0); + Assert.Null(MapViewportNotifier.TryProject(vp)); + } + + [Fact] + public void TryProject_returns_null_when_height_zero() + { + var vp = new Viewport(0, 0, 1, 0, 100, 0); + Assert.Null(MapViewportNotifier.TryProject(vp)); + } + + [Fact] + public void TryProject_world_view_returns_clamped_full_extents() + { + // A "whole world" view: centered on (0,0), resolution chosen + // so the half-extents reach the Mercator clamp. + // halfW = width * res / 2 = 1024 * 39135.76 / 2 ≈ 20037508.34 + var vp = new Viewport(0, 0, resolution: 39135.76, rotation: 0, width: 1024, height: 1024); + + var snap = MapViewportNotifier.TryProject(vp); + Assert.NotNull(snap); + + // Longitudes hit ±180 at the clamp. + Assert.InRange(snap!.MinLongitude, -180.001, -179.5); + Assert.InRange(snap.MaxLongitude, 179.5, 180.001); + + // Latitudes are clamped to ±~85.0511° (Mercator limit). + Assert.InRange(snap.MinLatitude, -85.1, -84.9); + Assert.InRange(snap.MaxLatitude, 84.9, 85.1); + } + + [Fact] + public void TryProject_small_view_round_trips_through_spherical_mercator() + { + // Drive the projection in the opposite direction first so the + // test asserts agreement, not magic numbers. + var (xMin, yMin) = SphericalMercator.FromLonLat(-1.0, -1.0); + var (xMax, yMax) = SphericalMercator.FromLonLat(1.0, 1.0); + var halfW = (xMax - xMin) / 2.0; + var halfH = (yMax - yMin) / 2.0; + var centerX = (xMax + xMin) / 2.0; + var centerY = (yMax + yMin) / 2.0; + const double width = 256.0; + var resolution = (halfW * 2.0) / width; + var height = (halfH * 2.0) / resolution; + + var vp = new Viewport(centerX, centerY, resolution, rotation: 0, width: width, height: height); + var snap = MapViewportNotifier.TryProject(vp); + + Assert.NotNull(snap); + Assert.Equal(-1.0, snap!.MinLongitude, 4); + Assert.Equal(1.0, snap.MaxLongitude, 4); + Assert.Equal(-1.0, snap.MinLatitude, 4); + Assert.Equal(1.0, snap.MaxLatitude, 4); + } +} diff --git a/tests/EncDotNet.S100.Viewer.Tests/SettingsViewModelAisActivationTests.cs b/tests/EncDotNet.S100.Viewer.Tests/SettingsViewModelAisActivationTests.cs new file mode 100644 index 0000000..b3fad21 --- /dev/null +++ b/tests/EncDotNet.S100.Viewer.Tests/SettingsViewModelAisActivationTests.cs @@ -0,0 +1,90 @@ +using System; +using System.IO; +using EncDotNet.S100.Viewer; +using EncDotNet.S100.Viewer.ViewModels; +using Xunit; + +namespace EncDotNet.S100.Viewer.Tests; + +public class SettingsViewModelAisActivationTests +{ + private static ViewerSettings NewSettings() + { + var path = Path.Combine(Path.GetTempPath(), $"settings-{Guid.NewGuid():N}.json"); + return new ViewerSettings { SettingsFilePath = path }; + } + + [Fact] + public void Defaults_to_AisOverlaySettings_default_when_unset() + { + var s = NewSettings(); + var vm = new SettingsViewModel(s); + // When AisOverlay block is absent, VM falls back to the + // AisOverlaySettings default (50°), matching the gate that + // would apply at runtime if the user enabled AIS without + // touching the threshold. + Assert.Equal(50.0, vm.AisActivationViewportSpanDegrees); + } + + [Fact] + public void Hydrates_from_persisted_value() + { + var s = NewSettings(); + s.AisOverlay = new AisOverlaySettings { ActivationViewportSpanDegrees = 12.5 }; + var vm = new SettingsViewModel(s); + Assert.Equal(12.5, vm.AisActivationViewportSpanDegrees); + } + + [Fact] + public void Setting_persists_to_settings_block() + { + var s = NewSettings(); + try + { + var vm = new SettingsViewModel(s); + vm.AisActivationViewportSpanDegrees = 30.0; + Assert.Equal(30.0, s.AisOverlay?.ActivationViewportSpanDegrees); + } + finally + { + if (File.Exists(s.SettingsFilePath)) File.Delete(s.SettingsFilePath); + } + } + + [Theory] + [InlineData(0.0)] + [InlineData(-5.0)] + public void Non_positive_normalises_to_null(double v) + { + var s = NewSettings(); + try + { + var vm = new SettingsViewModel(s); + vm.AisActivationViewportSpanDegrees = v; + Assert.Null(vm.AisActivationViewportSpanDegrees); + Assert.Null(s.AisOverlay?.ActivationViewportSpanDegrees); + } + finally + { + if (File.Exists(s.SettingsFilePath)) File.Delete(s.SettingsFilePath); + } + } + + [Fact] + public void Null_persists_as_null_meaning_no_gate() + { + var s = NewSettings(); + s.AisOverlay = new AisOverlaySettings { ActivationViewportSpanDegrees = 25.0 }; + try + { + var vm = new SettingsViewModel(s); + vm.AisActivationViewportSpanDegrees = null; + Assert.Null(vm.AisActivationViewportSpanDegrees); + Assert.Null(s.AisOverlay?.ActivationViewportSpanDegrees); + } + finally + { + if (File.Exists(s.SettingsFilePath)) File.Delete(s.SettingsFilePath); + } + } +}