Bring soundings in line with chart standards - #180
Merged
Conversation
perf/soundings.py holds four absolute measurements of one stem's sounding
field, so a selection change is scoped and judged by evidence rather than
taste:
interpolation S-4 B-410a's "final test of depth selection" — no source
pixel may be shoaler than interpolation from the charted
soundings and contours, evaluated per display zoom
rings every closed isobath enclosing a shoal must carry the
least depth inside it (S-4 B-410b)
density nearest-neighbour spacing in mm at chart scale, against
the practice figures (critical/supporting/fill)
floor what each candidate SOUND_MIN_DEPTH_M admits and drops
Measuring per zoom is the point: the shoalest pixel of every cell is
emitted at the finest level, so violations only appear once the pyramid
thins the field.
The fixtures gain iberian-abyssal (GEBCO-only, 1358-3937 m, no land): every
other site is Gulf marsh or Delmarva lagoon, so shoal-water measurements had
no control where selection should trivially pass. Coarse macrotiles build at
a lower stem zoom (SITE_Z) so the crop covers comparable ground.
Position: every sounding sits on the pixel it was measured at. "There is no 'sounding, out of position' in an ENC" (S-57 UOC 5.3); the jittered lattice placement put a quarter of a sampled New York view's soundings on land, displaced by up to a cell (~400 m). The shoalest-cell grid carries the winning pixel's coordinates through every pyramid level. Precision: S-4 B-412's bands replace the 6 m cut — decimetres to 21 m, half metres to 31 m, whole metres beyond — and every unit conversion floors through one epsilon (floor(0.3 * 10) evaluates to 2, so 0.3 m charted as 0.2 m; the same error made 3 fathoms 4 feet compute as 3 feet). Fathoms stay whole in the tiles: a chart prints fathoms-and-feet below 11 fathoms, and a fathom is exactly six feet, so the viewer derives both digits from depth_ft rather than shipping a mixed-radix number. Floor: 0.2 m, from the measured waterline-noise knee (admitted cells grow smoothly from 1.0 down to 0.1 m, then jump 2-5x at 0.1 -> 0.0 across the marsh fixtures). B-410b puts least depths at the top of what must survive selection, so the floor is as low as the data supports. Least depths: the least depth inside every closed isobath is emitted as a prime sounding — a closed contour with no sounding on it is a charted shoal a mariner interpolates across at the ring's own level (measured on the terrebonne fixture: 4 of 4 shoal rings bare, understating by up to 1.30 m; B-410a violations with contours 86% -> 35% once charted). Detection runs on one decimated read bounded to SHOAL_DETECT_PX and peaks are pinned by one small full-res read each: the windows have no overviews, so per-component full-res reads ground the whole 4.33 GB file repeatedly and were OOM-killed at >10 GB; this pass peaks at 0.84 GB on the same window. A prime sounding is never zoom-capped, and displays exactly where its enclosing isobath displays: its minzoom is the ring's display zoom off CONTOUR_TIERS. B-410b's "must always be shown" is a claim about the charted feature — once generalization drops the ring at a scale, the chart makes no claim there for the sounding to complete. Promoting primes past their rings put decimetre least depths over the generalized coastline of coarse views; on the rebuilt New York stem the 2-5 m ring class (2,603 of 4,127 primes) moves from z8 to z11 and inland Long Island renders clean, with 4 of 105 rendered soundings on the land code at native resolution. prime rides to the tiles as a property (tippecanoe -y is an allowlist; an attribute absent from VECTOR_ATTRS is silently dropped however faithfully the pipeline wrote it) so the style can rank it for label collisions.
Sloping numerals (S-4 B-412.1) via a dedicated sounding face, Noto Sans Italic — upright is the posture B-412.4 reserves for soundings of lower reliability, and every label was set in it. Glyphs come from tiles.openwaters.io/fonts (openwatersio/tile-fonts): the MapLibre demo stack ships three of the ten subscript digits, and a missing glyph is not an error, it simply does not draw, so a decimetre digit would vanish off the chart. The sub-unit digit is set smaller and dropped below the baseline (B-412.1), never shown when zero, with no separator — a point would misread 3 fathoms 4 feet as 3.4 fathoms. Metres carry decimetres; fathoms derive fathoms-and-feet from depth_ft, six to the fathom. Label size is a flat 18 px em (~13 px digit height, S-52 5.2.1(2)'s ~3.5 mm): "text size should never be decreased when zooming out" (S-52 3.1.5), so the zoom ramp is gone and low-zoom clutter is thinned by per-feature minzoom and collision, never by shrinking the type. One colour for the whole field, like a paper chart — S-4 sets every sounding in one style, and hazard is carried by the depth-area tint and the isobaths. prime (the least depth inside a closed isobath) outranks the field for label collisions, so a deeper neighbour can never displace it, but gets no ink of its own: it is a topological fact of the build window (a shelf whose contour closes beyond the window edge is never prime, a 0.5 m wrinkle at 29 m is), and painting it black read as a hazard ranking it is not. The viewer's click popup floors depths toward shoal like everything else on the chart; it rounded, so 16.6 m of water read 17 m.
The audit findings, the measurements that scoped each phase, and the design record — including the approaches that were tried and reverted, with the numbers that killed them.
S-4 B-410a's "final test of depth selection" — no ground shoaler than a mariner would infer by interpolating the charted field — becomes a selector, not just a gate. Per display zoom, coarse to fine, the displayed field is interpolated against the DEM and repaired where it understates the water, each insertion at its measured pixel, uncapped from the zoom that needed it: a sounding's minzoom is the coarsest zoom that needed it. A regression fixture holds a violation only the finer zoom's field can see (outside the coarse field's convex hull), so the per-zoom loop cannot silently degrade to a first-zoom pass. Deep-side repair is the default. Where the charted field understates the water by half its true depth (B-403.1a's "full range of depth"), the ground is charted — which is what makes a dredged channel's deep line appear with no channel-specific code: the New York window gains 2,552 insertions (+1.5%), 236 of them the 26-31 m line and infill of the Ambrose corridor through ~10 m ambient, including the Narrows scour. Deep-side stops at the navigational band — without that bound the flat abyssal control drew 1,018 insertions bounding 3,000 m slopes; with it the control draws zero — and respects a screen-px spacing floor (REPAIR_SPACING_PX, default 8): these points exist for legibility, so legibility may thin them. Swept 0-16 px on the marsh fixture: at 8 px the field grows 13.5%, the deep residual falls 5.2% -> ~0, and every shoal ring carries its least depth (bare rings 1/4 -> 0/4 with contours charted). Shoal-side repair exists but is opt-in (REPAIR_SHOAL). The mariner of B-410a interpolates from soundings AND contours, and with the isobaths charted — cut from this same DEM — the measured shoal residual at charted precision is 0.08% before any repair. A soundings-only interpolant, by contrast, manufactures shoal "violations" in the tent around every deep insertion and cascades pitch-spaced points zoom after zoom (measured: every zoom capped out, nearest-neighbour medians of 0.5 px). The shoal side is already guarded by the shoalest-per-cell lattice, the prime rings, and the band tints. One pass per zoom, judged against the field as it stood: iterating re-judges against fresh insertions and seesaws. Deep pins take the measured pixel closest to the decimated value rather than the extreme — pinned extremes mint sharp control points whose own tents violate the opposite tolerance. Both sides keep the decimation pitch from the existing field: a violation within a pitch of a charted sounding is a near-duplicate, not a suppressed hazard.
On an incompatible TileJSON schema the viewer removes the map and is meant to replace it with an explanation; with the replacement commented out, version skew rendered a blank page with no diagnosis.
The sub-unit digit is the typeface's own subscript glyph (U+2080-2089, present ten-of-ten in the self-hosted stack) followed by a hair space of air. The previous construction — a scaled `format` section with vertical-align — aligned em-boxes rather than baselines, so the digit sat visibly high and crowded the integer; the type designer's glyph carries the drop, the size, and the sidebearings. No decimal separator: S-4 B-412.1 separates only when the digits share a baseline, and on the fathom ladder a point would misread 3 fathoms 4 feet as 3.4 fathoms. Label size ramps 12 px at z8 to 18 px at z13 — a deliberate deviation from S-52 3.1.5's "never decrease when zooming out", which assumes ECDIS's fixed compilation scale; at a z8 overview full-size type shouts over a whole sea. Chart-scale size is unchanged at 18 px. The chart sets Medium (soundings in Medium Italic): a halo eats into a light stroke, so weight buys legibility at small sizes that size alone cannot, and S-52's stroked sounding glyphs sit nearer Medium than Regular anyway. Halo softened to half alpha, and the soundings' collision padding drops to the renderer default — padding is the render-side density knob (centre spacing is label width plus twice the padding), and the old 20 px forced ~17 mm "fill" spacing everywhere, culling soundings the pipeline deliberately carries. Tests pin the invariants and free the knobs: sounding posture must be an italic face (the weight is flavor tuning), sub-unit strings are asserted exactly, and prime still outranks the field for collisions.
Layer structure moves out of the 850-line index into per-layer modules — depth-shading (with the relief ramp), depth-areas (with the isobath ladders and the safety-contour snap both it and the contours share), hillshade, contours, soundings, coverage — with appearance staying in flavor.ts and index.ts reduced to composition: sources(), layers(), style(), applyState(), readDepth(). The public API is unchanged, and every expression moved verbatim. Each layer module also declares WHICH of its properties depend on the mariner settings (unit/safety/shading/hillshade), and applyState walks those declarations generically. The old hand-maintained per-layer switch silently skipped any state-dependent property it didn't know about — a layer gaining one now declares it in the same file, same diff. The generic walk emits the identical call set to the switch it replaces. Relative imports carry .js extensions so the built package resolves under pure Node ESM, not just bundlers, and package.json ships every module for the development condition that serves raw TypeScript.
Paint order runs forward (later on top); symbol placement runs in reverse (the last symbol layer claims space first and wins cross-layer collisions — verified against MapLibre's placement loop). The soundings- after-contour-labels position is deliberate under both readings, and is the kind of fact the next reader would plausibly 'fix' backwards.
There was a problem hiding this comment.
Pull request overview
Updates the sounding generation pipeline and MapLibre style to better match nautical chart standards (S-4/S-52/S-57), improving both safety-critical behavior (shoal retention, true-position encoding) and chart typography, while introducing a measurement harness to quantify conformance.
Changes:
- Reworks sounding generation to place soundings at the source pixel, retain least depths over enclosed shoals (“prime” soundings), and optionally repair the field based on interpolation error thresholds.
- Refactors the style into per-layer modules and updates sounding typography (subscript glyphs, fathoms-and-feet rendering, uniform sounding ink, italic-only soundings) plus state application plumbing.
- Adds performance/conformance measurement tooling and fixtures updates, and documents the updated schema and plan.
Reviewed changes
Copilot reviewed 18 out of 18 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| style/soundings.ts | New soundings symbol-layer module with chart-typography formatting (subscripts, fathoms/feet) and collision priority rules. |
| style/package.json | Adjusts published file set to include the new per-layer TS modules. |
| style/index.ts | Refactors style assembly into composed per-layer modules; updates applyState to use per-layer state declarations; switches glyph host. |
| style/index.test.ts | Expands tests to validate new sounding expressions, collision priority, glyph hosting, and state application behavior. |
| style/hillshade.ts | New hillshade layer module plus state declaration for applyState. |
| style/flavor.ts | New shared flavor/types module and shared label size expression; adds sounding-specific italic font. |
| style/depth-shading.ts | New depth-shading (color-relief) layer module and state declaration. |
| style/depth-areas.ts | New DEPARE fill module; centralizes safety contour snapping shared with contours. |
| style/coverage.ts | New provenance/coverage overlay layer family module. |
| style/contours.ts | New contours layer modules (lines + labels) and state declarations; shares safety snapping with depth areas. |
| pipelines/soundings_run.py | Major overhaul of sounding selection: true-position placement, prime soundings for enclosed shoals, error-driven repair, updated precision rules. |
| pipelines/perf/soundings.py | New conformance measurement harness (B-410a interpolation gate, rings, density, floor measurements) with self-checks. |
| pipelines/perf/fixtures.py | Adds an abyssal-plane control fixture and updates fixture zoom selection logic. |
| pipelines/contour_run.py | Ensures prime attribute is preserved into vector tiles via tippecanoe allowlist/types. |
| Justfile | Wires the new soundings perf self-check into just test-perf. |
| index.js | Updates click readout depth formatting to avoid overstating depth (flooring toward shoaler). |
| docs/schema.md | Updates sounding field documentation to reflect new precision bands and fathoms/feet derivation rules. |
| docs/plans/2026-08-17-soundings-chart-conformance.md | Adds a detailed planning/measurement document capturing findings, phases, and rationale. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
The constant was assigned twice with two versions of the same comment, left over from editing the detection path in two passes. Same value both times, so nothing changes but the reader's confidence about which one is authoritative.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Soundings are the numbers a mariner actually reads a chart by, and ours diverged from the paper-chart standards in ways that ranged from cosmetic to unsafe. An audit against S-4, S-52, and the S-57 encoding guide turned up eleven divergences. This fixes seven of them, measures the rest, and leaves behind a harness that can tell whether the field is good.
The two that mattered most for safety:
Shoal depths were being thrown away. A 1.0 m floor deleted exactly the least depths that S-4 B-410b ranks above everything else. The floor is now 0.2 m, picked from the measured point where real shoals stop and waterline noise starts, not from a guess.
Soundings sat where they weren't. Each one was placed on a lattice node rather than on the pixel its depth came from — in a sampled New York view, a quarter of them landed on dry land, displaced by up to 400 m. The winning pixel's position now rides up the pyramid with the depth, so every sounding is where its depth is. S-57 UOC §5.3 is blunt about this: there is no "sounding, out of position" in an ENC.
Channels had no deep line. Taking only the shoalest pixel per cell meant a dredged channel could never show its axis — B-410b wants maximum depths too, and B-403.1a asks for "the full range of depth." Rather than special-casing channels, soundings are now inserted wherever the charted field interpolates wrong by more than a threshold, deep side included. The Ambrose Channel gets its 26-31 m line and axis infill out of a rule that knows nothing about channels.
Also fixed: decimetre precision now runs to 21 m and half-metres to 31 m per B-412 (it cut off at 6 m); fathoms print as fathoms-and-feet below 11 fathoms the way charts do; and the numbers are set in sloping type, since upright is the posture B-412.4 reserves for unreliable soundings — we had every sounding claiming to be untrustworthy.
Typography
The decimetre digit is a real subscript glyph rather than a scaled-down copy of the numeral. MapLibre's
formatwithvertical-alignaligns em-boxes, not baselines, so the shrunken digit floated visibly high — the type designer already solved this, and the glyph carries the correct drop and sidebearings.Getting there meant hosting the fonts: the MapLibre demo stack ships three of the ten subscript digits, and a missing glyph doesn't error, it silently doesn't draw. The full stack now lives at
tiles.openwaters.io/fontsfrom the new tile-fonts repo.Measurement
S-4 B-410a states an acceptance test outright: no source depth should be shoaler than a mariner would get by interpolating from the charted soundings and contours.
pipelines/perf/soundings.pyimplements it as a standing gate, and it's what scoped the rest of this work — including talking me out of a redesign, since the violation rate turns out to be flat across zoom, so thinning was never the cause.It also surfaced something not in the original audit: regions built from coarse sources starve as you zoom in. One stem freezes at 9,438 soundings past z10, so by z14 they sit 224 mm apart at chart scale. Correct per the current design, and exactly backwards from what a mariner wants — the weakest data presents as the emptiest chart precisely when someone zooms in for detail. Filed as a follow-up rather than fixed here.
Style structure
The style module was one 850-line file. It's now one module per layer family, with appearance in
flavor.tsandindex.tsreduced to composition. The public API is unchanged and every expression moved verbatim.One behavioural improvement rode along: each layer now declares which of its properties depend on the mariner settings, and
applyStatewalks those declarations. The hand-maintained list it replaces would silently skip any property nobody remembered to add to it.Out of scope
Datum correction (finding 1) and CATZOC/
M_QUALquality attribution (finding 2) are tracked in their own workstreams. Drying heights (finding 7) are blocked on the datum work — printing a drying height off an MSL-referenced source would be worse than printing nothing.Findings, phases, and the full measurements are in docs/plans/2026-08-17-soundings-chart-conformance.md.
Remaining
Closes openwatersio/seamap#73.