diff --git a/internal/engine/bake/bake.go b/internal/engine/bake/bake.go index ef3de11..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}, } } @@ -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 @@ -280,11 +281,14 @@ 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 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 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 } @@ -310,6 +314,14 @@ 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). + curDateStart string + curDateEnd 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 @@ -387,10 +399,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)) } @@ -634,6 +651,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 = fb.DateStart, fb.DateEnd zMin := bandZMin(fb.DisplayCategory, scamin, dr.Min, cellLat) class := f.ObjectClass() drval1, drval2 := depthVals(f.Attributes(), class) @@ -694,6 +714,34 @@ 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. 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(norm(start))}) + } + if end != "" { + attrs = append(attrs, mvt.KeyValue{Key: "date_end", Value: mvt.StringVal(norm(end))}) + } + rec := int64(0) + if recurring { + rec = 1 + } + attrs = append(attrs, mvt.KeyValue{Key: "date_recurring", Value: mvt.IntVal(rec)}) + 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. @@ -855,6 +903,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) return extra } r := routed{ @@ -939,10 +990,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 +1011,16 @@ 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), + scamin: b.curScamin, + dateStart: b.curDateStart, dateEnd: b.curDateEnd, + legNorm: legNorm, }) } } @@ -1132,7 +1198,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 +1246,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 +1371,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 +1661,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 { @@ -1636,6 +1702,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) tb.Layer("sector_lines").AddLines(paths, attrs) } } @@ -1724,44 +1793,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 +1871,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..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") @@ -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) } } @@ -388,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), @@ -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 7c56880..f47472b 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 @@ -110,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 3969172..b59a6bd 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. @@ -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 a2ad4a7..094ac71 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,19 +210,30 @@ 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. 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 lowered 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) } @@ -223,6 +241,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 +320,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..b7da0dd 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) } } @@ -326,12 +373,44 @@ 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 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}}}, @@ -344,24 +423,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/s101lower.go b/internal/engine/portrayal/s101emit.go similarity index 57% rename from internal/engine/portrayal/s101lower.go rename to internal/engine/portrayal/s101emit.go index 9b60034..03db125 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, @@ -130,11 +138,106 @@ func LowerS101(cmd instructions.DrawCommand, geom S101Geometry, cat *catalog.Cat 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 } } +// 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 deleted file mode 100644 index 5a1e212..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 lowering 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). - -// 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 -// 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 lowers 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/internal/engine/s101/complex.go b/internal/engine/s101/complex.go index 3f43ba9..9957d5e 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 { @@ -134,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 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..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) @@ -97,6 +134,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 +171,10 @@ func Reduce(ins []Instruction) (cmds []DrawCommand, unsupported []string) { textAlignH string textAlignV string textVOffset float64 + dateStart string + dateEnd string + timeValid string + curAug *AugmentedGeom // current constructed figure (ray/arc), if any seenUnsup = map[string]bool{} ) noteUnsupported := func(kind string) { @@ -144,6 +197,10 @@ 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) } @@ -163,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 @@ -194,16 +264,34 @@ 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", - "AlertReference", "Warning", "Error", "Hover", "SpatialReference": - // no-op for primitive lowering + // --- 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", + // 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": @@ -215,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) + } +} diff --git a/web/src/chart-canvas/chart-canvas.mjs b/web/src/chart-canvas/chart-canvas.mjs index 7b92ef0..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")) { + 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/chart-sources.mjs b/web/src/chart-canvas/chart-sources.mjs index 52d71f4..3df2f54 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 @@ -207,31 +206,59 @@ 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); - 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); - 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. + 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); - this._scaminRebuildT = setTimeout(() => { if (this.getMap()) this.rebuild(); }, 450); + 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 (same value set): 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) -- @@ -447,11 +474,21 @@ export class ChartSources { // merged-upload `all` source needs its max synced to the loaded archive (an // upload may bake to <18; requesting above its max would read blank). _updateSourceZoom() { - const map = this.getMap(), all = this._bands.all; - const src = map && map.getSource("chart-all"); - if (src && all && src.maxzoom !== undefined) { - src.minzoom = all.minZoom; - src.maxzoom = all.maxZoom; + const map = this.getMap(); + if (!map) return; + // Hold every loaded band source's maxzoom at its archive's REAL deepest baked + // zoom (PMTiles header), in place — so MapLibre overzooms the deepest tile it + // has instead of requesting empty tiles past the bake (which read as a blank + // band when the static band.bake and the actual archive drift, e.g. after a + // band-range change before a re-bake). No restyle. The merged "all" source has + // no per-band overzoom so its minzoom tracks the archive too; the per-band + // sources keep minzoom 0 for the sub-band SCAMIN features. + for (const slug of Object.keys(this._bands)) { + const arc = this._bands[slug]; + const src = map.getSource("chart-" + slug); + if (!src || !arc || src.maxzoom === undefined) continue; + src.maxzoom = arc.maxZoom; + if (slug === "all") src.minzoom = arc.minZoom; } } @@ -516,12 +553,15 @@ export class ChartSources { // cache-bust token bumped by setArchive/refresh. Sources for not-yet-loaded // bands resolve to blank tiles (harmless) until an archive is added. const sources = {}; - // Per-band prebaked sources in BOTH modes. The source maxzoom is band.bake — - // the top zoom the archive actually contains — so MapLibre serves real tiles up - // to there and client-overzooms above it (base fills + the finest band fill the - // finer zooms for free; coarser bands' lines/patterns are cut in the bake or - // capped on the layer, so they don't bleed into a finer band's area). + // Per-band prebaked sources. The source maxzoom is the loaded archive's REAL + // deepest baked zoom (from its PMTiles header), NOT the static band.bake — so + // MapLibre overzooms the deepest tile it actually has instead of requesting + // empty tiles past the bake (which read as a whole blank band when band.bake + // and the archive drift, e.g. after a band-range change before a re-bake). The + // client overzooms above it (base fills + the finest band fill the finer zooms + // for free). Falls back to band.bake until an archive is loaded. for (const band of CHART_BANDS) { + const archive = this._bands[band.slug]; sources["chart-" + band.slug] = { type: "vector", tiles: [`chart-${band.slug}://${v}/{z}/{x}/{y}`], @@ -531,7 +571,7 @@ export class ChartSources { // band min), and minzoom only adds requests when the VIEW is coarse (few // tiles), so it's cheap. Per-SCAMIN bucket layers gate the exact display scale. minzoom: 0, - maxzoom: band.bake, + maxzoom: (archive && archive.maxZoom) || band.bake, }; } if (this._server) { 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)); +}); 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, []); +}); 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..21636cc 100644 --- a/web/src/chart-canvas/s52-style.mjs +++ b/web/src/chart-canvas/s52-style.mjs @@ -235,11 +235,74 @@ 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)); + // 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 1c48747..8d93a52 100644 --- a/web/src/chartplotter.mjs +++ b/web/src/chartplotter.mjs @@ -75,6 +75,15 @@ 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, + // "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) @@ -438,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 }); } @@ -513,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. @@ -1084,26 +1101,56 @@ 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); + }); + } + } + + // 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 @@ -1734,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 = `
+