From 49e9cd983104ac82ae60ba3c76e4c8b1d2a86f0d Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 26 Jun 2026 14:44:06 -0400 Subject: [PATCH 01/17] fix(portrayal): draw M_QUAL data-quality (CATZOC) symbology MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QualityOfBathymetricData.lua iterates feature.zoneOfConfidence and reads each instance's categoryOfZoneOfConfidenceInData to pick the DQUAL data-quality area fill (the triangle-with-stars / oval-with-U patterns + dashed boundary). But the S-57→S-101 bridge never synthesized that complex attribute: S-57 stores the zone of confidence as a flat CATZOC simple, so the rule saw an empty zoneOfConfidence and emitted nothing — the entire "quality of data" symbology was missing (Chart 1 page 243 / PresLib PDF 252, the major finding for that panel). buildRoot now wraps M_QUAL's CATZOC (and DATSTA/DATEND, if present) in one zoneOfConfidence instance, the same flat-simple→complex pattern already used for clearances, orientation, and date ranges. Re-rendering page 243 now shows all four zones (A1 6-star triangle, B 3-star triangle, D oval-with-asterisks, U oval-with-U) with their dashed quality boundaries, matching the reference plot. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/engine/s101/complex.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/internal/engine/s101/complex.go b/internal/engine/s101/complex.go index 9957d5e..6705b0a 100644 --- a/internal/engine/s101/complex.go +++ b/internal/engine/s101/complex.go @@ -140,6 +140,24 @@ func (e *Engine) buildRoot(objClass string, s57, derived map[string]string, name root.addChild("periodicDateRange", pdr) } + // Zone of confidence (S-57 M_QUAL CATZOC → the S-101 zoneOfConfidence complex + // attribute). QualityOfBathymetricData iterates feature.zoneOfConfidence and reads + // each instance's categoryOfZoneOfConfidenceInData (+ optional fixedDateRange) to + // pick the DQUAL data-quality fill; S-57 stores CATZOC as a flat simple, so wrap it + // in one zoneOfConfidence instance (one M_QUAL feature carries one CATZOC). Without + // this the rule sees no zones and the data-quality symbology never draws. + if objClass == "M_QUAL" && s57["CATZOC"] != "" { + zoc := newCNode() + zoc.addSimple("categoryOfZoneOfConfidenceInData", s57["CATZOC"]) + if s57["DATSTA"] != "" || s57["DATEND"] != "" { + fdr := newCNode() + fdr.addSimple("dateStart", s57["DATSTA"]) + fdr.addSimple("dateEnd", s57["DATEND"]) + zoc.addChild("fixedDateRange", fdr) + } + root.addChild("zoneOfConfidence", zoc) + } + // Topmark (buoys/beacons): a co-located S-57 TOPMAR feature folded in by the // baker → the S-101 topmark complex attribute the TOPMAR02 CSP reads. if len(topmark) > 0 { From ec97fb26ef8f1c6f6a9d55b0401058cf8d16eb9c Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 26 Jun 2026 15:15:27 -0400 Subject: [PATCH 02/17] fix(bake): place widely-spaced fill patterns as lattice symbols, not textures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit S-52 PresLib §8.5.4 distinguishes "fill patterns" (widely-spaced discrete symbols — quality-of-data, aquaculture, fishing) from "textures" (dense continuous fills — dredged dots, night diamonds). We baked both as a MapLibre fill-pattern, which tiles one raster cell locked to tile/screen pixels: the phase is arbitrary relative to an area, so symbols never centre and get clipped mid-glyph at the boundary ("strange looking pattern fill" the spec warns against) — visible as half-symbols spilling across the M_QUAL data-quality boxes. Now the widely-spaced patterns are placed as discrete WHOLE point symbols on a geographically-anchored V1/V2 lattice (mm → ground metres at the cell scale): - a symbol is drawn only where its footprint fits inside the polygon (inset test), so it never clips or overhangs the boundary (§8.5.1.1); - an area too small to seat ≥2 symbols collapses to one symbol centred on the representative point (§8.5.2), matching the reference plot's one-per-box; - dense textures (DIAMOND1, DRGARE01, NODATA03, …) stay tiled fill-patterns. The S-57→S-101 bridge annotates the sparse AreaFill (V1/V2/symbol) on the PatternFill; the baker does the lattice placement (it has the scale + world coords). Verified on Chart 1 page 243: data-quality zones A1/B/D/u now show one whole centred symbol with no spillage. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/engine/bake/bake.go | 107 +++++++++++++++++++++++++ internal/engine/portrayal/primitive.go | 10 +++ internal/engine/portrayal/s101build.go | 24 +++++- internal/engine/portrayal/s101emit.go | 14 ++++ 4 files changed, 154 insertions(+), 1 deletion(-) diff --git a/internal/engine/bake/bake.go b/internal/engine/bake/bake.go index 388e23e..ea35b74 100644 --- a/internal/engine/bake/bake.go +++ b/internal/engine/bake/bake.go @@ -1085,6 +1085,12 @@ func (b *Baker) route(p portrayal.Primitive, class string, drawPrio, cat int, zr r.attrs = common(extra...) b.add(r, ringsBbox(v.Rings)) case portrayal.PatternFill: + if v.Sparse { + // Widely-spaced fill pattern (§8.5.4): place discrete WHOLE symbols on a + // geographic lattice instead of a tiled, edge-clipped texture. + b.routeSparsePattern(v, common, r) + break + } r.layer, r.kind, r.nrings = scaminLayer("area_patterns", r.bcScamin), mvt.GeomPolygon, normRings(v.Rings) r.attrs = common(mvt.KeyValue{Key: "pattern_name", Value: mvt.StringVal(v.PatternName)}) b.add(r, ringsBbox(v.Rings)) @@ -1207,6 +1213,107 @@ func (b *Baker) routeSymbol(v portrayal.SymbolCall, common func(...mvt.KeyValue) b.add(r, ptBbox(v.Anchor)) } +// routeSparsePattern places a widely-spaced S-52 "fill pattern" (PresLib §8.5.4) +// as individual WHOLE symbols on a fixed geographic lattice, instead of a tiled +// MapLibre fill-pattern (which clips symbols mid-glyph at the area edge and can't +// adapt its spacing to the area). The lattice is spanned by the pattern's V1/V2 +// vectors (display millimetres → ground metres at the cell's compilation scale) +// and phase-anchored to a global origin, so adjacent areas align and the pattern +// doesn't drift on pan (§8.5.4: "based on a geographical position … not on an edge +// of the … area"). A symbol is emitted only where a lattice point falls INSIDE the +// polygon (whole, never clipped); if the area is too small to contain one, a +// single symbol is centred on the representative point (the small-area case). +func (b *Baker) routeSparsePattern(v portrayal.PatternFill, common func(...mvt.KeyValue) []mvt.KeyValue, r routed) { + emit := func(ll geo.LatLon) { + b.routeSymbol(portrayal.SymbolCall{ + Anchor: ll, + SymbolName: v.SymbolRef, + Scale: portrayal.DefaultPxPerSymbolUnit, + CentreOnArea: true, // centre each glyph on its lattice point + SoundingDepthM: nan32f, + DangerDepthM: nan32f, + }, common, r) + } + + scale := float64(b.curCscl) + if scale <= 0 || len(v.Rings) == 0 || len(v.Rings[0]) < 3 { + emit(v.Anchor) // no scale/geometry → fall back to one centred symbol + return + } + // Lattice basis in ground metres: P mm on the chart at 1:scale = P/1000·scale m. + // V1 is horizontal (east); V1[1] is always 0 in the catalogue. + const mPerMM = 1.0 / 1000.0 + v1e := v.V1[0] * mPerMM * scale + v2e := v.V2[0] * mPerMM * scale + v2n := v.V2[1] * mPerMM * scale + if v1e < 1 || v2n < 1 { + emit(v.Anchor) + return + } + // Local equirectangular metres with a GLOBAL (0,0) origin so the lattice phase + // is shared across areas; the east scale uses cos(latRef) for the cell's band. + bb := ringsBbox(v.Rings) + latRef := (bb.MinLat + bb.MaxLat) / 2 + const mPerDegLat = 111320.0 + mPerDegLon := mPerDegLat * math.Cos(latRef*math.Pi/180) + if mPerDegLon < 1 { + emit(v.Anchor) + return + } + // Convert rings to [lon,lat] for the even-odd point-in-polygon test. + rings := make([][][]float64, len(v.Rings)) + for i, ring := range v.Rings { + rr := make([][]float64, len(ring)) + for k, p := range ring { + rr[k] = []float64{p.Lon, p.Lat} + } + rings[i] = rr + } + // A symbol must remain INSIDE the area (§8.5.1.1) — placing it only where its + // centre is inside still lets a glyph overhang the boundary. So require the + // symbol's footprint to fit: test the lattice point plus four cardinal offsets + // at ~symbol-half-extent (a fraction of the cell) are all inside. This also + // makes a SMALL area (≈ one cell, e.g. the Chart-1 quality boxes) hold no + // fitting lattice point, so it collapses to one centred symbol (§8.5.2 / the + // spec's "closer together for a small area" intent) rather than a clutter of + // edge-clipped repeats. + insetLon := 0.4 * v1e / mPerDegLon + insetLat := 0.4 * v2n / mPerDegLat + fits := func(lon, lat float64) bool { + return pointInRings(lon, lat, rings) && + pointInRings(lon-insetLon, lat, rings) && pointInRings(lon+insetLon, lat, rings) && + pointInRings(lon, lat-insetLat, rings) && pointInRings(lon, lat+insetLat, rings) + } + eMin, eMax := bb.MinLon*mPerDegLon, bb.MaxLon*mPerDegLon + nMin, nMax := bb.MinLat*mPerDegLat, bb.MaxLat*mPerDegLat + jLo, jHi := int(math.Floor(nMin/v2n)), int(math.Ceil(nMax/v2n)) + var pts []geo.LatLon + const maxLattice = 4000 // perf guard; sparse patterns never approach this + for j := jLo; j <= jHi && len(pts) < maxLattice; j++ { + n := float64(j) * v2n + iLo := int(math.Floor((eMin - float64(j)*v2e) / v1e)) + iHi := int(math.Ceil((eMax - float64(j)*v2e) / v1e)) + for i := iLo; i <= iHi && len(pts) < maxLattice; i++ { + e := float64(i)*v1e + float64(j)*v2e + lon, lat := e/mPerDegLon, n/mPerDegLat + if fits(lon, lat) { + pts = append(pts, geo.LatLon{Lat: lat, Lon: lon}) + } + } + } + // A small area that seats at most one symbol gets a single CENTRED symbol on + // the representative point (§8.5.2) — centred in its box, not parked at an + // off-centre global-lattice point. Only a larger area (≥2 fitting points) draws + // the spaced lattice. + if len(pts) <= 1 { + emit(v.Anchor) + return + } + for _, ll := range pts { + emit(ll) + } +} + // emitScaleBoundaries draws S-52 DATCVR §10.1.9.1 "chart scale boundaries": a // line where the navigational purpose changes — i.e. along a cell's data-coverage // (M_COVR CATCOV=1) edge wherever STRICTLY-COARSER data lies just outside it. The diff --git a/internal/engine/portrayal/primitive.go b/internal/engine/portrayal/primitive.go index 367ebd2..f7f97ab 100644 --- a/internal/engine/portrayal/primitive.go +++ b/internal/engine/portrayal/primitive.go @@ -113,6 +113,16 @@ type SymbolCall struct { type PatternFill struct { Rings [][]geo.LatLon PatternName string + // Sparse marks a WIDELY-SPACED S-52 "fill pattern" (PresLib §8.5.4) — a + // pattern of discrete symbols (quality-of-data, aquaculture, fishing…) rather + // than a dense texture. The baker places it as individual WHOLE symbols on a + // geographic lattice instead of a tiled fill-pattern, so a symbol is never + // clipped mid-glyph at the area edge (the "strange looking pattern fill" the + // spec warns against). When set, the fields below carry the lattice. + Sparse bool + SymbolRef string // the pattern's point symbol (sprite-atlas name) + V1, V2 [2]float64 // lattice basis vectors, millimetres (display) + Anchor geo.LatLon // area representative point (small-area fallback) } // LinePattern draws a polyline with a complex linestyle (LC instruction). diff --git a/internal/engine/portrayal/s101build.go b/internal/engine/portrayal/s101build.go index e6860c0..442057c 100644 --- a/internal/engine/portrayal/s101build.go +++ b/internal/engine/portrayal/s101build.go @@ -426,17 +426,39 @@ func (b *S101Builder) buildFeatureBody(f *s57.Feature, stream string) FeatureBui // commandsNeedAnchor reports whether any reduced draw command consumes the // feature anchor — the anchored ops emitPrimitives reads geom.Anchor for: point // symbols, text, and sector/augmented figures. Fills and boundary lines don't, -// so an area emitting only those skips the expensive polylabel anchor. +// so an area emitting only those skips the expensive polylabel anchor. A SPARSE +// fill pattern (lattice-placed symbols) needs it too, for the small-area +// fallback (one centred symbol when no lattice point lands inside). func commandsNeedAnchor(cmds []instructions.DrawCommand) bool { for _, c := range cmds { switch c.Op { case instructions.OpPoint, instructions.OpText, instructions.OpAugmentedLine: return true + case instructions.OpAreaFill: + if sparseFillPatterns[c.Reference] { + return true + } } } return false } +// sparseFillPatterns are the S-52 "fill patterns" (PresLib §8.5.4): WIDELY-SPACED +// symbol patterns (lattice cell ≳20 mm) placed as discrete whole symbols on a +// geographic lattice rather than tiled as a texture — so a symbol is never +// clipped mid-glyph at the area boundary (the "strange looking pattern fill" +// §8.5.4 warns against) and small areas still get a centred symbol. Dense +// "textures" (DRGARE dots, DIAMOND1 night-shading, NODATA/PRTSUR dashes, ICEARE, +// FOULAR, TSSJCT, vegetation) stay tiled fill-patterns. +var sparseFillPatterns = map[string]bool{ + "DQUALA11": true, "DQUALA21": true, "DQUALB01": true, + "DQUALC01": true, "DQUALD01": true, "DQUALU01": true, + "MARCUL02": true, // aquaculture / marine farm + "FSHFAC03": true, "FSHFAC04": true, "FSHHAV02": true, // fishing facility / fish haven + "AIRARE02": true, // airport / airfield + "SNDWAV01": true, // sand waves +} + // strokeRunsFor returns the drawable polylines an S-101 line draw strokes for a // feature, honoring S-52 §8.6.2 masking exactly as the S-52 walker does: a line // feature's drawable parts, or — for an area — its drawable boundary, each with diff --git a/internal/engine/portrayal/s101emit.go b/internal/engine/portrayal/s101emit.go index e81865b..927d791 100644 --- a/internal/engine/portrayal/s101emit.go +++ b/internal/engine/portrayal/s101emit.go @@ -57,6 +57,20 @@ func emitPrimitives(cmd instructions.DrawCommand, geom S101Geometry, cat *catalo if len(geom.Rings) == 0 { return nil } + // Widely-spaced "fill patterns" (§8.5.4) are placed as discrete whole + // symbols on a geographic lattice by the baker (no mid-glyph edge clip), + // not tiled as a texture. Carry the lattice (V1/V2 in mm) + a rep point. + if sparseFillPatterns[cmd.Reference] && cat != nil { + if af := cat.AreaFills[cmd.Reference]; af != nil { + return []Primitive{PatternFill{ + Rings: geom.Rings, PatternName: cmd.Reference, Sparse: true, + SymbolRef: af.SymbolRef, + V1: [2]float64{af.V1.X, af.V1.Y}, + V2: [2]float64{af.V2.X, af.V2.Y}, + Anchor: geom.Anchor, + }} + } + } return []Primitive{PatternFill{Rings: geom.Rings, PatternName: cmd.Reference}} case instructions.OpLine: From 54de6202f717aa18801ee863caf308f2310946a2 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 26 Jun 2026 15:29:27 -0400 Subject: [PATCH 03/17] fix(bake): centre sparse-pattern symbols on their pivot, not bbox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single centred symbol (and each lattice symbol) for a widely-spaced fill pattern was emitted with CentreOnArea, which routes the client to the "ctr:" sprite variant that centres the content BOUNDING BOX on the point. That variant exists for corner-pivot "…RES" symbols; the DQUAL/MARCUL/FSH… pattern symbols already carry their pivot at their designed centre (the at 0,0), so the normal pivot-on-point placement centres them exactly while the bbox-centring nudged them off (a downward triangle's bbox centre ≠ its pivot). Drop CentreOnArea so the pattern symbol sits on its pivot — quality-of-data zones now centre cleanly in their boxes. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/engine/bake/bake.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/internal/engine/bake/bake.go b/internal/engine/bake/bake.go index ea35b74..1fc2ec2 100644 --- a/internal/engine/bake/bake.go +++ b/internal/engine/bake/bake.go @@ -1225,11 +1225,15 @@ func (b *Baker) routeSymbol(v portrayal.SymbolCall, common func(...mvt.KeyValue) // single symbol is centred on the representative point (the small-area case). func (b *Baker) routeSparsePattern(v portrayal.PatternFill, common func(...mvt.KeyValue) []mvt.KeyValue, r routed) { emit := func(ll geo.LatLon) { + // No CentreOnArea: a pattern symbol carries its pivot at its designed + // centre (the at 0,0), so the normal pivot-on- + // point placement centres it exactly. CentreOnArea's "ctr:" variant centres + // the content BOUNDING BOX instead — meant for corner-pivot "…RES" symbols — + // and would nudge these off (a downward triangle's bbox centre ≠ its pivot). b.routeSymbol(portrayal.SymbolCall{ Anchor: ll, SymbolName: v.SymbolRef, Scale: portrayal.DefaultPxPerSymbolUnit, - CentreOnArea: true, // centre each glyph on its lattice point SoundingDepthM: nan32f, DangerDepthM: nan32f, }, common, r) From 92cf22bcdefc43bed939ec07c89d6b31ca240e9c Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 26 Jun 2026 15:39:14 -0400 Subject: [PATCH 04/17] fix(web): re-register area patterns at the new size when calibration changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Area fill-pattern TEXTURES bake the physical-size correction into each image's pixelRatio (0.08/FEATURE_SCALE / sizeScale), set once when the image is lazily registered. registerPattern bails on hasImage, so an already-registered pattern is never updated in place — when the mariner calibrates the screen (setPxPitch changes sizeScale, and with it _patternPixelRatio), the point symbols rescale via their data-driven icon-size but the textures keep their stale pixelRatio and render at the wrong (e.g. too-big) physical size relative to everything else. setPxPitch now drops the registered pattern images before rebuilding the style, so the styleimagemissing handler re-registers them at the freshly-computed ratio. The uncalibrated default already matched the reference plot; this fixes the size drift that only appears after calibrating. Co-Authored-By: Claude Opus 4.8 (1M context) --- web/src/chart-canvas/chart-canvas.mjs | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/web/src/chart-canvas/chart-canvas.mjs b/web/src/chart-canvas/chart-canvas.mjs index 77e0013..d104eec 100644 --- a/web/src/chart-canvas/chart-canvas.mjs +++ b/web/src/chart-canvas/chart-canvas.mjs @@ -662,7 +662,14 @@ export class ChartCanvas extends HTMLElement { const v = (typeof mm === "number" && mm > 0) ? mm : undefined; if (v === this._pxPitch) return; this._pxPitch = v; - if (this._map && this._sources) this._map.setStyle(this.buildStyle(), { diff: false, validate: false }); + if (this._map && this._sources) { + // Patterns bake the physical-size correction into their pixelRatio at + // registration; a calibration change alters that ratio but registerPattern + // never updates an existing image (hasImage guard). Drop them so they + // re-register at the new ratio (point symbols rescale via icon-size). + this._dropPatternImages(); + this._map.setStyle(this.buildStyle(), { diff: false, validate: false }); + } } // Switch the basemap live: "coastline" (offline GSHHG land/lakes), "osm" @@ -1062,6 +1069,22 @@ export class ChartCanvas extends HTMLElement { } } + // Remove every registered pattern image so the styleimagemissing handler + // re-registers it at the CURRENT _patternPixelRatio. Used after a calibration + // change (setPxPitch) recomputes that ratio: the physical-size correction is + // baked into a pattern's pixelRatio at registration and never updated in place + // (registerPattern's hasImage guard), so a stale image keeps the old size. + _dropPatternImages() { + if (!this._map || !this._patterns) return; + for (const name in this._patterns) { + if (name === "_meta") continue; + const id = PAT_PREFIX + name; + if (this._map.hasImage(id)) { + try { this._map.removeImage(id); } catch (e) { /* already gone */ } + } + } + } + // S-52 PresLib §14.5 text-group selection. Each text feature carries the baked // `tgrp` tag (the DISPLAY param of its TX/TE, §14.4); the mariner toggles which // groups are visible, independent of display category. Returns a MapLibre filter From 30db686ab736eb80aca3dd2de235ded6c9cdaccd Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 26 Jun 2026 15:54:18 -0400 Subject: [PATCH 05/17] fix(portrayal): centre area symbols on the centroid, not the pole of inaccessibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit S-52 PresLib §8.5.3 specifies the area representative point is the CENTRE OF GRAVITY (centroid) by default, using another point only when the centroid falls outside the area. We always used the pole of inaccessibility (polylabel). For a wide rectangle the pole is not unique — it slides along the horizontal mid-line — so a centred symbol (e.g. the area TS_PAD tidal-stream diamond) landed visibly off-centre even though its box looked symmetric. areaLabelPoint now returns the ring centroid when it lies inside the even-odd area, falling back to the polylabel search for concave/holed shapes where the centroid is outside. Rectangular and convex areas now centre exactly (the area TS_PAD diamond, and the quality-of-data boxes, sit dead-centre); concave shapes keep the robust pole point. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/engine/portrayal/build.go | 49 ++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/internal/engine/portrayal/build.go b/internal/engine/portrayal/build.go index 80d7f4f..4e91e4c 100644 --- a/internal/engine/portrayal/build.go +++ b/internal/engine/portrayal/build.go @@ -222,6 +222,15 @@ func areaLabelPoint(rings [][]geo.LatLon) (geo.LatLon, bool) { return geo.LatLon{}, false } ext := rings[0] + // S-52 §8.5.3: the representative point is the area's CENTRE OF GRAVITY by + // default; only when the centroid falls outside the area (concave / holed + // shapes) is another point used. Prefer the centroid when it's inside — it + // sits at the true centre, whereas the pole of inaccessibility below drifts + // off-centre along a wide shape's mid-line (a wide rectangle's pole is not + // unique), which left centred symbols visibly off-centre. + if c, ok := ringCentroid(ext); ok && pointInRingsEvenOdd(c, rings) { + return c, true + } minLat, minLon := math.Inf(1), math.Inf(1) maxLat, maxLon := math.Inf(-1), math.Inf(-1) var sumLat, sumLon float64 @@ -298,6 +307,46 @@ func areaLabelPoint(rings [][]geo.LatLon) (geo.LatLon, bool) { return geo.LatLon{Lat: best.y, Lon: best.x / kx}, true } +// ringCentroid returns the area centroid (centre of gravity) of a polygon ring +// via the shoelace formula. ok is false for a degenerate (zero-area) ring. The +// ring may be open or closed; edges wrap. Computed in raw lon/lat — the slight +// cos(lat) skew is immaterial for a centring point over a chart-sized area. +func ringCentroid(ring []geo.LatLon) (geo.LatLon, bool) { + n := len(ring) + if n < 3 { + return geo.LatLon{}, false + } + var a, cx, cy float64 + for i := 0; i < n; i++ { + j := (i + 1) % n + cross := ring[i].Lon*ring[j].Lat - ring[j].Lon*ring[i].Lat + a += cross + cx += (ring[i].Lon + ring[j].Lon) * cross + cy += (ring[i].Lat + ring[j].Lat) * cross + } + if math.Abs(a) < 1e-12 { + return geo.LatLon{}, false + } + a *= 0.5 + return geo.LatLon{Lat: cy / (6 * a), Lon: cx / (6 * a)}, true +} + +// pointInRingsEvenOdd reports whether p is inside the even-odd union of rings +// (exterior boundary + holes): inside the exterior AND outside every hole. +func pointInRingsEvenOdd(p geo.LatLon, rings [][]geo.LatLon) bool { + inside := false + for _, ring := range rings { + n := len(ring) + for i, j := 0, n-1; i < n; j, i = i, i+1 { + if (ring[i].Lat > p.Lat) != (ring[j].Lat > p.Lat) && + p.Lon < (ring[j].Lon-ring[i].Lon)*(p.Lat-ring[i].Lat)/(ring[j].Lat-ring[i].Lat)+ring[i].Lon { + inside = !inside + } + } + } + return inside +} + // plCell is one square candidate region in the polylabel search (scaled space). type plCell struct { x, y, half float64 // centre and half-size From f86b75004df30035bf192e2418cc469942a0c44f Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 26 Jun 2026 16:23:52 -0400 Subject: [PATCH 06/17] fix(depth): bake the 0 m drying-line contour value; label contours line-centred MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two contour-labelling fixes: - contourValdco dropped VALDCO=0, so the drying line / chart-datum contour was never baked with a value and could never be labelled "0" (the spec plots show it). It now bakes VALDCO whenever it's explicitly present, including 0 — a missing value is still left unlabelled. Decluttering "0 by the shore" is the mariner's "contour labels" toggle (off by default), not a hard bake-time drop, so the choice is preserved. - The contour-labels layer used symbol-placement "line" with a 300 px spacing, which often places nothing on a short contour segment (the depth-shading demo panels). Switched to "line-center" — one value centred on each contour, as the spec plots show. Harness (preslib-chart1.mjs) now requests contour labels and the depth-shading demo's contour set (shallow 5 / safety 10 / deep 30, drying 0) so the page-243 panels render the intended bands + labels. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/engine/bake/bake.go | 11 ++++++----- scripts/preslib-chart1.mjs | 2 ++ web/src/chart-canvas/chart-style.mjs | 2 +- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/internal/engine/bake/bake.go b/internal/engine/bake/bake.go index 1fc2ec2..9500d6c 100644 --- a/internal/engine/bake/bake.go +++ b/internal/engine/bake/bake.go @@ -2454,11 +2454,12 @@ func contourValdco(attrs map[string]interface{}, class string) float32 { if class != "DEPCNT" { return nan32f } - // Only label contours deeper than chart datum. The 0 m contour is the - // shoreline/drying line; labelling it "0" all along the coast is clutter (the - // "0 by the shore" the mariner doesn't want), and a missing VALDCO is unknown, - // not zero. - if v, ok := floatAttr(attrs, "VALDCO"); ok && v > 0 { + // Bake the value whenever VALDCO is explicitly present — INCLUDING 0, the + // drying line / chart-datum contour, which the spec plots label "0". A missing + // VALDCO is unknown (not zero), so it's left unlabelled. Whether any of these + // actually draw is the mariner's "contour labels" toggle (off by default, so + // the "0 by the shore" stays decluttered until the mariner asks for labels). + if v, ok := floatAttr(attrs, "VALDCO"); ok { return float32(v) } return nan32f diff --git a/scripts/preslib-chart1.mjs b/scripts/preslib-chart1.mjs index 763cbf6..5cf8e20 100644 --- a/scripts/preslib-chart1.mjs +++ b/scripts/preslib-chart1.mjs @@ -67,6 +67,8 @@ const MARINER = { displayBase: true, displayStandard: true, displayOther: true, dataQuality: true, depthUnit: "m", + showContourLabels: true, // spec plots label every depth contour (the "0" drying line, "5", "10", "30") + shallowContour: 5, safetyContour: 10, deepContour: 30, // the depth-shading demo's contours (DEPCNT VALDCO 0/5/10/30) showFullSectorLines: false, boundaryStyle: "symbolized", simplifiedPoints: false, diff --git a/web/src/chart-canvas/chart-style.mjs b/web/src/chart-canvas/chart-style.mjs index 82a5392..a930c6b 100644 --- a/web/src/chart-canvas/chart-style.mjs +++ b/web/src/chart-canvas/chart-style.mjs @@ -222,7 +222,7 @@ function buildLayers(mariner, palette, atlasPpu, osm, sizeScale) { // toggled by the mariner's "contour labels" setting — no re-bake. { id: "contour-labels", type: "symbol", source: "chart", "source-layer": "lines", filter: ["all", ["==", ["get", "class"], "DEPCNT"], ["has", "valdco"]], - layout: { "symbol-placement": "line", "text-field": S52.contourLabelField(mariner), "text-font": FONT, "text-size": 10, "text-max-angle": 30, "symbol-spacing": 300, "text-allow-overlap": false, "text-optional": true, visibility: mariner.showContourLabels ? "visible" : "none" }, + layout: { "symbol-placement": "line-center", "text-field": S52.contourLabelField(mariner), "text-font": FONT, "text-size": 10, "text-max-angle": 30, "text-allow-overlap": false, "text-optional": true, visibility: mariner.showContourLabels ? "visible" : "none" }, paint: { "text-color": S52.contourLabelColor(active, palette), "text-halo-color": S52.textHaloColor(active), "text-halo-width": 1.2 } }, // Dredged-area depth label (S-52 row 47, client-side): DRVAL1 placed at the // DRGARE centroid, in the chosen depth unit. The baker drops the rule's From 1b04ebb4588e2244d06e3bccfe426d1b4358350a Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 26 Jun 2026 16:44:03 -0400 Subject: [PATCH 07/17] =?UTF-8?q?feat(s57):=20dash=20low-accuracy=20geomet?= =?UTF-8?q?ry=20(QUAPOS)=20=E2=80=94=20approximate-position=20line=20style?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit S-52 draws a feature whose position is approximate (QUAPOS not surveyed/precise) with a DASHED line — coastline-unsurveyed, rivers, roads, cables, depth contours, etc. The S-101 rules read this from a per-edge spatial-quality association the host stubs out, so it never fired and everything drew solid. QUAPOS is a SPATIAL-level attribute (ATTV on the edge VRID records), not a feature attribute, and was not parsed at all. This adds it end to end: - parser: parse ATTV on spatial records → spatialRecord.Quapos (reusing the ATTF attribute parser; ATTV has the same ATTL+ATVL layout). - geometry: aggregate the drawn edges' QUAPOS to a per-feature Geometry.Quapos — for line features (constructLineStringGeometry) and polygon boundaries (boundaryQuapos, same drawn-edge selection as drawableBoundaryLines). The feature is "low accuracy" when the majority of its drawn edges are. - pkg/s57: expose Geometry.Quapos through the public conversion. - portrayal: switch a low-accuracy feature's solid simple-line strokes to dashed (complex line styles and point symbols keep their catalogue look). Verified on Chart 1 page 241: "coastline surveyed" stays solid while "coastline unsurveyed", river, vegetation, road and cable render dashed, matching the plot. (Per-feature aggregate, not per-edge — a contour with mixed accuracy dashes by its majority; the depth-shading panel's contours carry no QUAPOS so stay solid.) Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/engine/portrayal/s101build.go | 14 +++++++++++++ internal/s57/parser/geometry.go | 27 ++++++++++++++++++++++++ internal/s57/parser/spatial.go | 24 +++++++++++++++++++++ internal/s57/parser/topology.go | 29 ++++++++++++++++++++++++++ pkg/s57/chart.go | 6 ++++++ 5 files changed, 100 insertions(+) diff --git a/internal/engine/portrayal/s101build.go b/internal/engine/portrayal/s101build.go index 442057c..92c7133 100644 --- a/internal/engine/portrayal/s101build.go +++ b/internal/engine/portrayal/s101build.go @@ -394,6 +394,20 @@ func (b *S101Builder) buildFeatureBody(f *s57.Feature, stream string) FeatureBui } } } + // Low-accuracy geometry (QUAPOS not surveyed/precise) is drawn DASHED — the S-52 + // approximate-position line style (DEPCNT03 dashes a low-accuracy depth contour; + // the same applies to coastline, rivers, tracks, …). The S-101 rules read this + // from a per-edge spatial-quality association we don't model, so apply it here + // from the parsed per-feature QUAPOS aggregate: switch the feature's solid simple + // strokes to dashed. Complex line styles and point symbols keep their look. + if q := f.Geometry().Quapos; q != 0 && q != 1 && q != 10 && q != 11 { + for i, p := range prims { + if sl, ok := p.(StrokeLine); ok && sl.Dash == DashSolid { + sl.Dash = DashDashed + prims[i] = sl + } + } + } // Centred-area symbol placement (S-52 PresLib §8.5.1): the pivot point is the // area's representative point (sg.Anchor), where the FIRST/primary centred symbol // sits "so it is evident which area the symbol applies to"; ADDITIONAL symbols diff --git a/internal/s57/parser/geometry.go b/internal/s57/parser/geometry.go index bd790ae..d8c6f3e 100644 --- a/internal/s57/parser/geometry.go +++ b/internal/s57/parser/geometry.go @@ -99,6 +99,12 @@ type Geometry struct { // backward compatibility. Empty/nil ⇒ no masking applied (or topology didn't // resolve) → fall back to stroking Coordinates. Lines [][][]float64 + + // Quapos is the feature's effective QUAPOS (quality of position): the value + // held by the majority of its drawn edges (S-57 QUAPOS is a spatial-level + // attribute on edges, not a feature attribute). 0 ⇒ none/surveyed. A + // low-accuracy value (not 1/10/11) drives the dashed depth contour (DEPCNT03). + Quapos int } // constructGeometry builds a Geometry from feature and spatial records. @@ -166,6 +172,9 @@ func constructLineStringGeometry(featureRec *featureRecord, spatialRecords map[s var curPart [][]float64 chainBroken := true // force a new part on the first drawable edge sawMask := false + // QUAPOS tally over the feature's DRAWN edges, to derive a feature-level + // quality of position (it's a spatial-level attribute, one per edge). + var quaposTotal, quaposLow, quaposLowVal int flushPart := func() { if len(curPart) >= 2 { lineParts = append(lineParts, curPart) @@ -238,6 +247,12 @@ func constructLineStringGeometry(featureRec *featureRecord, spatialRecords map[s chainBroken = true continue } + // Drawn edge: tally its QUAPOS for the feature-level aggregate. + quaposTotal++ + if isLowAccuracyQuapos(spatial.Quapos) { + quaposLow++ + quaposLowVal = spatial.Quapos + } first := []float64{edgeCoords[0][0], edgeCoords[0][1]} // Start a new part if the chain was broken (masked gap) or this edge does // not continue the previous part's last point. @@ -291,9 +306,20 @@ func constructLineStringGeometry(featureRec *featureRecord, spatialRecords map[s if sawMask { geom.Lines = lineParts } + // Feature is low-accuracy when most of its drawn edges are: dash the contour. + if quaposTotal > 0 && quaposLow*2 > quaposTotal { + geom.Quapos = quaposLowVal + } return geom, nil } +// isLowAccuracyQuapos reports whether a QUAPOS value means "low accuracy" — i.e. +// drawn dashed (S-52 DEPCNT03 / DEPARE03): present and not surveyed (1), precisely +// known (10), or calculated (11). 0 means the attribute was absent. +func isLowAccuracyQuapos(q int) bool { + return q != 0 && q != 1 && q != 10 && q != 11 +} + // constructPointGeometry builds point geometry from spatial references // S-57 §7.6 (31Main.pdf p74): Point features can reference: // - Single isolated node (RCNM=110) for simple point features @@ -540,6 +566,7 @@ func constructPolygonGeometry(featureRec *featureRecord, spatialRecords map[spat Coordinates: allCoords, // Flattened for backward compatibility Rings: rings, // Structured rings with usage indicators BoundaryLines: resolver.drawableBoundaryLines(edgeRefs, coalneEdges, maskCoast), + Quapos: resolver.boundaryQuapos(edgeRefs, coalneEdges, maskCoast), }, nil } diff --git a/internal/s57/parser/spatial.go b/internal/s57/parser/spatial.go index ca53602..0c5ce44 100644 --- a/internal/s57/parser/spatial.go +++ b/internal/s57/parser/spatial.go @@ -2,6 +2,8 @@ package parser import ( "encoding/binary" + "strconv" + "strings" "github.com/beetlebugorg/chartplotter/pkg/iso8211" ) @@ -26,6 +28,7 @@ type spatialRecord struct { VectorPointers []vectorPointer // VRPT pointers to other spatial records RecordVersion int // RVER - record version number UpdateInstr int // RUIN - update instruction + Quapos int // QUAPOS quality of position (S-57 spatial-level ATTV); 0 if absent } // spatialType represents the type of spatial record @@ -94,9 +97,30 @@ func parseSpatialRecordInternal(record *iso8211.DataRecord, comf int32, somf int spatialRec.VectorPointers = parseVectorPointers(vrptData) } + // Parse ATTV (spatial-level attributes) — QUAPOS lives here, not on the + // feature. Same ATTL+ATVL layout as a feature's ATTF, so reuse parseAttributes. + if attvData, ok := record.Fields["ATTV"]; ok { + if q, ok := atoiAttr(parseAttributes(attvData)["QUAPOS"]); ok { + spatialRec.Quapos = q + } + } + return spatialRec } +// atoiAttr reads an S-57 attribute value (parsed as a string) as an int. +func atoiAttr(v interface{}) (int, bool) { + s, ok := v.(string) + if !ok { + return 0, false + } + n, err := strconv.Atoi(strings.TrimSpace(s)) + if err != nil { + return 0, false + } + return n, true +} + // parseCoordinates2D extracts 2D coordinates from SG2D field // S-57 §7.7.1.6 (31Main.pdf p80): SG2D contains repeated coordinate pairs // Coordinates are stored as signed integers (b24 = int32) that need scaling by COMF diff --git a/internal/s57/parser/topology.go b/internal/s57/parser/topology.go index f1eebe9..cbc5a7a 100644 --- a/internal/s57/parser/topology.go +++ b/internal/s57/parser/topology.go @@ -131,6 +131,35 @@ func (r *polygonBuilder) drawableBoundaryLines(edgeRefs []spatialRef, coalneEdge return lines } +// boundaryQuapos returns the feature's effective QUAPOS over its DRAWN boundary +// edges — the same edge selection as drawableBoundaryLines (S-52 §8.6.2). Returns +// the low-accuracy value when the majority of drawn edges are low accuracy, else +// 0. (QUAPOS is a spatial-level attribute on the edge records.) +func (r *polygonBuilder) boundaryQuapos(edgeRefs []spatialRef, coalneEdges map[int64]bool, maskCoast bool) int { + var total, low, lowVal int + for _, er := range edgeRefs { + if er.Mask == 1 || er.Usage == 3 { + continue + } + if maskCoast && coalneEdges[er.RCID] { + continue + } + sp := r.spatialRecords[spatialKey{RCNM: int(spatialTypeEdge), RCID: er.RCID}] + if sp == nil { + continue + } + total++ + if isLowAccuracyQuapos(sp.Quapos) { + low++ + lowVal = sp.Quapos + } + } + if total > 0 && low*2 > total { + return lowVal + } + return 0 +} + // loadEdge loads an edge from spatial records, with caching // Returns cached edge if already loaded, otherwise loads from spatial record func (r *polygonBuilder) loadEdge(edgeID int64) (*edge, error) { diff --git a/pkg/s57/chart.go b/pkg/s57/chart.go index e8ffa55..1bc8a4b 100644 --- a/pkg/s57/chart.go +++ b/pkg/s57/chart.go @@ -547,6 +547,11 @@ type Geometry struct { // keeps the full concatenation for backward compatibility. Empty/nil ⇒ no // masking applied → stroke Coordinates. Lines [][][]float64 + + // Quapos is the feature's effective QUAPOS (quality of position), derived from + // its edges' spatial-level QUAPOS attribute. 0 ⇒ none/surveyed; a low-accuracy + // value (not 1/10/11) means the depth contour is drawn dashed (S-52 DEPCNT03). + Quapos int } // GeometryType represents the type of geometry. @@ -623,6 +628,7 @@ func convertChart(internal *parser.Chart) *Chart { Rings: rings, BoundaryLines: f.Geometry.BoundaryLines, Lines: f.Geometry.Lines, + Quapos: f.Geometry.Quapos, }, attributes: attributes, } From d3f89d9986d9319228ee84ce962ee52fe27f702f Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 26 Jun 2026 17:15:18 -0400 Subject: [PATCH 08/17] feat(web): date-dependency toggles in Advanced settings (highlight + filter) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The date pipeline already bakes date_start/date_end/date_recurring + the CHDATD01 "d" marker, and the client has the mandatory current-date filter (dateDependent) and the CHDATD01 highlight (highlightDateDependent) — but neither was reachable from the UI. Add two toggles under Advanced: - "Hide out-of-date features" (dateDependent, default on) — the S-52 §10.4.1.1 mandatory current-date filter; off shows seasonal/expired features regardless of their validity dates. - "Highlight date-dependent" (highlightDateDependent) — the S-52 §10.6.1.1 CHDATD01 "d" marker on features that carry date conditions. Both keys already trigger a restyle (chart-canvas setMariner), so this is purely the missing UI. The Chart 1 harness now enables both so page 239 renders the date-dependency demo: the "Periodic" feature and the expired "End date 27-08-2014" FlW light both show their magenta "d" markers, matching the plot. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/preslib-chart1.mjs | 2 ++ web/src/core/core-settings.mjs | 12 ++++++++++++ 2 files changed, 14 insertions(+) diff --git a/scripts/preslib-chart1.mjs b/scripts/preslib-chart1.mjs index 5cf8e20..6b5fbd0 100644 --- a/scripts/preslib-chart1.mjs +++ b/scripts/preslib-chart1.mjs @@ -69,6 +69,8 @@ const MARINER = { depthUnit: "m", showContourLabels: true, // spec plots label every depth contour (the "0" drying line, "5", "10", "30") shallowContour: 5, safetyContour: 10, deepContour: 30, // the depth-shading demo's contours (DEPCNT VALDCO 0/5/10/30) + highlightDateDependent: true, // show the CHDATD01 "d" markers (the date-dependency demo) + dateDependent: false, // date filter off so the expired "End date 27-08-2014" object still shows showFullSectorLines: false, boundaryStyle: "symbolized", simplifiedPoints: false, diff --git a/web/src/core/core-settings.mjs b/web/src/core/core-settings.mjs index 9c0c112..9e6ca5b 100644 --- a/web/src/core/core-settings.mjs +++ b/web/src/core/core-settings.mjs @@ -157,6 +157,18 @@ export function coreSettingsContributions(app) { key: "showCellBounds", type: "toggle", label: "Cell boundaries", desc: "Outline installed charts when zoomed out — tap one to jump to it", }, + // Date-dependency (S-52 §10.4.1.1 / §10.6.1.1). "Hide out-of-date features" + // is the mandatory current-date filter (default on); turning it off shows + // seasonal/expired features regardless of their validity dates. "Highlight" + // adds the CHDATD01 "d" marker to features that carry date conditions. + { + key: "dateDependent", type: "toggle", label: "Hide out-of-date features", + desc: "Hide seasonal or expired features outside their validity dates", default: true, + }, + { + key: "highlightDateDependent", type: "toggle", label: "Highlight date-dependent", + desc: "Mark features that carry date conditions with the “d” symbol", + }, ], }; From 573f6c1f440a7eb8fb0b79535c3fa4052c415ba6 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 26 Jun 2026 17:21:16 -0400 Subject: [PATCH 09/17] feat(web): "Viewing date" control to evaluate date-dependency for planning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The client already supports a pinned viewing date (mariner.dateView "YYYYMMDD" — _todayParts uses it, and it's in the restyle key set), but there was no way to set it. Add a "date" settings control type (native date input; stores the compact YYYYMMDD the model uses, blank = real today) and a "Viewing date" field under Advanced, beside the date-dependency toggles. Setting it evaluates date-dependent display (the hide-out-of-date filter and the "d" highlight) against that date instead of today — passage planning, and a way to preview seasonal/expired features. Co-Authored-By: Claude Opus 4.8 (1M context) --- web/src/core/core-settings.mjs | 4 ++++ web/src/plugins/settings-dialog.mjs | 5 +++++ web/src/plugins/settings-dialog.view.mjs | 10 ++++++++++ 3 files changed, 19 insertions(+) diff --git a/web/src/core/core-settings.mjs b/web/src/core/core-settings.mjs index 9e6ca5b..c68970e 100644 --- a/web/src/core/core-settings.mjs +++ b/web/src/core/core-settings.mjs @@ -169,6 +169,10 @@ export function coreSettingsContributions(app) { key: "highlightDateDependent", type: "toggle", label: "Highlight date-dependent", desc: "Mark features that carry date conditions with the “d” symbol", }, + { + key: "dateView", type: "date", label: "Viewing date", + desc: "Evaluate date-dependent features against this date for passage planning (blank = today)", + }, ], }; diff --git a/web/src/plugins/settings-dialog.mjs b/web/src/plugins/settings-dialog.mjs index ccd6716..35f7f84 100644 --- a/web/src/plugins/settings-dialog.mjs +++ b/web/src/plugins/settings-dialog.mjs @@ -139,6 +139,11 @@ export class SettingsDialog extends HTMLElement { if (!isFinite(v)) { this.render(); return; } apply(inp.dataset.contrib, inp.dataset.key, v); })); + + // A date input stores the compact "YYYYMMDD" the mariner model uses; blank + // clears it (unset = real today). + body.querySelectorAll('input[data-type="date"]').forEach((inp) => + (inp.onchange = () => apply(inp.dataset.contrib, inp.dataset.key, inp.value ? inp.value.replace(/-/g, "") : undefined))); } } diff --git a/web/src/plugins/settings-dialog.view.mjs b/web/src/plugins/settings-dialog.view.mjs index 30c4186..b652187 100644 --- a/web/src/plugins/settings-dialog.view.mjs +++ b/web/src/plugins/settings-dialog.view.mjs @@ -75,6 +75,12 @@ export const STYLE = ` } `; +// "YYYYMMDD" → "YYYY-MM-DD" for the native date input (blank if unset/invalid). +function ymdToInput(v) { + const s = String(v || ""); + return /^\d{8}$/.test(s) ? `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}` : ""; +} + // One control, dispatched by item.type. `value` is the item's current value (the // host read it from contribution.get). `on` reads a boolean key for `multi`. function control(item, value, on) { @@ -93,6 +99,10 @@ function control(item, value, on) { ].join("")}`; case "number": return `${item.unit ? `${esc(item.unit)}` : ""}`; + case "date": + // Value is stored as compact "YYYYMMDD" (mariner.dateView); the native date + // input wants "YYYY-MM-DD". Blank = unset (use real today). + return ``; case "select": return ``; From 0b815ff84f7205f4fa886d7e0185bea801eb01ee Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 26 Jun 2026 17:23:42 -0400 Subject: [PATCH 10/17] refactor(web): move "Highlight date-dependent" to General settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It's a standard S-52 §10.6.1.1 display highlight — sibling to "Information callouts" — not a developer/Advanced setting. Move the toggle to the General tab beside Information callouts. The current-date filter override ("Hide out-of-date features") and the planning "Viewing date" stay under Advanced. Co-Authored-By: Claude Opus 4.8 (1M context) --- web/src/core/core-settings.mjs | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/web/src/core/core-settings.mjs b/web/src/core/core-settings.mjs index c68970e..f118c16 100644 --- a/web/src/core/core-settings.mjs +++ b/web/src/core/core-settings.mjs @@ -83,6 +83,7 @@ export function coreSettingsContributions(app) { { key: "showIsolatedDangersShallow", type: "toggle", label: "Isolated dangers (shallow)", desc: "Only flag isolated dangers in shallow water" }, { key: "dataQuality", type: "toggle", label: "Data quality", desc: "Survey zones-of-confidence overlay" }, { key: "showInformCallouts", type: "toggle", label: "Information callouts", desc: "“Additional information available” (i) markers on features that carry notes" }, + { key: "highlightDateDependent", type: "toggle", label: "Highlight date-dependent", desc: "Mark features that carry date conditions with the “d” symbol" }, { key: "showMetaBounds", type: "toggle", label: "Metadata boundaries", desc: "Chart coverage & region indicator lines" }, { key: "showScaleBoundaries", type: "toggle", label: "Scale boundaries", desc: "Outline where more detailed charts exist", default: true }, { @@ -157,18 +158,15 @@ export function coreSettingsContributions(app) { key: "showCellBounds", type: "toggle", label: "Cell boundaries", desc: "Outline installed charts when zoomed out — tap one to jump to it", }, - // Date-dependency (S-52 §10.4.1.1 / §10.6.1.1). "Hide out-of-date features" - // is the mandatory current-date filter (default on); turning it off shows - // seasonal/expired features regardless of their validity dates. "Highlight" - // adds the CHDATD01 "d" marker to features that carry date conditions. + // Date-dependency (S-52 §10.4.1.1). "Hide out-of-date features" is the + // mandatory current-date filter (default on); turning it off shows + // seasonal/expired features regardless of their validity dates. The viewing + // date evaluates that filter (and the "Highlight date-dependent" markers, + // toggled under General) against a chosen date for passage planning. { key: "dateDependent", type: "toggle", label: "Hide out-of-date features", desc: "Hide seasonal or expired features outside their validity dates", default: true, }, - { - key: "highlightDateDependent", type: "toggle", label: "Highlight date-dependent", - desc: "Mark features that carry date conditions with the “d” symbol", - }, { key: "dateView", type: "date", label: "Viewing date", desc: "Evaluate date-dependent features against this date for passage planning (blank = today)", From aab801004838479c421a54447a8745a2752bd653 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 26 Jun 2026 17:41:55 -0400 Subject: [PATCH 11/17] fix(portrayal): draw the SWPARE51 swept-depth bracket on swept areas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The swept-area fallback (sweptAreaBuild — the S-101 SweptArea rule is an IHO gap) drew only the dashed boundary and the "swept to N" label, missing the SWPARE51 "⊔" swept-depth bracket the spec plots show with it (Chart 1 page 243 "swept to 4.5"). Place SY(SWPARE51) at the area's representative point, with the label top-anchored just below it so it drops under the bracket instead of overprinting it (the client text layer ignores per-feature pixel offsets, so the vertical position comes from the text anchor). Matches the reference plot: bracket on top, "swept to 4.5" beneath. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/engine/portrayal/build.go | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/internal/engine/portrayal/build.go b/internal/engine/portrayal/build.go index 4e91e4c..229315e 100644 --- a/internal/engine/portrayal/build.go +++ b/internal/engine/portrayal/build.go @@ -780,12 +780,23 @@ func sweptAreaBuild(f *s57.Feature) FeatureBuild { if len(prims) == 0 { return FeatureBuild{DisplayCategory: displayStandard} } - // "swept to " depth label at the area's representative point. - if d, ok := floatAttr(f.Attributes(), "DRVAL1"); ok { - if a, ok := areaSurfacePoint(ringLL(exteriorRing(g))); ok { + // Swept-depth notation at the area's representative point: the SWPARE51 "⊔" + // bracket centred on the point, with the "swept to " label just above + // it (S-101 HighConfidenceDepthArea: SY(SWPARE51) + text at LocalOffset 0,-3.51 + // mm). The S-101 SweptArea rule is an IHO gap, so this Go fallback reproduces it. + if a, ok := areaSurfacePoint(ringLL(exteriorRing(g))); ok { + prims = append(prims, SymbolCall{ + Anchor: a, SymbolName: "SWPARE51", Scale: DefaultPxPerSymbolUnit, + SoundingDepthM: nan32, DangerDepthM: nan32, + }) + if d, ok := floatAttr(f.Attributes(), "DRVAL1"); ok { prims = append(prims, DrawText{ + // VAlignTop anchors the text at its top edge on the rep point, so it + // drops BELOW the SWPARE51 bracket (which extends UP from the same + // point) instead of overprinting it — the client text layer ignores + // per-feature pixel offsets, so position via the anchor. Anchor: a, Text: "swept to " + strconv.FormatFloat(d, 'f', -1, 64), - FontSizePx: 11, ColorToken: "CHBLK", HAlign: HAlignCenter, VAlign: VAlignMiddle, + FontSizePx: 11, ColorToken: "CHBLK", HAlign: HAlignCenter, VAlign: VAlignTop, }) } } From a0f136fb56d3f46260cbb38df123cac4ae7e704e Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 26 Jun 2026 18:06:26 -0400 Subject: [PATCH 12/17] feat(portrayal): draw M_NSYS navigational-system-of-marks boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The S-101 NavigationalSystemOfMarks rule is an unofficial stub (NullInstruction only), so M_NSYS regions drew nothing (Chart 1 page 248 "IALA A / IALA B / no or other system" was blank). Add a Go-side build, like SWPARE, that strokes each ring with the S-52 NAVARE51 complex line (dashes + EMAREGR1 triangle markers) — the region outline is the same regardless of MARSYS (the system governs the buoy colours inside, not the boundary). M_NSYS is a META_BOUND_CLASS, gated behind the "Metadata boundaries" mariner toggle (off by default), so the Chart 1 harness now enables showMetaBounds to render the demo. The three IALA boxes match the plot; the system-change "A-B-A-B" transition line (a shared edge between different-MARSYS regions) needs spatial adjacency and is not yet drawn. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/engine/portrayal/build.go | 32 ++++++++++++++++++++++++++ internal/engine/portrayal/s101build.go | 9 ++++++++ scripts/preslib-chart1.mjs | 1 + 3 files changed, 42 insertions(+) diff --git a/internal/engine/portrayal/build.go b/internal/engine/portrayal/build.go index 229315e..87099f6 100644 --- a/internal/engine/portrayal/build.go +++ b/internal/engine/portrayal/build.go @@ -754,6 +754,38 @@ func newObjectBuild(f *s57.Feature) FeatureBuild { // so it errors and would be suppressed. The S-52 PresLib reference (page 243) // draws a dashed boundary around the area plus a "swept to " depth label, // so emit that. +// navSystemBuild draws an M_NSYS (navigational system of marks) area boundary — +// the S-52 NAVARE51 complex line (dashes + EMAREGR1 triangle markers) around the +// IALA-A / IALA-B / other-system region. The S-101 NavigationalSystemOfMarks rule +// is an unofficial stub (NullInstruction), so reproduce the S-52 lookup here. The +// boundary is the same regardless of MARSYS — the system only governs the buoy +// colours inside, not the region outline. +func navSystemBuild(f *s57.Feature) FeatureBuild { + g := f.Geometry() + if g.Type != s57.GeometryTypePolygon { + return FeatureBuild{DisplayCategory: displayStandard} + } + var prims []Primitive + for _, r := range g.Rings { + pts := make([]geo.LatLon, 0, len(r.Coordinates)) + for _, c := range r.Coordinates { + if len(c) >= 2 { + pts = append(pts, geo.LatLon{Lat: c[1], Lon: c[0]}) + } + } + if len(pts) > 1 && pts[0] != pts[len(pts)-1] { + pts = append(pts, pts[0]) // close the ring + } + if len(pts) >= 2 { + prims = append(prims, LinePattern{Points: pts, LinestyleName: "NAVARE51", ColorToken: "CHGRD"}) + } + } + if len(prims) == 0 { + return FeatureBuild{DisplayCategory: displayStandard} + } + return FeatureBuild{Primitives: prims, DisplayPriority: 12, DisplayCategory: displayStandard} +} + func sweptAreaBuild(f *s57.Feature) FeatureBuild { g := f.Geometry() if g.Type != s57.GeometryTypePolygon { diff --git a/internal/engine/portrayal/s101build.go b/internal/engine/portrayal/s101build.go index 92c7133..209fc49 100644 --- a/internal/engine/portrayal/s101build.go +++ b/internal/engine/portrayal/s101build.go @@ -300,6 +300,15 @@ func (b *S101Builder) buildFeatureBody(f *s57.Feature, stream string) FeatureBui return fb } } + // M_NSYS (navigational system of marks): the S-101 NavigationalSystemOfMarks + // rule is an UNOFFICIAL stub (NullInstruction only), so draw the S-52 boundary + // here — the NAVARE51 dashed triangle line around the IALA-A / IALA-B / other + // region. Bypasses the stub stream entirely. + if f.ObjectClass() == "M_NSYS" { + if nb := navSystemBuild(f); len(nb.Primitives) > 0 { + return nb + } + } // Genuinely-unknown object class (no S-101 alias) → the magenta "unknown // object" mark (S-52 §10.1.1 parity). if strings.HasPrefix(stream, "UNMAPPED:") { diff --git a/scripts/preslib-chart1.mjs b/scripts/preslib-chart1.mjs index 6b5fbd0..be58155 100644 --- a/scripts/preslib-chart1.mjs +++ b/scripts/preslib-chart1.mjs @@ -71,6 +71,7 @@ const MARINER = { shallowContour: 5, safetyContour: 10, deepContour: 30, // the depth-shading demo's contours (DEPCNT VALDCO 0/5/10/30) highlightDateDependent: true, // show the CHDATD01 "d" markers (the date-dependency demo) dateDependent: false, // date filter off so the expired "End date 27-08-2014" object still shows + showMetaBounds: true, // show meta-object boundaries — M_NSYS navigational-system-of-marks demo (page 248) showFullSectorLines: false, boundaryStyle: "symbolized", simplifiedPoints: false, From acd039d6c7a3e5eafa20d1267b6e4a0bb4171cef Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 26 Jun 2026 18:15:24 -0400 Subject: [PATCH 13/17] chore: clear gopls diagnostics in build.go / bake.go / s101build.go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove dead code in portrayal/build.go: applyDangerDepth, isSoundingDigit, geometryCode, goGeomType, cloneRings+clonePts, maxF32, isUnknownClass — all unreferenced leftovers from the S-52→S-101 migration (gopls unusedfunc). - interface{} → any across the three files (gofmt -r). - bake.go modernize: sort.Slice→slices.Sort, C-style loop→range-over-int, if/assign→max, and the cross-band suppression switch→tagged switch on r.kind. No behaviour change; build/vet/test green. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/engine/bake/bake.go | 29 +++---- internal/engine/portrayal/build.go | 109 ++----------------------- internal/engine/portrayal/s101build.go | 4 +- 3 files changed, 22 insertions(+), 120 deletions(-) diff --git a/internal/engine/bake/bake.go b/internal/engine/bake/bake.go index 9500d6c..3d102df 100644 --- a/internal/engine/bake/bake.go +++ b/internal/engine/bake/bake.go @@ -22,7 +22,7 @@ import ( "fmt" "log" "math" - "sort" + "slices" "strconv" "strings" @@ -641,7 +641,7 @@ func (b *Baker) ScaminValues() []uint32 { for v := range b.scaminSeen { out = append(out, v) } - sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + slices.Sort(out) return out } @@ -1357,7 +1357,7 @@ func (b *Baker) emitScaleBoundaries() { run = run[:0] minOut = cm.bandMin } - for i := 0; i < n; i++ { + for i := range n { lon1, lat1 := ring[i][0], ring[i][1] lon2, lat2 := ring[(i+1)%n][0], ring[(i+1)%n][1] dx, dy := lon2-lon1, lat2-lat1 @@ -1821,14 +1821,14 @@ func (b *Baker) emitTileInto(coord tile.TileCoord, extent uint32, buffer float64 } } else if r.cscl != 0 && r.layer != "scale_boundaries" && (r.kind == mvt.GeomPoint || r.kind == mvt.GeomLineString || r.layer == "area_patterns" || r.layer == "areas") { - switch { - case r.kind == mvt.GeomPoint: + switch r.kind { + case mvt.GeomPoint: // A point tests its OWN position — a boundary tile keeps coarse points // that fall outside the finer coverage. if s := b.coverageScaleAt(unnormY(r.wMinY), r.wMinX*360-180, bandZ, true); s != 0 && s < r.cscl { suppressed = true } - case r.kind == mvt.GeomLineString: + case mvt.GeomLineString: // Lines (incl. complex/symbolised) do NOT occlude. Each per-band archive // is a SEPARATE client source with no maxzoom cap on the fine bands, so a // coarse line kept in a tile a finer cell also covers DOUBLE-DRAWS: the @@ -2102,10 +2102,7 @@ func tessellateFigure(sp *sectorPrim, z uint32) []sectorStroke { if sweep == 0 { sweep = 360 // a zero sweep is a full all-round ring } - n := int(math.Ceil(math.Abs(sweep) / 3.0)) - if n < 8 { - n = 8 - } + n := max(int(math.Ceil(math.Abs(sweep)/3.0)), 8) pts := make([]geo.LatLon, n+1) for i := range pts { brg := sp.fig.StartDeg + sweep*float64(i)/float64(n) @@ -2432,7 +2429,7 @@ func ptBbox(p geo.LatLon) geo.BoundingBox { // depthVals returns a depth area's DRVAL1/DRVAL2 (metres) for the client's live // SEABED01 shading, or (NaN, NaN) for non-depth areas (so route() omits them). // DRVAL2 falls back to DRVAL1 when absent. Mirrors the is_depth gate. -func depthVals(attrs map[string]interface{}, class string) (float32, float32) { +func depthVals(attrs map[string]any, class string) (float32, float32) { if class != "DEPARE" && class != "DRGARE" { return nan32f, nan32f } @@ -2450,7 +2447,7 @@ func depthVals(attrs map[string]interface{}, class string) (float32, float32) { // contourValdco returns a DEPCNT depth contour's VALDCO (metres) so the client can // label it in the chosen depth unit, or NaN for non-contours / contours with no // value (so route() omits the tag and the client draws no label — not a "0"). -func contourValdco(attrs map[string]interface{}, class string) float32 { +func contourValdco(attrs map[string]any, class string) float32 { if class != "DEPCNT" { return nan32f } @@ -2468,7 +2465,7 @@ func contourValdco(attrs map[string]interface{}, class string) float32 { var nan32f = float32(math.NaN()) // floatAttr reads a numeric S-57 attribute (int/float/string) as a float64. -func floatAttr(attrs map[string]interface{}, key string) (float64, bool) { +func floatAttr(attrs map[string]any, key string) (float64, bool) { v, ok := attrs[key] if !ok { return 0, false @@ -2495,7 +2492,7 @@ func floatAttr(attrs map[string]interface{}, key string) (float64, bool) { // numbers are formatted minimally, satisfying the no-padding rule (§10.8 rule 3). // Returns "" for an attribute-free feature. json.Marshal sorts map keys, so the // output is deterministic (bake reproducibility). -func encodeS57Attrs(attrs map[string]interface{}) string { +func encodeS57Attrs(attrs map[string]any) string { if len(attrs) == 0 { return "" } @@ -2532,14 +2529,14 @@ func encodeS57Attrs(attrs map[string]interface{}) string { } // stringAttr returns the trimmed string value of a string attribute, or "". -func stringAttr(attrs map[string]interface{}, key string) string { +func stringAttr(attrs map[string]any, key string) string { if s, ok := attrs[key].(string); ok { return strings.TrimSpace(s) } return "" } -func intAttr(attrs map[string]interface{}, key string) uint32 { +func intAttr(attrs map[string]any, key string) uint32 { v, ok := attrs[key] if !ok { return 0 diff --git a/internal/engine/portrayal/build.go b/internal/engine/portrayal/build.go index 87099f6..9662e87 100644 --- a/internal/engine/portrayal/build.go +++ b/internal/engine/portrayal/build.go @@ -88,41 +88,8 @@ type FeatureBuildPass struct { Pts int } -// applyDangerDepth tags the DANGER01/DANGER02 symbol of a sounded obstruction / -// wreck / rock (one with VALSOU) with its depth and the deep variant, so the -// client swaps shallow<->deep (DANGER01<->DANGER02) against the LIVE safety -// contour with no re-bake (S-52 §13.2.x). It ONLY touches the DANGER01/02 pair — -// soundings, ISODGR01, OBSTRN11, DANGER03 and every other primitive the CSP -// emitted are left exactly as placed. The base symbol is normalised to DANGER01 -// (the shallow variant) so the client's coalesce picks DANGER01/DANGER02 by the -// live contour. -// -// (The CSPs — OBSTRN07 Continuation A, WRECKS05 — now emit DANGER01/02 + the -// sounding directly, so this is a post-tag rather than the old symbol-replacing -// override that dropped the sounding glyphs.) -func applyDangerDepth(prims []Primitive, class string, attrs map[string]interface{}) []Primitive { - if class != "OBSTRN" && class != "WRECKS" && class != "UWTROC" { - return prims - } - valsou, ok := floatAttr(attrs, "VALSOU") - if !ok { - return prims - } - for i := range prims { - sc, ok := prims[i].(SymbolCall) - if !ok || (sc.SymbolName != "DANGER01" && sc.SymbolName != "DANGER02") { - continue - } - sc.SymbolName = "DANGER01" - sc.DangerDepthM = float32(valsou) - sc.DeepSymbolName = "DANGER02" - prims[i] = sc - } - return prims -} - // stringAttr returns an attribute's encoded string value, or "" when absent. -func stringAttr(attrs map[string]interface{}, key string) string { +func stringAttr(attrs map[string]any, key string) string { if v, ok := attrs[key]; ok { if s, ok := encodeAttr(v); ok { return s @@ -131,7 +98,7 @@ func stringAttr(attrs map[string]interface{}, key string) string { return "" } -func floatAttr(attrs map[string]interface{}, key string) (float64, bool) { +func floatAttr(attrs map[string]any, key string) (float64, bool) { v, ok := attrs[key] if !ok || v == nil { return 0, false @@ -155,13 +122,9 @@ func floatAttr(attrs map[string]interface{}, key string) (float64, bool) { // -- helpers ----------------------------------------------------------------- -func isSoundingDigit(name string) bool { - return strings.HasPrefix(name, "SOUNDG") || strings.HasPrefix(name, "SOUNDS") -} - // lookupAttributeText returns the textual value of an attribute for a label, or // ok=false when absent/empty (which suppresses the label, per S-52). -func lookupAttributeText(attrs map[string]interface{}, acronym string) (string, bool) { +func lookupAttributeText(attrs map[string]any, acronym string) (string, bool) { v, ok := attrs[acronym] if !ok || v == nil { return "", false @@ -172,7 +135,7 @@ func lookupAttributeText(attrs map[string]interface{}, acronym string) (string, return "", false } return t, true - case []string, []interface{}: + case []string, []any: return "", false // list attributes have no single label value default: return strings.TrimSpace(stringifyScalar(v)), true @@ -317,7 +280,7 @@ func ringCentroid(ring []geo.LatLon) (geo.LatLon, bool) { return geo.LatLon{}, false } var a, cx, cy float64 - for i := 0; i < n; i++ { + for i := range n { j := (i + 1) % n cross := ring[i].Lon*ring[j].Lat - ring[j].Lon*ring[i].Lat a += cross @@ -399,32 +362,6 @@ func pointInRing(p geo.LatLon, ring []geo.LatLon) bool { return in } -// geometryCode maps an s57 geometry type to the S-52 LUPT geometry code. -func geometryCode(t s57.GeometryType) string { - switch t { - case s57.GeometryTypePoint: - return "P" - case s57.GeometryTypeLineString: - return "L" - case s57.GeometryTypePolygon: - return "A" - default: - return "P" - } -} - -// goGeomType is the CSContext.GeometryType string form. -func goGeomType(code string) string { - switch code { - case "L": - return "Line" - case "A": - return "Area" - default: - return "Point" - } -} - // geometryOf converts s57 geometry to the portrayal geom (lat/lon). SOUNDG is // handled separately by BuildFeature (per-point). func geometryOf(g s57.Geometry) geom { @@ -490,20 +427,6 @@ func coordsToLatLon(coords [][]float64) []geo.LatLon { return out } -func cloneRings(rings [][]geo.LatLon) [][]geo.LatLon { - out := make([][]geo.LatLon, len(rings)) - for i, r := range rings { - out[i] = clonePts(r) - } - return out -} - -func clonePts(pts []geo.LatLon) []geo.LatLon { - out := make([]geo.LatLon, len(pts)) - copy(out, pts) - return out -} - // mapHJust / mapVJust map S-52 SHOWTEXT justification codes (§9.1) to alignments. // HJUST: 1=centre, 2=right, 3=left. VJUST: 1=bottom, 2=centre, 3=top. func mapHJust(h int) HAlign { @@ -529,20 +452,13 @@ func mapVJust(v int) VAlign { } } -func maxF32(a, b float32) float32 { - if a > b { - return a - } - return b -} - // formatSubstitute substitutes attribute values into a TE/TX C-printf format // string (S-52 §8.3.3.3 — e.g. "clr op %4.1lf" with VERCOP -> "clr op 12.3"). // Handles %[flags][width][.precision][l|h|L]conv; width/flags only affect // fixed-pitch padding so they are ignored, precision is honoured for floats. // Returns ok=false when a referenced attribute is absent — per S-52 a label with // a missing mandatory field is not drawn. -func formatSubstitute(attrs map[string]interface{}, format string, attrNames []string) (string, bool) { +func formatSubstitute(attrs map[string]any, format string, attrNames []string) (string, bool) { var out strings.Builder attrIdx := 0 i := 0 @@ -652,7 +568,7 @@ func zeroPad(s string, width int, flags string) string { // stringifyScalar renders a scalar attribute value as label text. Integer-valued // floats drop the decimal, matching the lookup attribute-text "{d}" output. -func stringifyScalar(v interface{}) string { +func stringifyScalar(v any) string { switch t := v.(type) { case string: return t @@ -672,17 +588,6 @@ func stringifyScalar(v interface{}) string { } } -// isUnknownClass reports that the S-57 parser could not resolve the feature's -// numeric object code to a catalogue acronym — it names such classes "OBJL_" -// (see internal/s57/parser/objectclass.go). These are proprietary / non-ENC -// classes (e.g. Inland ENC extensions) with no Presentation Library lookup entry. -// S-52 PresLib e4.0.0 §2.30 & §10.1.1: such objects must NOT be hidden — each is -// shown with the magenta question-mark SY(QUESMRK1) at IMO category Standard so -// the mariner is told an unknown object exists. -func isUnknownClass(objClass string) bool { - return strings.HasPrefix(objClass, "OBJL_") -} - // unknownObjectBuild is the §10.1.1 portrayal of an unknown-class feature: a // single QUESMRK1 question-mark symbol at the feature's position. func unknownObjectBuild(f *s57.Feature) FeatureBuild { diff --git a/internal/engine/portrayal/s101build.go b/internal/engine/portrayal/s101build.go index 209fc49..0e914f0 100644 --- a/internal/engine/portrayal/s101build.go +++ b/internal/engine/portrayal/s101build.go @@ -595,7 +595,7 @@ func primitiveName(t s57.GeometryType) string { // stringAttrs encodes S-57 attribute values as the strings ConvertEncodedValue // expects (enumeration/integer → digits, boolean → "1"/"0", text → as-is). -func stringAttrs(attrs map[string]interface{}) map[string]string { +func stringAttrs(attrs map[string]any) map[string]string { out := make(map[string]string, len(attrs)) for k, v := range attrs { if s, ok := encodeAttr(v); ok { @@ -605,7 +605,7 @@ func stringAttrs(attrs map[string]interface{}) map[string]string { return out } -func encodeAttr(v interface{}) (string, bool) { +func encodeAttr(v any) (string, bool) { switch t := v.(type) { case nil: return "", false From f946bed459fe6e6cbb0e9708c6e4178763567b13 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 26 Jun 2026 18:37:25 -0400 Subject: [PATCH 14/17] feat(portrayal): MARSYS51 A-B line on M_NSYS system-change boundaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Draw the IALA-A↔IALA-B transition with the S-52 MARSYS51 complex line (dashes + the embedded EMMARS01 "A" / EMMARS02 "B" symbols — "boundary between IALA-A and IALA-B systems"), while the rest of an M_NSYS region keeps the generic NAVARE51 triangle boundary. The transition is detected by edge coincidence: an M_NSYS boundary index records, per ~1cm-quantised segment, which IALA systems (MARSYS 1=A / 2=B) carry it; a segment shared by both an A region and a B region is the transition. navSystemBuild splits each ring into runs and strokes transition runs MARSYS51, the rest NAVARE51. Unit-tested with two abutting A/B regions. Note: the Chart 1 page-248 demo cell (the e4.0.0 DRAFT digital files) digitises its IALA-A and IALA-B regions ~11 m apart rather than sharing an edge, so the transition doesn't trigger there — it renders where regions genuinely abut (real ENCs / the final PresLib cell). Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/engine/portrayal/build.go | 75 +++++++++++++++++++++++- internal/engine/portrayal/navsys_test.go | 51 ++++++++++++++++ internal/engine/portrayal/s101build.go | 14 +++-- 3 files changed, 132 insertions(+), 8 deletions(-) create mode 100644 internal/engine/portrayal/navsys_test.go diff --git a/internal/engine/portrayal/build.go b/internal/engine/portrayal/build.go index 9662e87..da1231f 100644 --- a/internal/engine/portrayal/build.go +++ b/internal/engine/portrayal/build.go @@ -665,7 +665,7 @@ func newObjectBuild(f *s57.Feature) FeatureBuild { // is an unofficial stub (NullInstruction), so reproduce the S-52 lookup here. The // boundary is the same regardless of MARSYS — the system only governs the buoy // colours inside, not the region outline. -func navSystemBuild(f *s57.Feature) FeatureBuild { +func navSystemBuild(f *s57.Feature, nsysIdx *nsysIndex) FeatureBuild { g := f.Geometry() if g.Type != s57.GeometryTypePolygon { return FeatureBuild{DisplayCategory: displayStandard} @@ -681,8 +681,26 @@ func navSystemBuild(f *s57.Feature) FeatureBuild { if len(pts) > 1 && pts[0] != pts[len(pts)-1] { pts = append(pts, pts[0]) // close the ring } - if len(pts) >= 2 { - prims = append(prims, LinePattern{Points: pts, LinestyleName: "NAVARE51", ColorToken: "CHGRD"}) + if len(pts) < 2 { + continue + } + // Split the boundary into runs of like kind: a segment shared with the OTHER + // IALA system is drawn with the MARSYS51 "A-B" line (S-52: "boundary between + // IALA-A and IALA-B systems"); every other segment is the generic NAVARE51 + // triangle boundary. Consecutive same-kind segments form one complex-line run. + for i := 0; i < len(pts)-1; { + trans := nsysIdx.isTransition(pts[i], pts[i+1]) + j := i + 1 + for j < len(pts)-1 && nsysIdx.isTransition(pts[j], pts[j+1]) == trans { + j++ + } + run := append([]geo.LatLon(nil), pts[i:j+1]...) + ls := "NAVARE51" + if trans { + ls = "MARSYS51" + } + prims = append(prims, LinePattern{Points: run, LinestyleName: ls, ColorToken: "CHGRD"}) + i = j } } if len(prims) == 0 { @@ -691,6 +709,57 @@ func navSystemBuild(f *s57.Feature) FeatureBuild { return FeatureBuild{Primitives: prims, DisplayPriority: 12, DisplayCategory: displayStandard} } +// nsysIndex maps each M_NSYS boundary segment to the set of IALA systems whose +// region carries it (bit 1 = IALA-A / MARSYS 1, bit 2 = IALA-B / MARSYS 2). A +// segment with BOTH bits is the shared edge between an A region and a B region — +// the MARSYS51 "A-B" transition boundary. Built once per batch over all M_NSYS. +type nsysIndex struct { + seg map[nsysSeg]int +} + +// nsysSeg is an order-independent, ~1 cm-quantised boundary segment key. +type nsysSeg struct{ a0, a1, b0, b1 int64 } + +func nsysSegKey(p, q geo.LatLon) nsysSeg { + a0, a1 := int64(math.Round(p.Lat*1e7)), int64(math.Round(p.Lon*1e7)) + b0, b1 := int64(math.Round(q.Lat*1e7)), int64(math.Round(q.Lon*1e7)) + if a0 > b0 || (a0 == b0 && a1 > b1) { // canonical endpoint order + a0, a1, b0, b1 = b0, b1, a0, a1 + } + return nsysSeg{a0, a1, b0, b1} +} + +func buildNsysIndex(features []*s57.Feature) *nsysIndex { + idx := &nsysIndex{seg: map[nsysSeg]int{}} + for _, f := range features { + if f.ObjectClass() != "M_NSYS" { + continue + } + var bit int + switch m, _ := floatAttr(f.Attributes(), "MARSYS"); int(m) { + case 1: + bit = 1 // IALA-A + case 2: + bit = 2 // IALA-B + default: + continue // only A/B regions define the A-B transition + } + for _, r := range f.Geometry().Rings { + pts := coordsToLatLon(r.Coordinates) + for i := 0; i+1 < len(pts); i++ { + idx.seg[nsysSegKey(pts[i], pts[i+1])] |= bit + } + } + } + return idx +} + +// isTransition reports whether a boundary segment is shared between an IALA-A and +// an IALA-B region (both bits set) — the MARSYS51 A-B boundary. +func (idx *nsysIndex) isTransition(p, q geo.LatLon) bool { + return idx != nil && idx.seg[nsysSegKey(p, q)] == 3 +} + func sweptAreaBuild(f *s57.Feature) FeatureBuild { g := f.Geometry() if g.Type != s57.GeometryTypePolygon { diff --git a/internal/engine/portrayal/navsys_test.go b/internal/engine/portrayal/navsys_test.go new file mode 100644 index 0000000..a55663f --- /dev/null +++ b/internal/engine/portrayal/navsys_test.go @@ -0,0 +1,51 @@ +package portrayal + +import ( + "testing" + + "github.com/beetlebugorg/chartplotter/pkg/geo" + "github.com/beetlebugorg/chartplotter/pkg/s57" +) + +// TestNavSystemMarsysTransition: an M_NSYS IALA-A region and an IALA-B region +// that share a boundary edge draw that shared edge with the MARSYS51 "A-B" line +// (S-52 "boundary between IALA-A and IALA-B systems"), while their other edges +// use the generic NAVARE51 triangle boundary. +func TestNavSystemMarsysTransition(t *testing.T) { + poly := func(coords [][]float64, marsys string) *s57.Feature { + f := s57.NewFeature(0, "M_NSYS", s57.Geometry{ + Type: s57.GeometryTypePolygon, + Rings: []s57.Ring{{Usage: 1, Coordinates: coords}}, + }, map[string]any{"MARSYS": marsys}) + return &f + } + // Two unit squares sharing the lon=1 edge: A on the left, B on the right. + a := poly([][]float64{{0, 0}, {1, 0}, {1, 1}, {0, 1}, {0, 0}}, "1") // IALA-A + b := poly([][]float64{{1, 0}, {2, 0}, {2, 1}, {1, 1}, {1, 0}}, "2") // IALA-B + + idx := buildNsysIndex([]*s57.Feature{a, b}) + if !idx.isTransition(geo.LatLon{Lat: 0, Lon: 1}, geo.LatLon{Lat: 1, Lon: 1}) { + t.Fatal("the shared lon=1 edge should be an A-B transition") + } + if idx.isTransition(geo.LatLon{Lat: 0, Lon: 0}, geo.LatLon{Lat: 0, Lon: 1}) { + t.Fatal("a non-shared edge must not be a transition") + } + + var marsys, navare int + for _, p := range navSystemBuild(a, idx).Primitives { + if lp, ok := p.(LinePattern); ok { + switch lp.LinestyleName { + case "MARSYS51": + marsys++ + case "NAVARE51": + navare++ + } + } + } + if marsys == 0 { + t.Error("shared IALA-A/B edge should draw MARSYS51; got none") + } + if navare == 0 { + t.Error("non-shared edges should draw NAVARE51; got none") + } +} diff --git a/internal/engine/portrayal/s101build.go b/internal/engine/portrayal/s101build.go index 0e914f0..68851a1 100644 --- a/internal/engine/portrayal/s101build.go +++ b/internal/engine/portrayal/s101build.go @@ -181,6 +181,10 @@ func (b *S101Builder) BuildBatchFiltered(features []*s57.Feature, overrides map[ if err != nil { return nil, err } + // M_NSYS system-boundary index: which boundary segments are shared between an + // IALA-A and an IALA-B region (drawn with the MARSYS51 A-B line, vs NAVARE51 for + // the rest). Built from the FULL feature set so adjacency is seen in every pass. + nsysIdx := buildNsysIndex(features) out := make(map[int64]FeatureBuild, len(features)) for _, f := range features { if include != nil && !include(f) { @@ -188,7 +192,7 @@ func (b *S101Builder) BuildBatchFiltered(features []*s57.Feature, overrides map[ } // repID[f.ID()] is "" for the skipped (TOPMAR / non-spatial) features, and // streams[""] is "" — buildFeature then suppresses them, as before. - out[f.ID()] = b.buildFeature(f, streams[repID[f.ID()]]) + out[f.ID()] = b.buildFeature(f, streams[repID[f.ID()]], nsysIdx) } return out, nil } @@ -252,8 +256,8 @@ func (b *S101Builder) Build(f *s57.Feature) (FeatureBuild, bool) { // buildFeature turns one feature's emitted instruction stream into its FeatureBuild, // then adds the S-52 §10.6.1.1 additional-information indicator when the object // carries it (see addInformSymbol). -func (b *S101Builder) buildFeature(f *s57.Feature, stream string) FeatureBuild { - fb := b.buildFeatureBody(f, stream) +func (b *S101Builder) buildFeature(f *s57.Feature, stream string, nsysIdx *nsysIndex) FeatureBuild { + fb := b.buildFeatureBody(f, stream, nsysIdx) return addInformSymbol(fb, f) } @@ -290,7 +294,7 @@ func hasAdditionalInfo(attrs map[string]any) bool { } // buildFeatureBody turns one feature's emitted instruction stream into its FeatureBuild. -func (b *S101Builder) buildFeatureBody(f *s57.Feature, stream string) FeatureBuild { +func (b *S101Builder) buildFeatureBody(f *s57.Feature, stream string, nsysIdx *nsysIndex) FeatureBuild { // NEWOBJ with a SYMINS attribute: portray the producer's explicit symbol // instruction (S-52 SYMINS02) rather than the S-101 V-AIS alias the engine // emitted — SYMINS carries the real symbols, TX/TE labels, boundaries and fills @@ -305,7 +309,7 @@ func (b *S101Builder) buildFeatureBody(f *s57.Feature, stream string) FeatureBui // here — the NAVARE51 dashed triangle line around the IALA-A / IALA-B / other // region. Bypasses the stub stream entirely. if f.ObjectClass() == "M_NSYS" { - if nb := navSystemBuild(f); len(nb.Primitives) > 0 { + if nb := navSystemBuild(f, nsysIdx); len(nb.Primitives) > 0 { return nb } } From aac9265bc2100c758217f562c602810c25d09cf8 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 26 Jun 2026 18:58:03 -0400 Subject: [PATCH 15/17] fix(s101): M_NSYS A-B system line via ORIENT, not edge-adjacency The PresLib symbolized M_NSYS lookup (DAI LU00344-347) keys on ORIENT, not on which IALA systems border a segment: a region without a direction of buoyage draws the MARSYS51 "A-B" line (dashes + the embedded A/B symbols), and a region with ORIENT draws the NAVARE51 triangle boundary plus a direction-of-buoyage arrow (DIRBOYA1/B1/01 per MARSYS, rotated to ORIENT). The previous shared-edge index was wrong: the Chart-1 demo models the A-B line as its own no-ORIENT M_NSYS region (the IALA-A/B boxes sit ~11 m apart and share no edge), so MARSYS51 never appeared. Drive it off the feature's own ORIENT instead and the page-248 A-B-A-B line now renders. Drops the nsysIndex/buildNsysIndex/isTransition machinery and the nsysIdx threading through buildFeature/buildFeatureBody. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/engine/portrayal/build.go | 100 ++++++----------------- internal/engine/portrayal/navsys_test.go | 84 ++++++++++++------- internal/engine/portrayal/s101build.go | 18 ++-- 3 files changed, 87 insertions(+), 115 deletions(-) diff --git a/internal/engine/portrayal/build.go b/internal/engine/portrayal/build.go index da1231f..03262d7 100644 --- a/internal/engine/portrayal/build.go +++ b/internal/engine/portrayal/build.go @@ -659,17 +659,24 @@ func newObjectBuild(f *s57.Feature) FeatureBuild { // so it errors and would be suppressed. The S-52 PresLib reference (page 243) // draws a dashed boundary around the area plus a "swept to " depth label, // so emit that. -// navSystemBuild draws an M_NSYS (navigational system of marks) area boundary — -// the S-52 NAVARE51 complex line (dashes + EMAREGR1 triangle markers) around the -// IALA-A / IALA-B / other-system region. The S-101 NavigationalSystemOfMarks rule -// is an unofficial stub (NullInstruction), so reproduce the S-52 lookup here. The -// boundary is the same regardless of MARSYS — the system only governs the buoy -// colours inside, not the region outline. -func navSystemBuild(f *s57.Feature, nsysIdx *nsysIndex) FeatureBuild { +// navSystemBuild portrays an M_NSYS (navigational system of marks) area. The S-101 +// NavigationalSystemOfMarks rule is an unofficial stub (NullInstruction), so this +// reproduces the S-52 PresLib lookup (DAI LU00344–347, symbolized boundary): +// - a region WITHOUT a direction of buoyage (ORIENT) → the MARSYS51 "A-B" line +// (dashes + the embedded A/B symbols, "boundary between IALA-A and IALA-B"); +// - a region WITH ORIENT → the generic NAVARE51 triangle boundary plus a +// direction-of-buoyage arrow (DIRBOYA1 for IALA-A, DIRBOYB1 for IALA-B, else +// DIRBOY01), rotated to ORIENT. +func navSystemBuild(f *s57.Feature) FeatureBuild { g := f.Geometry() if g.Type != s57.GeometryTypePolygon { return FeatureBuild{DisplayCategory: displayStandard} } + orient, hasOrient := floatAttr(f.Attributes(), "ORIENT") + boundary := "MARSYS51" // no direction of buoyage → the A-B system boundary line + if hasOrient { + boundary = "NAVARE51" + } var prims []Primitive for _, r := range g.Rings { pts := make([]geo.LatLon, 0, len(r.Coordinates)) @@ -681,83 +688,30 @@ func navSystemBuild(f *s57.Feature, nsysIdx *nsysIndex) FeatureBuild { if len(pts) > 1 && pts[0] != pts[len(pts)-1] { pts = append(pts, pts[0]) // close the ring } - if len(pts) < 2 { - continue - } - // Split the boundary into runs of like kind: a segment shared with the OTHER - // IALA system is drawn with the MARSYS51 "A-B" line (S-52: "boundary between - // IALA-A and IALA-B systems"); every other segment is the generic NAVARE51 - // triangle boundary. Consecutive same-kind segments form one complex-line run. - for i := 0; i < len(pts)-1; { - trans := nsysIdx.isTransition(pts[i], pts[i+1]) - j := i + 1 - for j < len(pts)-1 && nsysIdx.isTransition(pts[j], pts[j+1]) == trans { - j++ - } - run := append([]geo.LatLon(nil), pts[i:j+1]...) - ls := "NAVARE51" - if trans { - ls = "MARSYS51" - } - prims = append(prims, LinePattern{Points: run, LinestyleName: ls, ColorToken: "CHGRD"}) - i = j + if len(pts) >= 2 { + prims = append(prims, LinePattern{Points: pts, LinestyleName: boundary, ColorToken: "CHGRD"}) } } if len(prims) == 0 { return FeatureBuild{DisplayCategory: displayStandard} } - return FeatureBuild{Primitives: prims, DisplayPriority: 12, DisplayCategory: displayStandard} -} - -// nsysIndex maps each M_NSYS boundary segment to the set of IALA systems whose -// region carries it (bit 1 = IALA-A / MARSYS 1, bit 2 = IALA-B / MARSYS 2). A -// segment with BOTH bits is the shared edge between an A region and a B region — -// the MARSYS51 "A-B" transition boundary. Built once per batch over all M_NSYS. -type nsysIndex struct { - seg map[nsysSeg]int -} - -// nsysSeg is an order-independent, ~1 cm-quantised boundary segment key. -type nsysSeg struct{ a0, a1, b0, b1 int64 } - -func nsysSegKey(p, q geo.LatLon) nsysSeg { - a0, a1 := int64(math.Round(p.Lat*1e7)), int64(math.Round(p.Lon*1e7)) - b0, b1 := int64(math.Round(q.Lat*1e7)), int64(math.Round(q.Lon*1e7)) - if a0 > b0 || (a0 == b0 && a1 > b1) { // canonical endpoint order - a0, a1, b0, b1 = b0, b1, a0, a1 - } - return nsysSeg{a0, a1, b0, b1} -} - -func buildNsysIndex(features []*s57.Feature) *nsysIndex { - idx := &nsysIndex{seg: map[nsysSeg]int{}} - for _, f := range features { - if f.ObjectClass() != "M_NSYS" { - continue - } - var bit int + // Direction-of-buoyage arrow at the region's representative point (DAI LU00345-347). + if hasOrient { + arrow := "DIRBOY01" switch m, _ := floatAttr(f.Attributes(), "MARSYS"); int(m) { case 1: - bit = 1 // IALA-A + arrow = "DIRBOYA1" case 2: - bit = 2 // IALA-B - default: - continue // only A/B regions define the A-B transition + arrow = "DIRBOYB1" } - for _, r := range f.Geometry().Rings { - pts := coordsToLatLon(r.Coordinates) - for i := 0; i+1 < len(pts); i++ { - idx.seg[nsysSegKey(pts[i], pts[i+1])] |= bit - } + if a, ok := areaSurfacePoint(coordsToLatLon(exteriorRing(g))); ok { + prims = append(prims, SymbolCall{ + Anchor: a, SymbolName: arrow, RotationDeg: float32(orient), RotationTrueNorth: true, + Scale: DefaultPxPerSymbolUnit, SoundingDepthM: nan32, DangerDepthM: nan32, + }) } } - return idx -} - -// isTransition reports whether a boundary segment is shared between an IALA-A and -// an IALA-B region (both bits set) — the MARSYS51 A-B boundary. -func (idx *nsysIndex) isTransition(p, q geo.LatLon) bool { - return idx != nil && idx.seg[nsysSegKey(p, q)] == 3 + return FeatureBuild{Primitives: prims, DisplayPriority: 12, DisplayCategory: displayStandard} } func sweptAreaBuild(f *s57.Feature) FeatureBuild { diff --git a/internal/engine/portrayal/navsys_test.go b/internal/engine/portrayal/navsys_test.go index a55663f..c5fdda8 100644 --- a/internal/engine/portrayal/navsys_test.go +++ b/internal/engine/portrayal/navsys_test.go @@ -3,49 +3,71 @@ package portrayal import ( "testing" - "github.com/beetlebugorg/chartplotter/pkg/geo" "github.com/beetlebugorg/chartplotter/pkg/s57" ) -// TestNavSystemMarsysTransition: an M_NSYS IALA-A region and an IALA-B region -// that share a boundary edge draw that shared edge with the MARSYS51 "A-B" line -// (S-52 "boundary between IALA-A and IALA-B systems"), while their other edges -// use the generic NAVARE51 triangle boundary. -func TestNavSystemMarsysTransition(t *testing.T) { - poly := func(coords [][]float64, marsys string) *s57.Feature { +// TestNavSystemMarsysLine: an M_NSYS region with NO direction of buoyage (no +// ORIENT) draws its boundary with the MARSYS51 "A-B" line (DAI LU00344) and emits +// no buoyage arrow. A region WITH ORIENT draws the generic NAVARE51 triangle +// boundary plus a direction-of-buoyage arrow keyed to MARSYS (DAI LU00345-347). +func TestNavSystemMarsysLine(t *testing.T) { + square := [][]float64{{0, 0}, {1, 0}, {1, 1}, {0, 1}, {0, 0}} + nsys := func(attrs map[string]any) *s57.Feature { f := s57.NewFeature(0, "M_NSYS", s57.Geometry{ Type: s57.GeometryTypePolygon, - Rings: []s57.Ring{{Usage: 1, Coordinates: coords}}, - }, map[string]any{"MARSYS": marsys}) + Rings: []s57.Ring{{Usage: 1, Coordinates: square}}, + }, attrs) return &f } - // Two unit squares sharing the lon=1 edge: A on the left, B on the right. - a := poly([][]float64{{0, 0}, {1, 0}, {1, 1}, {0, 1}, {0, 0}}, "1") // IALA-A - b := poly([][]float64{{1, 0}, {2, 0}, {2, 1}, {1, 1}, {1, 0}}, "2") // IALA-B - idx := buildNsysIndex([]*s57.Feature{a, b}) - if !idx.isTransition(geo.LatLon{Lat: 0, Lon: 1}, geo.LatLon{Lat: 1, Lon: 1}) { - t.Fatal("the shared lon=1 edge should be an A-B transition") - } - if idx.isTransition(geo.LatLon{Lat: 0, Lon: 0}, geo.LatLon{Lat: 0, Lon: 1}) { - t.Fatal("a non-shared edge must not be a transition") - } - - var marsys, navare int - for _, p := range navSystemBuild(a, idx).Primitives { - if lp, ok := p.(LinePattern); ok { - switch lp.LinestyleName { - case "MARSYS51": - marsys++ - case "NAVARE51": - navare++ + count := func(fb FeatureBuild) (marsys, navare, arrows int, arrow string) { + for _, p := range fb.Primitives { + switch v := p.(type) { + case LinePattern: + switch v.LinestyleName { + case "MARSYS51": + marsys++ + case "NAVARE51": + navare++ + } + case SymbolCall: + arrows++ + arrow = v.SymbolName } } + return } + + // No ORIENT → the A-B system boundary line, no arrow. + marsys, navare, arrows, _ := count(navSystemBuild(nsys(map[string]any{"MARSYS": "1"}))) if marsys == 0 { - t.Error("shared IALA-A/B edge should draw MARSYS51; got none") + t.Error("a region without ORIENT should draw MARSYS51; got none") + } + if navare != 0 { + t.Error("a region without ORIENT must not draw NAVARE51") } - if navare == 0 { - t.Error("non-shared edges should draw NAVARE51; got none") + if arrows != 0 { + t.Error("a region without ORIENT must not draw a buoyage arrow") + } + + // ORIENT present → NAVARE51 boundary + a direction-of-buoyage arrow per MARSYS. + for _, tc := range []struct { + marsys string + want string + }{ + {"1", "DIRBOYA1"}, // IALA-A + {"2", "DIRBOYB1"}, // IALA-B + {"10", "DIRBOY01"}, // other system + } { + marsys, navare, arrows, arrow := count(navSystemBuild(nsys(map[string]any{"MARSYS": tc.marsys, "ORIENT": 42.0}))) + if navare == 0 { + t.Errorf("MARSYS %s with ORIENT should draw NAVARE51; got none", tc.marsys) + } + if marsys != 0 { + t.Errorf("MARSYS %s with ORIENT must not draw MARSYS51", tc.marsys) + } + if arrows != 1 || arrow != tc.want { + t.Errorf("MARSYS %s with ORIENT: want one %s arrow, got %d (%s)", tc.marsys, tc.want, arrows, arrow) + } } } diff --git a/internal/engine/portrayal/s101build.go b/internal/engine/portrayal/s101build.go index 68851a1..3b3aad2 100644 --- a/internal/engine/portrayal/s101build.go +++ b/internal/engine/portrayal/s101build.go @@ -181,10 +181,6 @@ func (b *S101Builder) BuildBatchFiltered(features []*s57.Feature, overrides map[ if err != nil { return nil, err } - // M_NSYS system-boundary index: which boundary segments are shared between an - // IALA-A and an IALA-B region (drawn with the MARSYS51 A-B line, vs NAVARE51 for - // the rest). Built from the FULL feature set so adjacency is seen in every pass. - nsysIdx := buildNsysIndex(features) out := make(map[int64]FeatureBuild, len(features)) for _, f := range features { if include != nil && !include(f) { @@ -192,7 +188,7 @@ func (b *S101Builder) BuildBatchFiltered(features []*s57.Feature, overrides map[ } // repID[f.ID()] is "" for the skipped (TOPMAR / non-spatial) features, and // streams[""] is "" — buildFeature then suppresses them, as before. - out[f.ID()] = b.buildFeature(f, streams[repID[f.ID()]], nsysIdx) + out[f.ID()] = b.buildFeature(f, streams[repID[f.ID()]]) } return out, nil } @@ -256,8 +252,8 @@ func (b *S101Builder) Build(f *s57.Feature) (FeatureBuild, bool) { // buildFeature turns one feature's emitted instruction stream into its FeatureBuild, // then adds the S-52 §10.6.1.1 additional-information indicator when the object // carries it (see addInformSymbol). -func (b *S101Builder) buildFeature(f *s57.Feature, stream string, nsysIdx *nsysIndex) FeatureBuild { - fb := b.buildFeatureBody(f, stream, nsysIdx) +func (b *S101Builder) buildFeature(f *s57.Feature, stream string) FeatureBuild { + fb := b.buildFeatureBody(f, stream) return addInformSymbol(fb, f) } @@ -294,7 +290,7 @@ func hasAdditionalInfo(attrs map[string]any) bool { } // buildFeatureBody turns one feature's emitted instruction stream into its FeatureBuild. -func (b *S101Builder) buildFeatureBody(f *s57.Feature, stream string, nsysIdx *nsysIndex) FeatureBuild { +func (b *S101Builder) buildFeatureBody(f *s57.Feature, stream string) FeatureBuild { // NEWOBJ with a SYMINS attribute: portray the producer's explicit symbol // instruction (S-52 SYMINS02) rather than the S-101 V-AIS alias the engine // emitted — SYMINS carries the real symbols, TX/TE labels, boundaries and fills @@ -306,10 +302,10 @@ func (b *S101Builder) buildFeatureBody(f *s57.Feature, stream string, nsysIdx *n } // M_NSYS (navigational system of marks): the S-101 NavigationalSystemOfMarks // rule is an UNOFFICIAL stub (NullInstruction only), so draw the S-52 boundary - // here — the NAVARE51 dashed triangle line around the IALA-A / IALA-B / other - // region. Bypasses the stub stream entirely. + // here — MARSYS51 (the A-B system line) or NAVARE51 + a direction-of-buoyage + // arrow per ORIENT. Bypasses the stub stream entirely. if f.ObjectClass() == "M_NSYS" { - if nb := navSystemBuild(f, nsysIdx); len(nb.Primitives) > 0 { + if nb := navSystemBuild(f); len(nb.Primitives) > 0 { return nb } } From 4c57002b7f94e26c468f26623ab7f90feccf3126 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 26 Jun 2026 19:04:52 -0400 Subject: [PATCH 16/17] fix(s101): centre the M_NSYS direction-of-buoyage arrow on its area MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DIRBOY01/A1/B1 carry their catalogue pivot at the arrow tail (SVG 0,0, bottom-centre), so anchoring the pivot at the region centre pushed the whole arrow ~30 mm north and out the top of the small demo regions. Set CentreOnArea so the client centres the glyph on the representative point instead (S-52 §8.5.1), keeping the arrow inside its box. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/engine/portrayal/build.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/internal/engine/portrayal/build.go b/internal/engine/portrayal/build.go index 03262d7..dfcb38d 100644 --- a/internal/engine/portrayal/build.go +++ b/internal/engine/portrayal/build.go @@ -705,9 +705,13 @@ func navSystemBuild(f *s57.Feature) FeatureBuild { arrow = "DIRBOYB1" } if a, ok := areaSurfacePoint(coordsToLatLon(exteriorRing(g))); ok { + // Centre the arrow on the area's representative point: DIRBOY's catalogue + // pivot sits at the arrow's tail (SVG 0,0, bottom-centre), so honouring it + // would push the whole arrow ~30 mm north of the centre and out of a small + // region. CentreOnArea centres the glyph instead (S-52 §8.5.1). prims = append(prims, SymbolCall{ Anchor: a, SymbolName: arrow, RotationDeg: float32(orient), RotationTrueNorth: true, - Scale: DefaultPxPerSymbolUnit, SoundingDepthM: nan32, DangerDepthM: nan32, + CentreOnArea: true, Scale: DefaultPxPerSymbolUnit, SoundingDepthM: nan32, DangerDepthM: nan32, }) } } From eb6652b45aac0534c6c0f404245694a4db111f05 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 26 Jun 2026 19:06:51 -0400 Subject: [PATCH 17/17] style: gofmt navsys_test.go Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/engine/portrayal/navsys_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/engine/portrayal/navsys_test.go b/internal/engine/portrayal/navsys_test.go index c5fdda8..d28d934 100644 --- a/internal/engine/portrayal/navsys_test.go +++ b/internal/engine/portrayal/navsys_test.go @@ -55,8 +55,8 @@ func TestNavSystemMarsysLine(t *testing.T) { marsys string want string }{ - {"1", "DIRBOYA1"}, // IALA-A - {"2", "DIRBOYB1"}, // IALA-B + {"1", "DIRBOYA1"}, // IALA-A + {"2", "DIRBOYB1"}, // IALA-B {"10", "DIRBOY01"}, // other system } { marsys, navare, arrows, arrow := count(navSystemBuild(nsys(map[string]any{"MARSYS": tc.marsys, "ORIENT": 42.0})))