Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
49e9cd9
fix(portrayal): draw M_QUAL data-quality (CATZOC) symbology
beetlebugorg Jun 26, 2026
ec97fb2
fix(bake): place widely-spaced fill patterns as lattice symbols, not …
beetlebugorg Jun 26, 2026
54de620
fix(bake): centre sparse-pattern symbols on their pivot, not bbox
beetlebugorg Jun 26, 2026
92cf22b
fix(web): re-register area patterns at the new size when calibration …
beetlebugorg Jun 26, 2026
30db686
fix(portrayal): centre area symbols on the centroid, not the pole of …
beetlebugorg Jun 26, 2026
f86b750
fix(depth): bake the 0 m drying-line contour value; label contours li…
beetlebugorg Jun 26, 2026
1b04ebb
feat(s57): dash low-accuracy geometry (QUAPOS) — approximate-position…
beetlebugorg Jun 26, 2026
d3f89d9
feat(web): date-dependency toggles in Advanced settings (highlight + …
beetlebugorg Jun 26, 2026
573f6c1
feat(web): "Viewing date" control to evaluate date-dependency for pla…
beetlebugorg Jun 26, 2026
0b815ff
refactor(web): move "Highlight date-dependent" to General settings
beetlebugorg Jun 26, 2026
aab8010
fix(portrayal): draw the SWPARE51 swept-depth bracket on swept areas
beetlebugorg Jun 26, 2026
a0f136f
feat(portrayal): draw M_NSYS navigational-system-of-marks boundary
beetlebugorg Jun 26, 2026
acd039d
chore: clear gopls diagnostics in build.go / bake.go / s101build.go
beetlebugorg Jun 26, 2026
f946bed
feat(portrayal): MARSYS51 A-B line on M_NSYS system-change boundaries
beetlebugorg Jun 26, 2026
aac9265
fix(s101): M_NSYS A-B system line via ORIENT, not edge-adjacency
beetlebugorg Jun 26, 2026
4c57002
fix(s101): centre the M_NSYS direction-of-buoyage arrow on its area
beetlebugorg Jun 26, 2026
eb6652b
style: gofmt navsys_test.go
beetlebugorg Jun 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 130 additions & 21 deletions internal/engine/bake/bake.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import (
"fmt"
"log"
"math"
"sort"
"slices"
"strconv"
"strings"

Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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 <circle class="pivotPoint"> 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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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 ""
}
Expand Down Expand Up @@ -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
Expand Down
Loading