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
162 changes: 162 additions & 0 deletions docs/design/dynamic-source-pick.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
# Dynamic Source Pick — Design Note (PR-D4)

> Status: **In progress** — describes the click-to-identify support
> for dynamic feature sources shipped by PR-D4. See
> [Dynamic feature sources](dynamic-feature-source.md) for the
> underlying push-driven abstraction (PR-D1) and
> [AIS dynamic feature source](ais-source.md) for the motivating
> AIS overlay (PR-D3).

## 0. Problem

PR-D3 wired AIS targets and own-ship onto the map via
`DynamicSourceOverlayHost`. The overlay layers are pure rendering —
they carry no Mapsui feature attributes and are *not* enumerated by
the existing `IPickService.HandlePick(MapInfo)` path, which only
walks dataset-owned layers. Clicking an AIS target therefore does
nothing.

PR-D4 closes the gap: a click anywhere on the map collects both
dataset hits (existing path) and dynamic-source hits (new path) and
displays them side-by-side in the Pick Report panel.

## 1. Decisions

### Q1. Pick-hit shape — **sibling type**

`DynamicPickHit` is a new internal record next to `PickHit`.

```
internal sealed record DynamicPickHit(
string SourceId,
string SourceDisplayName,
string FeatureId,
string? Kind,
string DisplayLabel,
DateTimeOffset LastUpdated,
double Latitude,
double Longitude,
DynamicMotion? Motion,
DynamicVesselGeometry? VesselGeometry,
IReadOnlyList<DynamicPickAttributeRow> Attributes);
```

Rationale: `PickHit` is dataset-centric (FC-decoded feature type,
xlink references, dataset file name, station chart VM). Dynamic
features have a fundamentally different identity model — opaque
string id (MMSI for AIS, `"ownship"`), no FC, mutable position, free-
form `Attributes` dictionary. Mixing them invites optional-field
bloat. The pick report panel renders both with a small dispatch.

### Q2. Hit-test geometry & tolerance — **12 device-pixel radius, point-distance only for v1**

Hit testing happens in projected map units (Spherical Mercator). The
click delivers `(MPoint mapPoint, double resolution)`; tolerance is
`12 * resolution` (12 device pixels at the current zoom). Dynamic
features are converted with `SphericalMercator.FromLonLat`.

For v1 the tester treats every dynamic feature as a point regardless
of its `GeometryType` — only `Point` features ship today
(own-ship + AIS). When line/polygon dynamic features land they will
get their own paths via `Geometry.Distance`; this is noted in the
contract docs but unimplemented.

Per-renderer custom tolerance (e.g. "anywhere inside the hull
polygon") is **out of scope for v1** — see §3.

### Q3. Where pick is wired — **separate `IDynamicSourcePickService`**

A new service runs alongside `IPickService`. The click handler
(`MapInteractionController`) computes the world position from the
`MapInfo`, asks the dynamic-source service for hits, and forwards
both result lists to `IPickService.HandlePick(MapInfo, IReadOnlyList<DynamicPickHit>)`.
The pick service publishes both into `PickReportViewModel`.

Reasons:
- Different storage (registry of `IDynamicFeatureSource` vs loader
of `IDatasetProcessor`).
- Easier to test (no `MapInfo` mocking; pure pixel math).
- Keeps `PickService` from becoming a god service.

### Q4. Pick report panel UI — **sectioned single list**

The panel grows a third section between the dataset hit-list and
the identity / attributes block:

```
┌─ FEATURES (3) ─┐ ← existing dataset hit list (>1)
│ • Cargo vessel — MV ALPHA │
│ • DepthArea — 12345 │
└──────────────────────────────┘
┌─ DYNAMIC FEATURES (1) ─┐ ← new: shown when DynamicHits ≠ ∅
│ AIS — vessel.ais.cargo │
│ MV ALPHA · 4s ago │
│ 47.602°N 122.330°W │
│ COG 270° · SOG 12.4 kn │
│ MMSI: 367123456 │
│ … │
└──────────────────────────────┘
[identity / references / attributes block — dataset only]
```

Both sections are visible at once when both have hits. Dataset
section is hidden when `Hits.Count == 0`; dynamic section is hidden
when `DynamicHits.Count == 0`. `HasPick` is now true if **either**
list is non-empty.

### Q5. Localisation — **all new strings via Strings.resx**

Keys added: `PickReport_DynamicSection`, `PickReport_LastUpdatedRelative`,
`PickReport_Mmsi`, `PickReport_VesselName`, `PickReport_CallSign`,
`PickReport_Heading`, `PickReport_Cog`, `PickReport_Sog`,
`PickReport_Dimensions`, `PickReport_Position`, `Tooltip_DynamicHit`.
Tooltips on every new control.

### Q6. Selection / highlight — **deferred**

A future PR will add an S-52-style square selection ring drawn
around the picked dynamic feature. Out of scope for PR-D4.

## 2. Threading & lifetime

- `DynamicSourcePickService` is constructed against the existing
`DynamicFeatureSourceRegistryAccessor` (late-bound; the host
attaches the registry when MainWindow finishes wiring). When the
registry is unattached, `Pick(...)` returns an empty list — the
click is a dataset-only pick, not an error.
- `IDynamicFeatureSource.CurrentFeatures` is documented as safe from
any thread; the pick service is called on the UI thread (click
handler) and reads the immutable snapshot.
- The hit-tester is pure and stateless; tests drive it directly.

## 3. Out of scope

- Selection ring / highlight on the map.
- ARPA-style multi-target track viewer.
- AIS aids-to-navigation, base stations, SAR aircraft.
- CPA / TCPA computations.
- Picking on time-step coverage products (S-102 / S-104 / S-111) —
goes through dataset pick.
- Server-side / MCP exposure of dynamic picks.
- Per-source custom hit-test geometry (e.g. polygon hull regions).
Easy follow-up: the `IDynamicSourceHitTester` becomes pluggable
per `RendererKey`.

## 4. Test surfaces

- `DynamicSourceHitTesterTests` — point-in-tolerance hits, miss,
visibility filter, ordering by distance, multiple sources.
- `DynamicSourcePickServiceTests` — integration with a fake
registry; hits flow through the registry accessor.
- `PickReportViewModelTests` — `SetDynamicPicks` populates the
`DynamicHits` collection and flips `HasPick` true; clears revert.
- `PickServiceTests` — calling `HandlePick(null, dynamicHits)`
publishes the dynamic hits even with no `MapInfo`.

## 5. References

- S-52 / IEC 62388 — vessel symbology and pick ergonomics.
- IEC 61174 §8 — ECDIS interrogation requirements (aspirational;
the standard does not normatively define dynamic-target
interrogation, but the "show all object info under the cursor"
pattern is the governing convention).
2 changes: 2 additions & 0 deletions docs/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,7 @@
href: design/own-ship-symbology.md
- name: AIS dynamic feature source
href: design/ais-source.md
- name: Dynamic source pick
href: design/dynamic-source-pick.md
- name: S-98 interoperability
href: design/s98-interoperability.md
3 changes: 3 additions & 0 deletions src/EncDotNet.S100.Viewer/App.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,9 @@ private static IServiceProvider ConfigureServices()
services.AddSingleton<IToastService, ToastService>();
services.AddSingleton<IDatasetLoaderService, DatasetLoaderService>();
services.AddSingleton<IPickService, PickService>();
services.AddSingleton<EncDotNet.S100.Viewer.Services.DynamicSources.IDynamicSourcePickService>(sp =>
new EncDotNet.S100.Viewer.Services.DynamicSources.DynamicSourcePickService(
sp.GetRequiredService<EncDotNet.S100.Viewer.Services.DynamicSources.DynamicFeatureSourceRegistryAccessor>()));
services.AddSingleton<IFeatureSearchService, FeatureSearchService>();
services.AddSingleton<IFileDialogService, FileDialogService>();
services.AddSingleton<IExchangeSetService, ExchangeSetService>();
Expand Down
6 changes: 5 additions & 1 deletion src/EncDotNet.S100.Viewer/MainWindow.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,11 @@ internal MainWindow(
// Enable trackpad pan/pinch/rotate gestures, single/double-tap pick,
// long-press pick, mouse lat/lon readout, scale-bar/compass viewport
// sync, and the zoom in/out overlay buttons.
var interactionController = new MapInteractionController(_viewModel, _pickService, _loader);
var interactionController = new MapInteractionController(
_viewModel,
_pickService,
_loader,
App.Services.GetService<EncDotNet.S100.Viewer.Services.DynamicSources.IDynamicSourcePickService>());
interactionController.Attach(MapControl, ZoomInButton, ZoomOutButton, ZoomToExtentButton, ScaleBar, CompassRose);

// Wire the map-tool controller to the map: tools are registered with
Expand Down
14 changes: 14 additions & 0 deletions src/EncDotNet.S100.Viewer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,20 @@ Own-ship visibility is controlled by its row in the **Dynamic
Arrows** plane of the Layer Stack panel and is persisted between
sessions.

### Picking dynamic features

Click (or long-press, on touch) on any dynamic-source target
— own-ship, an AIS vessel pictogram — to identify it. The
**Pick Report** panel renders a *Dynamic sources* section above
the dataset hits showing the source display name, feature kind,
last-updated relative time, position, course / heading / speed
when available, and the full attribute snapshot (MMSI, vessel
name, call sign, etc. for AIS). Dataset and dynamic hits stack in
one panel so a single click reveals everything under the
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).

## Optional MCP server

The viewer can optionally host a Model Context Protocol server
Expand Down
21 changes: 21 additions & 0 deletions src/EncDotNet.S100.Viewer/Resources/Strings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -425,4 +425,25 @@ 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));

// PR-D4: Dynamic-source pick report.
public static string PickReport_DynamicSection => Get(nameof(PickReport_DynamicSection));
public static string PickReport_LastUpdatedRelative => Get(nameof(PickReport_LastUpdatedRelative));
public static string PickReport_LastUpdatedSecondsAgo => Get(nameof(PickReport_LastUpdatedSecondsAgo));
public static string PickReport_LastUpdatedMinutesAgo => Get(nameof(PickReport_LastUpdatedMinutesAgo));
public static string PickReport_LastUpdatedHoursAgo => Get(nameof(PickReport_LastUpdatedHoursAgo));
public static string PickReport_LastUpdatedJustNow => Get(nameof(PickReport_LastUpdatedJustNow));
public static string PickReport_Position => Get(nameof(PickReport_Position));
public static string PickReport_PositionFormat => Get(nameof(PickReport_PositionFormat));
public static string PickReport_Cog => Get(nameof(PickReport_Cog));
public static string PickReport_Heading => Get(nameof(PickReport_Heading));
public static string PickReport_Sog => Get(nameof(PickReport_Sog));
public static string PickReport_DegreesFormat => Get(nameof(PickReport_DegreesFormat));
public static string PickReport_KnotsFormat => Get(nameof(PickReport_KnotsFormat));
public static string PickReport_Dimensions => Get(nameof(PickReport_Dimensions));
public static string PickReport_DimensionsFormat => Get(nameof(PickReport_DimensionsFormat));
public static string PickReport_Mmsi => Get(nameof(PickReport_Mmsi));
public static string PickReport_VesselName => Get(nameof(PickReport_VesselName));
public static string PickReport_CallSign => Get(nameof(PickReport_CallSign));
public static string Tooltip_DynamicHit => Get(nameof(Tooltip_DynamicHit));
}
66 changes: 66 additions & 0 deletions src/EncDotNet.S100.Viewer/Resources/Strings.resx
Original file line number Diff line number Diff line change
Expand Up @@ -1126,4 +1126,70 @@ 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>

<!-- PR-D4: Dynamic-source pick report -->
<data name="PickReport_DynamicSection" xml:space="preserve">
<value>DYNAMIC FEATURES ({0})</value>
<comment>{0} = count of dynamic hits.</comment>
</data>
<data name="PickReport_LastUpdatedRelative" xml:space="preserve">
<value>Updated {0}</value>
<comment>{0} = formatted relative time string (e.g. "3s ago").</comment>
</data>
<data name="PickReport_LastUpdatedSecondsAgo" xml:space="preserve">
<value>{0}s ago</value>
<comment>{0} = whole seconds since the feature was updated.</comment>
</data>
<data name="PickReport_LastUpdatedMinutesAgo" xml:space="preserve">
<value>{0}m ago</value>
<comment>{0} = whole minutes since the feature was updated.</comment>
</data>
<data name="PickReport_LastUpdatedHoursAgo" xml:space="preserve">
<value>{0}h ago</value>
<comment>{0} = whole hours since the feature was updated.</comment>
</data>
<data name="PickReport_LastUpdatedJustNow" xml:space="preserve">
<value>just now</value>
</data>
<data name="PickReport_Position" xml:space="preserve">
<value>Position</value>
</data>
<data name="PickReport_PositionFormat" xml:space="preserve">
<value>{0:F4}°, {1:F4}°</value>
<comment>{0} = latitude, {1} = longitude. Use invariant culture.</comment>
</data>
<data name="PickReport_Cog" xml:space="preserve">
<value>COG</value>
</data>
<data name="PickReport_Heading" xml:space="preserve">
<value>Heading</value>
</data>
<data name="PickReport_Sog" xml:space="preserve">
<value>SOG</value>
</data>
<data name="PickReport_DegreesFormat" xml:space="preserve">
<value>{0:F1}°</value>
</data>
<data name="PickReport_KnotsFormat" xml:space="preserve">
<value>{0:F1} kn</value>
</data>
<data name="PickReport_Dimensions" xml:space="preserve">
<value>Dimensions</value>
</data>
<data name="PickReport_DimensionsFormat" xml:space="preserve">
<value>{0:F0} m × {1:F0} m</value>
<comment>{0} = length, {1} = beam, in metres.</comment>
</data>
<data name="PickReport_Mmsi" xml:space="preserve">
<value>MMSI</value>
</data>
<data name="PickReport_VesselName" xml:space="preserve">
<value>Name</value>
</data>
<data name="PickReport_CallSign" xml:space="preserve">
<value>Call sign</value>
</data>
<data name="Tooltip_DynamicHit" xml:space="preserve">
<value>Dynamic-source feature picked from the live overlay.</value>
</data>
</root>
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using EncDotNet.S100.DynamicSources;

namespace EncDotNet.S100.Viewer.Services.DynamicSources;

Expand Down Expand Up @@ -58,6 +59,9 @@ public IDynamicFeatureSourceRegistry? Current

public bool GetVisible(string sourceId) => _current?.GetVisible(sourceId) ?? true;

public IReadOnlyList<IDynamicFeatureSource> GetVisibleSourceInstances() =>
_current?.GetVisibleSourceInstances() ?? Array.Empty<IDynamicFeatureSource>();

public void SetVisible(string sourceId, bool visible) =>
_current?.SetVisible(sourceId, visible);

Expand Down
Loading
Loading