diff --git a/docs/design/dynamic-source-pick.md b/docs/design/dynamic-source-pick.md new file mode 100644 index 0000000..c857baf --- /dev/null +++ b/docs/design/dynamic-source-pick.md @@ -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 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)`. +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). diff --git a/docs/toc.yml b/docs/toc.yml index 2173178..bc447dd 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -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 diff --git a/src/EncDotNet.S100.Viewer/App.axaml.cs b/src/EncDotNet.S100.Viewer/App.axaml.cs index e5dca60..351c6d0 100644 --- a/src/EncDotNet.S100.Viewer/App.axaml.cs +++ b/src/EncDotNet.S100.Viewer/App.axaml.cs @@ -248,6 +248,9 @@ private static IServiceProvider ConfigureServices() services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(sp => + new EncDotNet.S100.Viewer.Services.DynamicSources.DynamicSourcePickService( + sp.GetRequiredService())); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/EncDotNet.S100.Viewer/MainWindow.axaml.cs b/src/EncDotNet.S100.Viewer/MainWindow.axaml.cs index ffa1185..06eb800 100644 --- a/src/EncDotNet.S100.Viewer/MainWindow.axaml.cs +++ b/src/EncDotNet.S100.Viewer/MainWindow.axaml.cs @@ -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()); interactionController.Attach(MapControl, ZoomInButton, ZoomOutButton, ZoomToExtentButton, ScaleBar, CompassRose); // Wire the map-tool controller to the map: tools are registered with diff --git a/src/EncDotNet.S100.Viewer/README.md b/src/EncDotNet.S100.Viewer/README.md index f8af7f3..e61888b 100644 --- a/src/EncDotNet.S100.Viewer/README.md +++ b/src/EncDotNet.S100.Viewer/README.md @@ -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 diff --git a/src/EncDotNet.S100.Viewer/Resources/Strings.cs b/src/EncDotNet.S100.Viewer/Resources/Strings.cs index 4ac607b..d9c14d8 100644 --- a/src/EncDotNet.S100.Viewer/Resources/Strings.cs +++ b/src/EncDotNet.S100.Viewer/Resources/Strings.cs @@ -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)); } diff --git a/src/EncDotNet.S100.Viewer/Resources/Strings.resx b/src/EncDotNet.S100.Viewer/Resources/Strings.resx index 019ef1e..3e7b5ab 100644 --- a/src/EncDotNet.S100.Viewer/Resources/Strings.resx +++ b/src/EncDotNet.S100.Viewer/Resources/Strings.resx @@ -1126,4 +1126,70 @@ 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. + + + + DYNAMIC FEATURES ({0}) + {0} = count of dynamic hits. + + + Updated {0} + {0} = formatted relative time string (e.g. "3s ago"). + + + {0}s ago + {0} = whole seconds since the feature was updated. + + + {0}m ago + {0} = whole minutes since the feature was updated. + + + {0}h ago + {0} = whole hours since the feature was updated. + + + just now + + + Position + + + {0:F4}°, {1:F4}° + {0} = latitude, {1} = longitude. Use invariant culture. + + + COG + + + Heading + + + SOG + + + {0:F1}° + + + {0:F1} kn + + + Dimensions + + + {0:F0} m × {1:F0} m + {0} = length, {1} = beam, in metres. + + + MMSI + + + Name + + + Call sign + + + Dynamic-source feature picked from the live overlay. + diff --git a/src/EncDotNet.S100.Viewer/Services/DynamicSources/DynamicFeatureSourceRegistryAccessor.cs b/src/EncDotNet.S100.Viewer/Services/DynamicSources/DynamicFeatureSourceRegistryAccessor.cs index 3b1e22c..ae49a60 100644 --- a/src/EncDotNet.S100.Viewer/Services/DynamicSources/DynamicFeatureSourceRegistryAccessor.cs +++ b/src/EncDotNet.S100.Viewer/Services/DynamicSources/DynamicFeatureSourceRegistryAccessor.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using EncDotNet.S100.DynamicSources; namespace EncDotNet.S100.Viewer.Services.DynamicSources; @@ -58,6 +59,9 @@ public IDynamicFeatureSourceRegistry? Current public bool GetVisible(string sourceId) => _current?.GetVisible(sourceId) ?? true; + public IReadOnlyList GetVisibleSourceInstances() => + _current?.GetVisibleSourceInstances() ?? Array.Empty(); + public void SetVisible(string sourceId, bool visible) => _current?.SetVisible(sourceId, visible); diff --git a/src/EncDotNet.S100.Viewer/Services/DynamicSources/DynamicSourceHitTester.cs b/src/EncDotNet.S100.Viewer/Services/DynamicSources/DynamicSourceHitTester.cs new file mode 100644 index 0000000..5c1b4dd --- /dev/null +++ b/src/EncDotNet.S100.Viewer/Services/DynamicSources/DynamicSourceHitTester.cs @@ -0,0 +1,190 @@ +using System; +using System.Collections.Generic; +using EncDotNet.S100.DynamicSources; +using EncDotNet.S100.Pipelines.Vector; +using Mapsui; +using Mapsui.Projections; + +namespace EncDotNet.S100.Viewer.Services.DynamicSources; + +/// +/// Pure, stateless hit-tester for dynamic features. +/// +/// +/// +/// Inputs: a click in Spherical Mercator world +/// units, the current viewport resolution (map units per +/// device pixel), and the candidate sources to walk. +/// +/// +/// Tolerance is fixed at device +/// pixels (12, matching the AIS pictogram outer-disc radius). The +/// effective hit radius in map units is +/// ToleranceDevicePixels * resolution. +/// +/// +/// v1 treats every dynamic feature as a point regardless of its +/// declared — only point features ship +/// today. Line / polygon dynamic features will get their own paths +/// when a producer needs them; see +/// docs/design/dynamic-source-pick.md §1 Q2. +/// +/// +internal static class DynamicSourceHitTester +{ + /// + /// Hit-test radius in device pixels. Matches the AIS pictogram's + /// outer disc so a click "on" the symbol picks reliably. + /// + public const double ToleranceDevicePixels = 12.0; + + /// + /// Returns hits ordered by ascending distance from + /// . Sources with no candidate features + /// (or zero matches) are silently skipped. + /// + /// Click position in Spherical Mercator world units. + /// Map units per device pixel at the current zoom. + /// + /// Sources to walk. Callers are expected to filter to currently + /// visible sources; the tester does not consult the registry + /// itself. + /// + public static IReadOnlyList HitTest( + MPoint mapPoint, + double resolution, + IEnumerable sources) + { + ArgumentNullException.ThrowIfNull(mapPoint); + ArgumentNullException.ThrowIfNull(sources); + + if (resolution <= 0 || double.IsNaN(resolution) || double.IsInfinity(resolution)) + { + return Array.Empty(); + } + + var toleranceMapUnits = ToleranceDevicePixels * resolution; + var toleranceSquared = toleranceMapUnits * toleranceMapUnits; + + var hits = new List(); + foreach (var source in sources) + { + if (source is null) continue; + foreach (var feature in source.CurrentFeatures) + { + if (feature.Coordinates is null || feature.Coordinates.Count == 0) + continue; + + // v1: point-only. Take the first coordinate as the + // representative point regardless of GeometryType. + var (lat, lon) = feature.Coordinates[0]; + if (double.IsNaN(lat) || double.IsNaN(lon)) continue; + if (lat < -90.0 || lat > 90.0) continue; + + var (x, y) = SphericalMercator.FromLonLat(lon, lat); + var dx = x - mapPoint.X; + var dy = y - mapPoint.Y; + var distSq = dx * dx + dy * dy; + var distance = Math.Sqrt(distSq); + + // Try the vessel-hull polygon first when present — + // matches the rendered shape so a click anywhere + // inside the drawn hull picks the vessel even when + // the antenna is far from the click. Distance reports + // 0 inside the polygon so closer-to-antenna hits + // still order ahead of edge hits. + var insideHull = feature.VesselGeometry is { } geom + && IsInsideVesselHull(mapPoint, lat, lon, geom, feature.Motion?.HeadingDeg ?? 0.0); + + if (insideHull) + { + hits.Add(new DynamicHit(source, feature, 0.0)); + continue; + } + + if (distSq <= toleranceSquared) + { + hits.Add(new DynamicHit(source, feature, distance)); + } + } + } + + hits.Sort(static (a, b) => a.DistanceMapUnits.CompareTo(b.DistanceMapUnits)); + return hits; + } + + /// + /// Returns when + /// (Spherical Mercator) lies inside the vessel hull described by + /// at antenna position + /// (, ) with heading + /// . Mirrors the 5-vertex hull polygon + /// produced by VesselSymbology. + /// + private static bool IsInsideVesselHull( + MPoint mapPoint, double lat, double lon, DynamicVesselGeometry geometry, double headingDeg) + { + if (geometry.LengthMetres <= 0 || geometry.BeamMetres <= 0) + return false; + + var theta = headingDeg * Math.PI / 180.0; + var sinT = Math.Sin(theta); + var cosT = Math.Cos(theta); + + var antX = -geometry.BeamMetres / 2.0 + geometry.PortOffsetMetres; + var antY = geometry.LengthMetres - geometry.BowOffsetMetres; + var halfBeam = geometry.BeamMetres / 2.0; + const double bowTaperRatio = 0.7; + var taperY = geometry.LengthMetres * bowTaperRatio; + + var local = new (double X, double Y)[] + { + ( 0, geometry.LengthMetres), + (+halfBeam, taperY), + (+halfBeam, 0), + (-halfBeam, 0), + (-halfBeam, taperY), + }; + + var ring = new (double X, double Y)[local.Length]; + const double metresPerDegLat = 111_320.0; + var cosLat = Math.Cos(lat * Math.PI / 180.0); + for (var i = 0; i < local.Length; i++) + { + var lx = local[i].X - antX; + var ly = local[i].Y - antY; + var east = lx * cosT + ly * sinT; + var north = -lx * sinT + ly * cosT; + var dLat = north / metresPerDegLat; + var dLon = cosLat == 0 ? 0.0 : east / (metresPerDegLat * cosLat); + var (mx, my) = SphericalMercator.FromLonLat(lon + dLon, lat + dLat); + ring[i] = (mx, my); + } + + return PointInPolygon(mapPoint.X, mapPoint.Y, ring); + } + + private static bool PointInPolygon(double px, double py, (double X, double Y)[] ring) + { + var inside = false; + for (int i = 0, j = ring.Length - 1; i < ring.Length; j = i++) + { + var xi = ring[i].X; var yi = ring[i].Y; + var xj = ring[j].X; var yj = ring[j].Y; + var intersect = ((yi > py) != (yj > py)) && + (px < (xj - xi) * (py - yi) / (yj - yi) + xi); + if (intersect) inside = !inside; + } + return inside; + } +} + +/// +/// Single hit returned by . Carries +/// the owning source and the picked feature so the pick service can +/// resolve display metadata. +/// +internal sealed record DynamicHit( + IDynamicFeatureSource Source, + DynamicFeature Feature, + double DistanceMapUnits); diff --git a/src/EncDotNet.S100.Viewer/Services/DynamicSources/DynamicSourceOverlayHost.cs b/src/EncDotNet.S100.Viewer/Services/DynamicSources/DynamicSourceOverlayHost.cs index 8fbd7ad..3da56b8 100644 --- a/src/EncDotNet.S100.Viewer/Services/DynamicSources/DynamicSourceOverlayHost.cs +++ b/src/EncDotNet.S100.Viewer/Services/DynamicSources/DynamicSourceOverlayHost.cs @@ -264,6 +264,23 @@ public bool GetVisible(string sourceId) } } + /// + public IReadOnlyList GetVisibleSourceInstances() + { + lock (_lock) + { + var list = new List(_ordered.Count); + foreach (var r in _ordered) + { + // Default to visible when no entry exists, matching + // GetVisible's contract. + if (_visibility.TryGetValue(r.Source.Id, out var v) && !v) continue; + list.Add(r.Source); + } + return list; + } + } + /// public void SetVisible(string sourceId, bool visible) { diff --git a/src/EncDotNet.S100.Viewer/Services/DynamicSources/DynamicSourcePickService.cs b/src/EncDotNet.S100.Viewer/Services/DynamicSources/DynamicSourcePickService.cs new file mode 100644 index 0000000..4975e1a --- /dev/null +++ b/src/EncDotNet.S100.Viewer/Services/DynamicSources/DynamicSourcePickService.cs @@ -0,0 +1,155 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using EncDotNet.S100.DynamicSources; +using EncDotNet.S100.Viewer.Resources; +using EncDotNet.S100.Viewer.ViewModels; +using Mapsui; + +namespace EncDotNet.S100.Viewer.Services.DynamicSources; + +/// +/// Default implementation. +/// Delegates the geometric hit-test to +/// and projects each hit into a +/// with localised attribute rows. +/// +internal sealed class DynamicSourcePickService : IDynamicSourcePickService +{ + private readonly IDynamicFeatureSourceRegistry _registry; + + public DynamicSourcePickService(IDynamicFeatureSourceRegistry registry) + { + ArgumentNullException.ThrowIfNull(registry); + _registry = registry; + } + + /// + public IReadOnlyList Pick(MPoint mapPoint, double resolution) + { + ArgumentNullException.ThrowIfNull(mapPoint); + + var sources = _registry.GetVisibleSourceInstances(); + if (sources.Count == 0) return Array.Empty(); + + var raw = DynamicSourceHitTester.HitTest(mapPoint, resolution, sources); + if (raw.Count == 0) return Array.Empty(); + + var hits = new List(raw.Count); + foreach (var hit in raw) + { + hits.Add(Project(hit)); + } + return hits; + } + + private static DynamicPickHit Project(DynamicHit hit) + { + var feature = hit.Feature; + var (lat, lon) = feature.Coordinates[0]; + + return new DynamicPickHit + { + SourceId = hit.Source.Id, + SourceDisplayName = hit.Source.Metadata.DisplayName, + FeatureId = feature.Id, + Kind = feature.Kind, + DisplayLabel = ResolveDisplayLabel(feature), + LastUpdated = feature.LastUpdated, + Latitude = lat, + Longitude = lon, + Motion = feature.Motion, + VesselGeometry = feature.VesselGeometry, + Attributes = BuildAttributeRows(feature), + }; + } + + /// + /// Picks a human-readable label, preferring a vessel name when + /// the source carries one (AIS static data) and falling back to + /// the feature id (MMSI for AIS, "ownship" for own-ship). + /// + private static string ResolveDisplayLabel(DynamicFeature feature) + { + if (feature.Attributes.TryGetValue("vesselName", out var name) + && name is string s && !string.IsNullOrWhiteSpace(s)) + { + return s; + } + return feature.Id; + } + + /// + /// Flattens motion / geometry / attributes into a single ordered + /// list of label-value rows. Order: position → motion (COG / + /// heading / SOG) → vessel geometry → declared attributes + /// (insertion order). Each row's value is pre-formatted. + /// + private static IReadOnlyList BuildAttributeRows(DynamicFeature feature) + { + var rows = new List(8); + var (lat, lon) = feature.Coordinates[0]; + + rows.Add(new DynamicPickAttributeRow( + Strings.PickReport_Position, + string.Format( + CultureInfo.InvariantCulture, + Strings.PickReport_PositionFormat, + lat, + lon))); + + if (feature.Motion is { } motion) + { + if (motion.CourseOverGroundDeg is { } cog) + rows.Add(new DynamicPickAttributeRow(Strings.PickReport_Cog, FormatDegrees(cog))); + if (motion.HeadingDeg is { } hdg) + rows.Add(new DynamicPickAttributeRow(Strings.PickReport_Heading, FormatDegrees(hdg))); + if (motion.SpeedOverGroundKn is { } sog) + rows.Add(new DynamicPickAttributeRow(Strings.PickReport_Sog, FormatKnots(sog))); + } + + if (feature.VesselGeometry is { } geom) + { + rows.Add(new DynamicPickAttributeRow( + Strings.PickReport_Dimensions, + string.Format( + CultureInfo.InvariantCulture, + Strings.PickReport_DimensionsFormat, + geom.LengthMetres, + geom.BeamMetres))); + } + + foreach (var (key, value) in feature.Attributes) + { + // vesselName already drives DisplayLabel — surface it in + // the row list too so the user sees it explicitly. + rows.Add(new DynamicPickAttributeRow( + MapAttributeKeyToLabel(key), + FormatAttributeValue(value))); + } + + return rows; + } + + private static string MapAttributeKeyToLabel(string key) => key switch + { + "mmsi" => Strings.PickReport_Mmsi, + "vesselName" => Strings.PickReport_VesselName, + "callSign" => Strings.PickReport_CallSign, + _ => key, + }; + + private static string FormatDegrees(double value) => + string.Format(CultureInfo.InvariantCulture, Strings.PickReport_DegreesFormat, value); + + private static string FormatKnots(double value) => + string.Format(CultureInfo.InvariantCulture, Strings.PickReport_KnotsFormat, value); + + private static string FormatAttributeValue(object? value) => value switch + { + null => string.Empty, + string s => s, + IFormattable f => f.ToString(null, CultureInfo.InvariantCulture), + _ => value.ToString() ?? string.Empty, + }; +} diff --git a/src/EncDotNet.S100.Viewer/Services/DynamicSources/IDynamicFeatureSourceRegistry.cs b/src/EncDotNet.S100.Viewer/Services/DynamicSources/IDynamicFeatureSourceRegistry.cs index c2eb527..196b97e 100644 --- a/src/EncDotNet.S100.Viewer/Services/DynamicSources/IDynamicFeatureSourceRegistry.cs +++ b/src/EncDotNet.S100.Viewer/Services/DynamicSources/IDynamicFeatureSourceRegistry.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using EncDotNet.S100.DynamicSources; namespace EncDotNet.S100.Viewer.Services.DynamicSources; @@ -53,6 +54,23 @@ internal interface IDynamicFeatureSourceRegistry /// void SetVisible(string sourceId, bool visible); + /// + /// Snapshot of currently registered, currently visible source + /// instances. Used by to + /// hit-test a click against the live feature snapshot. The order + /// matches (registration order). + /// + /// + /// Returns source instances rather than the + /// projection so the + /// pick path can read + /// and without an + /// extra round-trip. Hidden sources (visibility = false) are + /// excluded so a click on a "hidden" target never appears in the + /// pick report. + /// + IReadOnlyList GetVisibleSourceInstances(); + /// /// Raised when changes (register / dispose) /// or when transitions a source's diff --git a/src/EncDotNet.S100.Viewer/Services/DynamicSources/IDynamicSourcePickService.cs b/src/EncDotNet.S100.Viewer/Services/DynamicSources/IDynamicSourcePickService.cs new file mode 100644 index 0000000..b5b6828 --- /dev/null +++ b/src/EncDotNet.S100.Viewer/Services/DynamicSources/IDynamicSourcePickService.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using EncDotNet.S100.Viewer.ViewModels; +using Mapsui; + +namespace EncDotNet.S100.Viewer.Services.DynamicSources; + +/// +/// Resolves a click location to one or more dynamic-feature hits. +/// Sibling to : the click handler queries +/// both, then forwards the merged result to the pick report panel. +/// +/// +/// Returns an empty list when no dynamic source is registered, when +/// the registry is unattached (still booting), or when the click is +/// outside every source's hit-test tolerance. Callers therefore do +/// not need to handle a "no dynamic sources" case specially. +/// +internal interface IDynamicSourcePickService +{ + /// + /// Hit-test all visible dynamic sources at the given click point. + /// + /// Click position in Spherical Mercator world units. + /// Map units per device pixel at the current zoom. + /// Hits ordered by ascending distance from the click. + IReadOnlyList Pick(MPoint mapPoint, double resolution); +} diff --git a/src/EncDotNet.S100.Viewer/Services/IPickService.cs b/src/EncDotNet.S100.Viewer/Services/IPickService.cs index 975589e..0f20ebf 100644 --- a/src/EncDotNet.S100.Viewer/Services/IPickService.cs +++ b/src/EncDotNet.S100.Viewer/Services/IPickService.cs @@ -1,3 +1,5 @@ +using System.Collections.Generic; +using EncDotNet.S100.Viewer.ViewModels; using Mapsui; namespace EncDotNet.S100.Viewer.Services; @@ -16,7 +18,15 @@ internal interface IPickService /// pick-report update and a status-text message. Safe to call with /// null or with hits that don't carry a feature reference. /// - void HandlePick(MapInfo? mapInfo); + /// + /// Also accepts an optional list of dynamic-source hits collected + /// in parallel by . + /// When non-empty the dynamic hits are published into the pick + /// report's dynamic section; an empty list is equivalent to no + /// dynamic hits. Either list (or both) being non-empty causes the + /// panel to open. + /// + void HandlePick(MapInfo? mapInfo, IReadOnlyList? dynamicHits = null); /// /// Resolves an xlink-style reference from the currently selected hit diff --git a/src/EncDotNet.S100.Viewer/Services/MapInteractionController.cs b/src/EncDotNet.S100.Viewer/Services/MapInteractionController.cs index eeee5cb..a54db9f 100644 --- a/src/EncDotNet.S100.Viewer/Services/MapInteractionController.cs +++ b/src/EncDotNet.S100.Viewer/Services/MapInteractionController.cs @@ -6,6 +6,7 @@ using Avalonia.Interactivity; using Avalonia.Threading; using EncDotNet.S100.Viewer.Resources; +using EncDotNet.S100.Viewer.Services.DynamicSources; using EncDotNet.S100.Viewer.Tools; using EncDotNet.S100.Viewer.ViewModels; using EncDotNet.S100.Viewer.Views; @@ -43,6 +44,7 @@ internal sealed class MapInteractionController private readonly MainViewModel _viewModel; private readonly IPickService _pickService; private readonly IDatasetLoaderService _loader; + private readonly IDynamicSourcePickService? _dynamicPickService; private MapControl? _mapControl; @@ -82,6 +84,15 @@ public MapInteractionController( MainViewModel viewModel, IPickService pickService, IDatasetLoaderService loader) + : this(viewModel, pickService, loader, dynamicPickService: null) + { + } + + public MapInteractionController( + MainViewModel viewModel, + IPickService pickService, + IDatasetLoaderService loader, + IDynamicSourcePickService? dynamicPickService) { ArgumentNullException.ThrowIfNull(viewModel); ArgumentNullException.ThrowIfNull(pickService); @@ -90,6 +101,7 @@ public MapInteractionController( _viewModel = viewModel; _pickService = pickService; _loader = loader; + _dynamicPickService = dynamicPickService; } /// @@ -510,14 +522,31 @@ private void OnLongPressElapsed(object? sender, EventArgs e) var mapInfo = _mapControl.GetMapInfo(new ScreenPosition(origin.X, origin.Y), datasetLayers); _longPressOrigin = null; _longPressFired = true; - _pickService.HandlePick(mapInfo); + _pickService.HandlePick(mapInfo, CollectDynamicHits(mapInfo)); } private void PerformPickAt(BaseEventArgs e) { var datasetLayers = GetDatasetLayers(); var mapInfo = e.GetMapInfo?.Invoke(datasetLayers); - _pickService.HandlePick(mapInfo); + _pickService.HandlePick(mapInfo, CollectDynamicHits(mapInfo)); + } + + /// + /// Asks the dynamic-source pick service to hit-test the supplied + /// 's world position. Returns an empty list + /// when the service is unavailable (legacy ctor / test stubs) or + /// when the lacks a world position. + /// + private IReadOnlyList CollectDynamicHits(MapInfo? mapInfo) + { + if (_dynamicPickService is null || mapInfo?.WorldPosition is not { } world) + { + return Array.Empty(); + } + + var resolution = _mapControl?.Map?.Navigator?.Viewport.Resolution ?? double.NaN; + return _dynamicPickService.Pick(world, resolution); } private List GetDatasetLayers() diff --git a/src/EncDotNet.S100.Viewer/Services/PickService.cs b/src/EncDotNet.S100.Viewer/Services/PickService.cs index 14e02a1..f669dad 100644 --- a/src/EncDotNet.S100.Viewer/Services/PickService.cs +++ b/src/EncDotNet.S100.Viewer/Services/PickService.cs @@ -98,18 +98,32 @@ public PickService( }; } - public void HandlePick(MapInfo? mapInfo) + public void HandlePick(MapInfo? mapInfo, IReadOnlyList? dynamicHits = null) { using var __cmd = ViewerObservability.BeginCommand("pick"); + var dynamic = dynamicHits ?? Array.Empty(); + if (mapInfo is null) { + // Even with no MapInfo a pick may still carry dynamic hits + // (e.g. a future code path that pre-computes them); honour + // them if present, otherwise clear. + if (dynamic.Count > 0) + { + _pickReport.SetPicks(Array.Empty(), dynamic); + _status.StatusText = string.Format( + Strings.Status_FeatureSummary, + dynamic[0].DisplayLabel, + dynamic[0].FeatureId); + return; + } _pickReport.Clear(); return; } var hits = ResolveHits(mapInfo); - if (hits.Count == 0) + if (hits.Count == 0 && dynamic.Count == 0) { // No vector hit. Try a coverage fallback before clearing — // S-102/S-104/S-111 processors expose @@ -132,18 +146,35 @@ public void HandlePick(MapInfo? mapInfo) return; } - _pickReport.SetPicks(hits); - - // Status text follows the first (selected) hit, with a "+N more" - // suffix when additional features were resolved. - var first = hits[0]; - var primary = string.Format( - Strings.Status_FeatureSummary, - first.FeatureTypeName ?? first.FeatureType, - first.FeatureRef); - _status.StatusText = hits.Count > 1 - ? string.Format(Strings.Status_FeatureSummaryWithMore, primary, hits.Count - 1) - : primary; + _pickReport.SetPicks(hits, dynamic); + + // Status text follows the first hit. Dataset hits take + // precedence (their labels carry more dataset context); fall + // back to the first dynamic hit when no dataset hits are + // present. + if (hits.Count > 0) + { + var first = hits[0]; + var primary = string.Format( + Strings.Status_FeatureSummary, + first.FeatureTypeName ?? first.FeatureType, + first.FeatureRef); + var more = hits.Count - 1 + dynamic.Count; + _status.StatusText = more > 0 + ? string.Format(Strings.Status_FeatureSummaryWithMore, primary, more) + : primary; + } + else + { + var first = dynamic[0]; + var primary = string.Format( + Strings.Status_FeatureSummary, + first.DisplayLabel, + first.FeatureId); + _status.StatusText = dynamic.Count > 1 + ? string.Format(Strings.Status_FeatureSummaryWithMore, primary, dynamic.Count - 1) + : primary; + } } private List ResolveHits(MapInfo mapInfo) diff --git a/src/EncDotNet.S100.Viewer/ViewModels/DynamicPickHit.cs b/src/EncDotNet.S100.Viewer/ViewModels/DynamicPickHit.cs new file mode 100644 index 0000000..1e1577c --- /dev/null +++ b/src/EncDotNet.S100.Viewer/ViewModels/DynamicPickHit.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; +using EncDotNet.S100.DynamicSources; + +namespace EncDotNet.S100.Viewer.ViewModels; + +/// +/// Resolved snapshot of a single dynamic-feature hit produced by a pick +/// gesture. Sibling type to : dataset-owned features +/// flow through PickHit, dynamic-source features flow through +/// . The pick report panel renders the two +/// lists in adjacent sections. +/// +/// +/// The dataset and dynamic identity models are different enough — opaque +/// string id vs FC-bound feature ref, mutable position vs static +/// coordinates, free-form attribute dictionary vs FC-decoded attribute +/// list, no xlink references — that we keep the types separate rather +/// than threading optional fields through . See +/// docs/design/dynamic-source-pick.md §1 Q1. +/// +internal sealed record DynamicPickHit +{ + /// Source instance id (e.g. "ownship", "ais.aisstream"). + public required string SourceId { get; init; } + + /// Human-readable source name from . + public required string SourceDisplayName { get; init; } + + /// Source-stable feature id from . + public required string FeatureId { get; init; } + + /// Renderer-dispatch hint from . + public string? Kind { get; init; } + + /// + /// Display label preferred over in the hit + /// list. Picks the AIS vessel name when present, else the feature id. + /// + public required string DisplayLabel { get; init; } + + /// UTC of the most recent feature update. + public required DateTimeOffset LastUpdated { get; init; } + + /// WGS-84 latitude of the picked point. + public required double Latitude { get; init; } + + /// WGS-84 longitude of the picked point. + public required double Longitude { get; init; } + + /// Optional motion sidecar (COG / heading / SOG). + public DynamicMotion? Motion { get; init; } + + /// Optional vessel-geometry sidecar (length / beam). + public DynamicVesselGeometry? VesselGeometry { get; init; } + + /// Source-defined attribute rows (vessel name, MMSI, …). + public IReadOnlyList Attributes { get; init; } + = Array.Empty(); +} + +/// +/// Single row in a dump. Both the +/// label and the formatted value are pre-localised by the pick service +/// so the view binds them as plain strings. +/// +internal sealed record DynamicPickAttributeRow(string Label, string Value); diff --git a/src/EncDotNet.S100.Viewer/ViewModels/PickReportViewModel.cs b/src/EncDotNet.S100.Viewer/ViewModels/PickReportViewModel.cs index 2362836..3a65b94 100644 --- a/src/EncDotNet.S100.Viewer/ViewModels/PickReportViewModel.cs +++ b/src/EncDotNet.S100.Viewer/ViewModels/PickReportViewModel.cs @@ -217,6 +217,14 @@ private set } } + /// + /// True when the current pick includes at least one dataset-owned + /// feature. Drives visibility of the identity / references / + /// attributes sections in the panel; dynamic-only picks set this + /// to false and only render the dynamic-hits section. + /// + public bool HasDatasetPick => Hits.Count > 0; + /// public event EventHandler? ContentBecameAvailable; @@ -293,32 +301,67 @@ public PickHit? SelectedHit /// public event EventHandler? NavigateRequested; + /// + /// Dynamic-source hits collected by + /// . + /// Rendered in a sibling section beneath the dataset hit list. The + /// section is hidden when empty. + /// + public ObservableCollection DynamicHits { get; } = new(); + + /// True when at least one dynamic-source hit was returned by the most recent pick. + public bool HasDynamicHits => DynamicHits.Count > 0; + /// /// Replaces the current pick with the supplied list of hits. The first /// hit is selected by default. An empty list is equivalent to /// . /// public void SetPicks(IReadOnlyList hits) + => SetPicks(hits, Array.Empty()); + + /// + /// Replaces the current pick with the supplied dataset hits AND + /// dynamic-source hits. Either list may be empty; both being empty + /// is equivalent to . When dataset hits are + /// non-empty the first hit drives the detail view (existing + /// behaviour); when only dynamic hits are present the detail view + /// is left empty and only the dynamic section is shown. + /// + public void SetPicks( + IReadOnlyList hits, + IReadOnlyList dynamicHits) { ArgumentNullException.ThrowIfNull(hits); + ArgumentNullException.ThrowIfNull(dynamicHits); DisposeHitResources(); Hits.Clear(); foreach (var hit in hits) Hits.Add(hit); - if (hits.Count == 0) + DynamicHits.Clear(); + foreach (var dh in dynamicHits) + DynamicHits.Add(dh); + + if (hits.Count == 0 && dynamicHits.Count == 0) { Clear(); return; } - // Setting SelectedHit propagates the values into the detail fields. - // Order matters: HasPick and HasMultipleHits must be observable - // before consumers react to SelectedHit changes. HasPick = true; OnPropertyChanged(nameof(HasMultipleHits)); - SelectedHit = hits[0]; + OnPropertyChanged(nameof(HasDynamicHits)); + OnPropertyChanged(nameof(HasDatasetPick)); + SelectedHit = hits.Count > 0 ? hits[0] : null; + if (hits.Count == 0) + { + // ApplyHitToDetailFields(null) cleared the detail fields; + // re-raise the attribute panels so the view collapses them. + OnPropertyChanged(nameof(HasAttributes)); + OnPropertyChanged(nameof(HasReferences)); + } } /// @@ -357,6 +400,7 @@ public void Clear() { DisposeHitResources(); Hits.Clear(); + DynamicHits.Clear(); // SelectedHit setter rejects identical references; clear the backing // field directly so we always raise PropertyChanged when something // was selected. @@ -377,6 +421,8 @@ public void Clear() OnPropertyChanged(nameof(HasAttributes)); OnPropertyChanged(nameof(HasReferences)); OnPropertyChanged(nameof(HasMultipleHits)); + OnPropertyChanged(nameof(HasDynamicHits)); + OnPropertyChanged(nameof(HasDatasetPick)); } private void ApplyHitToDetailFields(PickHit? hit) diff --git a/src/EncDotNet.S100.Viewer/Views/PickReportView.axaml b/src/EncDotNet.S100.Viewer/Views/PickReportView.axaml index df90151..0cc9546 100644 --- a/src/EncDotNet.S100.Viewer/Views/PickReportView.axaml +++ b/src/EncDotNet.S100.Viewer/Views/PickReportView.axaml @@ -79,8 +79,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + @@ -133,7 +197,8 @@ + Padding="12,0,12,12" + IsVisible="{Binding HasDatasetPick}">