Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
200 changes: 200 additions & 0 deletions docs/design/ais-zoom-gated-subscription.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
# AIS zoom-gated subscription

> Status: implemented<br>
> 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)<br>
> 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.
2 changes: 2 additions & 0 deletions docs/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 3 additions & 0 deletions src/EncDotNet.S100.DynamicSources.Ais/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
26 changes: 26 additions & 0 deletions src/EncDotNet.S100.Viewer/AisOverlaySettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,32 @@ internal sealed class AisOverlaySettings
/// initial subscribe before the user has panned the map.
/// </summary>
public AisOverlayBoundingBox? InitialArea { get; set; }

/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// <para>
/// Default <c>50.0</c> — 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).
/// </para>
/// <para>
/// <see langword="null"/> disables the gate entirely — the
/// subscription starts immediately on viewer launch, matching
/// pre-PR behaviour. Values <c>&lt;= 0</c> are normalised to
/// <see langword="null"/> by the settings view-model so users
/// cannot configure a gate that never opens.
/// </para>
/// </remarks>
public double? ActivationViewportSpanDegrees { get; set; } = 50.0;
}

/// <summary>
Expand Down
8 changes: 8 additions & 0 deletions src/EncDotNet.S100.Viewer/App.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,14 @@ private static IServiceProvider ConfigureServices()
services.AddSingleton<EncDotNet.S100.DynamicSources.IDynamicFeatureSource>(sp =>
sp.GetRequiredService<EncDotNet.S100.Viewer.Services.DynamicSources.OwnShip.OwnShipSource>());

// 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<EncDotNet.S100.Viewer.Services.MapViewportNotifier>();
services.AddSingleton<EncDotNet.S100.Viewer.Services.IMapViewportNotifier>(sp =>
sp.GetRequiredService<EncDotNet.S100.Viewer.Services.MapViewportNotifier>());

// PR-D? upgraded own-ship symbology: register OwnShipRenderer
// under the "ownship" key so DynamicSourceOverlayHost resolves
// it for the own-ship source (RendererKey = "ownship").
Expand Down
12 changes: 12 additions & 0 deletions src/EncDotNet.S100.Viewer/MainWindow.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<IDynamicFeatureSource>) 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
Expand Down
17 changes: 17 additions & 0 deletions src/EncDotNet.S100.Viewer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/EncDotNet.S100.Viewer/Resources/Strings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
9 changes: 9 additions & 0 deletions src/EncDotNet.S100.Viewer/Resources/Strings.resx
Original file line number Diff line number Diff line change
Expand Up @@ -1126,6 +1126,15 @@ Open an S-101, S-124, S-125, S-201, or S-421 dataset to see display controls her
<value>Environment variable {0} is set; it will be used.</value>
<comment>{0} = environment variable name.</comment>
</data>
<data name="Settings_AisActivationSpan" xml:space="preserve">
<value>Activate at viewport span (degrees)</value>
</data>
<data name="Settings_AisActivationSpanTooltip" xml:space="preserve">
<value>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.</value>
</data>
<data name="Settings_AisActivationSpanHint" xml:space="preserve">
<value>Default 50°. Leave blank to subscribe immediately on launch (legacy behaviour). Once activated, the subscription stays active for the rest of the viewer session.</value>
</data>

<!-- PR-D4: Dynamic-source pick report -->
<data name="PickReport_DynamicSection" xml:space="preserve">
Expand Down
Loading
Loading