Depth basis indicator — Design Note
Status: Built (#1191): the #1191 parts of §5 shipped together; the adjusted states wait for #1132. A sub-issue of #1132 (chart-wide S-104 water level adjustment). This note designs the permanent indication of what the depths on the chart are relative to, and the state model behind it. #1132 will later drive that model; Chart-wide water level adjustment covers the adjustment itself.
The mocks for this note are the Depth Basis Indicator · design pass artboard on the Chart display page of the SoundCharts design canvas (https://claude.ai/artifact/2hWbgQ9wKM1gC2gM51mSCs): the status bar in each state (A1–A6) and its placement (A7), the flyout before and after #1132 (B1, B2), the Depth card in the new wording (C1–C3), and the Clearance readout and route-check findings (D1, D2).
0. Problem
In the ECDIS Ltd simulator trials comparing S-57 with S-100 products (Technical Report ECDIS/TR/2026/001: 100 seafarers, 10 ECDIS manufacturers, around 1,000 assessed hours, 20 pilotage and BRM scenarios; findings as summarised by George Koursaris), S-102 display quality was rated 71% negative. Some participants still worked out under-keel clearance by hand, because they couldn't tell which soundings had been adjusted for the tide.
SoundCharts has the same ambiguity. The depths drawn on the chart are always charted depths, relative to each dataset's vertical datum: S-101 depth areas and soundings, S-57 through the S-101 pipeline, and the S-102 surface. The S-104 water level is applied in three places, none of which is the chart:
| Where | Code | What the user sees |
|---|---|---|
| A picked point | Services/Depth/LocationDepthProbe.cs, Services/Depth/DepthAssimilationService.cs |
The Object information Depth card: Base, Tide, DEPTH AT SELECTED TIME or DEPTH (STATIC) |
| Depth over time at a point | ViewModels/DepthOverTimeViewModel.cs |
The card's chart: Tide-adjusted depth against Safety depth |
| Route clearance | EncDotNet.S100.Navigation/ClearanceCheck.UkcModel.cs |
The Clearance readout: Charted depth, Water level, UKC |
Nothing on the chart says which of these the drawn depths are. A mariner who has loaded S-104 and moved the Timeline can reasonably assume the depths and safety contour follow it. They don't.
S-164 conformance records the same gap as cross-cutting finding 7: no adjusted depths, no adjustment boundary, and "no permanent indication". The S-164 tests WaterLevelAdjustment and WLAFeatureInformation (S-98 Appendix D-2, D-3) are rated Partial for it.
1. Constraints
- Permanent. S-98 Appendix D expects a permanent indication while water level adjustment is on. The indicator can't be something the user dismisses, and it can't live only in a panel.
- True today, extended later. The indicator ships before #1132, so today it can only ever say "charted". It must not need redesigning when #1132 adds an adjusted state; #1132 should only add inputs.
- One vocabulary. The depth card, the Clearance readout and the route findings already describe the same quantities with different words (§4). The indicator is the third surface; it should settle the words, not add a fourth set.
- Not colour alone. The state must read from the text. Colour can add emphasis but must work in the Day, Dusk and Night palettes.
- Reachable by automation. Every status-bar control has an
automation id and accessible name (
AccessibilityGuardTests), and an agent should be able to read the basis without a screenshot.
2. Decision
2.1 Where it goes
A pill in the status bar of MainWindow.axaml, directly after the
OVERSCALE pill and before the MCP status. Both are chart-safety
indications about what the map is showing, so they sit together. It
follows the overscale pill's pattern exactly:
- a
Buttonwith aFlyout(Placement="Top") for the detail; - state on
MainViewModel(DepthBasis…properties next toIsOverscaleVisible,OverscaleFactorText,OverscaleCells); - pushed by an
Update…method, asUpdateOverscaleis.
Unlike the overscale pill, it is shown whenever a depth-bearing dataset is loaded and active, whether or not it is in view: S-101, S-57 (and S-401) cells, and S-102 surfaces. It is hidden only when no such dataset is loaded, because then there are no depths to qualify.
2.2 States and wording
The pill has a short label and a value, like OVERSCALE 4.6×.
Times use the user's Local/UTC setting through
TimeFormatting.Format.
| State | When | Pill | Emphasis |
|---|---|---|---|
| Hidden | No depth-bearing dataset loaded and active | — | — |
| Charted | No S-104 loaded, or none whose footprint meets the view | DEPTHS Charted | Neutral, like the scale readout |
| Charted, water level not applied | An active S-104 dataset's footprint meets the view, and the chart isn't adjusted | DEPTHS Charted · water level not applied | Informational accent |
| Adjusted (#1132) | Adjustment is on and applies somewhere in the view | DEPTHS + water level 14:20 | Distinct accent, always on |
| Adjusted, part of view (#1132) | As above, but the adjusted area doesn't cover all charted depths in view | DEPTHS + water level 14:20 · part of view | As Adjusted |
| Charted, adjustment unavailable (#1132) | Adjustment is on but can't apply in view: outside S-104 time range, NoData, or datums incompatible | DEPTHS Charted · no water level at 14:20 / Charted · datum mismatch | Caution accent |
The second row is the case the trial describes and the reason the issue exists: S-104 is on screen, the Timeline is moving, and the chart isn't following. It gets an accent; plain Charted doesn't, so the pill stays quiet for a user with only charts loaded.
Proposed Strings.resx keys, following the Status_Overscale*
pattern:
| Key | Value |
|---|---|
Status_DepthBasisLabel |
DEPTHS |
Status_DepthBasis_Charted |
Charted |
Status_DepthBasis_ChartedNotApplied |
Charted · water level not applied |
Status_DepthBasis_AdjustedFormat |
+ water level {0} |
Status_DepthBasis_AdjustedPartialFormat |
+ water level {0} · part of view |
Status_DepthBasis_NoWaterLevelAtFormat |
Charted · no water level at {0} |
Status_DepthBasis_DatumMismatch |
Charted · datum mismatch |
Label_DepthBasis |
Depth basis (automation name) |
Tooltip_DepthBasis |
What the depths and safety contour on the chart are relative to. Click for the sources, the vertical datum and where water level is applied. |
Only the first three rows ship with #1191. The Adjusted*,
NoWaterLevelAt* and DatumMismatch strings are added by #1132; they
are listed here so the vocabulary is settled once.
2.2.1 Fitting a narrow status bar
The status bar is one line, so the pill can't wrap. The right-hand
cluster in MainWindow.axaml is a horizontal StackPanel that doesn't
shrink, and at the window's 800 px minimum width the longest values
(Charted · water level not applied, + water level 14:20 · part of
view) don't fit beside the cursor position, scale and OVERSCALE.
An ellipsis isn't safe here: "Charted · water lev…" or "+ water level 14:20 · par…" can be read as a different state. So the pill never truncates. Each state has three whole forms, and the pill shows the longest one that fits:
| State | Full | Short | Compact |
|---|---|---|---|
| Charted | Charted | same | same |
| Charted, water level not applied | Charted · water level not applied | Charted · not applied | Charted + info icon |
| Adjusted (#1132) | + water level 14:20 | same | Adjusted 14:20 |
| Adjusted, part of view (#1132) | + water level 14:20 · part of view | Part adjusted 14:20 | same as Short |
| No water level (#1132) | Charted · no water level at 23:40 | Charted · no water level | Charted + caution icon |
| Datum mismatch (#1132) | Charted · datum mismatch | same | Charted + caution icon |
Rules:
- Every form is a true statement on its own. Only the qualifier is dropped; the DEPTHS label, the accent and the state's icon stay, and Compact always shows the icon so the state doesn't rest on colour.
- Shrink order: the left-hand status message ellipsizes first (it isn't safety information), then the pill steps Full → Short → Compact. OVERSCALE and DEPTHS are never hidden.
- The Full form is always available: it is the pill's tooltip, its
accessible name (with
Label_DepthBasis) and the flyout's first line, whatever form is drawn. - Each form is its own
Strings.resxentry (…_Short,…_Compactsuffixes on the keys above), so a translation picks a form that fits instead of being cut. DepthBasisTextreturns all three forms; the view picks one by measuring, so the evaluator and its tests stay independent of layout. The pill'sTextBlocksetsTextWrapping="NoWrap"andTextTrimming="None".
The mock is part A8 of the Depth Basis Indicator · design pass artboard.
2.3 The flyout
The flyout answers three questions: what are the depths relative to,
what water level is loaded, and where is it applied. Its sections, with
the header style of Status_OverscalePopupHeader:
DEPTHS ON THE CHART
- One line for the basis: "Charted depths, relative to the vertical datum below. The safety contour and depth shading use these values." After #1132, when adjusted: "Charted depth + water level at 14:20 (S-104 cbofs, run 12:00Z). The safety contour and depth shading use the adjusted values."
- Vertical datum: the datum label from
VerticalDatums.TryGetLabelfor the depth sources in view, for example "Mean Lower Low Water". When sources in view disagree, "Several datums in view" followed by one line per datum and the datasets on it. When a source doesn't declare one, "Not declared" (the existingClearance_DatumUnknowntext). - Sources in view: the S-102 surfaces and S-101 cells contributing depths in the view, by display name, as the overscale flyout lists cells. Capped at five with "and N more".
WATER LEVEL
- Not loaded: "No S-104 water level loaded."
- Loaded, not applied: each active S-104 dataset meeting the view, with its kind (grid or station), its run, and its time range, then "Not applied to the chart."
- After #1132: which dataset supplies the adjustment at the view time
(the
WaterLevelPreferencechoice), the step time actually used, and the datum agreement (DatumAgreement): same, unconfirmed or different.
WHERE WATER LEVEL IS APPLIED
A fixed list that tells the user where they can get an adjusted depth today:
- "Object information: the Depth card at a picked point."
- "Route check: clearance at each ETA."
- After #1132: "The chart, at the Timeline's view time." with a link button to the adjustment setting in Mariner Settings.
The flyout has no controls of its own until #1132; it is read-only information plus, later, one link.
2.4 The state model
One evaluator, one service, one record. #1132 consumes the record and supplies one input; it doesn't add a second source of truth.
namespace EncDotNet.S100.Viewer.Services.Depth;
/// What the depths drawn on the chart are relative to.
internal enum DepthBasisKind
{
None, // no depth-bearing dataset loaded: indicator hidden
Charted, // charted depths, relative to each dataset's vertical datum
Adjusted, // charted depth + S-104 water level (#1132)
}
/// Why the chart shows charted depths when water level exists.
internal enum DepthBasisReason
{
None, // Kind is None or Adjusted, or no S-104 meets the view
NotApplied, // S-104 meets the view; chart adjustment off or not built
WaterLevel, // adjustment on but unavailable: see WaterLevelStatus
}
internal sealed record DepthBasisSource(
string DatasetId, // display name, as the Datasets panel shows it
string Spec, // "S-101", "S-102", "S-104", ...
int? VerticalDatumCode, // S-100 vertical datum code, or null when not declared
DateTime? Start = null, // S-104 only: first time step (UTC)
DateTime? End = null, // S-104 only: last time step (UTC)
WaterLevelSourceKind? Kind = null);
internal sealed record DepthBasis(
DepthBasisKind Kind,
DepthBasisReason Reason,
WaterLevelStatus? WaterLevelStatus, // set when Reason is WaterLevel, or Kind is Adjusted
DateTimeOffset? ViewTime, // the Timeline's view time the basis was evaluated at
DateTime? WaterLevelStepTime, // the S-104 step actually used (#1132)
bool CoversWholeView, // false for "part of view" (#1132)
DatumAgreement? Datum, // depth vs water level, when both are known
IReadOnlyList<DepthBasisSource> DepthSources,
IReadOnlyList<DepthBasisSource> WaterLevelSources)
{
public static DepthBasis Hidden { get; } = …;
}
WaterLevelStatus and DatumAgreement are the existing public enums in
EncDotNet.S100.Navigation (PassageCheckResult.cs, WaterLevel.cs).
Reusing them means the indicator, the route check and the chart
adjustment report a gap with the same reason codes: NoCoverage,
BeforeStart, AfterEnd, NoData, DatumMismatch.
The pieces:
DepthBasisEvaluator(static, pure): takes aDepthBasisInputssnapshot — active datasets with spec, footprint and datum; the view extent; the view time; and anIWaterLevelAdjustmentState— and returns aDepthBasis. All the decisions in §2.2 live here, so they are unit-testable without Avalonia.IWaterLevelAdjustmentState: the seam for #1132. It answers "is chart adjustment on, and what did it apply in this view at this time?" For #1191 the only implementation isNoWaterLevelAdjustment, which always reports off. #1132 registers its own implementation (ChartWaterLevelAdjustmentService, whose result is theChartAdjustmentStaterecord in Chart-wide water level adjustment §7) and raises itsChangedevent when the setting or the applied result changes.DepthBasisService: owns the currentDepthBasis, recomputes it, and raisesChanged. It listens toIDatasetLoaderService.DatasetLoaded,DatasetRemoved,ActiveChangedandLayerStackChanged;IViewTime.Changed; andIWaterLevelAdjustmentState.Changed. Viewport changes reach it the way they reach the overscale pill:MapInteractionControllercalls it next toUpdateOverscale(viewport). Recomputation is cheap (footprint intersection and a few lookups) and is coalesced to the next UI frame.DepthBasisText(static): the only place that turns aDepthBasisinto strings, for the pill, the flyout, the depth card (§4) and the MCPdisplayfield (§2.6).
Whether a dataset meets the view comes from the same test the
Timeline's In map view filter uses (ITimelineMapScope.IsInMapView
in Services/TimelineMapScope.cs). Datums come from where
each product already declares them: the S-102 root verticalDatum
(S102Dataset.VerticalDatum), the S-104 dataset's verticalDatum, and
for S-101 the SoundingDatum meta-feature, read as
CatalogChartedDepthSource.SoundingDatumAt already does for the route
check.
2.5 Vertical datum display
The datum is shown in the flyout, not on the pill. The pill must stay short enough for the status bar, and in most views there is a single datum, so repeating it on the pill would be noise. Where datums in view disagree, the flyout says so (§2.3); when #1132 refuses adjustment for incompatible datums, the pill says datum mismatch.
Today BaseDepthResult.VerticalDatumCode is null for S-101 sources
("on the chart's sounding datum"). The route check reads the
SoundingDatum meta-feature instead. The indicator needs the S-101
datum too, so step 2 of §5 makes the depth probe read it the same way.
This also lets DepthAssimilationService.IsDatumMismatch, which
currently checks only S-102 bases, apply the "assume and warn" policy
to S-101 bases.
2.6 MCP
A new read-only viewer tool, get_depth_basis, in
McpTools/ViewerStateTools.cs next to get_timeline_state. It reads
DepthBasisService.Current and returns:
{
"kind": "charted",
"reason": "not_applied",
"waterLevelStatus": null,
"viewTime": "2026-10-31T14:20:00Z",
"waterLevelStepTime": null,
"coversWholeView": true,
"datum": null,
"display": "Charted · water level not applied",
"depthSources": [
{ "id": "US5WA22M", "spec": "S-101", "verticalDatum": 12, "verticalDatumLabel": "Mean Lower Low Water" }
],
"waterLevelSources": [
{ "id": "104US00_CBOFS_20261031T12Z", "spec": "S-104", "kind": "grid",
"start": "2026-10-31T12:00:00Z", "end": "2026-11-02T12:00:00Z", "verticalDatum": 12 }
]
}
Codes are snake_case, as the other viewer tools report them. display
is the pill text exactly as shown, so an agent driving a scenario can
assert what the user sees. The same object is added as a depthBasis
field to the viewer's get_check_state result
(McpTools/PassageCheckTools.cs), so an agent reporting a route's
clearance also reports whether the chart it is looking at is adjusted.
The shared check_route tool in EncDotNet.S100.Mcp.Tools has no map,
so it doesn't get the field.
2.7 Accessibility
- Automation id
Map.DepthBasis; accessible name fromLabel_DepthBasis;AutomationProperties.HelpTextfromTooltip_DepthBasis, as the overscale button does. - The accessible name includes the state ("Depth basis: Charted, water level not applied"), not just the label, so a screen reader announces it without opening the flyout.
- When the kind changes between
ChartedandAdjusted, the change is announced politely, where Avalonia's automation peer supports a live region. A change that removes adjustment is the one a mariner must not miss. - Colours come from
DynamicResourcebrushes defined per palette, not the overscale pill's hard-coded hex values, so Dusk and Night stay legible. The text carries the state in every case.
3. Alternatives rejected
- Wait for #1132. The ambiguity exists now. With S-104 loaded, the Depth card and the route check already show adjusted values while the chart doesn't, which is the confusing case the trial describes.
- A map watermark or chart-corner label. It competes with chart content and moves with pan and zoom chrome. The status bar already carries the overscale indication in the same role.
- Show the indicator only when S-104 is loaded. Then its absence would have to mean "charted", which is exactly the inference the trial says mariners get wrong. A permanent Charted costs one short pill.
- A per-dataset flag in the Datasets panel. The question is about the picture, not one dataset. A panel can be closed.
- Compute the basis in
MainViewModel. It would be easy to bind, but #1132, the depth card and MCP all need it, andMainViewModelis already large. A service with a pure evaluator is testable and has one owner.
4. Shared vocabulary
The three depth surfaces use different words for the same quantities.
This note settles on the S-98 and S-104 terms, "water level" and
"adjusted", and retires "tide" and "base" in the UI. (Code names such as
S104TideCandidate are left alone.)
| Quantity | Settled term | Today: depth card | Today: Clearance readout and findings |
|---|---|---|---|
| Depth relative to the vertical datum | Charted depth | Base | Charted depth |
| S-104 height above that datum | Water level | Tide | Water level |
| Their sum | Adjusted depth | Tide-adjusted depth, DEPTH AT SELECTED TIME | — |
| No water level used | water level not applied | DEPTH (STATIC), "No tide data covers this location…" | "tide not applied", no tide, "No S-104 at this ETA · tide not applied" |
String changes, all in Resources/Strings.resx:
| Key | Today | Proposed |
|---|---|---|
Pick_Depth_Label_Base |
Base | Charted depth |
Pick_Depth_Label_Tide |
Tide | Water level |
Pick_Depth_Series_TideAdjusted |
Tide-adjusted depth | Adjusted depth |
Pick_Depth_Label_DepthNow |
DEPTH AT SELECTED TIME | ADJUSTED DEPTH AT SELECTED TIME |
Pick_Depth_Label_DepthStatic |
DEPTH (STATIC) | CHARTED DEPTH · WATER LEVEL NOT APPLIED |
Pick_Depth_NoTide |
No tide data covers this location — depth is static (base value only, no time-based correction). | No S-104 water level covers this location, so this is the charted depth. |
Findings_TideNotApplied |
tide not applied | water level not applied |
Findings_TideNotAppliedForFormat |
tide not applied for {0:0.0} NM | water level not applied for {0:0.0} NM |
Check_ChipNoTide |
no tide | no water level |
Clearance_NoTide |
No S-104 at this ETA · tide not applied | No S-104 at this ETA · water level not applied |
Clearance_TideDatumMismatch |
S-104 on another datum · tide not applied | S-104 on another datum · water level not applied |
Clearance_Gap_DatumMismatch |
Datum mismatch, tide not applied | Datum mismatch, water level not applied |
The depth card also gains one line under its header, from
DepthBasisText: "The chart shows charted depths" or, after #1132, "The
chart shows adjusted depths at 14:20". The card then says both what the
point's depth is and how it relates to what the chart draws there.
Key names stay as they are, to keep the diff to values; renaming keys is
a separate cleanup. Docs that quote these labels
(src/EncDotNet.S100.Viewer/README.md, docs/how-a-route-check-works.md,
docs/route-checks.md) are updated in the same change.
5. Implementation plan
Each step is a separate pull request that builds and passes on its own.
- State model and evaluator. Add
DepthBasisKind,DepthBasisReason,DepthBasisSource,DepthBasis,DepthBasisInputs,IWaterLevelAdjustmentState,NoWaterLevelAdjustmentandDepthBasisEvaluatorundersrc/EncDotNet.S100.Viewer/Services/Depth/. Unit tests only; nothing is wired up. - Datums. Read the S-101
SoundingDatumfor depth sources (the evaluator) and for picked points (BaseDepthResolver, soBaseDepthResult.VerticalDatumCodeis set for S-101 sources). ExtendDepthAssimilationService.IsDatumMismatchto S-101 bases. Share the lookup withCatalogChartedDepthSourcerather than copying it. - Service. Add
DepthBasisService, register it, and wire its inputs: dataset events,IViewTime.Changed, and a viewport call fromMapInteractionControllerbesideUpdateOverscale. - Status bar. Add
MainViewModel.UpdateDepthBasis(DepthBasis)and its bound properties; add the pill and flyout toMainWindow.axaml; add theStatus_DepthBasis*,Label_DepthBasisandTooltip_DepthBasisstrings; add per-palette brushes. - Vocabulary. Apply the string changes in §4, add the depth card's basis line, and update the docs that quote the labels.
- MCP. Add
get_depth_basis, its DTOs inViewerMcpJsonContext, and thedepthBasisfield on the viewer'sget_check_stateresult. Document both indocs/mcp-server.md. - Docs and conformance. Describe the indicator in the viewer README (next to "Overscale indication"), and update the WaterLevelAdjustment and WLAFeatureInformation rows in S-164 conformance to say the permanent indication exists and adjustment is still #1132.
#1132 then adds its IWaterLevelAdjustmentState implementation and the
Adjusted, NoWaterLevelAt and DatumMismatch strings. The pill,
flyout, service and MCP tool need no structural change.
6. Tests
DepthBasisEvaluatorTests(Viewer.Tests): a table over the inputs. No depth datasets → hidden. S-101 only → Charted. S-102 + S-104 outside the view → Charted. S-104 meeting the view → Charted, NotApplied. S-104 inactive (Active off) → Charted. EachWaterLevelStatusthrough a fakeIWaterLevelAdjustmentState→ the matching reason. Partial coverage →CoversWholeView = false. Mixed datums → both listed,Datumreported.DepthBasisTextTests: every state's pill and flyout text, Local and UTC time formats, and a non-English current culture for the number and time formats.DepthBasisServiceTests: recomputes on each input event and raisesChangedonce per coalesced burst.DepthAssimilationServiceTests: S-101 bases with known, unknown and differing datums.- MCP:
get_depth_basisreturns the evaluator's state, anddisplaymatches the pill text. - Accessibility:
AccessibilityGuardTestscovers the new button automatically; add an assertion that its accessible name includes the state. - Visual regression: one status-bar snapshot per state shipped in #1191, in Day and Night.
7. Open questions
- Should plain Charted show at all with no S-104 loaded? This note says yes, because the absence of the pill would otherwise carry meaning. A setting to hide it would be reasonable for chart-only use, but never while adjustment is on.
- View or whole session? The NotApplied emphasis uses S-104 footprints in the view. A user zoomed away from S-104 coverage sees plain Charted. That seems right but should be checked against the S-164 WLAOverlap data.
- Station series. A fixed-station S-104 (data coding format 8) has
no footprint beyond its station positions. Should a station near the
view count as "meets the view"? The route check limits stations by
distance (
MaxStationDistanceMetersinCatalogWaterLevelSource); reuse that limit. - Key renames. §4 keeps the
Tidekey names for a smaller diff. Rename them in the same series or leave for a later cleanup? - The
s100CLI and headless renders.s100 renderdraws charted depths only. Should a render note the basis in its metadata or legend, so an exported PNG isn't mistaken for an adjusted chart?