From 36593060a2dbd1a0977bf2b5de02d639aee25661 Mon Sep 17 00:00:00 2001 From: Phillip Hoff Date: Sat, 30 May 2026 23:20:54 -0700 Subject: [PATCH] Add zoom-gated AIS subscription with viewport-span threshold The AIS overlay used to subscribe to aisstream.io immediately on viewer startup, using whatever the initial (effectively global) viewport was. That flooded the map with vessels far from anything the user was looking at, wasted aisstream.io quota, and slowed cold start. This change introduces a viewer-side gate that defers the first subscription until the visible viewport's lat-span and lon-span have both shrunk to or below a configurable threshold. Once the gate trips, the subscription is opened 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 even if the user later zooms back out. * AisOverlaySettings.ActivationViewportSpanDegrees (nullable double, default 50.0; null preserves the legacy subscribe-immediately behaviour) is round-trippable through ViewerSettings JSON. * DeferredAisFeatureSource decorator wraps the real AisDynamicFeatureSource and gates construction on the first qualifying viewport snapshot. * IMapViewportNotifier + MapViewportNotifier translate Mapsui's EPSG:3857 viewport into a lat/lon snapshot and publish it to subscribers. MainWindow binds the notifier to the live Navigator before the dynamic-source overlay host is built. * Settings dialog gets a localised numeric input for the threshold with tooltip + helper text. * Design doc docs/design/ais-zoom-gated-subscription.md captures the Q1-Q6 decisions; viewer + AIS READMEs and docs/toc.yml are updated. Tests: AisOverlaySettings round-trip; DeferredAisFeatureSource gate-closed/open semantics, one-shot activation, debounced UpdateArea, dispose; MapViewportNotifier projection; SettingsViewModel persistence and <=0 normalisation; factory deferred-wrapper branch. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/design/ais-zoom-gated-subscription.md | 200 ++++++++++ docs/toc.yml | 2 + .../README.md | 3 + .../AisOverlaySettings.cs | 26 ++ src/EncDotNet.S100.Viewer/App.axaml.cs | 8 + src/EncDotNet.S100.Viewer/MainWindow.axaml.cs | 12 + src/EncDotNet.S100.Viewer/README.md | 17 + .../Resources/Strings.cs | 3 + .../Resources/Strings.resx | 9 + .../AisOverlayServiceCollectionExtensions.cs | 46 ++- .../Ais/DeferredAisFeatureSource.cs | 302 +++++++++++++++ .../Services/IMapViewportNotifier.cs | 39 ++ .../Services/MapViewportNotifier.cs | 129 +++++++ .../Services/MapViewportSnapshot.cs | 41 +++ .../ViewModels/SettingsViewModel.cs | 26 ++ .../Views/SettingsView.axaml | 17 + .../AisOverlaySettingsTests.cs | 80 ++++ .../DynamicSources/AisOverlayFactoryTests.cs | 81 +++++ .../DeferredAisFeatureSourceTests.cs | 343 ++++++++++++++++++ .../MapViewportNotifierTests.cs | 68 ++++ .../SettingsViewModelAisActivationTests.cs | 90 +++++ 21 files changed, 1529 insertions(+), 13 deletions(-) create mode 100644 docs/design/ais-zoom-gated-subscription.md create mode 100644 src/EncDotNet.S100.Viewer/Services/DynamicSources/Ais/DeferredAisFeatureSource.cs create mode 100644 src/EncDotNet.S100.Viewer/Services/IMapViewportNotifier.cs create mode 100644 src/EncDotNet.S100.Viewer/Services/MapViewportNotifier.cs create mode 100644 src/EncDotNet.S100.Viewer/Services/MapViewportSnapshot.cs create mode 100644 tests/EncDotNet.S100.Viewer.Tests/AisOverlaySettingsTests.cs create mode 100644 tests/EncDotNet.S100.Viewer.Tests/DynamicSources/DeferredAisFeatureSourceTests.cs create mode 100644 tests/EncDotNet.S100.Viewer.Tests/MapViewportNotifierTests.cs create mode 100644 tests/EncDotNet.S100.Viewer.Tests/SettingsViewModelAisActivationTests.cs 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); + } + } +}