EncDotNet.S100.Renderers.Mapsui
0.16.0
dotnet add package EncDotNet.S100.Renderers.Mapsui --version 0.16.0
NuGet\Install-Package EncDotNet.S100.Renderers.Mapsui -Version 0.16.0
<PackageReference Include="EncDotNet.S100.Renderers.Mapsui" Version="0.16.0" />
<PackageVersion Include="EncDotNet.S100.Renderers.Mapsui" Version="0.16.0" />
<PackageReference Include="EncDotNet.S100.Renderers.Mapsui" />
paket add EncDotNet.S100.Renderers.Mapsui --version 0.16.0
#r "nuget: EncDotNet.S100.Renderers.Mapsui, 0.16.0"
#:package EncDotNet.S100.Renderers.Mapsui@0.16.0
#addin nuget:?package=EncDotNet.S100.Renderers.Mapsui&version=0.16.0
#tool nuget:?package=EncDotNet.S100.Renderers.Mapsui&version=0.16.0
EncDotNet.S100.Renderers.Mapsui
Rendering of S-100 data into Mapsui map layers with CRS projection.
Overview
This library bridges the S-100 portrayal pipeline output to Mapsui map layers, including full CRS projection support (EPSG:3857 Web Mercator). Key types include:
MapsuiCoverageRenderer—ICoverageRenderer<ILayer>implementation that renders coverage data as a georeferenced raster overlay (S-102 / S-104 / S-111).MapsuiCoverageArrowRenderer— renders current arrows (e.g. from S-111 data) as a georeferenced raster layer.MapsuiDisplayListRenderer— product-agnostic vector renderer that consumes a list ofDrawingInstructions plus anIFeatureGeometryProviderand produces aMemoryLayerof styled point/line/area/text features. Used by every S-100 vector product (S-101, S-124, S-129, S-421); no per-spec subclass is required.MapsuiDatasetRenderer— the entry point that converts a dataset processor's renderer-neutral portrayal output into a Mapsui-typedDatasetResult(layers + extent). It consumes theIVectorPortrayalSource/ICoveragePortrayalSourceseam exposed byEncDotNet.S100.Datasets.Pipelinesand owns everything Mapsui-specific: the NTS pattern-clip cache, feature-type tagging, out-of-scale-band cap application, S-101 area/lineILayerbuild, the S-111 arrow renderer, and the Mapsui-typed S-98 layer-stack. This is the seed of the future multi-layer renderer in issue #213 (which will adoptIS100DatasetRenderer<IReadOnlyList<ILayer>>); adopting that interface later is purely additive.
Dependency direction (issue #189). This package now references
EncDotNet.S100.Datasets.Pipelines(not the other way round), so that the Pipelines assembly — and the headless facade / CLI built on it — stay Mapsui-free. As a consequence this package multi-targetsnet10.0only (it depends on the net10.0-only Pipelines assembly), whereas the rest of the libraries multi-targetnet8.0;net10.0. The Mapsui-typedDatasetResultkeeps its originalEncDotNet.S100.Datasets.Pipelinesnamespace (the type physically moved here) so consumerusingdirectives resolve unchanged.
CRS transforms moved to the Mapsui-free
EncDotNet.S100.Crs.ProjNetpackage (ProjNetCrsTransformFactory) so headless consumers can reproject coverage products without linking a map renderer.
MapsuiDisplayListRenderer lowers the display list through the shared,
backend-agnostic vector rendering core in
EncDotNet.S100.Renderers.Skia.Scene (VectorSceneBuilder → VectorScene of
PaintOps). All S-100 Part 9 portrayal-correctness logic — draw ordering,
colour/symbol/line-style resolution, mm→px conversion, text-anchor selection,
and the lat/lon → EPSG:3857 projection half — lives in that core and is shared
with the headless SkiaDisplayListRenderer; this renderer only constructs
Mapsui IFeature/style objects from the resolved IR. Pattern fills are the one
exception: they are not yet part of the IR and keep their dedicated pattern
collection / priority-clip / insert phase here.
MapsuiDisplayListRenderer honours the relevant S-100 Part 9 conventions:
- Pen widths and text/symbol offsets specified in millimetres on the nominal display surface are converted to screen pixels using the standard
1 px = 0.32 mmratio (S-100 Part 9 §3.10.4). <foreground>/<background>colours accept either a palette token or a literal#RRGGBB/RRGGBBAAhex value, with the optionaltransparencyattribute applied as alpha attenuation.- Text alignment, mm offsets, and
textLinestart/end offsets (Relative or Absolute) are honoured per S-100 Part 9 §11.4. LineStyleProvider,SymbolProvider, andAreaFillProvidercallbacks let the host project plug in a portrayal catalogue without coupling the renderer to a specific dataset library.
Sharing processed-SVG and pattern-tile work across renders
MapsuiDisplayListRenderer resolves SVG symbols and rasterises area-fill pattern tiles lazily on first reference. The processed-SVG output depends on the active ColorPalette (fill/stroke colours are recoloured against the palette), and pattern-tile rasterisation is comparatively expensive.
When a single dataset is re-rendered repeatedly — typical when toggling palettes, scrubbing time-steps, or changing mariner settings — assign a single MapsuiRenderAssetCache instance to the renderer's AssetCache property on every Render() call:
private readonly MapsuiRenderAssetCache _renderAssetCache = new();
// per Render():
var renderer = new MapsuiDisplayListRenderer
{
Palette = palette,
AssetCache = _renderAssetCache,
SymbolProvider = name => catalogue.GetSymbol(name).SvgContent,
AreaFillProvider = name => catalogue.GetAreaFill(name),
};
The cache segments entries per palette (Day / Dusk / Night) so flipping back and forth keeps every palette warm. When AssetCache is unset, the renderer falls back to a per-instance cache, which preserves legacy behaviour for ad-hoc / one-shot callers.
Caching the coverage projection layout across re-renders
MapsuiCoverageRenderer reprojects every grid node from the coverage's
native CRS to Web Mercator and derives a node→pixel mapping. That work
depends only on the grid geometry (native CRS, dimensions, and the
affine origin/spacing), so it is independent of the colour palette,
ECDIS display mode, and the per-cell values. The renderer caches the
resulting int[] node→pixel index array (along with the output raster
dimensions and Mercator extent) keyed on those geometry parameters, and
reuses it whenever the next render presents the same geometry — e.g. a
palette switch or a coverage time-step change. Only the value
classification + pixel fill + PNG encode re-run; the projection pass is
skipped.
To benefit, keep the renderer instance alive across renders rather than
constructing a fresh one each time (S102DatasetProcessor and
S104DatasetProcessor hold the renderer in a field). The cache is a
single-slot, value-keyed entry published atomically, so it stays
correct if a renderer is ever reused for a different geometry (the key
mismatch forces a rebuild). It caches only the compact index array, not
the per-node Mercator coordinates, to bound memory (~4 MB per
megapixel grid).
Dynamic feature sources
EncDotNet.S100.Renderers.Mapsui.DynamicSources hosts the Mapsui-bound side of the dynamic-feature-source abstraction defined in EncDotNet.S100.Core (see docs/design/dynamic-feature-source.md). Renderers turn DynamicFeature snapshots into Mapsui IFeature + IStyle instances that the viewer's DynamicSourceOverlayHost attaches to a MemoryLayer on the overlay tier of IMapHost.
IDynamicFeatureRenderer—CanRender+Rendercontract. Implementations are stateless functions of one feature; the overlay host owns the layer-level state and UI-thread marshalling.DefaultDynamicFeatureRenderer— geometry-kind-dispatching fallback: coloured disc + optional speed-scaled heading line (six-minute predictor capped at 10 nm) forPoint, stroked polyline forCurve, translucent fill + outline forSurface. Also the safety-net renderer when a source'sRendererKeyisnullor unregistered.OwnShipRenderer— own-ship symbology under key"ownship". Draws a true-scale 5-vertex hull polygon when the on-screen vessel length exceedsMinVesselPixels(22 px ≈ 6 mm @ 96 dpi), a coloured disc otherwise, plus a heading vector with filled-triangle arrowhead in both modes and a CCRP cross at the GPS antenna in outline mode. UsesDynamicFeature.VesselGeometry(CCRP offsets) to place the hull around the antenna and gates the outline / pictogram via mutually-exclusiveMinVisible/MaxVisiblestyles so the renderer stays viewport-agnostic. Falls back to pictogram-only when noVesselGeometryis supplied (e.g. AIS targets with unknown dimensions). Seedocs/design/own-ship-symbology.md.KindMatchingRenderer— dispatches byDynamicFeature.Kindvia exact match or dot-namespaced prefix match (e.g."vessel"matches"vessel.cargo"). Longest-key-first ordering keeps prefix matching deterministic.CompositeDynamicFeatureRenderer— first-CanRender-wins fallthrough over an ordered list. Conventional ordering: per-kind specialists first,DefaultDynamicFeatureRendererlast.DynamicFeatureRendererServiceCollectionExtensions— DI helpers that register renderers under the same string key a source advertises viaDynamicSourceMetadata.RendererKey:// Register a source and its renderer in one call: services.AddDynamicFeatureSource<MyAisFeed, MyVesselRenderer>("vessel"); // Or just a renderer, for cross-source sharing: services.AddDynamicFeatureRenderer<MyVesselRenderer>("vessel");The viewer's overlay host resolves the renderer at registration time via
IServiceProvider.GetKeyedService<IDynamicFeatureRenderer>(source.Metadata.RendererKey).
Performance instrumentation
The renderer ships with optional OpenTelemetry instrumentation that attributes paint cost down to the style-renderer, layer, and geometry vertex count. All instruments are sub-millisecond per paint when no OTel listener is attached, so they are safe to leave in production builds.
| Instrument | Unit | Tags | Purpose |
|---|---|---|---|
s100.map.paint.duration |
ms | — | Compositor-thread paint wall-time per frame |
s100.map.paint.interval |
ms | — | Time between paints (idle gaps > 500 ms dropped) |
s100.map.paint.style.calls |
count | style, layer, points |
Style-renderer Draw calls per paint |
s100.map.paint.style.duration |
ms | style, layer, points |
Cumulative Draw duration per paint |
s100.layer.get_features.duration |
ms | layer |
Layer-level filter cost per GetFeatures call |
s100.layer.get_features.visible / total |
count | layer |
Visible / total feature counts per call |
s100.layer.get_features.fps |
gauge | layer |
Effective GetFeatures rate per layer |
s100.pattern_fill.draw.duration |
ms | — | AnchoredPatternFillRenderer per-call cost |
The points tag is bucketed (1-9, 10-99, 100-999, 1k-10k,
10k-100k, 100k+) to keep histogram cardinality bounded while
still revealing whether a layer's cost is driven by many cheap draws
or a few expensive ones.
To capture a measurement session, run the viewer with the OTel console exporter enabled:
ENC_DOTNET_OTEL_CONSOLE=1 OTEL_METRIC_EXPORT_INTERVAL=2000 \
dotnet run -c Release --project src/EncDotNet.S100.Viewer
Histograms are emitted every 2 s with cumulative counts and per-bucket
distributions. Aggregate by (layer, points) to identify which
geometries are dominating paint time — empirically, ~93% of paint cost
on real-world S-101 datasets is spent on geometries with ≥100 vertices,
with per-vertex cost ~1 µs. See
docs/design/mapsui-performance.md
for the full investigation and optimization plan.
Resolution-aware geometry simplification
Issue #164 adds an opt-in resolution-aware Douglas-Peucker simplification path that reduces the vertex count Skia tessellates per frame. Polylines in the 1k–10k bucket on real S-101 datasets typically simplify by 5–10× at typical pan zooms with no visible quality regression at the default 0.5-pixel tolerance.
Pipeline placement
Simplification lives on InstrumentedMemoryLayer.GetFeatures,
because that is the only seam in the pipeline that has access to the
current zoom (resolution, m/px in EPSG:3857). When a layer has
simplification enabled, every visible feature is routed through a
per-layer SimplificationCache:
- Cache key:
(original-feature reference, half-octave bucket). - Bucket:
round(log2(resolution) × 2). Tolerance for a bucket ispixelTolerance × 2^(bucket / 2)metres. - Algorithm: NTS'
DouglasPeuckerSimplifier, lines and multi-lines only in v1. Polygons, points, and other types pass through unchanged. (Polygon support is deferred until topology preservation + validation is wired in.) - Eviction: on bucket transition, drop entries from buckets outside
[active − 1, active + 1]. If the cache's tracked coordinate count still exceedsMaxCachedCoordinates(default 5 M ≈ 80 MB), the bucket farthest from the active one is dropped next, until under budget. - Simplified clones share style instances by reference and copy all
fields (including
S100.FeatureRef); they also carry anS100.OriginalFeatureback-reference. UseSimplification.GetOriginal(feature)to recover the unsimplified feature for picking / info-on-click.
Wiring
using EncDotNet.S100.Renderers.Mapsui;
using EncDotNet.S100.Renderers.Mapsui.Simplification;
if (layer is InstrumentedMemoryLayer iml)
{
iml.EnableSimplification(
DouglasPeuckerLineSimplifier.Instance,
SimplificationOptions.Default);
}
In the desktop viewer this is driven by the
Simplify line geometry (experimental) setting, applied in
DatasetLoaderService before the optional rasterization wrap.
Telemetry
| Instrument | Unit | Tags | Purpose |
|---|---|---|---|
s100.simplify.cache.hit.count |
count | s100.product |
Simplified clone served from cache |
s100.simplify.cache.miss.count |
count | s100.product |
DP invocation triggered |
s100.simplify.duration |
ms | s100.product |
Per-feature DP cost (miss only) |
s100.simplify.coords.in |
count | s100.product |
Original-geometry vertex count (miss) |
s100.simplify.coords.out |
count | s100.product |
Simplified-geometry vertex count (miss) |
s100.simplify.cache.coords.tracked |
count | s100.product |
Live coords in cache across all buckets |
The acceptance bar from issue #164 is steady-state hit rate ≥ 95%
and ≥ 50% reduction in s100.map.paint.duration mean on the
multi-S-101 workload from the perf review.
Known limits
- v1 simplifies only line geometry; polygons (e.g. depth areas) and points are unaffected. The perf review shows lines dominate the paint cost in real datasets, so this still hits the projected budget.
- The miss path runs synchronously on the render thread. After a
zoom-band transition, the first paint at the new bucket may stall
briefly while the visible set is simplified; subsequent frames hit
the cache. An async / pre-warm path is documented as future work
in
docs/design/mapsui-performance.md. - The cache is sized by coordinate count, not entry count, so a handful of very dense polylines and many small features have comparable budget cost.
Pattern-fill clip generalization
Independently of the resolution-aware line simplification above,
MapsuiDisplayListRenderer generalizes the polygon geometry used when
clipping tiled pattern fills against each other (display priority)
and against non-patterned solid fills such as land. S-101
quality/coverage areas (e.g. M_QUAL) can follow the coastline with
tens of thousands of vertices, the bulk of which are sub-pixel at chart
display scales. The NetTopologySuite Difference/Union overlay
operations these geometries feed are super-linear in vertex count, so a
single pathological area could dominate the whole frame (observed:
~10 s of an ~11 s frame on one 64k-vertex pattern zone in a real 2.35 MB
cell).
Before the overlay, each merged pattern geometry and the land exclusion
mask are passed through NTS TopologyPreservingSimplifier at a fixed
1 m (EPSG:3857) tolerance (PatternClipSimplifyToleranceMetres).
Topology-preserving simplification keeps the inputs valid for overlay;
the result is buffer(0)-repaired if it still validates as invalid, and
falls back to the original geometry on any failure. Because the clipped
boundary only bounds a tiled raster pattern fill, the generalization is
visually negligible (the S-101 visual-regression snapshot is unchanged).
An envelope-intersection test also short-circuits Difference when the
clip mask is disjoint from the entry. Together these cut the pattern
clip from ~11 s to well under 1 s on the affected cell, shaving ~6 s off
every S-101 frame (not just re-renders).
Caching the pattern-fill clip across palette switches
Even after generalization, the priority clip is the dominant warm cost on
the densest cells (profiling on a ~64,000-vertex M_QUAL coverage area:
the clip is on the order of seconds, dominated by a single Buffer(0)
validity repair). The clip runs once per layer build
(Render) — not per frame — and re-fires on dataset load, palette
(Day/Dusk/Night) switch, and ECDIS display-setting changes. Crucially the
clipped boundary geometry is palette-independent: the renderer groups
pattern entries by the palette-independent area-fill reference, so only the
tile colours change per palette (applied after clipping).
IPatternClipCache lets a caller reuse the clip result across re-renders
whose clip inputs are unchanged — most importantly a palette switch.
Assign an InMemoryPatternClipCache (a single-slot cache that bounds
memory to one cell) and a key that fully identifies the clip inputs:
private readonly InMemoryPatternClipCache _patternClipCache = new();
var renderer = new MapsuiDisplayListRenderer
{
// … palette, providers, asset cache …
PatternClipCache = _patternClipCache,
PatternClipCacheKey = portrayalCacheKey, // mariner + ECDIS display state
};
When both PatternClipCache and PatternClipCacheKey are set, the
renderer obtains the clipped geometry via GetOrCompute; a palette switch
with the same key is a cache hit that skips the overlay entirely
(measured on the dense trial cell: a cold Day render ~6 s, the subsequent
Night palette switch ~0.2 s). When either is unset the clip is computed
inline, preserving behaviour for S-57/S-131/GML products and the line
renderer (which has no pattern fills).
Two implementations ship behind this contract:
InMemoryPatternClipCache— a single-slot, per-processor cache that bounds memory to one cell. It only eliminates re-clip cost for re-renders of the same already-open dataset (palette/display switches) and is lost on close/restart.DiskPatternClipCache— a process-wide, disk-backed cache (ctor(string cacheDirectory, long maxBytes)). It persists each clip result as a WKB sidecar (filename =SHA256(key)hex +.clip) so the cold first open of a previously-seen cell skips the overlay, even after a restart. Writes are atomic (temp file + move) and a total-bytes LRU cap evicts least-recently-accessed entries; any IO/deserialization error orFormatVersionmismatch is treated as a miss (recompute) and never throws to the caller. Because the disk cache is process-global, the key must be fully qualified by the caller — the S-101 processor composes{datasetScope}|{portrayalKey}, wheredatasetScopeencodes the dataset content hash, clip parameters (PatternClipSimplifyToleranceMetres,MinPointsToSimplifyForClip), CRS, and theDiskPatternClipCache.FormatVersionstamp, so persisted geometry auto-invalidates when content, parameters, or the serialization format change.
// Per-processor in-memory (step 1):
private readonly InMemoryPatternClipCache _patternClipCache = new();
// Or one shared disk cache for the whole process (step 2):
var sharedClipCache = new DiskPatternClipCache(cacheDir, maxBytes: 256L * 1024 * 1024);
var renderer = new MapsuiDisplayListRenderer
{
// … palette, providers, asset cache …
PatternClipCache = sharedClipCache,
PatternClipCacheKey = $"{datasetScope}|{portrayalCacheKey}",
};
Installation
dotnet add package EncDotNet.S100.Renderers.Mapsui
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- EncDotNet.S100.Core (>= 0.16.0)
- EncDotNet.S100.Datasets.Pipelines (>= 0.16.0)
- EncDotNet.S100.Datasets.S101 (>= 0.16.0)
- EncDotNet.S100.Datasets.S421 (>= 0.16.0)
- EncDotNet.S100.Portrayals (>= 0.16.0)
- EncDotNet.S100.Renderers.Skia (>= 0.16.0)
- Mapsui (>= 5.0.2)
- Mapsui.Nts (>= 5.0.2)
- Mapsui.Rendering.Skia (>= 5.0.2)
- Mapsui.Tiling (>= 5.0.2)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.0)
- SkiaSharp (>= 3.119.1)
-
net8.0
- EncDotNet.S100.Core (>= 0.16.0)
- EncDotNet.S100.Datasets.Pipelines (>= 0.16.0)
- EncDotNet.S100.Datasets.S101 (>= 0.16.0)
- EncDotNet.S100.Datasets.S421 (>= 0.16.0)
- EncDotNet.S100.Portrayals (>= 0.16.0)
- EncDotNet.S100.Renderers.Skia (>= 0.16.0)
- Mapsui (>= 5.0.2)
- Mapsui.Nts (>= 5.0.2)
- Mapsui.Rendering.Skia (>= 5.0.2)
- Mapsui.Tiling (>= 5.0.2)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.0)
- SkiaSharp (>= 3.119.1)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.16.0 | 95 | 6/8/2026 |
| 0.15.0 | 93 | 6/6/2026 |
| 0.14.0 | 90 | 6/6/2026 |
| 0.13.0 | 91 | 6/3/2026 |
| 0.12.0 | 93 | 5/29/2026 |
| 0.11.0 | 96 | 5/19/2026 |
| 0.10.0 | 93 | 5/16/2026 |
| 0.9.0 | 91 | 5/15/2026 |
| 0.8.0 | 94 | 5/13/2026 |
| 0.7.0 | 93 | 5/12/2026 |
| 0.6.0 | 109 | 5/8/2026 |
| 0.5.0 | 99 | 5/4/2026 |
| 0.4.0 | 94 | 5/1/2026 |
| 0.3.0 | 100 | 4/29/2026 |
| 0.2.0 | 105 | 4/14/2026 |
| 0.1.2 | 107 | 4/11/2026 |
| 0.1.1 | 99 | 4/11/2026 |