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);
+ }
+ }
+}