diff --git a/internal/engine/bake/bake.go b/internal/engine/bake/bake.go index 388e23e..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 } @@ -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,111 @@ 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) { + // 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, + 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 @@ -1246,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 @@ -1710,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 @@ -1991,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) @@ -2321,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 } @@ -2339,15 +2447,16 @@ 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 } - // 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 @@ -2356,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 @@ -2383,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 "" } @@ -2420,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 80d7f4f..dfcb38d 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 @@ -222,6 +185,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 +270,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 := range n { + 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 @@ -350,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 { @@ -441,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 { @@ -480,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 @@ -603,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 @@ -623,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 { @@ -705,6 +659,65 @@ 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 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)) + 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: boundary, ColorToken: "CHGRD"}) + } + } + if len(prims) == 0 { + return FeatureBuild{DisplayCategory: displayStandard} + } + // 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: + arrow = "DIRBOYA1" + case 2: + 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, + CentreOnArea: true, Scale: DefaultPxPerSymbolUnit, SoundingDepthM: nan32, DangerDepthM: nan32, + }) + } + } + return FeatureBuild{Primitives: prims, DisplayPriority: 12, DisplayCategory: displayStandard} +} + func sweptAreaBuild(f *s57.Feature) FeatureBuild { g := f.Geometry() if g.Type != s57.GeometryTypePolygon { @@ -731,12 +744,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, }) } } diff --git a/internal/engine/portrayal/navsys_test.go b/internal/engine/portrayal/navsys_test.go new file mode 100644 index 0000000..d28d934 --- /dev/null +++ b/internal/engine/portrayal/navsys_test.go @@ -0,0 +1,73 @@ +package portrayal + +import ( + "testing" + + "github.com/beetlebugorg/chartplotter/pkg/s57" +) + +// 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: square}}, + }, attrs) + return &f + } + + 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("a region without ORIENT should draw MARSYS51; got none") + } + if navare != 0 { + t.Error("a region without ORIENT must not draw NAVARE51") + } + 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/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..3b3aad2 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 — 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); 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:") { @@ -394,6 +403,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 @@ -426,17 +449,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 @@ -550,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 { @@ -560,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 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: 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 { 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, } diff --git a/scripts/preslib-chart1.mjs b/scripts/preslib-chart1.mjs index 763cbf6..be58155 100644 --- a/scripts/preslib-chart1.mjs +++ b/scripts/preslib-chart1.mjs @@ -67,6 +67,11 @@ 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) + 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, 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 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 diff --git a/web/src/core/core-settings.mjs b/web/src/core/core-settings.mjs index 9c0c112..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,6 +158,19 @@ 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). "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: "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 ``;