From d99e18610c0975986e73ca42b99f84cb68d56f11 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 24 Jun 2026 20:57:33 -0400 Subject: [PATCH 01/13] =?UTF-8?q?feat(s101):=20date-dependent=20portrayal?= =?UTF-8?q?=20+=20LinePlacement=20on=20line=20symbols;=20"lower"=20?= =?UTF-8?q?=E2=86=92=20"emit"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The S-101 close-the-gap set (two gaps + a rename). Single commit because the changes are entangled across engine.go / s101build.go and each must compile. Gap #3 — honor LinePlacement on point symbols: emitPrimitives now places a point symbol at the LinePlacement:Relative, position along a line feature (arc-length, cos-lat corrected) instead of collapsing every such symbol to the feature midpoint — route / track arrows, cable & pipeline markers. Gap #2 — date-dependent portrayal: Our engine.run() loop mirrored main.lua's success path but omitted its date handling, so seasonal features (e.g. Chesapeake Bay buoys & lights with PERSTA/PEREND, lifted for winter ice) emitted no Date:/TimeValid: and got no marker. - bridge: synthesize the S-101 fixedDateRange (DATSTA/DATEND) and periodicDateRange (PERSTA/PEREND) complex attributes from the flat S-57 simple attributes (dateStart/dateEnd alias both pairs, so the wrapping complex attribute carries the fixed-vs-periodic distinction). - engine: wire ProcessFixedAndPeriodicDates + AddDateDependentSymbol in, so a dated feature emits Date:/TimeValid: and the CHDATD01 marker symbol. - instructions.Reduce: capture the Date:/TimeValid: modifiers (previously dropped) into DrawCommand; FeatureBuild surfaces the feature-level range as the seam a future date-aware baker tag + client live-date filter uses. Rename — drop the compiler jargon "lower"/"lowering" for the S-52-era "emit": LowerS101 → emitPrimitives, S101Builder.lower → buildFeature, s101lower.go → s101emit.go (+ test); comments reworded throughout. Audit stays clean (0 errors / 0 unmapped); new tests cover both gaps. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/engine/portrayal/build.go | 11 +++ internal/engine/portrayal/primitive.go | 4 +- internal/engine/portrayal/s101build.go | 32 ++++--- internal/engine/portrayal/s101build_test.go | 51 +++++++++- .../portrayal/{s101lower.go => s101emit.go} | 92 +++++++++++++++++-- .../{s101lower_test.go => s101emit_test.go} | 52 +++++------ internal/engine/portrayal/s101sector.go | 8 +- internal/engine/s101/complex.go | 22 +++++ internal/engine/s101/engine.go | 16 +++- pkg/s100/instructions/instructions.go | 29 +++++- 10 files changed, 258 insertions(+), 59 deletions(-) rename internal/engine/portrayal/{s101lower.go => s101emit.go} (62%) rename internal/engine/portrayal/{s101lower_test.go => s101emit_test.go} (76%) diff --git a/internal/engine/portrayal/build.go b/internal/engine/portrayal/build.go index 7c56880..f6812d9 100644 --- a/internal/engine/portrayal/build.go +++ b/internal/engine/portrayal/build.go @@ -21,6 +21,17 @@ type FeatureBuild struct { Primitives []Primitive DisplayPriority int DisplayCategory int + // DateStart/DateEnd/TimeValid carry the feature's date dependency when it has + // one (S-57 DATSTA/DATEND fixed window or PERSTA/PEREND periodic window), so the + // baker can tag the feature and a date-aware client show/hide it against the + // current date. Empty when the feature is not date-dependent. DateStart/DateEnd + // are S-57 date strings — full "YYYYMMDD" (fixed) or partial "--MMDD" recurring + // each year (periodic); TimeValid is the interval kind (closedInterval / + // geSemiInterval / leSemiInterval). The feature also carries the CHDATD01 + // date-dependent marker symbol among its primitives. + DateStart string + DateEnd string + TimeValid string } // geom is the portrayal-space geometry handed to the instruction walk. It mirrors diff --git a/internal/engine/portrayal/primitive.go b/internal/engine/portrayal/primitive.go index 3969172..d3dcfd7 100644 --- a/internal/engine/portrayal/primitive.go +++ b/internal/engine/portrayal/primitive.go @@ -1,6 +1,6 @@ // Package portrayal turns one S-57 feature into a stream of viewport-independent -// lat/lon Primitives by running the S-101 portrayal rules and lowering the -// emitted drawing instructions, stopping short of projection and colour +// lat/lon Primitives by running the S-101 portrayal rules and emitting a +// primitive for each drawing instruction, stopping short of projection and colour // resolution. Colour stays as *token* strings; the tile engine // projects/clips/encodes the Primitives into MVT and the browser resolves // Day/Dusk/Night from colortables.json. diff --git a/internal/engine/portrayal/s101build.go b/internal/engine/portrayal/s101build.go index a2ad4a7..4d14cb9 100644 --- a/internal/engine/portrayal/s101build.go +++ b/internal/engine/portrayal/s101build.go @@ -61,8 +61,8 @@ func newS101Builder(catalogFS fs.FS, fcBytes []byte) (*S101Builder, error) { // S101Builder is the feature-build seam: it runs the S-101 portrayal rules (via // the fc-backed Lua engine) for a batch of features, parses each emitted -// instruction stream, and lowers each draw onto the feature geometry to produce -// the Primitive stream the baker consumes. +// instruction stream, and emits a primitive for each draw onto the feature +// geometry to produce the Primitive stream the baker consumes. type S101Builder struct { rulesFS fs.FS fcCat *fc.Catalogue @@ -70,7 +70,8 @@ type S101Builder struct { } // BuildBatch portrays a whole cell's features in ONE engine pass (one chunk -// compile, one portrayal context) and lowers each onto its geometry. A fresh +// compile, one portrayal context) and emits primitives for each onto its +// geometry. A fresh // Lua state is used and closed here so the per-cell caches don't accumulate. // Returns featureID → build for every feature. func (b *S101Builder) BuildBatch(features []*s57.Feature) (map[int64]FeatureBuild, error) { @@ -111,7 +112,7 @@ func (b *S101Builder) BuildBatchOverrides(features []*s57.Feature, overrides map // resolve a REAL point spatial (HostGetSpatial '#P'/'#M'). SOUNDG is a // multipoint (the Sounding rule iterates each point's depth); other point // features are a single point. This is required even when the geometry is - // otherwise attached by the Go lowering: a rule that reads feature.Point / + // otherwise attached when the Go side emits primitives: a rule that reads feature.Point / // feature.Spatial would otherwise hit the framework's GetSpatial infinite // recursion (it reads self['Spatial'] right after assigning it nil, which // re-fires __index) — the cause of the OBSTRN/WRECKS stack overflows. @@ -143,7 +144,7 @@ func (b *S101Builder) BuildBatchOverrides(features []*s57.Feature, overrides map } out := make(map[int64]FeatureBuild, len(features)) for _, f := range features { - out[f.ID()] = b.lower(f, streams[strconv.FormatInt(f.ID(), 10)]) + out[f.ID()] = b.buildFeature(f, streams[strconv.FormatInt(f.ID(), 10)]) } return out, nil } @@ -158,8 +159,8 @@ func (b *S101Builder) Build(f *s57.Feature) (FeatureBuild, bool) { return m[f.ID()], true } -// lower turns one feature's emitted instruction stream into its FeatureBuild. -func (b *S101Builder) lower(f *s57.Feature, stream string) FeatureBuild { +// buildFeature turns one feature's emitted instruction stream into its FeatureBuild. +func (b *S101Builder) buildFeature(f *s57.Feature, stream string) FeatureBuild { // Genuinely-unknown object class (no S-101 alias) → the magenta "unknown // object" mark (S-52 §10.1.1 parity). if strings.HasPrefix(stream, "UNMAPPED:") { @@ -180,10 +181,16 @@ func (b *S101Builder) lower(f *s57.Feature, stream string) FeatureBuild { var prims []Primitive priority := 0 cat := 0 // unset; resolved from the viewing groups the rule emits + var dateStart, dateEnd, timeValid string for _, c := range cmds { if c.Priority > priority { priority = c.Priority } + // Date dependency is feature-level (one Date:/TimeValid: pair the rule emits + // up front, carried onto every draw): capture it once for the FeatureBuild. + if timeValid == "" && (c.TimeValid != "" || c.DateStart != "" || c.DateEnd != "") { + dateStart, dateEnd, timeValid = c.DateStart, c.DateEnd, c.TimeValid + } // The shallow-water pattern (SEABED01 emits AreaFillReference:DIAMOND1 in // viewing group 90000 on every depth area shallower than the safety // contour) is a MARINER SELECTION, not a fixed portrayal. The client owns @@ -203,9 +210,9 @@ func (b *S101Builder) lower(f *s57.Feature, stream string) FeatureBuild { if dc := displayCategoryForViewingGroup(c.ViewingGroup); dc != 0 && (cat == 0 || dc < cat) { cat = dc } - prims = append(prims, LowerS101(c, sg, b.Catalog)...) + prims = append(prims, emitPrimitives(c, sg, b.Catalog)...) } - // Soundings: tag each lowered glyph with its depth so the baker emits the + // Soundings: tag each emitted glyph with its depth so the baker emits the // numeric depth + S/G palette variants. Without this the client's depth-unit // conversion (synthSounding) and SNDFRM04 safety-depth split fall back to the // static metric glyphs and never react to those settings. @@ -213,7 +220,7 @@ func (b *S101Builder) lower(f *s57.Feature, stream string) FeatureBuild { attachSoundingDepths(prims, soundingPoints(f.Geometry())) } // Sector / directional light figures: the rule's AugmentedRay / ArcByRadius - // geometry is fixed display-mm (screen size) and isn't lowered onto geographic + // geometry is fixed display-mm (screen size) and isn't emitted onto geographic // geometry; emit a SectorLight the baker tessellates per-zoom instead. prims = append(prims, sectorLightPrims(f, anchor)...) if cat == 0 { @@ -223,6 +230,9 @@ func (b *S101Builder) lower(f *s57.Feature, stream string) FeatureBuild { Primitives: prims, DisplayPriority: priority, DisplayCategory: cat, + DateStart: dateStart, + DateEnd: dateEnd, + TimeValid: timeValid, } } @@ -299,7 +309,7 @@ func soundingPoints(g s57.Geometry) [][3]float64 { return pts } -// attachSoundingDepths sets SoundingDepthM on each lowered sounding glyph from the +// attachSoundingDepths sets SoundingDepthM on each emitted sounding glyph from the // SOUNDG multipoint, matching by anchor (the glyphs are placed at their sounding's // lon/lat via AugmentedPoint). The depth then reaches the baker, which emits the // numeric depth + S/G palette variants the client needs for live depth-unit diff --git a/internal/engine/portrayal/s101build_test.go b/internal/engine/portrayal/s101build_test.go index 51746d7..cc93639 100644 --- a/internal/engine/portrayal/s101build_test.go +++ b/internal/engine/portrayal/s101build_test.go @@ -33,6 +33,53 @@ func s101Builder(t *testing.T) *S101Builder { return b } +// s101BuilderEmbedded builds from the in-repo embedded catalogue (the one the +// baker ships), so the test runs without an external catalogue checkout. +func s101BuilderEmbedded(t *testing.T) *S101Builder { + t.Helper() + pc := "../s101catalog/catalog/PortrayalCatalog" + fcPath := "../s101catalog/catalog/FeatureCatalogue.xml" + if _, err := os.Stat(filepath.Join(pc, "Rules", "main.lua")); err != nil { + t.Skip("no embedded catalogue") + } + b, err := NewS101Builder(pc, fcPath) + if err != nil { + t.Fatal(err) + } + return b +} + +// TestS101BuildDateDependent: a seasonal buoy (S-57 PERSTA/PEREND) is portrayed +// date-dependent — buildFeature surfaces the periodic range on the FeatureBuild +// and the CHDATD01 date-dependent marker symbol is emitted (the S-101 +// ProcessFixedAndPeriodicDates path wired into the engine). +func TestS101BuildDateDependent(t *testing.T) { + b := s101BuilderEmbedded(t) + buoy := s57.NewFeature(1, "BOYLAT", + s57.Geometry{Type: s57.GeometryTypePoint, Coordinates: [][]float64{{-76.3, 38.9}}}, + map[string]interface{}{"CATLAM": 2, "BOYSHP": 2, "COLOUR": "3", "PERSTA": "--0301", "PEREND": "--1201"}, + ) + build, ok := b.Build(&buoy) + if !ok { + t.Fatal("build failed") + } + if build.DateStart != "--0301" || build.DateEnd != "--1201" { + t.Errorf("date range = %q..%q, want --0301..--1201", build.DateStart, build.DateEnd) + } + if build.TimeValid != "closedInterval" { + t.Errorf("TimeValid = %q, want closedInterval", build.TimeValid) + } + hasMarker := false + for _, p := range build.Primitives { + if sc, ok := p.(SymbolCall); ok && sc.SymbolName == "CHDATD01" { + hasMarker = true + } + } + if !hasMarker { + t.Errorf("no CHDATD01 date-dependent marker emitted; got %#v", build.Primitives) + } +} + // TestS101BuildPointSymbol drives a real S-57 feature through the full build // seam: S-57 acronyms → S-101 rule → instructions → geometry-placed Primitive. func TestS101BuildPointSymbol(t *testing.T) { @@ -65,7 +112,7 @@ func TestS101BuildPointSymbol(t *testing.T) { } // TestS101BuildAreaFillAndLine drives a polygon feature; the SiloTank surface -// branch emits ColorFill:CHBRN + a boundary line, lowered onto the rings. +// branch emits ColorFill:CHBRN + a boundary line, emitted onto the rings. func TestS101BuildAreaFillAndLine(t *testing.T) { b := s101Builder(t) ring := [][]float64{{0, 0}, {0, 1}, {1, 1}, {1, 0}, {0, 0}} @@ -88,7 +135,7 @@ func TestS101BuildAreaFillAndLine(t *testing.T) { t.Fatalf("want FillPolygon CHBRN, got %#v", build.Primitives) } if len(fill.Rings) == 0 || len(fill.Rings[0]) == 0 { - t.Errorf("fill not lowered onto geometry: %+v", fill.Rings) + t.Errorf("fill not emitted onto geometry: %+v", fill.Rings) } } diff --git a/internal/engine/portrayal/s101lower.go b/internal/engine/portrayal/s101emit.go similarity index 62% rename from internal/engine/portrayal/s101lower.go rename to internal/engine/portrayal/s101emit.go index 9b60034..c23ba2a 100644 --- a/internal/engine/portrayal/s101lower.go +++ b/internal/engine/portrayal/s101emit.go @@ -2,16 +2,18 @@ package portrayal import ( "math" + "strconv" + "strings" "github.com/beetlebugorg/chartplotter/pkg/geo" "github.com/beetlebugorg/chartplotter/pkg/s100/catalog" "github.com/beetlebugorg/chartplotter/pkg/s100/instructions" ) -// This file is the S-101 drawing-command lowering: it turns one resolved S-101 -// drawing command (from pkg/s100/instructions) plus the feature geometry into a -// viewport-independent Primitive that everything downstream (projection, MVT -// bake, client colour resolution) consumes. Colour stays a token; line-style +// This file emits primitives from S-101 drawing commands: it turns one resolved +// S-101 drawing command (from pkg/s100/instructions) plus the feature geometry +// into a viewport-independent Primitive that everything downstream (projection, +// MVT bake, client colour resolution) consumes. Colour stays a token; line-style // refs resolve against the S-101 catalogue. // mmPerSymbolUnit-derived conversions. S-101 widths/offsets are millimetres; @@ -37,13 +39,13 @@ type S101Geometry struct { Lines [][]geo.LatLon } -// LowerS101 maps one resolved S-101 draw command onto engine Primitives, +// emitPrimitives maps one resolved S-101 draw command onto engine Primitives, // attaching geometry and resolving line-style references against the catalogue. // It returns a slice because an area-boundary line fans into one line primitive // per ring; fills/symbols/text are a single primitive. -// An empty slice means nothing to draw (a no-op, an unlowered draw kind, or a +// An empty slice means nothing to draw (a no-op, an unhandled draw kind, or a // draw whose geometry is missing). -func LowerS101(cmd instructions.DrawCommand, geom S101Geometry, cat *catalog.Catalog) []Primitive { +func emitPrimitives(cmd instructions.DrawCommand, geom S101Geometry, cat *catalog.Catalog) []Primitive { switch cmd.Op { case instructions.OpColorFill: if len(geom.Rings) == 0 { @@ -84,6 +86,12 @@ func LowerS101(cmd instructions.DrawCommand, geom S101Geometry, cat *catalog.Cat anchor := geom.Anchor if cmd.HasAnchor { // an AugmentedPoint draw (e.g. one SOUNDG sounding) anchor = geo.LatLon{Lat: cmd.Anchor[1], Lon: cmd.Anchor[0]} + } else if p, ok := pointAlongLines(geom.Lines, cmd.LinePlacement); ok { + // A point symbol placed at a relative position along a line feature + // (LinePlacement:Relative,) — e.g. a recommended-track / route + // arrow or a cable/pipeline marker. Without this every such symbol + // collapsed to the feature's midpoint anchor. + anchor = p } return []Primitive{SymbolCall{ Anchor: anchor, @@ -135,6 +143,76 @@ func LowerS101(cmd instructions.DrawCommand, geom S101Geometry, cat *catalog.Cat } } +// pointAlongLines returns the point at the LinePlacement position along the line +// runs, or ok=false when the placement isn't a usable "Relative," spec or +// there's no line geometry (the caller then keeps the feature anchor). frac is +// clamped to [0,1] and measured by arc length across all runs, with a cos-lat +// correction so the fraction tracks ground distance rather than raw degrees. +func pointAlongLines(lines [][]geo.LatLon, placement string) (geo.LatLon, bool) { + if placement == "" || len(lines) == 0 { + return geo.LatLon{}, false + } + mode, val, _ := strings.Cut(placement, ",") + if !strings.EqualFold(strings.TrimSpace(mode), "Relative") { + return geo.LatLon{}, false // Absolute / unknown placement: keep the anchor + } + frac, err := strconv.ParseFloat(strings.TrimSpace(val), 64) + if err != nil { + return geo.LatLon{}, false + } + frac = math.Max(0, math.Min(1, frac)) + + // Total arc length of all runs (cos-lat corrected planar approximation — + // chart line features are short enough that great-circle curvature is + // negligible at this placement precision). + seg := func(a, b geo.LatLon) float64 { + dLat := b.Lat - a.Lat + dLon := (b.Lon - a.Lon) * math.Cos((a.Lat+b.Lat)*0.5*math.Pi/180) + return math.Hypot(dLat, dLon) + } + var total float64 + for _, run := range lines { + for i := 1; i < len(run); i++ { + total += seg(run[i-1], run[i]) + } + } + if total == 0 { + // Degenerate (all coincident): fall back to the first vertex. + for _, run := range lines { + if len(run) > 0 { + return run[0], true + } + } + return geo.LatLon{}, false + } + + target := frac * total + var acc float64 + for _, run := range lines { + for i := 1; i < len(run); i++ { + d := seg(run[i-1], run[i]) + if acc+d >= target { + t := 0.0 + if d > 0 { + t = (target - acc) / d + } + return geo.LatLon{ + Lat: run[i-1].Lat + t*(run[i].Lat-run[i-1].Lat), + Lon: run[i-1].Lon + t*(run[i].Lon-run[i-1].Lon), + }, true + } + acc += d + } + } + // frac == 1 (or rounding): the last vertex of the last non-empty run. + for i := len(lines) - 1; i >= 0; i-- { + if n := len(lines[i]); n > 0 { + return lines[i][n-1], true + } + } + return geo.LatLon{}, false +} + func hAlign(s string) HAlign { switch s { case "Center": diff --git a/internal/engine/portrayal/s101lower_test.go b/internal/engine/portrayal/s101emit_test.go similarity index 76% rename from internal/engine/portrayal/s101lower_test.go rename to internal/engine/portrayal/s101emit_test.go index 18ff9a4..0267981 100644 --- a/internal/engine/portrayal/s101lower_test.go +++ b/internal/engine/portrayal/s101emit_test.go @@ -9,8 +9,8 @@ import ( "github.com/beetlebugorg/chartplotter/pkg/s100/instructions" ) -// lowerStream parses+reduces an S-101 stream and lowers each draw command. -func lowerStream(t *testing.T, stream string, geom S101Geometry, cat *catalog.Catalog) []Primitive { +// emitStream parses+reduces an S-101 stream and emits primitives for each draw command. +func emitStream(t *testing.T, stream string, geom S101Geometry, cat *catalog.Catalog) []Primitive { t.Helper() cmds, unsup := instructions.Reduce(instructions.ParseStream(stream)) if len(unsup) != 0 { @@ -18,15 +18,15 @@ func lowerStream(t *testing.T, stream string, geom S101Geometry, cat *catalog.Ca } var out []Primitive for _, c := range cmds { - out = append(out, LowerS101(c, geom, cat)...) + out = append(out, emitPrimitives(c, geom, cat)...) } return out } -func TestLowerRapidsCurveToStrokeLine(t *testing.T) { +func TestEmitRapidsCurveToStrokeLine(t *testing.T) { geom := S101Geometry{Lines: [][]geo.LatLon{{{}, {}}}} stream := "ViewingGroup:32050;DrawingPriority:9;DisplayPlane:UnderRadar;LineStyle:_simple_,,0.96,CHGRD;LineInstruction:_simple_" - prims := lowerStream(t, stream, geom, nil) + prims := emitStream(t, stream, geom, nil) if len(prims) != 1 { t.Fatalf("want 1 primitive, got %d", len(prims)) } @@ -42,26 +42,26 @@ func TestLowerRapidsCurveToStrokeLine(t *testing.T) { } } -func TestLowerRapidsSurfaceToFillPolygon(t *testing.T) { +func TestEmitRapidsSurfaceToFillPolygon(t *testing.T) { geom := S101Geometry{Rings: [][]geo.LatLon{{{}, {}, {}}}} - prims := lowerStream(t, "ViewingGroup:32050;ColorFill:CHGRD", geom, nil) + prims := emitStream(t, "ViewingGroup:32050;ColorFill:CHGRD", geom, nil) fp, ok := prims[0].(FillPolygon) if !ok || fp.ColorToken != "CHGRD" || len(fp.Rings) != 1 { t.Fatalf("want FillPolygon CHGRD, got %T %+v", prims[0], prims[0]) } } -func TestLowerRapidsPointNullSuppressed(t *testing.T) { - prims := lowerStream(t, "ViewingGroup:32050;NullInstruction", S101Geometry{}, nil) +func TestEmitRapidsPointNullSuppressed(t *testing.T) { + prims := emitStream(t, "ViewingGroup:32050;NullInstruction", S101Geometry{}, nil) if len(prims) != 0 { - t.Fatalf("NullInstruction should lower to nothing, got %d", len(prims)) + t.Fatalf("NullInstruction should emit nothing, got %d", len(prims)) } } -func TestLowerPointSymbol(t *testing.T) { +func TestEmitPointSymbol(t *testing.T) { geom := S101Geometry{Anchor: geo.LatLon{}} stream := "ViewingGroup:25010;LocalOffset:1,-2;Rotation:45;PointInstruction:BCNCAR01" - sc, ok := lowerStream(t, stream, geom, nil)[0].(SymbolCall) + sc, ok := emitStream(t, stream, geom, nil)[0].(SymbolCall) if !ok { t.Fatalf("want SymbolCall") } @@ -76,12 +76,12 @@ func TestLowerPointSymbol(t *testing.T) { } } -func TestLowerComplexLineResolvesPenColor(t *testing.T) { +func TestEmitComplexLineResolvesPenColor(t *testing.T) { cat := &catalog.Catalog{LineStyles: map[string]*catalog.LineStyle{ "ACHARE51": {ID: "ACHARE51", PenColor: "CHMGD"}, }} geom := S101Geometry{Lines: [][]geo.LatLon{{{}, {}}}} - lp, ok := lowerStream(t, "LineInstruction:ACHARE51", geom, cat)[0].(LinePattern) + lp, ok := emitStream(t, "LineInstruction:ACHARE51", geom, cat)[0].(LinePattern) if !ok { t.Fatalf("want LinePattern") } @@ -90,25 +90,25 @@ func TestLowerComplexLineResolvesPenColor(t *testing.T) { } } -func TestLowerAreaFillReference(t *testing.T) { +func TestEmitAreaFillReference(t *testing.T) { geom := S101Geometry{Rings: [][]geo.LatLon{{{}, {}}}} - prims := lowerStream(t, "AreaFillReference:DRGARE01", geom, nil) + prims := emitStream(t, "AreaFillReference:DRGARE01", geom, nil) pf, ok := prims[0].(PatternFill) if !ok || pf.PatternName != "DRGARE01" || len(pf.Rings) == 0 { t.Fatalf("want PatternFill DRGARE01 on rings, got %#v", prims) } } -// TestLowerAreaBoundaryLine: a boundary line strokes EACH drawable run, not +// TestEmitAreaBoundaryLine: a boundary line strokes EACH drawable run, not // empty geometry. The regression: an area feature has no Lines unless the -// builder fills them from its (masked) boundary; lowering onto empty geometry +// builder fills them from its (masked) boundary; emitting onto empty geometry // yielded a NaN/Inf bbox the baker dropped ("skipping prim with implausible // bbox"). Here two drawable runs ⇒ two LinePatterns. -func TestLowerAreaBoundaryLine(t *testing.T) { +func TestEmitAreaBoundaryLine(t *testing.T) { run1 := []geo.LatLon{{Lat: 0, Lon: 0}, {Lat: 0, Lon: 1}, {Lat: 1, Lon: 1}} run2 := []geo.LatLon{{Lat: 0.2, Lon: 0.2}, {Lat: 0.2, Lon: 0.4}, {Lat: 0.4, Lon: 0.4}} geom := S101Geometry{Lines: [][]geo.LatLon{run1, run2}} - prims := lowerStream(t, "LineInstruction:CTNARE51", geom, nil) + prims := emitStream(t, "LineInstruction:CTNARE51", geom, nil) if len(prims) != 2 { t.Fatalf("want one line per run (2), got %d: %#v", len(prims), prims) } @@ -118,15 +118,15 @@ func TestLowerAreaBoundaryLine(t *testing.T) { t.Fatalf("run %d: want LinePattern, got %T", i, p) } if lp.LinestyleName != "CTNARE51" || len(lp.Points) < 2 { - t.Errorf("run %d lowered onto empty/wrong geometry: %+v", i, lp) + t.Errorf("run %d emitted onto empty/wrong geometry: %+v", i, lp) } } } -// TestLowerLineNoGeometry: a line draw with no drawable runs lowers to nothing +// TestEmitLineNoGeometry: a line draw with no drawable runs emits nothing // rather than a degenerate primitive. -func TestLowerLineNoGeometry(t *testing.T) { - if prims := lowerStream(t, "LineInstruction:CTNARE51", S101Geometry{}, nil); len(prims) != 0 { +func TestEmitLineNoGeometry(t *testing.T) { + if prims := emitStream(t, "LineInstruction:CTNARE51", S101Geometry{}, nil); len(prims) != 0 { t.Fatalf("want no primitives for empty geometry, got %d", len(prims)) } } @@ -164,10 +164,10 @@ func TestStrokeRunsForMasking(t *testing.T) { } } -func TestLowerText(t *testing.T) { +func TestEmitText(t *testing.T) { geom := S101Geometry{Anchor: geo.LatLon{}} stream := "ViewingGroup:25010;FontColor:CHBLK;TextAlignHorizontal:Center;TextInstruction:Fl.R.4s" - dt, ok := lowerStream(t, stream, geom, nil)[0].(DrawText) + dt, ok := emitStream(t, stream, geom, nil)[0].(DrawText) if !ok { t.Fatalf("want DrawText") } diff --git a/internal/engine/portrayal/s101sector.go b/internal/engine/portrayal/s101sector.go index 5a1e212..75a1ca0 100644 --- a/internal/engine/portrayal/s101sector.go +++ b/internal/engine/portrayal/s101sector.go @@ -12,15 +12,15 @@ import ( // lights. The S-101 LightSectored rule expresses sector legs and arcs as // AugmentedRay / ArcByRadius geometry-construction instructions whose lengths are // fixed display millimetres (LocalCRS) — a fixed SCREEN size that can't be baked -// into geographic tile geometry. So instead of lowering those instructions, the +// into geographic tile geometry. So instead of emitting from those instructions, the // sector geometry is carried as a SectorLight primitive (anchor + S-52 sector // parameters) and tessellated per-zoom at bake time into the screen-space // `sector_lines` layer (bake.expandSector), exactly as the former S-52 LIGHTS06 -// path did. The rule's text descriptions still lower normally (OpText). +// path did. The rule's text descriptions still emit normally (OpText). // sectorLightPrims returns the SectorLight primitive(s) for a LIGHTS feature, or // nil if it carries no sector/directional figure (a plain light is portrayed by -// its flare symbol, which lowers from the rule's PointInstruction). One S-57 +// its flare symbol, which emits from the rule's PointInstruction). One S-57 // LIGHTS feature is one sector; multiple sectors at a position are separate // co-located features (the baker dedupes identical geometry). func sectorLightPrims(f *s57.Feature, anchor geo.LatLon) []Primitive { @@ -47,7 +47,7 @@ func sectorLightPrims(f *s57.Feature, anchor geo.LatLon) []Primitive { // Directional light of long range (≥10 NM) with no sector limits: the rule // draws a full coloured ring (ArcByRadius 0–360). Shorter-range directional - // lights fall back to the flare symbol, which lowers from the rule's + // lights fall back to the flare symbol, which emits from the rule's // PointInstruction, so they need no SectorLight here. if hasListVal(stringAttr(a, "CATLIT"), 1) && vnr >= 10 { return []Primitive{SectorLight{ diff --git a/internal/engine/s101/complex.go b/internal/engine/s101/complex.go index 3f43ba9..c0948bf 100644 --- a/internal/engine/s101/complex.go +++ b/internal/engine/s101/complex.go @@ -118,6 +118,28 @@ func (e *Engine) buildRoot(objClass string, s57, derived map[string]string, name root.addChild("orientation", o) } + // Date ranges: S-57 stores DATSTA/DATEND (a one-time fixed window) and + // PERSTA/PEREND (a recurring seasonal window) as flat simple attributes; S-101 + // wraps each in a complex attribute (fixedDateRange / periodicDateRange) whose + // dateStart/dateEnd the framework's ProcessFixedAndPeriodicDates reads to mark + // a feature date-dependent (emit Date:/TimeValid: + the CHDATD01 marker). The + // dateStart/dateEnd S-101 codes alias BOTH the fixed and periodic S-57 pairs, + // so the wrapping complex attribute — not the alias — carries the distinction; + // read the S-57 source pairs directly. (A periodic value is an S-57 partial + // date, e.g. "--0315" = March 15 every year.) + if s57["DATSTA"] != "" || s57["DATEND"] != "" { + fdr := newCNode() + fdr.addSimple("dateStart", s57["DATSTA"]) + fdr.addSimple("dateEnd", s57["DATEND"]) + root.addChild("fixedDateRange", fdr) + } + if s57["PERSTA"] != "" || s57["PEREND"] != "" { + pdr := newCNode() + pdr.addSimple("dateStart", s57["PERSTA"]) + pdr.addSimple("dateEnd", s57["PEREND"]) + root.addChild("periodicDateRange", pdr) + } + // 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/engine/s101/engine.go b/internal/engine/s101/engine.go index 2100e86..9ec0855 100644 --- a/internal/engine/s101/engine.go +++ b/internal/engine/s101/engine.go @@ -163,7 +163,7 @@ func NewEngineFS(rules fs.FS, cat *fc.Catalogue) (*Engine, error) { // Surface objects via the framework constructors. We model each feature's // geometry as ONE association of its primitive type — enough for the line/area // rules to iterate GetFlattenedSpatialAssociations without erroring; the actual -// boundary/fill geometry is attached by the Go lowering (LowerS101), not read +// boundary/fill geometry is attached by the Go side when it emits primitives, not read // from Lua. Surfaces resolve (HostGetSpatial) to a surface with a single // exterior-ring curve. _HostFeaturePrimitive (Go) gives the primitive type. const spatialGlue = ` @@ -272,9 +272,10 @@ func (e *Engine) run() error { } fmt.Fprintf(&b, "table.insert(cps, PortrayalCreateContextParameter(%q, %q, %q))\n", p.name, p.typ, def) } - // Mirror main.lua's ProcessFeaturePortrayalItem success path (rule + feature - // name + nautical info), but on error we suppress the feature rather than - // fall back to Default (which would stamp QUESMRK1 everywhere). + // Mirror main.lua's ProcessFeaturePortrayalItem success path (date ranges + + // rule + feature name + nautical info + date-dependent marker), but on error + // we suppress the feature rather than fall back to Default (which would stamp + // QUESMRK1 everywhere). b.WriteString(`PortrayalInitializeContextParameters(cps) _RESULTS = {} local ctx = portrayalContext.ContextParameters @@ -282,12 +283,19 @@ for _, item in ipairs(portrayalContext.FeaturePortrayalItems) do local feature = item.Feature local fp = item:NewFeaturePortrayal() local ok, err = pcall(function() + -- Fixed/periodic date ranges (synthesized from S-57 DATSTA/DATEND + + -- PERSTA/PEREND): emit Date:/TimeValid: annotations and report whether the + -- feature is date-dependent so the CHDATD01 marker is added below. + local dateDependent = ProcessFixedAndPeriodicDates(feature, fp) require(feature.Code) local vg = _G[feature.Code](feature, fp, ctx) if not fp.GetFeatureNameCalled then PortrayFeatureName(feature, fp, ctx, 32, 24, vg, nil, 'TextAlignHorizontal:Center;TextAlignVertical:Top;LocalOffset:0,-3.51;FontColor:CHBLK') end ProcessNauticalInformation(feature, fp, ctx, vg) + if dateDependent then + AddDateDependentSymbol(feature, fp, ctx, vg) + end end) if ok then _RESULTS[feature.ID] = table.concat(fp.DrawingInstructions, ';') diff --git a/pkg/s100/instructions/instructions.go b/pkg/s100/instructions/instructions.go index 2d0874a..21c729a 100644 --- a/pkg/s100/instructions/instructions.go +++ b/pkg/s100/instructions/instructions.go @@ -97,6 +97,18 @@ type DrawCommand struct { TextAlignV string // "Top" | "Bottom" | "Center" TextVOffset float64 + // Date dependency (S-101 §ProcessFixedAndPeriodicDates): a Date:/TimeValid: + // modifier pair the rule emits for a feature with a fixed (DATSTA/DATEND) or + // periodic (PERSTA/PEREND) date range. DateStart/DateEnd are S-57 date strings + // — full "YYYYMMDD" for a fixed range, or an S-57 partial "--MMDD" recurring + // each year for a periodic one (either may be empty for a semi-open interval). + // TimeValid is the interval kind ("closedInterval" | "geSemiInterval" | + // "leSemiInterval"). Empty when the feature carries no date dependency. Carried + // so a date-aware consumer can show/hide the feature against the current date. + DateStart string + DateEnd string + TimeValid string + Raw string // the originating draw token, for debugging } @@ -122,6 +134,9 @@ func Reduce(ins []Instruction) (cmds []DrawCommand, unsupported []string) { textAlignH string textAlignV string textVOffset float64 + dateStart string + dateEnd string + timeValid string seenUnsup = map[string]bool{} ) noteUnsupported := func(kind string) { @@ -144,6 +159,7 @@ func Reduce(ins []Instruction) (cmds []DrawCommand, unsupported []string) { c.FontColor, c.FontSizePx = fontColor, fontSize c.TextAlignH, c.TextAlignV, c.TextVOffset = textAlignH, textAlignV, textVOffset } + c.DateStart, c.DateEnd, c.TimeValid = dateStart, dateEnd, timeValid cmds = append(cmds, c) } @@ -194,10 +210,17 @@ func Reduce(ins []Instruction) (cmds []DrawCommand, unsupported []string) { textAlignV = arg(in, 0) case "TextVerticalOffset": textVOffset = atof(arg(in, 0)) - // modifiers we intentionally ignore for geometry lowering - case "ScaleMinimum", "ScaleMaximum", "Date", "Time", "DateTime", "TimeValid", + // --- date-dependency modifiers (annotate subsequent draws) --- + case "Date": + // "Date:," (either side may be empty for a semi-open + // interval, e.g. "Date:,--1201"). A bare "Date:" sets the start. + dateStart, dateEnd = arg(in, 0), arg(in, 1) + case "TimeValid": + timeValid = arg(in, 0) + // modifiers we intentionally ignore when emitting primitives + case "ScaleMinimum", "ScaleMaximum", "Time", "DateTime", "AlertReference", "Warning", "Error", "Hover", "SpatialReference": - // no-op for primitive lowering + // no-op for primitive emission // --- draws (consume state) --- case "PointInstruction": From 0122c2703c085c5d207ccccba13d3bd6353e6edf Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 24 Jun 2026 21:27:10 -0400 Subject: [PATCH 02/13] feat(s101): drive sector lights from the catalogue's constructed geometry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gap #1 of the S-101 close-the-gap set. The S-101 rule emits a sector/directional light's legs and arc as AugmentedRay / ArcByRadius geometry-construction instructions (screen-mm sizes); we were DROPPING them (OpOther) and instead re-deriving the figure in Go from the raw S-57 attributes (sectorLightPrims + expandSector). That Go re-derivation is a spec-divergence source — it has to reproduce the from-seaward +180 bearing reversal and the colour mapping itself, and got them subtly wrong (white lights drawn with a Go colour + an OUTLW arc backing instead of the catalogue's LITYW arc over a CHBLK backing). Now we consume the catalogue's output, which is authoritative under S-101: - instructions.Reduce parses AugmentedRay/ArcByRadius into a current figure and emits each stroking LineInstruction as OpAugmentedLine carrying the ray/arc params + the rule's simple-line style; ClearGeometry resets. - a new AugmentedFigure primitive carries one stroked element (leg or arc/ring) with the rule's colour/width/dash; emitPrimitives builds it; buildFeature tags LIGHTS legs with VALNMR for the full-light-lines variant. - the baker's tessellateFigure replaces expandSector: it tessellates each figure per-zoom from the rule's bearings/radii (no Go +180 reversal, no hardcoded OUTLW/widths), preserving the sleg short/full toggle, dedup and scamin. - retire sectorLightPrims, SectorLight/SectorParams, expandSector and s101sector.go. Verified end-to-end on US5MD1MC: the three sector lights now portray a red (LITRD) and two white→yellow (LITYW) arcs over CHBLK backings — zero OUTLW. Geometry (leg lengths, arc radii, final bearings) is unchanged; only the catalogue-correct colours change. Audit stays clean (LIGHTS 183/183/0/0/0). Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/engine/bake/bake.go | 197 +++++++++++--------- internal/engine/bake/bake_test.go | 73 +++++--- internal/engine/bake/complexline.go | 2 +- internal/engine/portrayal/build.go | 10 + internal/engine/portrayal/primitive.go | 54 +++--- internal/engine/portrayal/s101build.go | 19 +- internal/engine/portrayal/s101build_test.go | 45 +++-- internal/engine/portrayal/s101emit.go | 25 +++ internal/engine/portrayal/s101sector.go | 107 ----------- pkg/s100/instructions/instructions.go | 81 +++++++- pkg/s100/instructions/instructions_test.go | 53 ++++++ 11 files changed, 382 insertions(+), 284 deletions(-) delete mode 100644 internal/engine/portrayal/s101sector.go diff --git a/internal/engine/bake/bake.go b/internal/engine/bake/bake.go index ef3de11..6420bc1 100644 --- a/internal/engine/bake/bake.go +++ b/internal/engine/bake/bake.go @@ -267,12 +267,13 @@ type routed struct { bcBase bool // attrs is variable-only; rebuild the base from bc* at emit } -// sectorPrim is a LIGHTS06 sector light. Its geometry (dashed legs, OUTLW-backed -// coloured arc / ring) is screen-px sized, so it is tessellated per zoom at emit -// time into the lines layer rather than stored as fixed lat/lon geometry. +// sectorPrim is one constructed sector-light figure element (a dashed leg, or a +// black-backed coloured arc / ring) the S-101 rule emitted via AugmentedRay / +// ArcByRadius. Its mm sizes are screen-px, so it is tessellated per zoom at emit +// time into the sector_lines layer rather than stored as fixed lat/lon geometry — +// driven by the catalogue's bearings/radii/colours, not a Go re-derivation. type sectorPrim struct { - anchor geo.LatLon - params portrayal.SectorParams + fig portrayal.AugmentedFigure class string cell string drawPrio int @@ -282,9 +283,9 @@ type sectorPrim struct { scamin uint32 // SCAMIN denominator of the parent LIGHTS (0 = none); emitted as `scamin` so the client's per-SCAMIN bucket layer gates the exact display cutoff, same as point symbols/text // legNorm is the full-length leg reach (VALNMR nominal range) as a fraction // of the normalized world — a fixed GROUND distance, so zoom-independent - // (unlike the 25 mm short legs / ring, which are screen-px). Drives the tile + // (unlike the 25 mm short leg / arc, which are screen-px). Drives the tile // enumeration + emit margin so the long legs aren't culled near tile edges. - // 0 when the light has no VALNMR (only the screen-px figure spills). + // 0 for an arc, or a leg whose light has no VALNMR (only the screen-px spills). legNorm float64 } @@ -387,10 +388,15 @@ type covMeta struct { rings [][][]float64 } -// sectorKey identifies a sector light's geometry (anchor + params) for dedup. +// sectorKey identifies one constructed sector-figure element (anchor + ray/arc +// params + stroke) for dedup — co-located lights often repeat identical sectors. type sectorKey struct { - lat, lon, s1, s2, r int64 - col string + lat, lon int64 + ray bool + p1, p2, p3 int64 // ray: bearing, length, 0 — arc: radius, start, sweep + col string + w int64 + dashed bool } func quantDeg(f float64) int64 { return int64(math.Round(f * 1e6)) } @@ -939,10 +945,20 @@ func (b *Baker) route(p portrayal.Primitive, class string, drawPrio, cat int, zr b.add(r, ptBbox(v.Anchor)) case portrayal.SymbolCall: b.routeSymbol(v, common, r) - case portrayal.SectorLight: - // Dedupe identical sector geometry (co-located lights often repeat sectors). - k := sectorKey{quantDeg(v.Anchor.Lat), quantDeg(v.Anchor.Lon), - quantDeg(v.Sector.StartAngleDeg), quantDeg(v.Sector.EndAngleDeg), quantDeg(v.Sector.RadiusNM), v.Sector.ColorToken} + case portrayal.AugmentedFigure: + // One constructed sector-figure element (leg / arc). Dedupe identical + // elements (co-located lights often repeat sectors). + var p1, p2, p3 int64 + if v.Ray { + p1, p2 = quantDeg(v.BearingDeg), quantDeg(v.LengthMM) + } else { + p1, p2, p3 = quantDeg(v.RadiusMM), quantDeg(v.StartDeg), quantDeg(v.SweepDeg) + } + k := sectorKey{ + lat: quantDeg(v.Anchor.Lat), lon: quantDeg(v.Anchor.Lon), + ray: v.Ray, p1: p1, p2: p2, p3: p3, + col: v.ColorToken, w: quantDeg(v.WidthMM), dashed: v.Dash == portrayal.DashDashed, + } if b.seenSector != nil { if _, dup := b.seenSector[k]; dup { return @@ -950,11 +966,15 @@ func (b *Baker) route(p portrayal.Primitive, class string, drawPrio, cat int, zr b.seenSector[k] = struct{}{} } b.bbox.ExtendPoint(v.Anchor) + var legNorm float64 + if v.Ray && v.FullLengthNM > 0 { + legNorm = sectorLegFullNorm(v.Anchor.Lat, v.FullLengthNM) + } b.sectors = append(b.sectors, sectorPrim{ - anchor: v.Anchor, params: v.Sector, class: class, cell: b.curCell, + fig: v, class: class, cell: b.curCell, drawPrio: drawPrio, cat: cat, zMin: zMin, natMax: zr.Max, scamin: b.curScamin, - legNorm: sectorLegFullNorm(v.Anchor.Lat, v.Sector.RadiusNM), + legNorm: legNorm, }) } } @@ -1132,7 +1152,7 @@ func (b *Baker) TileCoords(extent uint32) []tile.TileCoord { // the arc is clipped dead at the tile boundary. for i := range b.sectors { sp := &b.sectors[i] - ax, ay := normX(sp.anchor.Lon), normY(sp.anchor.Lat) + ax, ay := normX(sp.fig.Anchor.Lon), normY(sp.fig.Anchor.Lat) for z := sp.zMin; z <= b.clampZMax(sp.natMax); z++ { // Full-length legs (sp.legNorm, a fixed ground distance) can reach far // past the screen-px figure, so enumerate every tile they cross. @@ -1180,7 +1200,7 @@ func (b *Baker) TileCoordsBand(extent, bandMin, bandMax uint32) []tile.TileCoord if sp.natMax != bandMax { continue } - ax, ay := normX(sp.anchor.Lon), normY(sp.anchor.Lat) + ax, ay := normX(sp.fig.Anchor.Lon), normY(sp.fig.Anchor.Lat) // Like the flare prim (see lo := r.zMin above), a SCAMIN-bearing sector may // sit BELOW bandMin — keep it AVAILABLE down to its SCAMIN scale so the // client's per-SCAMIN bucket gates the exact cutoff. Non-SCAMIN sectors have @@ -1305,10 +1325,10 @@ func primTileSpan(r *routed, z uint32, bufFrac float64) int64 { return (xMax - xMin + 1) * (yMax - yMin + 1) } -// sectorRadiusNorm is the LIGHTS06 sector figure's maximum extent (the 26 mm -// ring) in normalized-world units at zoom z. The geometry is laid out in a -// 256-px-per-tile space (see expandSector's worldPx), so the spill is a fixed -// fraction of a tile at every zoom: 26 mm × px/mm ÷ 256 ÷ 2^z. +// sectorRadiusNorm is the sector figure's maximum screen-px extent (the 26 mm +// all-round ring) in normalized-world units at zoom z. The geometry is laid out +// in a 256-px-per-tile space (see tessellateFigure's worldPx), so the spill is a +// fixed fraction of a tile at every zoom: 26 mm × px/mm ÷ 256 ÷ 2^z. func sectorRadiusNorm(z uint32) float64 { return 26.0 * float64(portrayal.DefaultPxPerSymbolUnit) * 100.0 / 256.0 / math.Pow(2, float64(z)) } @@ -1595,11 +1615,11 @@ func (b *Baker) emitTileInto(coord tile.TileCoord, extent uint32, buffer float64 continue } margin := math.Max(sectorRadiusNorm(coord.Z), sp.legNorm) + spill - ax, ay := normX(sp.anchor.Lon), normY(sp.anchor.Lat) + ax, ay := normX(sp.fig.Anchor.Lon), normY(sp.fig.Anchor.Lat) if ax < tnx0-margin || ax > tnx1+margin || ay < tny0-margin || ay > tny1+margin { continue } - for _, st := range expandSector(sp.anchor, sp.params, coord.Z) { + for _, st := range tessellateFigure(sp, coord.Z) { runs := tile.ClipLine(projectRing(st.points, proj), rect) paths := make([][]mvt.IPoint, 0, len(runs)) for _, run := range runs { @@ -1724,44 +1744,72 @@ type sectorStroke struct { sleg int } -// expandSector tessellates a LIGHTS06 sector at anchor into lat/lon line strokes -// sized for integer zoom z (screen-px radii). A ring is one OUTLW-backed -// coloured circle (26 mm); a sector is two dashed CHBLK -// legs (25 mm) plus an OUTLW-backed coloured arc (20 mm). SECTR1/2 are from -// seaward, so bearings are reversed +180. -func expandSector(anchor geo.LatLon, p portrayal.SectorParams, z uint32) []sectorStroke { +// tessellateFigure tessellates one constructed sector-figure element (sp.fig) at +// integer zoom z into lat/lon line strokes, screen-px sized (the mm sizes are +// fixed display millimetres, hence per-zoom). A leg (ray) becomes one stroke from +// the anchor along its bearing; when the light has a nominal range it also emits +// the extended "full light lines" leg, the two tagged sleg 0/1 for the client's +// live toggle. An arc/ring becomes one polyline stroke. Colour, width and dash +// all come from the rule's LineStyle — including the black backing under a +// coloured arc and a white light's yellow (LITYW) arc — not a Go re-derivation. +// The rule has already applied the from-seaward +180 bearing reversal, so the +// bearings/angles are used as-is. +func tessellateFigure(sp *sectorPrim, z uint32) []sectorStroke { worldPx := 256.0 * math.Pow(2, float64(z)) - ax, ay := normX(anchor.Lon)*worldPx, normY(anchor.Lat)*worldPx + ax, ay := normX(sp.fig.Anchor.Lon)*worldPx, normY(sp.fig.Anchor.Lat)*worldPx pxPerMM := float64(portrayal.DefaultPxPerSymbolUnit) * 100.0 - color := p.ColorToken - if color == "" { - color = "LITRD" - } - - sweep := p.EndAngleDeg - p.StartAngleDeg - isRing := math.Abs(sweep) < 1e-6 || math.Abs(math.Abs(sweep)-360) < 1e-6 - if isRing { - return emitArc(nil, ax, ay, worldPx, 26.0, color, 0, 360, pxPerMM) - } - a1 := p.StartAngleDeg + 180.0 - a2 := p.EndAngleDeg + 180.0 - if a2 <= a1 { - a2 += 360.0 - } - legShort := 25.0 * pxPerMM - // Full leg extends to the VALNMR nominal range (S-52 LIGHTS06 note 1). Never - // shorter than the 25 mm default, so the "full length" toggle only ever grows - // the leg. Both variants are baked (tagged sleg 0/1); the client shows one. - legFull := sectorLegFullNorm(anchor.Lat, p.RadiusNM) * worldPx + widthPx := sp.fig.WidthMM * pxPerMM + dashed := sp.fig.Dash == portrayal.DashDashed + + if !sp.fig.Ray { // arc / ring + radius := sp.fig.RadiusMM * pxPerMM + if radius <= 0 { + return nil + } + sweep := sp.fig.SweepDeg + 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 + } + pts := make([]geo.LatLon, n+1) + for i := range pts { + brg := sp.fig.StartDeg + sweep*float64(i)/float64(n) + dx, dy := bearingToScreen(brg) + pts[i] = sunproject(ax+dx*radius, ay+dy*radius, worldPx) + } + // One stroke; the rule emits the black backing and the coloured arc as + // separate figures, so the double-stroke is preserved by draw order. Arcs/ + // rings carry no leg tag (sleg -1) — always shown, regardless of the toggle. + return []sectorStroke{{points: pts, colorToken: sp.fig.ColorToken, widthPx: float32(widthPx), dashed: dashed, sleg: -1}} + } + + // Leg (ray): the rule's length, plus the extended full-length variant when a + // nominal range is known. + emit := func(out []sectorStroke, lenPx float64, sleg int) []sectorStroke { + if lenPx <= 0 { + return out + } + dx, dy := bearingToScreen(sp.fig.BearingDeg) + pts := []geo.LatLon{ + sunproject(ax, ay, worldPx), + sunproject(ax+dx*lenPx, ay+dy*lenPx, worldPx), + } + return append(out, sectorStroke{points: pts, colorToken: sp.fig.ColorToken, widthPx: float32(widthPx), dashed: dashed, sleg: sleg}) + } + legShort := sp.fig.LengthMM * pxPerMM + if sp.fig.FullLengthNM <= 0 { + return emit(nil, legShort, -1) // can't extend: the leg is always shown + } + legFull := sectorLegFullNorm(sp.fig.Anchor.Lat, sp.fig.FullLengthNM) * worldPx if legFull < legShort { legFull = legShort } var out []sectorStroke - out = emitLeg(out, ax, ay, worldPx, a1, legShort, 0) - out = emitLeg(out, ax, ay, worldPx, a2, legShort, 0) - out = emitLeg(out, ax, ay, worldPx, a1, legFull, 1) - out = emitLeg(out, ax, ay, worldPx, a2, legFull, 1) - out = emitArc(out, ax, ay, worldPx, 20.0, color, a1, a2, pxPerMM) + out = emit(out, legShort, 0) + out = emit(out, legFull, 1) return out } @@ -1774,43 +1822,6 @@ func sunproject(x, y, worldPx float64) geo.LatLon { return geo.LatLon{Lat: unnormY(y / worldPx), Lon: x/worldPx*360 - 180} } -func emitLeg(out []sectorStroke, ax, ay, worldPx, bearingDeg, lenPx float64, sleg int) []sectorStroke { - if lenPx <= 0 { - return out - } - dx, dy := bearingToScreen(bearingDeg) - pts := []geo.LatLon{ - sunproject(ax, ay, worldPx), - sunproject(ax+dx*lenPx, ay+dy*lenPx, worldPx), - } - return append(out, sectorStroke{points: pts, colorToken: "CHBLK", widthPx: 1, dashed: true, sleg: sleg}) -} - -func emitArc(out []sectorStroke, ax, ay, worldPx, radiusMM float64, color string, a1, a2, pxPerMM float64) []sectorStroke { - radius := radiusMM * pxPerMM - sweep := a2 - a1 - if radius <= 0 || sweep <= 0 { - return out - } - n := int(math.Ceil(sweep / 3.0)) - if n < 8 { - n = 8 - } - pts := make([]geo.LatLon, n+1) - for i := range pts { - brg := a1 + sweep*float64(i)/float64(n) - dx, dy := bearingToScreen(brg) - pts[i] = sunproject(ax+dx*radius, ay+dy*radius, worldPx) - } - // OUTLW underlay (4 px) beneath, then the coloured arc (2 px) on top. Arcs/ - // rings carry no leg tag (sleg -1) — always shown, regardless of the toggle. - pts2 := make([]geo.LatLon, len(pts)) - copy(pts2, pts) - out = append(out, sectorStroke{points: pts, colorToken: "OUTLW", widthPx: 4, dashed: false, sleg: -1}) - out = append(out, sectorStroke{points: pts2, colorToken: color, widthPx: 2, dashed: false, sleg: -1}) - return out -} - // anyCoarserOverlaps reports whether a strictly-coarser-band eligible primitive's // world bbox overlaps r (AABB only). Gates down-fill suppression. func (b *Baker) anyCoarserOverlaps(eligible []int, r *routed) bool { diff --git a/internal/engine/bake/bake_test.go b/internal/engine/bake/bake_test.go index 47e57c3..2df9787 100644 --- a/internal/engine/bake/bake_test.go +++ b/internal/engine/bake/bake_test.go @@ -343,41 +343,40 @@ func TestSoundingGrouping(t *testing.T) { } func TestSectorLights(t *testing.T) { - // expandSector: a sector -> 2 short legs (sleg 0) + 2 full-length legs - // (sleg 1) + OUTLW underlay + coloured arc (both sleg -1, always shown). - anchor := mustLatLon(38.97, -76.49) - strokes := expandSector(anchor, sp(0, 90, "LITRD"), 14) - if len(strokes) != 6 { - t.Fatalf("sector strokes = %d, want 6", len(strokes)) - } - if !strokes[0].dashed || strokes[0].colorToken != "CHBLK" || strokes[0].sleg != 0 { + // tessellateFigure drives each constructed figure element off the rule's params. + // A leg with a nominal range emits the short (sleg 0) + extended full-length + // (sleg 1) variants, both dashed in the rule's colour, for the client's toggle. + leg := tessellateFigure(legPrim(90, 8), 14) + if len(leg) != 2 { + t.Fatalf("leg strokes = %d, want 2 (short + full)", len(leg)) + } + if !leg[0].dashed || leg[0].colorToken != "CHBLK" || leg[0].sleg != 0 { t.Error("stroke 0 should be the dashed CHBLK short leg (sleg 0)") } - if !strokes[2].dashed || strokes[2].colorToken != "CHBLK" || strokes[2].sleg != 1 { - t.Error("stroke 2 should be the dashed CHBLK full-length leg (sleg 1)") - } - if strokes[4].colorToken != "OUTLW" || strokes[4].widthPx != 4 || strokes[4].sleg != -1 { - t.Error("stroke 4 should be the 4px OUTLW arc underlay (sleg -1)") + if leg[1].sleg != 1 { + t.Error("stroke 1 should be the full-length leg (sleg 1)") } - if strokes[5].colorToken != "LITRD" || strokes[5].widthPx != 2 || strokes[5].dashed || strokes[5].sleg != -1 { - t.Error("stroke 5 should be the 2px solid LITRD arc (sleg -1)") - } - // A light with a VALNMR nominal range emits full legs (sleg 1) longer than - // the 25 mm short legs (sleg 0) — the toggle's whole point. - spR := sp(0, 90, "LITRD") - spR.RadiusNM = 8 - withR := expandSector(anchor, spR, 14) legLen := func(s sectorStroke) float64 { return absf(s.points[1].Lat-s.points[0].Lat) + absf(s.points[1].Lon-s.points[0].Lon) } - if legLen(withR[2]) <= legLen(withR[0]) { - t.Errorf("full leg (%.6f) should be longer than short leg (%.6f)", legLen(withR[2]), legLen(withR[0])) + if legLen(leg[1]) <= legLen(leg[0]) { + t.Errorf("full leg (%.6f) should be longer than short leg (%.6f)", legLen(leg[1]), legLen(leg[0])) + } + // A leg with no nominal range is a single always-shown stroke (sleg -1). + plain := tessellateFigure(legPrim(90, 0), 14) + if len(plain) != 1 || plain[0].sleg != -1 { + t.Errorf("plain leg = %+v, want 1 stroke sleg -1", plain) } - // Screen-fixed: lat span ~halves per zoom level. - r14 := expandSector(anchor, sp(0, 0, "LITYW"), 14) // ring - r15 := expandSector(anchor, sp(0, 0, "LITYW"), 15) - span := func(s []sectorStroke) float64 { return absf(s[len(s)-1].points[0].Lat - anchor.Lat) } - if ratio := span(r14) / span(r15); ratio < 1.9 || ratio > 2.1 { + // An arc/ring is one stroke in the rule's colour (white light → yellow LITYW), + // always shown (sleg -1). Screen-fixed: the radius ~halves per zoom level. + arc14 := tessellateFigure(arcPrim(26, 0, 360, "LITYW"), 14) + if len(arc14) != 1 || arc14[0].colorToken != "LITYW" || arc14[0].sleg != -1 { + t.Fatalf("arc = %+v, want 1 LITYW stroke sleg -1", arc14) + } + arc15 := tessellateFigure(arcPrim(26, 0, 360, "LITYW"), 15) + anchor := mustLatLon(38.97, -76.49) + span := func(s []sectorStroke) float64 { return absf(s[0].points[0].Lat - anchor.Lat) } + if ratio := span(arc14) / span(arc15); ratio < 1.9 || ratio > 2.1 { t.Errorf("ring radius ratio z14/z15 = %.3f, want ~2", ratio) } } @@ -419,8 +418,22 @@ func TestUpSuppressionPointOverlap(t *testing.T) { } func mustLatLon(lat, lon float64) geo.LatLon { return geo.LatLon{Lat: lat, Lon: lon} } -func sp(start, end float64, color string) portrayal.SectorParams { - return portrayal.SectorParams{StartAngleDeg: start, EndAngleDeg: end, ColorToken: color} + +// legPrim / arcPrim build a one-element sector figure (as the rule emits) at a +// fixed anchor: a dashed CHBLK leg at the given bearing (fullNM>0 enables the +// extended variant), or a coloured arc/ring. +func legPrim(bearingDeg, fullNM float64) *sectorPrim { + return §orPrim{fig: portrayal.AugmentedFigure{ + Anchor: mustLatLon(38.97, -76.49), Ray: true, BearingDeg: bearingDeg, + LengthMM: 25, ColorToken: "CHBLK", WidthMM: 0.32, Dash: portrayal.DashDashed, + FullLengthNM: fullNM, + }} +} +func arcPrim(radiusMM, startDeg, sweepDeg float64, color string) *sectorPrim { + return §orPrim{fig: portrayal.AugmentedFigure{ + Anchor: mustLatLon(38.97, -76.49), RadiusMM: radiusMM, + StartDeg: startDeg, SweepDeg: sweepDeg, ColorToken: color, WidthMM: 0.64, + }} } func absf(x float64) float64 { diff --git a/internal/engine/bake/complexline.go b/internal/engine/bake/complexline.go index 783d6e9..d727d3a 100644 --- a/internal/engine/bake/complexline.go +++ b/internal/engine/bake/complexline.go @@ -60,7 +60,7 @@ func (b *Baker) emitComplexLine(r *routed, proj tile.Projector, rect tile.Rect, } // Screen px -> tile units. The baker lays figures out in 256-px-per-tile space - // (see sectorRadiusNorm/expandSector); one tile is `extent` units wide. + // (see sectorRadiusNorm/tessellateFigure); one tile is `extent` units wide. pxScale := float64(extent) / 256.0 period := info.periodPx * pxScale if period < 1e-6 { diff --git a/internal/engine/portrayal/build.go b/internal/engine/portrayal/build.go index f6812d9..f47472b 100644 --- a/internal/engine/portrayal/build.go +++ b/internal/engine/portrayal/build.go @@ -121,6 +121,16 @@ func applyDangerDepth(prims []Primitive, class string, attrs map[string]interfac return prims } +// stringAttr returns an attribute's encoded string value, or "" when absent. +func stringAttr(attrs map[string]interface{}, key string) string { + if v, ok := attrs[key]; ok { + if s, ok := encodeAttr(v); ok { + return s + } + } + return "" +} + func floatAttr(attrs map[string]interface{}, key string) (float64, bool) { v, ok := attrs[key] if !ok || v == nil { diff --git a/internal/engine/portrayal/primitive.go b/internal/engine/portrayal/primitive.go index d3dcfd7..b59a6bd 100644 --- a/internal/engine/portrayal/primitive.go +++ b/internal/engine/portrayal/primitive.go @@ -141,29 +141,37 @@ type TextHalo struct { WidthPx float32 } -// SectorLight is LIGHTS06 sector geometry (legs / arc / ring). Only the lat/lon -// anchor plus the S-52 sector parameters are cached; the screen-space -// tessellation happens at projection time because radii are display millimetres. -type SectorLight struct { +// AugmentedFigure is one stroked element of a screen-space figure the S-101 rule +// CONSTRUCTED via AugmentedRay / ArcByRadius (a light-sector leg or arc/ring) — +// driven by the catalogue's own bearings, radii, colours and widths rather than a +// Go re-derivation from S-57 attributes. One primitive = one stroked element; a +// sectored light emits several (two dashed legs, then a black-backed coloured +// arc). The mm sizes are screen-fixed, so the baker tessellates per-zoom into +// `sector_lines`; it cannot bake as static geographic geometry. +type AugmentedFigure struct { Anchor geo.LatLon - Sector SectorParams -} - -// SectorParams carries the S-52 LIGHTS06 sector parameters. Populated from the -// s52 SectorInstruction the CS procedure emits. -type SectorParams struct { - StartAngleDeg float64 // SECTR1, 0=North, clockwise - EndAngleDeg float64 // SECTR2, 0=North, clockwise - RadiusNM float64 // VALNMR nominal range, nautical miles - ColorToken string // LITRD/LITGN/LITYW/... - Transparency int // 0=opaque..3=75% - ShowLegs bool + Ray bool // true: a straight leg (Bearing/Length); false: an arc/ring + // Ray params (true-north bearing, already from-seaward-reversed by the rule). + BearingDeg float64 + LengthMM float64 + // Arc params (centred on Anchor); a full 360° sweep is an all-round ring. + RadiusMM float64 + StartDeg float64 + SweepDeg float64 + // Stroke style, from the rule's LineStyle:_simple_. + ColorToken string + WidthMM float64 + Dash Dash + // FullLengthNM is the LIGHTS nominal range (VALNMR); when set on a ray, the + // baker also emits the "full light lines" leg variant extended to that range + // (S-52 LIGHTS06 note 1), tagged for the client's live toggle. 0 = no variant. + FullLengthNM float64 } -func (FillPolygon) isPrimitive() {} -func (StrokeLine) isPrimitive() {} -func (SymbolCall) isPrimitive() {} -func (PatternFill) isPrimitive() {} -func (LinePattern) isPrimitive() {} -func (DrawText) isPrimitive() {} -func (SectorLight) isPrimitive() {} +func (FillPolygon) isPrimitive() {} +func (StrokeLine) isPrimitive() {} +func (SymbolCall) isPrimitive() {} +func (PatternFill) isPrimitive() {} +func (LinePattern) isPrimitive() {} +func (DrawText) isPrimitive() {} +func (AugmentedFigure) isPrimitive() {} diff --git a/internal/engine/portrayal/s101build.go b/internal/engine/portrayal/s101build.go index 4d14cb9..094ac71 100644 --- a/internal/engine/portrayal/s101build.go +++ b/internal/engine/portrayal/s101build.go @@ -219,10 +219,21 @@ func (b *S101Builder) buildFeature(f *s57.Feature, stream string) FeatureBuild { if f.ObjectClass() == "SOUNDG" { attachSoundingDepths(prims, soundingPoints(f.Geometry())) } - // Sector / directional light figures: the rule's AugmentedRay / ArcByRadius - // geometry is fixed display-mm (screen size) and isn't emitted onto geographic - // geometry; emit a SectorLight the baker tessellates per-zoom instead. - prims = append(prims, sectorLightPrims(f, anchor)...) + // Sector / directional lights: the rule constructs the legs + arc as screen-space + // AugmentedFigure elements (emitted above from the AugmentedRay / ArcByRadius + // instructions). Tag each leg with the light's nominal range (VALNMR) so the + // baker can also emit the "full light lines" leg variant for the client's live + // toggle (S-52 LIGHTS06 note 1). + if f.ObjectClass() == "LIGHTS" { + if vnr, ok := floatAttr(f.Attributes(), "VALNMR"); ok && vnr > 0 { + for i := range prims { + if fig, ok := prims[i].(AugmentedFigure); ok && fig.Ray { + fig.FullLengthNM = vnr + prims[i] = fig + } + } + } + } if cat == 0 { cat = displayStandard // no display-category band emitted (e.g. text-only) } diff --git a/internal/engine/portrayal/s101build_test.go b/internal/engine/portrayal/s101build_test.go index cc93639..c2dcc69 100644 --- a/internal/engine/portrayal/s101build_test.go +++ b/internal/engine/portrayal/s101build_test.go @@ -374,11 +374,11 @@ func TestS101NameLabel(t *testing.T) { } // TestS101BuildSectorLight drives an S-57 sectored light through the full build -// seam and asserts a SectorLight primitive is produced (the fixed-screen-size -// sector legs/arc the baker tessellates into the sector_lines layer), with the -// S-57 sector limits/colour carried through unflipped. +// seam and asserts the rule's constructed AugmentedFigure elements come through: +// the dashed legs (rays, tagged with the nominal range for the full-light-lines +// toggle) and the coloured arc — driven by the catalogue, not a Go re-derivation. func TestS101BuildSectorLight(t *testing.T) { - b := s101Builder(t) + b := s101BuilderEmbedded(t) lt := s57.NewFeature(1, "LIGHTS", s57.Geometry{Type: s57.GeometryTypePoint, Coordinates: [][]float64{{12.5, 55.7}}}, @@ -391,24 +391,33 @@ func TestS101BuildSectorLight(t *testing.T) { if !ok { t.Fatal("build failed") } - var sec *SectorLight - for i := range build.Primitives { - if s, ok := build.Primitives[i].(SectorLight); ok { - sec = &s - break + var legs, arcs int + var arcColor string + for _, p := range build.Primitives { + fig, ok := p.(AugmentedFigure) + if !ok { + continue + } + if fig.Ray { + legs++ + if fig.FullLengthNM != 9 { + t.Errorf("leg nominal range = %v, want 9 (from VALNMR)", fig.FullLengthNM) + } + } else { + arcs++ + if fig.ColorToken == "LITRD" { + arcColor = fig.ColorToken + } } } - if sec == nil { - t.Fatalf("no SectorLight emitted; got %#v", build.Primitives) - } - if sec.Sector.StartAngleDeg != 45 || sec.Sector.EndAngleDeg != 90 { - t.Errorf("angles = %v..%v, want 45..90 (unflipped seaward)", sec.Sector.StartAngleDeg, sec.Sector.EndAngleDeg) + if legs < 2 { + t.Errorf("legs = %d, want >=2 (two sector limits)", legs) } - if sec.Sector.ColorToken != "LITRD" { - t.Errorf("colour = %q, want LITRD (red)", sec.Sector.ColorToken) + if arcs < 1 { + t.Errorf("arcs = %d, want >=1 (the sector arc)", arcs) } - if sec.Sector.RadiusNM != 9 { - t.Errorf("radius = %v, want 9 NM", sec.Sector.RadiusNM) + if arcColor != "LITRD" { + t.Errorf("no LITRD (red) arc found; COLOUR=3 should portray red") } } diff --git a/internal/engine/portrayal/s101emit.go b/internal/engine/portrayal/s101emit.go index c23ba2a..03db125 100644 --- a/internal/engine/portrayal/s101emit.go +++ b/internal/engine/portrayal/s101emit.go @@ -138,6 +138,31 @@ func emitPrimitives(cmd instructions.DrawCommand, geom S101Geometry, cat *catalo Group: cmd.ViewingGroup, }} + case instructions.OpAugmentedLine: + // A screen-space sector figure element (ray/arc) the rule constructed; carry + // it with the rule's stroke style for the baker to tessellate per-zoom. + ag := cmd.Augmented + if ag == nil { + return nil + } + fig := AugmentedFigure{Anchor: geom.Anchor} + if cmd.SimpleLine != nil { + fig.ColorToken = cmd.SimpleLine.Color + fig.WidthMM = cmd.SimpleLine.Width + fig.Dash = dashFor(cmd.SimpleLine.DashLength) + } + switch ag.Kind { + case instructions.AugRay: + fig.Ray = true + fig.BearingDeg = ag.BearingDeg + fig.LengthMM = ag.LengthMM + case instructions.AugArc: + fig.RadiusMM = ag.RadiusMM + fig.StartDeg = ag.StartDeg + fig.SweepDeg = ag.SweepDeg + } + return []Primitive{fig} + default: // OpNull, OpOther return nil } diff --git a/internal/engine/portrayal/s101sector.go b/internal/engine/portrayal/s101sector.go deleted file mode 100644 index 75a1ca0..0000000 --- a/internal/engine/portrayal/s101sector.go +++ /dev/null @@ -1,107 +0,0 @@ -package portrayal - -import ( - "strconv" - "strings" - - "github.com/beetlebugorg/chartplotter/pkg/geo" - "github.com/beetlebugorg/chartplotter/pkg/s57" -) - -// This file produces the SectorLight primitive for S-57 sectored / directional -// lights. The S-101 LightSectored rule expresses sector legs and arcs as -// AugmentedRay / ArcByRadius geometry-construction instructions whose lengths are -// fixed display millimetres (LocalCRS) — a fixed SCREEN size that can't be baked -// into geographic tile geometry. So instead of emitting from those instructions, the -// sector geometry is carried as a SectorLight primitive (anchor + S-52 sector -// parameters) and tessellated per-zoom at bake time into the screen-space -// `sector_lines` layer (bake.expandSector), exactly as the former S-52 LIGHTS06 -// path did. The rule's text descriptions still emit normally (OpText). - -// sectorLightPrims returns the SectorLight primitive(s) for a LIGHTS feature, or -// nil if it carries no sector/directional figure (a plain light is portrayed by -// its flare symbol, which emits from the rule's PointInstruction). One S-57 -// LIGHTS feature is one sector; multiple sectors at a position are separate -// co-located features (the baker dedupes identical geometry). -func sectorLightPrims(f *s57.Feature, anchor geo.LatLon) []Primitive { - if f.ObjectClass() != "LIGHTS" { - return nil - } - a := f.Attributes() - vnr, _ := floatAttr(a, "VALNMR") - colour := sectorColorToken(stringAttr(a, "COLOUR")) - - s1, ok1 := floatAttr(a, "SECTR1") - s2, ok2 := floatAttr(a, "SECTR2") - if ok1 && ok2 { - // Sectored light: two limits → legs + coloured arc. expandSector reverses - // the from-seaward bearings itself, so pass SECTR1/SECTR2 unflipped. - return []Primitive{SectorLight{ - Anchor: anchor, - Sector: SectorParams{ - StartAngleDeg: s1, EndAngleDeg: s2, RadiusNM: vnr, - ColorToken: colour, ShowLegs: true, - }, - }} - } - - // Directional light of long range (≥10 NM) with no sector limits: the rule - // draws a full coloured ring (ArcByRadius 0–360). Shorter-range directional - // lights fall back to the flare symbol, which emits from the rule's - // PointInstruction, so they need no SectorLight here. - if hasListVal(stringAttr(a, "CATLIT"), 1) && vnr >= 10 { - return []Primitive{SectorLight{ - Anchor: anchor, - Sector: SectorParams{ - StartAngleDeg: 0, EndAngleDeg: 0, // sweep 0 → ring (expandSector) - RadiusNM: vnr, ColorToken: colour, ShowLegs: false, - }, - }} - } - return nil -} - -// sectorColorToken maps an S-57 COLOUR list to the sector arc's S-52 colour -// token, mirroring the LightSectored rule's colour selection: red→LITRD, -// green→LITGN, white/yellow/orange→LITYW, anything else→the magenta default. -func sectorColorToken(colour string) string { - c1, c2 := 0, 0 - parts := strings.Split(colour, ",") - if len(parts) > 0 { - c1, _ = strconv.Atoi(strings.TrimSpace(parts[0])) - } - if len(parts) > 1 { - c2, _ = strconv.Atoi(strings.TrimSpace(parts[1])) - } - switch { - case c1 == 3 || (c1 == 1 && c2 == 3): // red, or white & red - return "LITRD" - case c1 == 4 || (c1 == 1 && c2 == 4): // green, or white & green - return "LITGN" - case c1 == 1 || c1 == 6 || c1 == 11: // white, yellow, orange - return "LITYW" - default: - return "CHMGD" - } -} - -// hasListVal reports whether the S-57 comma-separated list value contains want. -func hasListVal(csv string, want int) bool { - for _, p := range strings.Split(csv, ",") { - if n, err := strconv.Atoi(strings.TrimSpace(p)); err == nil && n == want { - return true - } - } - return false -} - -// stringAttr returns an attribute's value as a string (S-57 encodes list values -// as a comma-separated string), or "" when absent. -func stringAttr(attrs map[string]interface{}, key string) string { - if v, ok := attrs[key]; ok { - if s, ok := encodeAttr(v); ok { - return s - } - } - return "" -} diff --git a/pkg/s100/instructions/instructions.go b/pkg/s100/instructions/instructions.go index 21c729a..00fe20c 100644 --- a/pkg/s100/instructions/instructions.go +++ b/pkg/s100/instructions/instructions.go @@ -53,9 +53,42 @@ const ( OpAreaFill DrawOp = "AreaFill" // tiled area fill referencing a fill/pattern OpText DrawOp = "Text" // text label OpNull DrawOp = "Null" // explicit no-op (suppress) - OpOther DrawOp = "Other" // recognized draw we don't lower yet (gap) + // OpAugmentedLine strokes a screen-space figure the rule CONSTRUCTED via an + // AugmentedRay / ArcByRadius instruction (a light-sector leg or arc/ring), + // rather than the feature's own geometry. The mm sizes are screen-fixed, so the + // figure is carried (see DrawCommand.Augmented) for per-zoom tessellation. + OpAugmentedLine DrawOp = "AugmentedLine" + OpOther DrawOp = "Other" // recognized draw we don't emit yet (gap) ) +// AugGeomKind is the kind of constructed figure element a DrawCommand.Augmented +// carries. +type AugGeomKind uint8 + +const ( + AugRay AugGeomKind = iota // a straight leg from the anchor (AugmentedRay) + AugArc // a circular arc/ring centred on the anchor (ArcByRadius) +) + +// AugmentedGeom is one screen-space figure element the rule constructed and a +// LineInstruction then strokes — a light-sector leg (ray) or its arc/ring. All +// sizes are display millimetres (the rule emits them in LocalCRS), so the baker +// tessellates per-zoom; they cannot bake as static geographic geometry. +type AugmentedGeom struct { + Kind AugGeomKind + // Ray ("AugmentedRay:,,,"): a leg from the anchor + // at BearingDeg (true-north; the rule has already applied the from-seaward + // +180 reversal) of length LengthMM. + BearingDeg float64 + LengthMM float64 + // Arc ("ArcByRadius:,,,,"): centred on the + // anchor, RadiusMM, from StartDeg sweeping SweepDeg degrees clockwise. A full + // 360° sweep is an all-round ring. + RadiusMM float64 + StartDeg float64 + SweepDeg float64 +} + // SimpleLine is an inline "LineStyle:_simple_,,," definition, // referenced by a subsequent "LineInstruction:_simple_". type SimpleLine struct { @@ -88,7 +121,11 @@ type DrawCommand struct { RotationTrueNorth bool LinePlacement string // raw, e.g. "Relative,0.5" - SimpleLine *SimpleLine // set when Op==OpLine and Reference=="_simple_" + SimpleLine *SimpleLine // set when Op==OpLine/OpAugmentedLine and Reference=="_simple_" + + // Augmented is set when Op==OpAugmentedLine: the constructed ray/arc this draw + // strokes (with the SimpleLine style). The baker tessellates it per-zoom. + Augmented *AugmentedGeom // Text style (set on OpText): the resolved text is in Reference. FontColor string // colour token (e.g. CHBLK) @@ -137,6 +174,7 @@ func Reduce(ins []Instruction) (cmds []DrawCommand, unsupported []string) { dateStart string dateEnd string timeValid string + curAug *AugmentedGeom // current constructed figure (ray/arc), if any seenUnsup = map[string]bool{} ) noteUnsupported := func(kind string) { @@ -159,6 +197,9 @@ func Reduce(ins []Instruction) (cmds []DrawCommand, unsupported []string) { c.FontColor, c.FontSizePx = fontColor, fontSize c.TextAlignH, c.TextAlignV, c.TextVOffset = textAlignH, textAlignV, textVOffset } + if op == OpAugmentedLine { + c.SimpleLine, c.Augmented = simple, curAug + } c.DateStart, c.DateEnd, c.TimeValid = dateStart, dateEnd, timeValid cmds = append(cmds, c) } @@ -179,9 +220,22 @@ func Reduce(ins []Instruction) (cmds []DrawCommand, unsupported []string) { // geographic point (x=lon, y=lat) — SOUNDG emits one per sounding. anchor, hasAnchor = [2]float64{atof(arg(in, 1)), atof(arg(in, 2))}, true case "ClearGeometry": - // End of an augmented-geometry run: drop the explicit anchor/offset so - // later draws re-attach to the feature geometry. + // End of an augmented-geometry run: drop the explicit anchor/offset and + // the constructed figure so later draws re-attach to the feature geometry. hasAnchor, anchor, offset = false, [2]float64{}, [2]float64{} + curAug = nil + // --- geometry construction (screen-space figures the rule builds) --- + case "AugmentedRay": + // "AugmentedRay:,,," — a leg from the + // anchor. The rule emits the bearing already from-seaward-reversed. + curAug = &AugmentedGeom{Kind: AugRay, BearingDeg: atof(arg(in, 1)), LengthMM: atof(arg(in, 3))} + case "ArcByRadius": + // "ArcByRadius:,,,," — an arc/ring + // centred on the anchor (the cx,cy offset is 0 for sector figures). + curAug = &AugmentedGeom{Kind: AugArc, RadiusMM: atof(arg(in, 2)), StartDeg: atof(arg(in, 3)), SweepDeg: atof(arg(in, 4))} + case "AugmentedPath": + // Declares the CRS sequence stitching the preceding ray/arc into one path; + // each constructed element is already carried by curAug, so this is a no-op. case "Rotation": // S-101 form: "Rotation:," where CRS is GeographicCRS // (true-north, rotates with the chart) or PortrayalCRS (screen). A @@ -219,14 +273,25 @@ func Reduce(ins []Instruction) (cmds []DrawCommand, unsupported []string) { timeValid = arg(in, 0) // modifiers we intentionally ignore when emitting primitives case "ScaleMinimum", "ScaleMaximum", "Time", "DateTime", - "AlertReference", "Warning", "Error", "Hover", "SpatialReference": + "AlertReference", "Warning", "Error", "Hover", "SpatialReference", + // area-placement / scale-factor modifiers: meaningful only for area-fill + // placement, not for the point / fill / line / text / augmented draws we + // emit. They ride along on the AddDateDependentSymbol geometry-reset + // preamble, so ignore them rather than report them as gaps. + "AreaPlacement", "AreaCRS", "ScaleFactor": // no-op for primitive emission // --- draws (consume state) --- case "PointInstruction": emit(OpPoint, arg(in, 0), in.Raw) case "LineInstruction", "LineInstructionUnsuppressed": - emit(OpLine, arg(in, 0), in.Raw) + // When a figure (ray/arc) is current, the line strokes THAT (screen-space + // sector geometry); otherwise it strokes the feature's own geometry. + if curAug != nil { + emit(OpAugmentedLine, arg(in, 0), in.Raw) + } else { + emit(OpLine, arg(in, 0), in.Raw) + } case "ColorFill": emit(OpColorFill, arg(in, 0), in.Raw) case "AreaFillReference": @@ -238,8 +303,8 @@ func Reduce(ins []Instruction) (cmds []DrawCommand, unsupported []string) { case "NullInstruction": emit(OpNull, "", in.Raw) - // recognized-but-not-yet-lowered draws → gap - case "AugmentedRay", "AugmentedPath", "ArcByRadius", "CoverageFill": + // recognized-but-not-yet-emitted draws → gap + case "CoverageFill": emit(OpOther, in.Kind, in.Raw) default: diff --git a/pkg/s100/instructions/instructions_test.go b/pkg/s100/instructions/instructions_test.go index 10727a1..826fd90 100644 --- a/pkg/s100/instructions/instructions_test.go +++ b/pkg/s100/instructions/instructions_test.go @@ -107,3 +107,56 @@ func TestUnsupportedSurfaced(t *testing.T) { t.Fatalf("want [Foo], got %v", unsup) } } + +// TestSectorAugmentedGeometry: a real LightSectored stream (captured from the +// S-101 Lua engine) constructs two dashed CHBLK legs and a black-backed coloured +// arc via AugmentedRay/ArcByRadius. Each LineInstruction must stroke the current +// figure as an OpAugmentedLine carrying the ray/arc params + the simple-line +// style — never collapse to an OpLine or land in unsupported. +func TestSectorAugmentedGeometry(t *testing.T) { + stream := "ViewingGroup:27070;DrawingPriority:24;DisplayPlane:UnderRadar;Hover:true;" + + "AugmentedRay:GeographicCRS,83,LocalCRS,25;Dash:0,3.6;LineStyle:_simple_,5.4,0.32,CHBLK;LineInstruction:_simple_;" + + "AugmentedRay:GeographicCRS,247,LocalCRS,25;LineInstruction:_simple_;" + + "ArcByRadius:0,0,20,83,164;AugmentedPath:LocalCRS,GeographicCRS,LocalCRS;" + + "LineStyle:_simple_,,1.28,CHBLK;LineInstruction:_simple_;" + + "LineStyle:_simple_,,0.64,LITYW;LineInstruction:_simple_;ClearGeometry" + cmds, unsup := Reduce(ParseStream(stream)) + if len(unsup) != 0 { + t.Fatalf("unexpected unsupported: %v", unsup) + } + var aug []DrawCommand + for _, c := range cmds { + if c.Op == OpAugmentedLine { + aug = append(aug, c) + } else if c.Op == OpLine { + t.Errorf("augmented stroke collapsed to OpLine: %+v", c) + } + } + if len(aug) != 4 { + t.Fatalf("want 4 augmented strokes (2 legs + 2 arc), got %d", len(aug)) + } + // Leg 1: ray at 83°, length 25mm, dashed CHBLK. + if aug[0].Augmented == nil || aug[0].Augmented.Kind != AugRay || + aug[0].Augmented.BearingDeg != 83 || aug[0].Augmented.LengthMM != 25 { + t.Errorf("leg1 ray = %+v", aug[0].Augmented) + } + if aug[0].SimpleLine == nil || aug[0].SimpleLine.Color != "CHBLK" || aug[0].SimpleLine.DashLength == 0 { + t.Errorf("leg1 style = %+v", aug[0].SimpleLine) + } + // Leg 2: ray at 247°, inherits the same dashed CHBLK style. + if aug[1].Augmented == nil || aug[1].Augmented.BearingDeg != 247 { + t.Errorf("leg2 ray = %+v", aug[1].Augmented) + } + // Arc backing: radius 20mm, start 83°, sweep 164°, CHBLK 1.28mm solid. + if aug[2].Augmented == nil || aug[2].Augmented.Kind != AugArc || + aug[2].Augmented.RadiusMM != 20 || aug[2].Augmented.StartDeg != 83 || aug[2].Augmented.SweepDeg != 164 { + t.Errorf("arc backing = %+v", aug[2].Augmented) + } + if aug[2].SimpleLine == nil || aug[2].SimpleLine.Color != "CHBLK" || aug[2].SimpleLine.Width != 1.28 { + t.Errorf("arc backing style = %+v", aug[2].SimpleLine) + } + // Arc colour: white light portrayed yellow (LITYW), 0.64mm. + if aug[3].SimpleLine == nil || aug[3].SimpleLine.Color != "LITYW" || aug[3].SimpleLine.Width != 0.64 { + t.Errorf("arc colour style = %+v", aug[3].SimpleLine) + } +} From 1123f76f47db1edca0f230f734fe53c33ec8daa8 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 24 Jun 2026 22:00:26 -0400 Subject: [PATCH 03/13] feat(bake): bake date-validity tags for the mandatory current-date filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation for the S-52 §10.4.1.1 / Fig 1 date-dependent display rule: a date-dependent object outside its period is not displayed for the current date (the first gate of the ECDIS display pipeline). The portrayal already surfaces the period on FeatureBuild (DATSTA/DATEND fixed, PERSTA/PEREND recurring); the baker now stamps date_start / date_end / time_valid onto every one of the feature's primitives — point_symbols, text, lines, areas and sector_lines — so the client can apply the filter live without a re-bake. Threaded via b.curDate* (set per pass, like curScamin) into the common() attr builder, plus carried on sectorPrim because sectors tessellate at tile-emit time after curDate* has moved on. Verified on US4MD81M: the seasonal Slaughter Creek / Marshy Creek buoys carry the tags on point_symbols + text. Client-side filter (current-date evaluation incl. recurring --MMDD ranges, the date-range mariner control, and re-gating CHDATD01 to the 90022 highlight toggle) is the next step. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/engine/bake/bake.go | 44 +++++++++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/internal/engine/bake/bake.go b/internal/engine/bake/bake.go index 6420bc1..8eff361 100644 --- a/internal/engine/bake/bake.go +++ b/internal/engine/bake/bake.go @@ -281,6 +281,9 @@ type sectorPrim struct { zMin uint32 natMax uint32 scamin uint32 // SCAMIN denominator of the parent LIGHTS (0 = none); emitted as `scamin` so the client's per-SCAMIN bucket layer gates the exact display cutoff, same as point symbols/text + // Date validity of the parent LIGHTS (S-52 §10.4.1.1); empty when none. Carried + // because sectors tessellate at tile-emit time, after b.curDate* has moved on. + dateStart, dateEnd, timeValid string // legNorm is the full-length leg reach (VALNMR nominal range) as a fraction // of the normalized world — a fixed GROUND distance, so zoom-independent // (unlike the 25 mm short leg / arc, which are screen-px). Drives the tile @@ -311,6 +314,15 @@ type Baker struct { curObjnam string // OBJNAM of the feature currently being expanded (for the inspector) curLight string // light characteristic string of the current LIGHTS feature (e.g. "Fl.R.4s") curAttrs string // compact JSON of the feature's full S-57 attribute set (acronym→value) for the cursor-pick report (S-52 PresLib §10.8); "" when the feature has none + // Date dependency (S-52 PresLib §10.4.1.1 / Fig 1): the current feature's + // validity period, baked onto every one of its primitives so the client can + // apply the MANDATORY date filter — a date-dependent object outside its period + // is not displayed for the current date. Empty when the feature has no period. + // Values are S-57 date strings: full "YYYYMMDD" (fixed) or partial "--MMDD" + // recurring each year (periodic); curTimeValid is the interval kind. + curDateStart string + curDateEnd string + curTimeValid string // Co-located-light combination (S-52 LIGHTS06): when several LIGHTS share a // position, the first is "primary" (one flare + a merged multi-line label); // the rest are suppressed (flare + text dropped, sectors kept). seenSector @@ -640,6 +652,9 @@ func (b *Baker) AddCell(chart *s57.Chart) { scamin := intAttr(f.Attributes(), "SCAMIN") b.curScamin = scamin // baked as the `scamin` tag → client per-SCAMIN bucket layers b.recordScamin(scamin) // publish the band's distinct values (manifest → TileJSON) + // Date validity period (S-52 §10.4.1.1) — baked onto each primitive so the + // client applies the mandatory current-date filter. + b.curDateStart, b.curDateEnd, b.curTimeValid = fb.DateStart, fb.DateEnd, fb.TimeValid zMin := bandZMin(fb.DisplayCategory, scamin, dr.Min, cellLat) class := f.ObjectClass() drval1, drval2 := depthVals(f.Attributes(), class) @@ -700,6 +715,26 @@ func (b *Baker) AddCell(chart *s57.Chart) { } } +// appendDateTags adds the feature's date-validity period (S-52 §10.4.1.1) to a +// tile feature's attrs when it has one, so the client's mandatory current-date +// filter can hide it outside its period. date_start/date_end are S-57 date +// strings (full "YYYYMMDD" or recurring "--MMDD"); time_valid is the interval kind. +func appendDateTags(attrs []mvt.KeyValue, start, end, timeValid string) []mvt.KeyValue { + if start == "" && end == "" { + return attrs + } + if start != "" { + attrs = append(attrs, mvt.KeyValue{Key: "date_start", Value: mvt.StringVal(start)}) + } + if end != "" { + attrs = append(attrs, mvt.KeyValue{Key: "date_end", Value: mvt.StringVal(end)}) + } + if timeValid != "" { + attrs = append(attrs, mvt.KeyValue{Key: "time_valid", Value: mvt.StringVal(timeValid)}) + } + return attrs +} + // routeSoundingGroup emits one soundings feature for a whole sounding number // (the comma-joined digit-glyph list), carrying depth + both palette variants so // the client runs SNDFRM04's safety-depth split live. @@ -861,6 +896,9 @@ func (b *Baker) route(p portrayal.Primitive, class string, drawPrio, cat int, zr if b.curAttrs != "" { extra = append(extra, mvt.KeyValue{Key: "s57", Value: mvt.StringVal(b.curAttrs)}) } + // Date validity (S-52 §10.4.1.1): the client's mandatory current-date filter + // hides a date-dependent feature outside its period. + extra = appendDateTags(extra, b.curDateStart, b.curDateEnd, b.curTimeValid) return extra } r := routed{ @@ -973,7 +1011,8 @@ func (b *Baker) route(p portrayal.Primitive, class string, drawPrio, cat int, zr b.sectors = append(b.sectors, sectorPrim{ fig: v, class: class, cell: b.curCell, drawPrio: drawPrio, cat: cat, zMin: zMin, natMax: zr.Max, - scamin: b.curScamin, + scamin: b.curScamin, + dateStart: b.curDateStart, dateEnd: b.curDateEnd, timeValid: b.curTimeValid, legNorm: legNorm, }) } @@ -1656,6 +1695,9 @@ func (b *Baker) emitTileInto(coord tile.TileCoord, extent uint32, buffer float64 if sp.scamin != 0 { attrs = append(attrs, mvt.KeyValue{Key: "scamin", Value: mvt.IntVal(int64(sp.scamin))}) } + // Date validity of the parent light (S-52 §10.4.1.1) — so a seasonal + // sector light's figure hides with its flare/text under the date filter. + attrs = appendDateTags(attrs, sp.dateStart, sp.dateEnd, sp.timeValid) tb.Layer("sector_lines").AddLines(paths, attrs) } } From a625affa05aea9914d41977475620bac770b0db4 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 24 Jun 2026 22:13:05 -0400 Subject: [PATCH 04/13] feat(date): mandatory current-date filter for date-dependent features MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the S-52 PresLib §10.4.1.1 / Fig 1 rule — the first gate of the ECDIS display pipeline: a date-dependent object outside its validity period is not displayed for the viewing date. Default-on, as the spec mandates. Baker: refine the baked period into a filter-ready form — date_recurring (present iff dated; 1 = recurring month-day range, 0 = one-off full date) plus the comparable bound strings date_start/date_end ("MMDD" or "YYYYMMDD"), each present only when that bound exists (one-sided = semi-open). Replaces the raw "--MMDD" + time_valid tags so the client filter needs no slice/format-detection. Client: S52.dateFilter builds a MapLibre expression comparing the viewing date (real today, or mariner.dateView for planning) to the bounds — with year-wrap for a recurring range whose start month-day is after its end (e.g. 1101 → 0315) and semi-open handling. Folded into combineFilters so every chart layer (point symbols, text, sector lines, lines, areas) is gated uniformly; mariner.dateDependent (default true) is the escape valve. Reactivity wired into setMariner. The period logic is unit-tested (node --test, _inDatePeriod reference mirrors the expression: summer/winter-wrap/fixed/semi-open/undated). The MapLibre expression itself still needs in-browser confirmation. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/engine/bake/bake.go | 35 +++++++----- web/src/chart-canvas/chart-canvas.mjs | 2 +- web/src/chart-canvas/s52-style.date.test.mjs | 53 ++++++++++++++++++ web/src/chart-canvas/s52-style.mjs | 58 ++++++++++++++++++++ web/src/chartplotter.mjs | 5 ++ 5 files changed, 138 insertions(+), 15 deletions(-) create mode 100644 web/src/chart-canvas/s52-style.date.test.mjs diff --git a/internal/engine/bake/bake.go b/internal/engine/bake/bake.go index 8eff361..a6f8bdc 100644 --- a/internal/engine/bake/bake.go +++ b/internal/engine/bake/bake.go @@ -283,7 +283,7 @@ type sectorPrim struct { scamin uint32 // SCAMIN denominator of the parent LIGHTS (0 = none); emitted as `scamin` so the client's per-SCAMIN bucket layer gates the exact display cutoff, same as point symbols/text // Date validity of the parent LIGHTS (S-52 §10.4.1.1); empty when none. Carried // because sectors tessellate at tile-emit time, after b.curDate* has moved on. - dateStart, dateEnd, timeValid string + dateStart, dateEnd string // legNorm is the full-length leg reach (VALNMR nominal range) as a fraction // of the normalized world — a fixed GROUND distance, so zoom-independent // (unlike the 25 mm short leg / arc, which are screen-px). Drives the tile @@ -319,10 +319,9 @@ type Baker struct { // apply the MANDATORY date filter — a date-dependent object outside its period // is not displayed for the current date. Empty when the feature has no period. // Values are S-57 date strings: full "YYYYMMDD" (fixed) or partial "--MMDD" - // recurring each year (periodic); curTimeValid is the interval kind. + // recurring each year (periodic). curDateStart string curDateEnd string - curTimeValid string // Co-located-light combination (S-52 LIGHTS06): when several LIGHTS share a // position, the first is "primary" (one flare + a merged multi-line label); // the rest are suppressed (flare + text dropped, sectors kept). seenSector @@ -654,7 +653,7 @@ func (b *Baker) AddCell(chart *s57.Chart) { b.recordScamin(scamin) // publish the band's distinct values (manifest → TileJSON) // Date validity period (S-52 §10.4.1.1) — baked onto each primitive so the // client applies the mandatory current-date filter. - b.curDateStart, b.curDateEnd, b.curTimeValid = fb.DateStart, fb.DateEnd, fb.TimeValid + b.curDateStart, b.curDateEnd = fb.DateStart, fb.DateEnd zMin := bandZMin(fb.DisplayCategory, scamin, dr.Min, cellLat) class := f.ObjectClass() drval1, drval2 := depthVals(f.Attributes(), class) @@ -717,21 +716,29 @@ func (b *Baker) AddCell(chart *s57.Chart) { // appendDateTags adds the feature's date-validity period (S-52 §10.4.1.1) to a // tile feature's attrs when it has one, so the client's mandatory current-date -// filter can hide it outside its period. date_start/date_end are S-57 date -// strings (full "YYYYMMDD" or recurring "--MMDD"); time_valid is the interval kind. -func appendDateTags(attrs []mvt.KeyValue, start, end, timeValid string) []mvt.KeyValue { +// filter can hide it outside its period. The period is baked in a filter-friendly +// form: date_start / date_end are the comparable bound strings — a recurring +// month-day "MMDD" (from an S-57 "--MMDD" partial) or a full "YYYYMMDD" — each +// present only when that bound exists (a one-sided range is semi-open); the +// boolean date_recurring (present iff the feature is dated) tells the client which +// "today" form to compare against and lexicographic compare does the rest. +func appendDateTags(attrs []mvt.KeyValue, start, end string) []mvt.KeyValue { if start == "" && end == "" { return attrs } + recurring := strings.HasPrefix(start, "--") || strings.HasPrefix(end, "--") + norm := func(s string) string { return strings.TrimPrefix(s, "--") } if start != "" { - attrs = append(attrs, mvt.KeyValue{Key: "date_start", Value: mvt.StringVal(start)}) + attrs = append(attrs, mvt.KeyValue{Key: "date_start", Value: mvt.StringVal(norm(start))}) } if end != "" { - attrs = append(attrs, mvt.KeyValue{Key: "date_end", Value: mvt.StringVal(end)}) + attrs = append(attrs, mvt.KeyValue{Key: "date_end", Value: mvt.StringVal(norm(end))}) } - if timeValid != "" { - attrs = append(attrs, mvt.KeyValue{Key: "time_valid", Value: mvt.StringVal(timeValid)}) + rec := int64(0) + if recurring { + rec = 1 } + attrs = append(attrs, mvt.KeyValue{Key: "date_recurring", Value: mvt.IntVal(rec)}) return attrs } @@ -898,7 +905,7 @@ func (b *Baker) route(p portrayal.Primitive, class string, drawPrio, cat int, zr } // Date validity (S-52 §10.4.1.1): the client's mandatory current-date filter // hides a date-dependent feature outside its period. - extra = appendDateTags(extra, b.curDateStart, b.curDateEnd, b.curTimeValid) + extra = appendDateTags(extra, b.curDateStart, b.curDateEnd) return extra } r := routed{ @@ -1012,7 +1019,7 @@ func (b *Baker) route(p portrayal.Primitive, class string, drawPrio, cat int, zr fig: v, class: class, cell: b.curCell, drawPrio: drawPrio, cat: cat, zMin: zMin, natMax: zr.Max, scamin: b.curScamin, - dateStart: b.curDateStart, dateEnd: b.curDateEnd, timeValid: b.curTimeValid, + dateStart: b.curDateStart, dateEnd: b.curDateEnd, legNorm: legNorm, }) } @@ -1697,7 +1704,7 @@ func (b *Baker) emitTileInto(coord tile.TileCoord, extent uint32, buffer float64 } // Date validity of the parent light (S-52 §10.4.1.1) — so a seasonal // sector light's figure hides with its flare/text under the date filter. - attrs = appendDateTags(attrs, sp.dateStart, sp.dateEnd, sp.timeValid) + attrs = appendDateTags(attrs, sp.dateStart, sp.dateEnd) tb.Layer("sector_lines").AddLines(paths, attrs) } } diff --git a/web/src/chart-canvas/chart-canvas.mjs b/web/src/chart-canvas/chart-canvas.mjs index 7b92ef0..2db65be 100644 --- a/web/src/chart-canvas/chart-canvas.mjs +++ b/web/src/chart-canvas/chart-canvas.mjs @@ -929,7 +929,7 @@ export class ChartCanvas extends HTMLElement { // Display category (multi-select) and boundary symbolization both filter // every chart layer by a baked per-feature tag (cat / bnd) — re-apply the // combined feature filter. Instant — no re-bake. - if (keys.some((k) => k === "displayBase" || k === "displayStandard" || k === "displayOther" || k === "boundaryStyle" || k === "simplifiedPoints" || k === "showFullSectorLines" || k === "showIsolatedDangersShallow" || k === "dataQuality" || k === "showMetaBounds")) { + if (keys.some((k) => k === "displayBase" || k === "displayStandard" || k === "displayOther" || k === "boundaryStyle" || k === "simplifiedPoints" || k === "showFullSectorLines" || k === "showIsolatedDangersShallow" || k === "dataQuality" || k === "showMetaBounds" || k === "dateDependent" || k === "dateView")) { this.applyFeatureFilters(); } } diff --git a/web/src/chart-canvas/s52-style.date.test.mjs b/web/src/chart-canvas/s52-style.date.test.mjs new file mode 100644 index 0000000..5760ee1 --- /dev/null +++ b/web/src/chart-canvas/s52-style.date.test.mjs @@ -0,0 +1,53 @@ +// Logic tests for the date-dependent display period (S-52 §10.4.1.1), the +// reference _inDatePeriod that the dateFilter MapLibre expression mirrors. +// Run: node --test web/src/chart-canvas/s52-style.date.test.mjs +import test from "node:test"; +import assert from "node:assert/strict"; +import { _inDatePeriod, dateFilter } from "./s52-style.mjs"; + +const seasonalSummer = { date_recurring: 1, date_start: "0315", date_end: "1201" }; // Slaughter Creek buoy +const seasonalWinter = { date_recurring: 1, date_start: "1101", date_end: "0315" }; // wraps the year +const fixedRange = { date_recurring: 0, date_start: "20240101", date_end: "20241231" }; +const openStart = { date_recurring: 1, date_start: "0401" }; // on station from Apr, no end +const openEnd = { date_recurring: 1, date_end: "1115" }; // until mid-Nov +const undated = {}; // not date-dependent + +test("recurring summer range — in vs out of season", () => { + assert.equal(_inDatePeriod(seasonalSummer, "20260624", "0624"), true, "June is in season"); + assert.equal(_inDatePeriod(seasonalSummer, "20260101", "0101"), false, "January is out"); + assert.equal(_inDatePeriod(seasonalSummer, "20261215", "1215"), false, "mid-December is out"); + assert.equal(_inDatePeriod(seasonalSummer, "20260315", "0315"), true, "start day inclusive"); + assert.equal(_inDatePeriod(seasonalSummer, "20261201", "1201"), true, "end day inclusive"); +}); + +test("recurring winter range — year wrap", () => { + assert.equal(_inDatePeriod(seasonalWinter, "20261215", "1215"), true, "December is in (>= start)"); + assert.equal(_inDatePeriod(seasonalWinter, "20260101", "0101"), true, "January is in (<= end)"); + assert.equal(_inDatePeriod(seasonalWinter, "20260624", "0624"), false, "June is out"); +}); + +test("fixed full-date range compares YYYYMMDD", () => { + assert.equal(_inDatePeriod(fixedRange, "20240615", "0615"), true, "2024 mid-year is in"); + assert.equal(_inDatePeriod(fixedRange, "20260624", "0624"), false, "2026 is after the 2024 range"); +}); + +test("semi-open ranges", () => { + assert.equal(_inDatePeriod(openStart, "20260624", "0624"), true, "after start, no end"); + assert.equal(_inDatePeriod(openStart, "20260201", "0201"), false, "before start"); + assert.equal(_inDatePeriod(openEnd, "20260624", "0624"), true, "before end, no start"); + assert.equal(_inDatePeriod(openEnd, "20261215", "1215"), false, "after end"); +}); + +test("undated features always show", () => { + assert.equal(_inDatePeriod(undated, "20260624", "0624"), true); +}); + +test("dateFilter builds a valid expression array with the viewing date pinned", () => { + const f = dateFilter({ dateView: "20260624" }); + assert.equal(Array.isArray(f), true); + assert.equal(f[0], "any"); + // today's parts must appear as string literals in the expression + const flat = JSON.stringify(f); + assert.match(flat, /"20260624"/); + assert.match(flat, /"0624"/); +}); diff --git a/web/src/chart-canvas/s52-style.mjs b/web/src/chart-canvas/s52-style.mjs index cf9a96f..12c6be6 100644 --- a/web/src/chart-canvas/s52-style.mjs +++ b/web/src/chart-canvas/s52-style.mjs @@ -235,11 +235,69 @@ export function sectorLegFilter(mariner) { return ["in", ["coalesce", ["get", "sleg"], 2], ["literal", [2, rank]]]; } +// Date-dependent display (S-52 PresLib §10.4.1.1 / Fig 1, MANDATORY): a feature +// with a validity period is shown only when the viewing date falls inside it — +// the first gate of the ECDIS display pipeline. The baker stamps a filter-ready +// period: date_recurring (present iff dated; 1 = a recurring month-day range, +// 0 = a one-off full-date range) plus the comparable bound strings date_start / +// date_end ("MMDD" or "YYYYMMDD"), each present only when that bound exists (a +// one-sided range is semi-open). A feature with no date_recurring is undated and +// always shown. The viewing date is real "today" unless the mariner pins one +// (mariner.dateView, "YYYYMMDD") for passage planning. +function _todayParts(mariner) { + let ymd = mariner && mariner.dateView; + if (!/^\d{8}$/.test(ymd || "")) { + const d = new Date(); + const p = (n) => String(n).padStart(2, "0"); + ymd = `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}`; + } + return { ymd, mmdd: ymd.slice(4) }; +} + +// _inDatePeriod is the plain-JS reference for the dateFilter expression below +// (kept in lockstep, unit-tested): undated → true; else compare the viewing date +// (mmdd for recurring, ymd for full) to the bounds, with year-wrap for a +// recurring range whose start month-day is after its end (e.g. 1101 → 0315). +export function _inDatePeriod(props, ymd, mmdd) { + if (props.date_recurring === undefined || props.date_recurring === null) return true; + const T = Number(props.date_recurring) === 1 ? mmdd : ymd; + const S = props.date_start, E = props.date_end; + const hasS = S !== undefined && S !== null && S !== ""; + const hasE = E !== undefined && E !== null && E !== ""; + if (hasS && hasE) return S <= E ? (T >= S && T <= E) : (T >= S || T <= E); + if (hasS) return T >= S; + if (hasE) return T <= E; + return true; +} + +export function dateFilter(mariner) { + const { ymd, mmdd } = _todayParts(mariner); + const T = ["case", ["==", ["coalesce", ["get", "date_recurring"], 0], 1], mmdd, ymd]; + const hasS = ["has", "date_start"], hasE = ["has", "date_end"]; + return ["any", + ["!", ["has", "date_recurring"]], // undated → always shown + ["let", "T", T, "S", ["coalesce", ["get", "date_start"], ""], "E", ["coalesce", ["get", "date_end"], ""], + ["case", + ["all", hasS, hasE], + ["case", + ["<=", ["var", "S"], ["var", "E"]], + ["all", [">=", ["var", "T"], ["var", "S"]], ["<=", ["var", "T"], ["var", "E"]]], + ["any", [">=", ["var", "T"], ["var", "S"]], ["<=", ["var", "T"], ["var", "E"]]]], + hasS, [">=", ["var", "T"], ["var", "S"]], + hasE, ["<=", ["var", "T"], ["var", "E"]], + true]]]; +} + // Combine a layer's intrinsic (base) filter with the live category + // boundary-style filters (the two client-side portrayal axes baked as // per-feature `cat`/`bnd`). export function combineFilters(base, mariner) { const parts = ["all", categoryFilter(mariner), boundaryFilter(mariner), pointStyleFilter(mariner), sectorLegFilter(mariner)]; + // Date-dependent display (S-52 §10.4.1.1, mandatory + default-on): hide a + // dated feature outside its validity period for the viewing date. The escape + // valve mariner.dateDependent === false shows all dates (only dated features + // are ever affected; an undated feature always passes dateFilter). + if (mariner.dateDependent !== false) parts.push(dateFilter(mariner)); // Meta-object coverage/region boundary lines are gated separately from the // "Other" display category (mariner.showMetaBounds, off by default), since // they read as cell boundaries and aren't useful alongside other "Other" data. diff --git a/web/src/chartplotter.mjs b/web/src/chartplotter.mjs index 1c48747..be8afeb 100644 --- a/web/src/chartplotter.mjs +++ b/web/src/chartplotter.mjs @@ -75,6 +75,11 @@ const DEFAULT_MARINER = { showScaleBoundaries: false, // DATCVR §10.1.9.1 chart scale boundaries — off by default (opt-in) // Individually-selectable "Other" items (S-52/IMO), all default on. showSoundings: true, + // Date-dependent display (S-52 §10.4.1.1, MANDATORY): show a dated feature only + // when the viewing date is within its validity period. Default on (spec); set + // false to show all dates regardless. dateView ("YYYYMMDD") pins a planning + // date; unset = real today. + dateDependent: true, // S-52 PresLib §14.5 text groupings — the mariner toggles text by group, // independent of display category (each TX/TE carries a group number, §14.4). showLightDescriptions: true, // group 23: light characteristics (e.g. Fl(2)R 10s) From b1c34445b2d8464d5e49f6b6cef9694f5c4a33e8 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 24 Jun 2026 22:14:04 -0400 Subject: [PATCH 05/13] feat(date): re-gate CHDATD01 to the optional "Highlight date dependent" toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the over-display from the gap-#2 marker work: CHDATD01 was emitted always- on, but per S-52 §10.6.1.1 it is the optional viewing-group-90022 "Highlight date dependent" overlay (the Feature-highlights layer, alongside Highlight info / document) — toggleable and off by default. The marker bakes its symbol_name (as categoryFilter already relies on for ISODGR01), so combineFilters now drops symbol_name=="CHDATD01" unless mariner.highlightDateDependent is on (default false). Composes correctly with the date filter: out of period the whole feature (marker included) is already gone; in period the marker shows only when the highlight is enabled. Co-Authored-By: Claude Opus 4.8 (1M context) --- web/src/chart-canvas/chart-canvas.mjs | 2 +- web/src/chart-canvas/s52-style.mjs | 5 +++++ web/src/chartplotter.mjs | 4 ++++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/web/src/chart-canvas/chart-canvas.mjs b/web/src/chart-canvas/chart-canvas.mjs index 2db65be..5cc03a3 100644 --- a/web/src/chart-canvas/chart-canvas.mjs +++ b/web/src/chart-canvas/chart-canvas.mjs @@ -929,7 +929,7 @@ export class ChartCanvas extends HTMLElement { // Display category (multi-select) and boundary symbolization both filter // every chart layer by a baked per-feature tag (cat / bnd) — re-apply the // combined feature filter. Instant — no re-bake. - if (keys.some((k) => k === "displayBase" || k === "displayStandard" || k === "displayOther" || k === "boundaryStyle" || k === "simplifiedPoints" || k === "showFullSectorLines" || k === "showIsolatedDangersShallow" || k === "dataQuality" || k === "showMetaBounds" || k === "dateDependent" || k === "dateView")) { + if (keys.some((k) => k === "displayBase" || k === "displayStandard" || k === "displayOther" || k === "boundaryStyle" || k === "simplifiedPoints" || k === "showFullSectorLines" || k === "showIsolatedDangersShallow" || k === "dataQuality" || k === "showMetaBounds" || k === "dateDependent" || k === "dateView" || k === "highlightDateDependent")) { this.applyFeatureFilters(); } } diff --git a/web/src/chart-canvas/s52-style.mjs b/web/src/chart-canvas/s52-style.mjs index 12c6be6..21636cc 100644 --- a/web/src/chart-canvas/s52-style.mjs +++ b/web/src/chart-canvas/s52-style.mjs @@ -298,6 +298,11 @@ export function combineFilters(base, mariner) { // valve mariner.dateDependent === false shows all dates (only dated features // are ever affected; an undated feature always passes dateFilter). if (mariner.dateDependent !== false) parts.push(dateFilter(mariner)); + // Date-dependent indicator (S-52 §10.6.1.1, viewing group 90022 "Highlight date + // dependent"): the CHDATD01 marker is an OPTIONAL highlight, OFF by default — + // shown only when the mariner enables it. (Out of period it is already gone via + // the date filter above; this only governs in-period features.) + if (!mariner.highlightDateDependent) parts.push(["!=", ["coalesce", ["get", "symbol_name"], ""], "CHDATD01"]); // Meta-object coverage/region boundary lines are gated separately from the // "Other" display category (mariner.showMetaBounds, off by default), since // they read as cell boundaries and aren't useful alongside other "Other" data. diff --git a/web/src/chartplotter.mjs b/web/src/chartplotter.mjs index be8afeb..c6bece3 100644 --- a/web/src/chartplotter.mjs +++ b/web/src/chartplotter.mjs @@ -80,6 +80,10 @@ const DEFAULT_MARINER = { // false to show all dates regardless. dateView ("YYYYMMDD") pins a planning // date; unset = real today. dateDependent: true, + // "Highlight date dependent" (S-52 §10.6.1.1, viewing group 90022): the CHDATD01 + // marker on in-period date-dependent features — an optional highlight, off by + // default (opt-in), like the info/document highlights. + highlightDateDependent: false, // S-52 PresLib §14.5 text groupings — the mariner toggles text by group, // independent of display category (each TX/TE carries a group number, §14.4). showLightDescriptions: true, // group 23: light characteristics (e.g. Fl(2)R 10s) From 2c8647d5f15835bcdf9820879e760320631a2d6f Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 24 Jun 2026 22:29:56 -0400 Subject: [PATCH 06/13] fix(s101): all-around lights show full characteristic, not just the colour MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lots of lights rendered as a bare colour letter ("G", "R", "Y") instead of their full characteristic. Root cause: LITDSN02 reads lightCharacteristic / signalGroup / signalPeriod from the rhythmOfLight complex attribute, but the bridge never synthesized it — it builds sectorCharacteristics (so SECTORED lights, which read their character from there, were fine) but all-around lights read rhythmOfLight and got nil, dropping everything but the colour (which LITDSN02 takes as a separate argument). The S-57 LITCHR/SIGPER were present all along. Synthesize rhythmOfLight from S-57 LITCHR/SIGGRP/SIGPER in the bridge, mirroring buildLightSectors. Verified on US5MD1MC: the 8 colour-only lights now render "Fl G 4s", "Fl R 2.5s", "Fl(4+3) Y 30s", … (short-text count 8 → 0); sectored lights unchanged; audit clean (LIGHTS 183/183/0/0/0). Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/engine/portrayal/s101build_test.go | 32 +++++++++++++++++++++ internal/engine/s101/complex.go | 27 +++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/internal/engine/portrayal/s101build_test.go b/internal/engine/portrayal/s101build_test.go index c2dcc69..b7da0dd 100644 --- a/internal/engine/portrayal/s101build_test.go +++ b/internal/engine/portrayal/s101build_test.go @@ -373,6 +373,38 @@ func TestS101NameLabel(t *testing.T) { } } +// TestS101BuildAllAroundLightCharacteristic: an all-around light's description +// (LITDSN02) must carry its character + period, not collapse to just the colour. +// LITDSN02 reads these from the rhythmOfLight complex attribute, which the bridge +// synthesizes from S-57 LITCHR/SIGGRP/SIGPER — without it the text was e.g. "G". +func TestS101BuildAllAroundLightCharacteristic(t *testing.T) { + b := s101BuilderEmbedded(t) + lt := s57.NewFeature(1, "LIGHTS", + s57.Geometry{Type: s57.GeometryTypePoint, Coordinates: [][]float64{{12.5, 55.7}}}, + map[string]interface{}{"LITCHR": 4, "COLOUR": "4", "SIGPER": "1"}, // Quick, green, 1s + ) + build, ok := b.Build(<) + if !ok { + t.Fatal("build failed") + } + var text string + for _, p := range build.Primitives { + if dt, ok := p.(DrawText); ok && strings.ContainsAny(dt.Text, "QGFlsm") { + text = dt.Text + break + } + } + if !strings.Contains(text, "Q") { + t.Errorf("light text = %q, want the Quick character 'Q' (rhythmOfLight not synthesized?)", text) + } + if !strings.Contains(text, "G") { + t.Errorf("light text = %q, want the green colour 'G'", text) + } + if !strings.Contains(text, "1s") { + t.Errorf("light text = %q, want the 1s period", text) + } +} + // TestS101BuildSectorLight drives an S-57 sectored light through the full build // seam and asserts the rule's constructed AugmentedFigure elements come through: // the dashed legs (rays, tagged with the nominal range for the full-light-lines diff --git a/internal/engine/s101/complex.go b/internal/engine/s101/complex.go index c0948bf..9957d5e 100644 --- a/internal/engine/s101/complex.go +++ b/internal/engine/s101/complex.go @@ -156,9 +156,36 @@ func (e *Engine) buildRoot(objClass string, s57, derived map[string]string, name // Light sectors / directional character (LIGHTS routed to LightSectored). e.buildLightSectors(root, objClass, s57) + // rhythmOfLight — the light-description complex (lightCharacteristic / + // signalGroup / signalPeriod) that LITDSN02 reads to build the characteristic + // text. Sectored lights carry these inside sectorCharacteristics (above), but + // LightAllAround reads them from rhythmOfLight; without it an all-around light's + // description collapses to just its colour ("G" instead of "Q G 1s"). + e.buildRhythmOfLight(root, objClass, s57) + return root } +// buildRhythmOfLight synthesizes the rhythmOfLight complex attribute from the S-57 +// LITCHR / SIGGRP / SIGPER simple attributes, so LITDSN02 can render the full +// light characteristic for lights that read it from rhythmOfLight (not the +// sectorCharacteristics complex). Built whenever any of the three is present. +func (e *Engine) buildRhythmOfLight(root *cnode, objClass string, s57 map[string]string) { + if objClass != "LIGHTS" { + return + } + if s57["LITCHR"] == "" && s57["SIGGRP"] == "" && s57["SIGPER"] == "" { + return + } + rol := newCNode() + rol.addSimple("lightCharacteristic", s57["LITCHR"]) + if g := s57["SIGGRP"]; g != "" { + rol.simple["signalGroup"] = e.splitValue("signalGroup", g) + } + rol.addSimple("signalPeriod", s57["SIGPER"]) + root.addChild("rhythmOfLight", rol) +} + // buildLightSectors synthesizes the nested sectorCharacteristics → lightSector → // sectorLimit / directionalCharacter structure LightSectored reads, from the S-57 // LIGHTS simple attributes. One S-57 LIGHTS feature carries exactly one sector From edc98d7e4e55f5abc2f0413120fe254e70b00619 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 24 Jun 2026 22:43:13 -0400 Subject: [PATCH 07/13] =?UTF-8?q?fix(web):=20stop=20the=20zoom/pan=20flick?= =?UTF-8?q?er=20=E2=80=94=20re-gate=20SCAMIN=20buckets=20in=20place?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chart flickered "like a cache reloads" while zooming/panning. Root cause: on map "idle", _refreshScaminBuckets() scheduled rebuild() — a full map.setStyle(buildStyle(), {diff:false}) — which tears down and re-registers every source, layer, sprite and pattern. It fired whenever the per-SCAMIN bucket minzooms needed updating: the centre latitude drifting >2° (any mode; the only trigger in server mode) or a new SCAMIN value loading in a fresh tile (PMTiles). But latitude drift only SHIFTS the existing buckets' minzooms — no layers added. So re-gate them in place with map.setLayerZoomRange per "#sm" bucket layer (minzoom = scaminDisplayZoom(scamin, lat), maxzoom preserved) instead of a full restyle: same native gating, zero teardown, no flicker. The full rebuild now runs ONLY when a genuinely-new SCAMIN value needs new bucket layers (PMTiles, rare, converges). A value crossing the band floor self-corrects on the next such rebuild; for the sub-2° drift this runs on the error is <0.05 zoom (already the system's tolerance). Logic unit-tested (mock map: only #sm layers re-gated, to the lat-adjusted minzoom, maxzoom kept). Co-Authored-By: Claude Opus 4.8 (1M context) --- web/src/chart-canvas/chart-sources.mjs | 43 ++++++++++++--- .../chart-sources.scamin.test.mjs | 52 +++++++++++++++++++ 2 files changed, 88 insertions(+), 7 deletions(-) create mode 100644 web/src/chart-canvas/chart-sources.scamin.test.mjs diff --git a/web/src/chart-canvas/chart-sources.mjs b/web/src/chart-canvas/chart-sources.mjs index 52d71f4..bd697ce 100644 --- a/web/src/chart-canvas/chart-sources.mjs +++ b/web/src/chart-canvas/chart-sources.mjs @@ -207,9 +207,11 @@ export class ChartSources { // pinned server buckets to the initial/equator latitude). if (this._server) { if (!latShift) return; - this._scaminLat = lat; + // The SCAMIN value set is fixed (from each set's TileJSON); latitude drift only + // shifts the per-value bucket MINZOOMS — re-gate them in place (no flicker) + // rather than a full style rebuild. clearTimeout(this._scaminRebuildT); - this._scaminRebuildT = setTimeout(() => { if (this.getMap()) this.rebuild(); }, 450); + this._scaminRebuildT = setTimeout(() => this._reapplyScaminMinzooms(), 120); return; } const seen = new Set(this._scaminValues); @@ -226,12 +228,39 @@ export class ChartSources { const grew = seen.size !== before; if (!grew && !latShift) return; this._scaminValues = [...seen].sort((a, b) => a - b); - this._scaminLat = lat; - // Debounce the (heavy) style rebuild so a burst of values loaded across several - // tiles coalesces into ONE rebuild. Converges: once every value in view is known, - // no further growth ⇒ no rebuild, and SCAMIN gating is then fully native. clearTimeout(this._scaminRebuildT); - this._scaminRebuildT = setTimeout(() => { if (this.getMap()) this.rebuild(); }, 450); + if (grew) { + // A genuinely-new SCAMIN value needs new bucket LAYERS, so only this case takes + // the full (heavy, flickering) style rebuild — debounced to coalesce a burst of + // values across tiles into ONE rebuild. Converges: once every value in view is + // known, no further growth ⇒ no rebuild. + this._scaminLat = lat; + this._scaminRebuildT = setTimeout(() => { if (this.getMap()) this.rebuild(); }, 450); + } else { + // Latitude drift only (no new values): re-gate the existing buckets in place. + this._scaminRebuildT = setTimeout(() => this._reapplyScaminMinzooms(), 120); + } + } + + // Re-apply the latitude-dependent SCAMIN bucket minzooms IN PLACE — no style + // rebuild, so no flicker / tile reload. Each per-value bucket layer id ends in + // "#sm"; its native minzoom is scaminDisplayZoom(scamin, lat), which + // drifts slightly with the centre latitude (cos-lat). setLayerZoomRange re-gates + // each without tearing down sources/sprites (unlike rebuild()'s full setStyle). + // A value crossing the band floor into the #no bucket self-corrects on the next + // genuine rebuild; for the sub-2° drift this runs on, the error is < 0.05 zoom. + _reapplyScaminMinzooms() { + const m = this.getMap(); + if (!m) return; + const lat = m.getCenter().lat; + let style; + try { style = m.getStyle(); } catch (e) { return; } + for (const L of (style && style.layers) || []) { + const hit = /#sm(\d+(?:\.\d+)?)$/.exec(L.id); + if (!hit) continue; + try { m.setLayerZoomRange(L.id, scaminDisplayZoom(+hit[1], lat), L.maxzoom != null ? L.maxzoom : 24); } catch (e) { /* layer removed mid-update */ } + } + this._scaminLat = lat; } // -- runtime chart API (driven by the shell, via the element) -- diff --git a/web/src/chart-canvas/chart-sources.scamin.test.mjs b/web/src/chart-canvas/chart-sources.scamin.test.mjs new file mode 100644 index 0000000..1a98d6b --- /dev/null +++ b/web/src/chart-canvas/chart-sources.scamin.test.mjs @@ -0,0 +1,52 @@ +// Tests the in-place SCAMIN bucket re-gate (_reapplyScaminMinzooms): on latitude +// drift it must setLayerZoomRange only the per-value "#sm" bucket layers, +// to scaminDisplayZoom(scamin, lat), preserving each layer's maxzoom — and never +// touch non-bucket layers (no full style rebuild → no flicker). +// Run: node --test web/src/chart-canvas/chart-sources.scamin.test.mjs +import test from "node:test"; +import assert from "node:assert/strict"; +import { ChartSources, scaminDisplayZoom } from "./chart-sources.mjs"; + +function mockMap(lat, layers) { + const calls = []; + return { + calls, + getCenter: () => ({ lat }), + getStyle: () => ({ layers }), + setLayerZoomRange: (id, min, max) => calls.push({ id, min, max }), + }; +} + +test("re-gates only #sm bucket layers, to the latitude-adjusted minzoom", () => { + const lat = 38.97; + const layers = [ + { id: "point_symbols@chesapeake-harbour#sm30000" }, + { id: "text@chesapeake-harbour#sm12000", maxzoom: 12 }, // capped band keeps its maxzoom + { id: "point_symbols@chesapeake-harbour#no" }, // always-from-floor bucket — untouched + { id: "areas@chesapeake-harbour" }, // non-bucket — untouched + { id: "lines-solid" }, // unrelated layer + ]; + const map = mockMap(lat, layers); + const cs = new ChartSources({ assets: "", getMap: () => map, rebuild: () => { throw new Error("must not rebuild"); } }); + cs._reapplyScaminMinzooms(); + + // Only the two #sm layers were re-gated. + assert.equal(map.calls.length, 2); + const byId = Object.fromEntries(map.calls.map((c) => [c.id, c])); + assert.ok(byId["point_symbols@chesapeake-harbour#sm30000"]); + assert.ok(byId["text@chesapeake-harbour#sm12000"]); + // Minzoom equals the build-time formula. + assert.equal(byId["point_symbols@chesapeake-harbour#sm30000"].min, scaminDisplayZoom(30000, lat)); + assert.equal(byId["text@chesapeake-harbour#sm12000"].min, scaminDisplayZoom(12000, lat)); + // Capped layer keeps its maxzoom; uncapped gets the MapLibre max (24). + assert.equal(byId["text@chesapeake-harbour#sm12000"].max, 12); + assert.equal(byId["point_symbols@chesapeake-harbour#sm30000"].max, 24); + // The applied latitude is recorded so the next drift compares against it. + assert.equal(cs._scaminLat, lat); +}); + +test("minzoom shifts with latitude (cos-lat), proving the re-gate is needed", () => { + // Higher latitude → different display zoom for the same SCAMIN; the values must + // differ, else there'd be nothing to re-gate. + assert.notEqual(scaminDisplayZoom(30000, 10), scaminDisplayZoom(30000, 60)); +}); From 810ee8f078b18a9330228722b4e945d1e1b43408 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 24 Jun 2026 22:50:29 -0400 Subject: [PATCH 08/13] =?UTF-8?q?fix(web):=20read=20the=20published=20SCAM?= =?UTF-8?q?IN=20manifest=20from=20PMTiles=20=E2=80=94=20kills=20the=20zoom?= =?UTF-8?q?=20flicker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The actual fix for the PMTiles zoom flicker (the prior commit only addressed the latitude-drift case + a server path this deployment doesn't use). In PMTiles mode the client DISCOVERED SCAMIN values at runtime by scanning loaded tiles (querySourceFeatures) on every map idle; zooming into fresh tiles surfaced new values → grew the set → full map.setStyle() rebuild → the "cache reloads" flicker. The baker already publishes each archive's distinct SCAMIN denominators in the PMTiles JSON metadata (SetScamin → pmtiles.go), and the Go reader already parses it — but the web PMTiles source never read it. So: - PMTilesArchive.init() reads the manifest from the JSON metadata (header offsets 24/32, raw — writer sets internal-compression = none), exposing archive.scamin. - MultiArchive unions its packs' manifests (archive.scamin → band.scamin). - chart-sources sources _scaminValues from the loaded bands' manifests instead of scanning tiles. The set is known at LOAD, so zooming surfaces no new values and triggers NO rebuild. The full rebuild now fires only when the value set changes (a pack loads/unloads) — never during zoom. Latitude drift still uses the in-place setLayerZoomRange re-gate. Client-only — no re-bake needed (the manifest is already in baked archives). An archive without it (older) simply gets no SCAMIN gating until re-baked, but never the per-zoom rebuild. Unit-tested: MultiArchive unions/sorts/dedupes the sets and tolerates manifest-less packs. Co-Authored-By: Claude Opus 4.8 (1M context) --- web/src/chart-canvas/chart-sources.mjs | 37 ++++++++++---------- web/src/chart-canvas/pmtiles-source.mjs | 18 ++++++++++ web/src/chart-canvas/pmtiles-source.test.mjs | 19 ++++++++++ 3 files changed, 55 insertions(+), 19 deletions(-) create mode 100644 web/src/chart-canvas/pmtiles-source.test.mjs diff --git a/web/src/chart-canvas/chart-sources.mjs b/web/src/chart-canvas/chart-sources.mjs index bd697ce..168bd38 100644 --- a/web/src/chart-canvas/chart-sources.mjs +++ b/web/src/chart-canvas/chart-sources.mjs @@ -214,30 +214,29 @@ export class ChartSources { this._scaminRebuildT = setTimeout(() => this._reapplyScaminMinzooms(), 120); return; } - const seen = new Set(this._scaminValues); - const before = seen.size; - const srcs = CHART_BANDS.filter((b) => b.slug !== "all").map((b) => "chart-" + b.slug); - for (const src of srcs) { - if (!m.getSource(src)) continue; - for (const sl of SCAMIN_BUCKET_LAYERS) { - let fs; - try { fs = m.querySourceFeatures(src, { sourceLayer: sl }); } catch (e) { continue; } - for (const f of fs) { const s = f.properties && f.properties.scamin; if (s) seen.add(+s); } - } + // The SCAMIN value set is PUBLISHED in each PMTiles archive's JSON metadata + // (baker SetScamin), so read it from the loaded bands — known at LOAD, not + // scanned from tiles per frame. This is the key flicker fix: zooming surfaces + // no "new" values, so it never triggers a rebuild. (Older archives without the + // manifest publish nothing, so the set stays empty and SCAMIN gating is off — + // re-bake to restore it; this never re-introduces the per-zoom rebuild.) + const seen = new Set(); + for (const slug of Object.keys(this._bands)) { + for (const v of this._bands[slug].scamin || []) seen.add(+v); } - const grew = seen.size !== before; - if (!grew && !latShift) return; - this._scaminValues = [...seen].sort((a, b) => a - b); + const next = [...seen].sort((a, b) => a - b); + const changed = next.length !== this._scaminValues.length || next.some((v, i) => v !== this._scaminValues[i]); + if (!changed && !latShift) return; + this._scaminValues = next; clearTimeout(this._scaminRebuildT); - if (grew) { - // A genuinely-new SCAMIN value needs new bucket LAYERS, so only this case takes - // the full (heavy, flickering) style rebuild — debounced to coalesce a burst of - // values across tiles into ONE rebuild. Converges: once every value in view is - // known, no further growth ⇒ no rebuild. + if (changed) { + // The value set changed (a pack loaded/unloaded) → new bucket LAYERS are + // needed, so this case takes the full style rebuild. It fires at pack + // load/unload, NOT during zoom, so it doesn't flicker the zoom interaction. this._scaminLat = lat; this._scaminRebuildT = setTimeout(() => { if (this.getMap()) this.rebuild(); }, 450); } else { - // Latitude drift only (no new values): re-gate the existing buckets in place. + // Latitude drift only (same value set): re-gate the existing buckets in place. this._scaminRebuildT = setTimeout(() => this._reapplyScaminMinzooms(), 120); } } diff --git a/web/src/chart-canvas/pmtiles-source.mjs b/web/src/chart-canvas/pmtiles-source.mjs index d439c06..893a54d 100644 --- a/web/src/chart-canvas/pmtiles-source.mjs +++ b/web/src/chart-canvas/pmtiles-source.mjs @@ -152,6 +152,20 @@ export class PMTilesArchive { this.bounds = [e7(102), e7(106), e7(110), e7(114)]; // [W,S,E,N] const dir = await this._read(rootOff, rootLen); this._root = decodeDirectory(this._internalGz ? await gunzip(dir) : dir); + // SCAMIN manifest (JSON metadata): the baker publishes this archive's distinct + // SCAMIN denominators (pmtiles SetScamin) so the client builds the per-value + // bucket layers at LOAD, instead of discovering them from tiles as you zoom + // (which forced repeated full style rebuilds — a visible flicker). Best-effort: + // absence (older archive) falls back to the runtime-collection path. + this.scamin = []; + const metaOff = u64(24), metaLen = u64(32); + if (metaLen > 0 && metaLen < (1 << 20)) { + try { + const mb = await this._read(metaOff, metaLen); + const md = JSON.parse(new TextDecoder().decode(this._internalGz ? await gunzip(mb) : mb)); + if (Array.isArray(md.scamin)) this.scamin = md.scamin.map(Number); + } catch (e) { /* no / unparseable metadata → runtime fallback */ } + } return this; } @@ -230,6 +244,7 @@ export class MultiArchive { this.minZoom = 0; this.maxZoom = 16; this.bounds = null; + this.scamin = []; // union of the packs' published SCAMIN manifests } // Add (open) an archive from a Blob/File or a URL string. Returns the opened @@ -249,6 +264,7 @@ export class MultiArchive { _recompute() { let mn = 24, mx = 0, b = null; + const sc = new Set(); for (const a of this.archives) { mn = Math.min(mn, a.minZoom); mx = Math.max(mx, a.maxZoom); @@ -257,10 +273,12 @@ export class MultiArchive { ? [Math.min(b[0], a.bounds[0]), Math.min(b[1], a.bounds[1]), Math.max(b[2], a.bounds[2]), Math.max(b[3], a.bounds[3])] : a.bounds.slice(); } + for (const v of a.scamin || []) sc.add(v); } this.minZoom = this.archives.length ? mn : 0; this.maxZoom = this.archives.length ? mx : 16; this.bounds = b; + this.scamin = [...sc].sort((x, y) => x - y); } get tileCount() { return this.archives.reduce((s, a) => s + a.tileCount, 0); } diff --git a/web/src/chart-canvas/pmtiles-source.test.mjs b/web/src/chart-canvas/pmtiles-source.test.mjs new file mode 100644 index 0000000..add6a68 --- /dev/null +++ b/web/src/chart-canvas/pmtiles-source.test.mjs @@ -0,0 +1,19 @@ +// MultiArchive must union the packs' published SCAMIN manifests so the client +// builds the per-value bucket layers at load (no per-zoom tile discovery → no +// style-rebuild flicker). Run: node --test web/src/chart-canvas/pmtiles-source.test.mjs +import test from "node:test"; +import assert from "node:assert/strict"; +import { MultiArchive } from "./pmtiles-source.mjs"; + +test("MultiArchive unions the packs' published SCAMIN sets, sorted + deduped", () => { + const ma = new MultiArchive(); + ma.addOpened({ minZoom: 0, maxZoom: 14, bounds: null, scamin: [30000, 12000] }); + ma.addOpened({ minZoom: 0, maxZoom: 16, bounds: null, scamin: [12000, 90000] }); + assert.deepEqual(ma.scamin, [12000, 30000, 90000]); +}); + +test("MultiArchive tolerates packs without a SCAMIN manifest (older archive)", () => { + const ma = new MultiArchive(); + ma.addOpened({ minZoom: 0, maxZoom: 14, bounds: null }); // no .scamin + assert.deepEqual(ma.scamin, []); +}); From cb2abbac0b4c93cb14d34ee321f54fe89f919a8c Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Wed, 24 Jun 2026 22:59:16 -0400 Subject: [PATCH 09/13] fix(web): register addCatalogOverlay's map listeners once (stop the leak) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit addCatalogOverlay re-runs on every style.load to restore its style-scoped sources/layers (setStyle drops them). But it also registered map-level listeners — dragstart / dragend / click — which SURVIVE setStyle, so each rebuild leaked a duplicate set. Under the SCAMIN rebuild churn (fixed in 810ee8f) this compounded: many setStyle passes → many duplicate click/drag handlers + a sprite/style-JSON re-fetch (the loadJSON on the render loop) each pass → a CPU sink. Guard the map-level listeners + cursor behind a one-shot flag so they wire once, mirroring the existing getSource("focus") guard on the sources. With 810ee8f stopping the per-idle rebuild loop and this stopping the per-rebuild listener leak, the catalog overlay is fully idempotent across restyles. Co-Authored-By: Claude Opus 4.8 (1M context) --- web/src/chartplotter.mjs | 49 ++++++++++++++++++++++++---------------- 1 file changed, 29 insertions(+), 20 deletions(-) diff --git a/web/src/chartplotter.mjs b/web/src/chartplotter.mjs index c6bece3..987daab 100644 --- a/web/src/chartplotter.mjs +++ b/web/src/chartplotter.mjs @@ -1093,26 +1093,35 @@ export class ChartPlotter extends HTMLElement { // pick point (a click runs the cursor pick / district preview). The dev feature // inspector (now in DevTools) sets its own cursor + owns the hover/click/ // SHIFT+drag listeners; its click handler runs only while inspecting. - map.getCanvas().style.cursor = "crosshair"; - // While the user grabs and pans the chart, swap the crosshair for a closed- - // hand "grabbing" cursor so the drag reads as a grab; restore the ECDIS - // crosshair when the pan ends. The dev inspector owns its own cursor while - // armed (SHIFT+drag box-select), so don't fight it then. - map.on("dragstart", () => { if (!(this._devTools && this._devTools.inspecting)) map.getCanvas().style.cursor = "grabbing"; }); - map.on("dragend", () => { if (!(this._devTools && this._devTools.inspecting)) map.getCanvas().style.cursor = "crosshair"; }); - map.on("click", (e) => { - // The dev feature inspector (DevTools) owns clicks while it's armed — defer to - // it so a pick/coverage tap doesn't fire under an active inspect lock. - if (this._devTools && this._devTools.inspecting) return; - // (The Charts cell-picker tap-to-preview-a-district branch was removed with - // the main-map cell picker; the panel is the chart surface.) - // Zoomed out over an installed-chart coverage marker → fly to that chart at - // its detail zoom (so you can find + open installed charts without knowing - // where/at what zoom they live). Otherwise the default ECDIS cursor pick. - if (this._coverage && this._coverage.tapFlyTo(e.point)) return; - // Default chart-view interaction: ECDIS cursor pick (S-52 PresLib §10.8). - this._pickReportAt(e.point, e.originalEvent); - }); + // Map-level interaction listeners + cursor register ONCE: map events (and the + // canvas cursor) SURVIVE a setStyle rebuild, but this method re-runs on every + // style.load to restore the style-scoped sources/layers above — so re-adding + // them here would leak a duplicate dragstart/dragend/click set per rebuild + // (which compounded the SCAMIN-rebuild churn into a CPU sink). The guarded + // sources above already no-op on a redundant call; these must too. + if (!this._catalogMapWired) { + this._catalogMapWired = true; + map.getCanvas().style.cursor = "crosshair"; + // While the user grabs and pans the chart, swap the crosshair for a closed- + // hand "grabbing" cursor so the drag reads as a grab; restore the ECDIS + // crosshair when the pan ends. The dev inspector owns its own cursor while + // armed (SHIFT+drag box-select), so don't fight it then. + map.on("dragstart", () => { if (!(this._devTools && this._devTools.inspecting)) map.getCanvas().style.cursor = "grabbing"; }); + map.on("dragend", () => { if (!(this._devTools && this._devTools.inspecting)) map.getCanvas().style.cursor = "crosshair"; }); + map.on("click", (e) => { + // The dev feature inspector (DevTools) owns clicks while it's armed — defer to + // it so a pick/coverage tap doesn't fire under an active inspect lock. + if (this._devTools && this._devTools.inspecting) return; + // (The Charts cell-picker tap-to-preview-a-district branch was removed with + // the main-map cell picker; the panel is the chart surface.) + // Zoomed out over an installed-chart coverage marker → fly to that chart at + // its detail zoom (so you can find + open installed charts without knowing + // where/at what zoom they live). Otherwise the default ECDIS cursor pick. + if (this._coverage && this._coverage.tapFlyTo(e.point)) return; + // Default chart-view interaction: ECDIS cursor pick (S-52 PresLib §10.8). + this._pickReportAt(e.point, e.originalEvent); + }); + } } // Fetch the latest shared snapshot and install its cells locally, downloading From a9be235e2c7736b0969ec3137b7281694d632b5d Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 25 Jun 2026 04:45:41 -0400 Subject: [PATCH 10/13] fix(web): enforce the max-zoom scale floor on view change; cap at 1:4,000 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two zoom-limit fixes: 1. The 1:MIN_DETAIL_SCALE max-zoom floor was only applied at boot/restore/import, not on view change. A fly-to-chart raises the cap to reach a pack's detail and nothing put it back, so you could magnify to z18 ≈ 1:900. Re-apply the floor on every moveend (it's latitude-dependent), and cap the fly target at the destination's floor so it never overshoots then snaps back. 2. MIN_DETAIL_SCALE 2000 → 4000, and compute maxZoomForScaleFloor from the PHYSICAL scale (the px pitch the scalebar uses) instead of the OGC 0.28 mm reference — so the cap lands exactly on the 1:4,000 the HUD shows, not ~5% off. Band display-min realignment to the NOAA standard scales (harbour appearing at its coarse ~1:22k standard rather than 1:11k) is a separate, larger change (band zoom ranges + re-bake) — to follow. Co-Authored-By: Claude Opus 4.8 (1M context) --- web/src/chartplotter.mjs | 16 ++++++++++++---- web/src/lib/util.mjs | 16 ++++++++-------- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/web/src/chartplotter.mjs b/web/src/chartplotter.mjs index 987daab..a748199 100644 --- a/web/src/chartplotter.mjs +++ b/web/src/chartplotter.mjs @@ -447,11 +447,14 @@ export class ChartPlotter extends HTMLElement { } const need = packs.length === 1 ? (BAND_MINZOOM[BANDS[finest]] || 12) + 0.3 : 0; const cam = map.cameraForBounds([[w, s], [e, n]], { padding: 80 }); - const zoom = Math.min(18, Math.max(cam ? cam.zoom : Math.max(need, 9), need)); + // Cap the fly target at the destination's scale floor (not a raw z18) so we + // never overshoot it and snap back when moveend re-applies the floor. + const destLat = cam ? cam.center.lat : (s + n) / 2; + const zoom = Math.min(maxZoomForScaleFloor(destLat), Math.max(cam ? cam.zoom : Math.max(need, 9), need)); // Raise the dynamic zoom cap to the target FIRST — we're flying from open water - // (low cap) into the pack's coverage, so without this the fly clamps short and a - // berthing-only set wouldn't reach the zoom where it renders. _updateZoomCap - // recomputes at the destination (which has the charts) and won't yank back. + // (low cap, set at the prior latitude) into the pack's coverage, so without this + // the fly clamps short and a berthing-only set wouldn't reach the zoom where it + // renders. The moveend handler re-applies the floor at the destination. if (map.getMaxZoom() < zoom) map.setMaxZoom(zoom); map.flyTo({ center: cam ? cam.center : [(w + e) / 2, (s + n) / 2], zoom, duration: 1200 }); } @@ -522,6 +525,11 @@ export class ChartPlotter extends HTMLElement { this.addCatalogOverlay(map); this._refreshInstalledBounds(); }); + // Hold the 1:MIN_DETAIL_SCALE max-zoom floor on EVERY view change. It's + // latitude-dependent (recompute as the centre moves), and a fly-to-chart raises + // the cap to reach a pack's detail — without re-enforcing here that raised cap + // sticks and you can magnify past the floor (the 1:900 over-zoom). + map.on("moveend", () => this._applyScaleFloor()); await this.restoreArchive(); // Local serve: render every baked pack the server holds (survives reload). Prod // already loaded its prebaked archives in restoreArchive() above. diff --git a/web/src/lib/util.mjs b/web/src/lib/util.mjs index f677263..06295f9 100644 --- a/web/src/lib/util.mjs +++ b/web/src/lib/util.mjs @@ -76,14 +76,14 @@ export function zoomForScalePhysical(scale, lat, pxPitchMm = DEFAULT_PX_PITCH_MM } // Finest map scale we allow: don't magnify charts past 1:MIN_DETAIL_SCALE (past -// this it's just blocky overzoom). Inverse of scaleDenom — the (fractional) zoom -// whose scale at `lat` equals the floor (latitude-dependent). This is a PHYSICAL -// scale now that scaleDenom is physical: 1:2000 is the old 1:4000-nominal cap (the -// engine moved to the true 512-tile scale, ~2× finer), so the max-zoom-IN level the -// user can reach is unchanged — only the scale NUMBER for the same zoom halved. -export const MIN_DETAIL_SCALE = 2000; -export function maxZoomForScaleFloor(lat) { - const z = Math.log2(M_PER_PX_Z0 * Math.cos((lat * Math.PI) / 180) / (OGC_PX_M * MIN_DETAIL_SCALE)); +// this it's just blocky overzoom). The cap is a PHYSICAL scale — the 1:N a ruler +// laid on the screen measures (scaleDenomPhysical) — so it matches the HUD readout. +export const MIN_DETAIL_SCALE = 4000; +export function maxZoomForScaleFloor(lat, pxPitchMm = DEFAULT_PX_PITCH_MM) { + // Inverse of scaleDenomPhysical: the (fractional) zoom whose physical scale at + // `lat` equals the floor (latitude-dependent). Uses the same px pitch as the + // scalebar so the cap lands exactly on 1:MIN_DETAIL_SCALE as displayed. + const z = Math.log2(M_PER_PX_Z0 * Math.cos((lat * Math.PI) / 180) / ((clampPxPitch(pxPitchMm) / 1000) * MIN_DETAIL_SCALE)); return Math.max(1, Math.min(18, z)); } From 045c571df99cc90ecaf719b1b57d29479cb0d204 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 25 Jun 2026 04:59:18 -0400 Subject: [PATCH 11/13] feat(bands): align band zoom ranges to the NOAA standard scales (re-bake) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every band became best-available ~0.7 zoom too late — harbour didn't draw until ~1:11k (z14) even though its coarse standard is 1:22k. Realign each band's start zoom to its NOAA coarse standard scale (ENC Design Handbook Table 1) at ~40°N: band coarse scale {min,max}: was → now overview 1:10,000,000 {0,8} → {0,7} general 1:1,500,000 {8,10} → {7,9} coastal 1:350,000 {10,12}→ {9,11} approach 1:90,000 {12,14}→ {11,13} harbor 1:22,000 {14,16}→ {13,16} berthing 1:4,000 {16,18}→ {16,18} (1:4k ≈ z16, unchanged) Baker: Band.ZoomRange(), BakeBands(), bandBakeCeil(+2 sharpen now coastal 11→13, approach 13→15) — the doc comment already described this scheme; the code now matches it. BandForScale (CSCL→band) is scale-based and unchanged. Client: CHART_BANDS, BAND_DISPLAY_MIN (chart-sources), BAND_MINZOOM/BAND_MAXZOOM (bands.mjs) to the same ranges. REQUIRES A RE-BAKE — the baked tile zoom ranges changed (harbour now bakes from z13, etc.); old archives serve the old ranges until re-baked. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/engine/bake/bake.go | 30 +++++++++++++------------- internal/engine/bake/bake_test.go | 8 +++---- web/src/chart-canvas/chart-sources.mjs | 25 +++++++++++---------- web/src/lib/bands.mjs | 4 ++-- 4 files changed, 33 insertions(+), 34 deletions(-) diff --git a/internal/engine/bake/bake.go b/internal/engine/bake/bake.go index a6f8bdc..ae9891e 100644 --- a/internal/engine/bake/bake.go +++ b/internal/engine/bake/bake.go @@ -67,12 +67,12 @@ const generalOverzoomMin uint32 = 0 // berthing 18 — finest band, nothing finer to cut against; native detail. func bandBakeCeil(bandMax uint32) uint32 { switch bandMax { - case 12: // coastal: native max (z12) cuts too coarse; +2 → z14 to sharpen vs approach - return 14 + case 11: // coastal: native max (z11) cuts too coarse; +2 → z13 to sharpen vs approach + return 13 + case 13: // approach: native max (z13) cuts too coarse; +2 → z15 to sharpen vs harbor + return 15 default: // everyone else bakes to native max: - // overview(8)/general(10) are capped client-side (no overzoom cut to sharpen); - // approach(14) cuts vs harbor — fine, and deeper zooms over a whole district - // are the dominant size/index cost (Alaska approach: ~1.2 GB → ~80 MB); + // overview(7)/general(9) are capped client-side (no overzoom cut to sharpen); // harbor(16) cuts vs berthing; berthing(18) is the finest band. return bandMax } @@ -100,15 +100,15 @@ const ( func (b Band) ZoomRange() ZoomRange { switch b { case BandOverview: - return ZoomRange{0, 8} + return ZoomRange{0, 7} case BandGeneral: - return ZoomRange{8, 10} + return ZoomRange{7, 9} case BandCoastal: - return ZoomRange{10, 12} + return ZoomRange{9, 11} case BandApproach: - return ZoomRange{12, 14} + return ZoomRange{11, 13} case BandHarbor: - return ZoomRange{14, 16} + return ZoomRange{13, 16} default: // berthing return ZoomRange{16, 18} } @@ -126,11 +126,11 @@ type BakeBand struct { // chart- source. Max feeds EmitTileBandInto's band filter (natMax == Max). func BakeBands() []BakeBand { return []BakeBand{ - {"overview", 0, 8}, - {"general", 8, 10}, - {"coastal", 10, 12}, - {"approach", 12, 14}, - {"harbor", 14, 16}, + {"overview", 0, 7}, + {"general", 7, 9}, + {"coastal", 9, 11}, + {"approach", 11, 13}, + {"harbor", 13, 16}, {"berthing", 16, 18}, } } diff --git a/internal/engine/bake/bake_test.go b/internal/engine/bake/bake_test.go index 2df9787..4e24d68 100644 --- a/internal/engine/bake/bake_test.go +++ b/internal/engine/bake/bake_test.go @@ -15,8 +15,8 @@ import ( const goldenCell = "../../../testdata/US4MD81M.000" func TestBandForScale(t *testing.T) { - if BandForScale(12_000).ZoomRange() != (ZoomRange{14, 16}) { - t.Error("12k should be harbor [14,16]") + if BandForScale(12_000).ZoomRange() != (ZoomRange{13, 16}) { + t.Error("12k should be harbor [13,16]") } if BandForScale(3_000_000) != BandOverview { t.Error("3M should be overview") @@ -387,8 +387,8 @@ func TestSectorLights(t *testing.T) { // suppressed only where a finer prim sits on it. zMax is set > natMax to simulate // the overzoomed coarse prim and drive the branch directly. func TestUpSuppressionPointOverlap(t *testing.T) { - coastal := BandCoastal.ZoomRange() // {10,12} - harbor := BandHarbor.ZoomRange() // {14,16} + coastal := BandCoastal.ZoomRange() // {9,11} + harbor := BandHarbor.ZoomRange() // {13,16} base := geo.LatLon{Lat: 38.97, Lon: -76.49} mk := func(b *Baker, ll geo.LatLon, layer string, zr ZoomRange, zMax uint32) { r := routed{layer: layer, kind: mvt.GeomPoint, npoint: normPt(ll), diff --git a/web/src/chart-canvas/chart-sources.mjs b/web/src/chart-canvas/chart-sources.mjs index 168bd38..153da92 100644 --- a/web/src/chart-canvas/chart-sources.mjs +++ b/web/src/chart-canvas/chart-sources.mjs @@ -28,23 +28,22 @@ import { PMTilesArchive, MultiArchive } from "./pmtiles-source.mjs"; // would be pure buffer) and the client overzooms it to fill berth level. MUST // match the baker's bandBakeCeil (internal/engine/bake/bake.go). export const CHART_BANDS = [ - { slug: "overview", min: 0, max: 8, bake: 8 }, - { slug: "general", min: 8, max: 10, bake: 10 }, - { slug: "coastal", min: 10, max: 12, bake: 14 }, - { slug: "approach", min: 12, max: 14, bake: 14 }, - { slug: "harbor", min: 14, max: 16, bake: 16 }, + { slug: "overview", min: 0, max: 7, bake: 7 }, + { slug: "general", min: 7, max: 9, bake: 9 }, + { slug: "coastal", min: 9, max: 11, bake: 13 }, + { slug: "approach", min: 11, max: 13, bake: 15 }, + { slug: "harbor", min: 13, max: 16, bake: 16 }, { slug: "berthing", min: 16, max: 18, bake: 18 }, { slug: "all", min: 0, max: 18, bake: 18 }, ]; -// Lowest display zoom each band's chart layers actually DRAW at — the scale where -// that band becomes the best-available chart, per the NOAA ENC scheme (ENC Design -// Handbook Table 1: two standard scales per usage band ≈ two web-Mercator zooms; -// e.g. Approach 1:90k/1:45k ⇒ shows ~z12–14 ≈ 1:130k–1:32k at mid-US latitudes). -// Overview/general draw from z0 so they gap-fill on zoom-out; the finer bands start -// at their band so they don't appear a full zoom (≈½ band) too coarse. Applied as a -// LAYER minzoom (the baked source may serve lower) so it works without a re-bake. -export const BAND_DISPLAY_MIN = { overview: 0, general: 0, coastal: 10, approach: 12, harbor: 14, berthing: 16, all: 0 }; +// Lowest display zoom each band's chart layers actually DRAW at — the zoom whose +// physical scale equals the band's COARSE NOAA standard scale, where the band first +// becomes the best-available chart (ENC Design Handbook Table 1, at ~40°N): +// coastal 1:350k≈z9, approach 1:90k≈z11, harbor 1:22k≈z13, berthing 1:4k≈z16. +// Overview/general draw from z0 so they gap-fill on zoom-out. Must match the baker's +// Band.ZoomRange() (a re-bake aligns the tiles); applied as a LAYER minzoom. +export const BAND_DISPLAY_MIN = { overview: 0, general: 0, coastal: 9, approach: 11, harbor: 13, berthing: 16, all: 0 }; // Vector SOURCE-LAYERS whose features carry SCAMIN and are split into per-SCAMIN // bucket layers (each with a native fractional minzoom) so SCAMIN is honored diff --git a/web/src/lib/bands.mjs b/web/src/lib/bands.mjs index 32b309a..fc0b6b6 100644 --- a/web/src/lib/bands.mjs +++ b/web/src/lib/bands.mjs @@ -12,11 +12,11 @@ export const BAND_COLOR = { overview: "#7e57c2", general: "#5c6bc0", coastal: "# // Native MIN display zoom per band. Below it a cell's chart detail isn't baked, so // the shell draws its coverage outline instead. General is overzoomed out to z0 (it // renders where no overview covers — see generalOverzoomMin in the baker). -export const BAND_MINZOOM = { overview: 0, general: 0, coastal: 10, approach: 12, harbor: 14, berthing: 16 }; +export const BAND_MINZOOM = { overview: 0, general: 0, coastal: 9, approach: 11, harbor: 13, berthing: 16 }; // Native MAX display zoom per band. Drives the overscale cap: zooming past a band's // native max + OVERSCALE_MARGIN over open water just enlarges blank water, so // _updateZoomCap clamps to the finest band that actually covers the view. -export const BAND_MAXZOOM = { overview: 8, general: 10, coastal: 12, approach: 14, harbor: 16, berthing: 18 }; +export const BAND_MAXZOOM = { overview: 7, general: 9, coastal: 11, approach: 13, harbor: 16, berthing: 18 }; export const OVERSCALE_MARGIN = 2; // zoom-in levels allowed past the finest covering band // Usage bands in coarse→fine order, for the dev band-filter rows. export const DEV_BANDS = BANDS; From a6ee6d0e3a45da298a7095ccfecd5a59f9d420e1 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 25 Jun 2026 05:14:26 -0400 Subject: [PATCH 12/13] feat(web): restore the "share this view" link button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A refactor dropped the share-view button; the read side (parseViewHash / #v= and the /api/share snapshot loader) survived, so only the create-link UI was missing. Add it back: a share button in the bottom-right control cluster copies a #v=lon,lat,zoom[,bearing,pitch] link to the clipboard (cells/tiles already live on the server, so the opener — including a headless browser for debugging — just reopens the same camera). Restores _shareView verbatim from its original form. Co-Authored-By: Claude Opus 4.8 (1M context) --- web/src/chartplotter.mjs | 22 ++++++++++++++++++++++ web/src/chartplotter.view.mjs | 3 +++ 2 files changed, 25 insertions(+) diff --git a/web/src/chartplotter.mjs b/web/src/chartplotter.mjs index a748199..8d93a52 100644 --- a/web/src/chartplotter.mjs +++ b/web/src/chartplotter.mjs @@ -1132,6 +1132,27 @@ export class ChartPlotter extends HTMLElement { } } + // Copy a link to the current view to the clipboard. The link carries ONLY the + // camera (#v=lon,lat,zoom[,bearing,pitch]) — the cells/tiles already live on the + // server (the hub), so the opener (incl. a headless browser used for debugging) + // just reopens the same spot. parseViewHash reads it back on boot. + _shareView(btn) { + const m = this._map; + if (!m) return; + try { + const c = m.getCenter(); + const parts = [+c.lng.toFixed(6), +c.lat.toFixed(6), +m.getZoom().toFixed(3)]; + const b = +m.getBearing().toFixed(1), p = +m.getPitch().toFixed(1); + if (b || p) { parts.push(b); if (p) parts.push(p); } // omit trailing zeros + const url = location.origin + location.pathname + "#v=" + parts.join(","); + console.log("[share] view link:", url); + copyText(url).then((ok) => { if (btn) flashBtn(btn, ok ? "✓ copied" : "✓"); }); + } catch (e) { + console.warn("[share] link failed:", e); + if (btn) flashBtn(btn, "✗"); + } + } + // Fetch the latest shared snapshot and install its cells locally, downloading // any not already stored through the server (which serves them from its cache — // including bytes the publisher uploaded — or fetches the NOAA url). Returns the @@ -1760,6 +1781,7 @@ export class ChartPlotter extends HTMLElement { $("settings-btn").onclick = () => this.toggleSection("settings"); $("close").onclick = () => this.closeDrawer(); $("scheme-toggle").onclick = () => this._cycleScheme(); + $("share-btn").onclick = (e) => this._shareView(e.currentTarget); this._syncSchemeUI(); // paint the toggle's initial icon // Escape closes the topmost open dialog/overlay (one per press). The // cursor-pick report closes itself (its own captured handler runs first). diff --git a/web/src/chartplotter.view.mjs b/web/src/chartplotter.view.mjs index 0011f91..7f30692 100644 --- a/web/src/chartplotter.view.mjs +++ b/web/src/chartplotter.view.mjs @@ -516,6 +516,9 @@ export const CHROME = `
+