From 3249122d1a4026ae8f3950fdc89e7533517f690e Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 25 Jun 2026 22:40:44 -0400 Subject: [PATCH] feat(own-ship): heading line, look-ahead, stale-GPS + Annapolis sim scenarios MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the own-ship UX spec and fixes a pre-existing rendering bug. Own-ship (web): - Self-heal vector layers each fix. The plugin is built on the canvas `ready` event (after the initial style.load) and the chart rebuilds its style with setStyle({diff:false}), which drops plugin layers — so _ensureLayers never effectively ran and the COG predictor never drew. The DOM Marker survived, masking it. Now _ensureLayers() runs at the top of each _update (~1 Hz). - Heading line: solid HDT line distinct from the dashed COG vector; the gap between them shows leeway/set. Short fixed line at rest. - Stale/lost GPS: a watchdog ages the last fresh fix (moved, or RMC/ZDA clock advanced) into live -> stale (>6s, greyed, vectors dropped, amber pill) -> lost (>20s, red pill). The boat freezes at the last fix, never vanishes. - Rotate breaks follow like pan (guarded on originalEvent so our own bearing holds don't self-break); pinch/wheel keeps follow. Camera (chart-canvas): look-ahead offset — in course-/head-up the vessel sits 1/3 up from the bottom; north-up stays centred. Simulator: named Annapolis scenarios (harbor, bay-crossing, bay-bridge, anchorage, collision, river, sailing) with own-ship route-following; `sailing` tacks with a varying leeway so heading != COG; `--drop-gps N` withholds position fixes to exercise stale/lost. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 3 + cmd/chartplotter/simulate.go | 59 +++++-- internal/engine/nmea/sim/scenarios.go | 92 +++++++++++ internal/engine/nmea/sim/scenarios_test.go | 96 +++++++++++ internal/engine/nmea/sim/sim.go | 71 +++++++-- web/src/chart-canvas/chart-canvas.mjs | 9 ++ web/src/plugins/own-ship.mjs | 177 +++++++++++++++++++-- 7 files changed, 469 insertions(+), 38 deletions(-) create mode 100644 internal/engine/nmea/sim/scenarios.go create mode 100644 internal/engine/nmea/sim/scenarios_test.go diff --git a/.gitignore b/.gitignore index ffea684..fdf67d7 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,9 @@ web/charts-user.* web/charts-index.json web/chartplotter.wasm +# Working design notes / specs (kept local, never committed) +/specs/ + # Editor / OS .DS_Store .idea/ diff --git a/cmd/chartplotter/simulate.go b/cmd/chartplotter/simulate.go index 3016443..ec774f1 100644 --- a/cmd/chartplotter/simulate.go +++ b/cmd/chartplotter/simulate.go @@ -25,21 +25,25 @@ import ( type simulateCmd struct { Host string `default:"127.0.0.1" help:"Bind host."` Port int `default:"10110" help:"Bind port (IANA NMEA-0183-over-IP)."` - Center string `default:"38.978,-76.478" help:"Own-ship start as lat,lon."` + Scenario string `help:"Named Annapolis preset (sets start/route/traffic). Use 'list' to print them."` + Center string `default:"38.978,-76.478" help:"Own-ship start as lat,lon (ignored when --scenario is set)."` Course float64 `default:"45" help:"Own-ship course (degrees true)."` Speed float64 `default:"6" help:"Own-ship speed (knots)."` Targets int `default:"6" help:"Number of AIS targets."` Collision bool `default:"true" negatable:"" help:"Put one target on a collision course."` + Sailing bool `help:"Own-ship tacks (COG weaves) with a varying leeway so heading ≠ COG."` + DropGPS int `name:"drop-gps" help:"Stop own-ship position fixes after N seconds (test stale/lost GPS); 0 = never."` Seed int64 `default:"1" help:"RNG seed (reproducible scenarios)."` Cell string `type:"existingfile" help:"S-57 cell (.000 or exchange .zip) to keep traffic in navigable water."` MinDepth float64 `name:"min-depth" default:"2" help:"Minimum charted depth (DRVAL1, m) for navigable water when --cell is set."` } func (c simulateCmd) Run() error { - lat, lon, err := parseLatLon(c.Center) - if err != nil { - return err + if strings.EqualFold(c.Scenario, "list") { + fmt.Print("Annapolis scenarios (--scenario ):\n", sim.ScenarioList()) + return nil } + var water *sim.WaterMask if c.Cell != "" { chart, err := loadCell(c.Cell) @@ -50,10 +54,30 @@ func (c simulateCmd) Run() error { fmt.Println("warning: no navigable depth areas (DEPARE ≥ min-depth) in cell; placing traffic unconstrained") } } - s := sim.New(sim.Options{ - Lat: lat, Lon: lon, Course: c.Course, Speed: c.Speed, - Targets: c.Targets, Collision: c.Collision, Seed: c.Seed, Water: water, - }) + + // A scenario fully defines the world's start/route/traffic; otherwise build it + // from the position/motion flags. + var opts sim.Options + desc := "" + if c.Scenario != "" { + sc, ok := sim.ScenarioByName(c.Scenario) + if !ok { + return fmt.Errorf("unknown scenario %q; --scenario list to see options", c.Scenario) + } + opts = sc.Options(c.Seed, water) + desc = sc.Desc + } else { + lat, lon, err := parseLatLon(c.Center) + if err != nil { + return err + } + opts = sim.Options{ + Lat: lat, Lon: lon, Course: c.Course, Speed: c.Speed, + Targets: c.Targets, Collision: c.Collision, Sailing: c.Sailing, + Seed: c.Seed, Water: water, + } + } + s := sim.New(opts) addr := net.JoinHostPort(c.Host, strconv.Itoa(c.Port)) ln, err := net.Listen("tcp", addr) @@ -61,9 +85,13 @@ func (c simulateCmd) Run() error { return fmt.Errorf("listen %s: %w", addr, err) } defer ln.Close() - fmt.Printf("nmea0183 simulator → tcp://%s (own-ship %.4f,%.4f @ %.0f° %.0fkn, %d AIS targets%s%s)\n", - addr, s.Own.Lat, s.Own.Lon, c.Course, c.Speed, c.Targets, - ifStr(c.Collision, ", 1 on collision course", ""), + if desc != "" { + fmt.Printf("scenario %q — %s\n", c.Scenario, desc) + } + fmt.Printf("nmea0183 simulator → tcp://%s (own-ship %.4f,%.4f @ %.0f° %.0fkn, %d AIS targets%s%s%s)\n", + addr, s.Own.Lat, s.Own.Lon, s.Own.Course, opts.Speed, opts.Targets, + ifStr(opts.Collision, ", 1 on collision course", ""), + ifStr(c.DropGPS > 0, fmt.Sprintf(", GPS drops at %ds", c.DropGPS), ""), ifStr(water != nil, ", in navigable water from "+filepath.Base(c.Cell), "")) fmt.Println("point a Connection at this host:port; Ctrl-C to stop") @@ -86,7 +114,14 @@ func (c simulateCmd) Run() error { for tick := 0; ; tick++ { <-ticker.C s.Step(1) - lines := s.OwnSentences() + // After --drop-gps seconds, withhold the position/motion fixes (depth/wind + // keep flowing) so the client sees a frozen-then-lost GPS. + var lines []string + if c.DropGPS > 0 && tick >= c.DropGPS { + lines = s.EnvSentences() + } else { + lines = s.OwnSentences() + } if tick%3 == 0 { lines = append(lines, s.AISPositions()...) } diff --git a/internal/engine/nmea/sim/scenarios.go b/internal/engine/nmea/sim/scenarios.go new file mode 100644 index 0000000..d99c0da --- /dev/null +++ b/internal/engine/nmea/sim/scenarios.go @@ -0,0 +1,92 @@ +package sim + +import ( + "fmt" + "strings" +) + +// scenarios.go holds named, ready-to-run simulation presets set around Annapolis, +// MD on the Chesapeake Bay — the same waters the default cell covers. Each is a +// realistic situation for exercising own-ship UX (follow / look-ahead / heading +// line / stale-GPS) and AIS/CPA: a harbour departure, a bay crossing, a Bay +// Bridge transit, sitting at anchor, a close-quarters CPA, and a tight river run. +// `cp simulate --scenario ` loads one; coordinates are WGS-84 lat,lon. + +// Scenario is a preset world: an own-ship start (+ optional route to steer) and a +// traffic mix. Seed and the optional water mask come from the command flags. +type Scenario struct { + Name string + Desc string + Lat, Lon float64 // own-ship start + Course float64 // initial course (deg true); ignored when Route is set + Speed float64 // knots + Route [][2]float64 // optional own-ship waypoints (lat,lon) + Targets int // AIS targets + Collision bool // put one target on a collision course + Sailing bool // own-ship tacks with varying leeway (heading ≠ COG) +} + +// Options turns the scenario into sim Options, folding in the run's seed and the +// optional navigable-water mask. +func (sc Scenario) Options(seed int64, water *WaterMask) Options { + return Options{ + Lat: sc.Lat, Lon: sc.Lon, Course: sc.Course, Speed: sc.Speed, + OwnRoute: sc.Route, Targets: sc.Targets, Collision: sc.Collision, + Sailing: sc.Sailing, Seed: seed, Water: water, + } +} + +// scenarios is the ordered registry (order = list/help order). +var scenarios = []Scenario{ + { + Name: "harbor", Desc: "Departing Annapolis harbour (Severn mouth out past Tolly Point)", + Lat: 38.9785, Lon: -76.4770, Speed: 5, Targets: 8, + Route: [][2]float64{{38.972, -76.466}, {38.962, -76.452}, {38.958, -76.440}}, + }, + { + Name: "bay-crossing", Desc: "Crossing the Chesapeake from Annapolis to the Eastern Shore", + Lat: 38.966, Lon: -76.430, Speed: 7, Targets: 6, + Route: [][2]float64{{38.964, -76.400}, {38.962, -76.370}, {38.960, -76.346}}, + }, + { + Name: "bay-bridge", Desc: "Transiting north under the Chesapeake Bay Bridge spans", + Lat: 38.975, Lon: -76.392, Speed: 6, Targets: 5, + Route: [][2]float64{{38.986, -76.390}, {38.995, -76.389}, {39.006, -76.388}}, + }, + { + Name: "anchorage", Desc: "At anchor in Whitehall Bay (idle GPS + heading-at-rest)", + Lat: 38.992, Lon: -76.448, Course: 200, Speed: 0, Targets: 3, + }, + { + Name: "collision", Desc: "Close-quarters crossing in the open bay (CPA alert)", + Lat: 38.950, Lon: -76.420, Course: 80, Speed: 7, Targets: 6, Collision: true, + }, + { + Name: "river", Desc: "Gunkholing up the South River (tight quarters, course-up)", + Lat: 38.913, Lon: -76.458, Speed: 4, Targets: 4, + Route: [][2]float64{{38.917, -76.474}, {38.920, -76.492}, {38.922, -76.508}}, + }, + { + Name: "sailing", Desc: "Sailboat working to windward in the bay (COG tacks, heading ≠ COG)", + Lat: 38.945, Lon: -76.415, Course: 30, Speed: 5, Targets: 4, Sailing: true, + }, +} + +// ScenarioByName returns the named scenario (case-insensitive), or ok=false. +func ScenarioByName(name string) (Scenario, bool) { + for _, sc := range scenarios { + if strings.EqualFold(sc.Name, name) { + return sc, true + } + } + return Scenario{}, false +} + +// ScenarioList is a human-readable "name — description" listing, one per line. +func ScenarioList() string { + var b strings.Builder + for _, sc := range scenarios { + fmt.Fprintf(&b, " %-13s %s\n", sc.Name, sc.Desc) + } + return b.String() +} diff --git a/internal/engine/nmea/sim/scenarios_test.go b/internal/engine/nmea/sim/scenarios_test.go new file mode 100644 index 0000000..eb0489c --- /dev/null +++ b/internal/engine/nmea/sim/scenarios_test.go @@ -0,0 +1,96 @@ +package sim + +import ( + "math" + "testing" + + "github.com/beetlebugorg/chartplotter/internal/engine/nmea" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// parseSim parses the given sentences into a fresh VesselState through the real +// NMEA parser (the path the server uses). +func parseSim(t *testing.T, lines []string) *nmea.VesselState { + t.Helper() + vs := &nmea.VesselState{} + p := &nmea.Parser{} + for _, ln := range lines { + sent, err := nmea.ParseSentence(ln) + require.NoErrorf(t, err, "sentence must frame: %s", ln) + p.Apply(sent, vs) + } + return vs +} + +// Every registered scenario must resolve and yield runnable Options. +func TestScenarios_Registry(t *testing.T) { + require.NotEmpty(t, scenarios) + for _, sc := range scenarios { + got, ok := ScenarioByName(sc.Name) + require.Truef(t, ok, "scenario %q should resolve", sc.Name) + assert.Equal(t, sc.Name, got.Name) + s := New(sc.Options(1, nil)) + assert.NotZero(t, s.Own.Lat) + assert.NotZero(t, s.Own.Lon) + } + _, ok := ScenarioByName("nope") + assert.False(t, ok) +} + +// A scenario with an own-ship route steers own-ship toward its first waypoint +// (course points roughly down-route, and the boat makes progress that way). +func TestScenarios_OwnRouteSteers(t *testing.T) { + sc, ok := ScenarioByName("harbor") + require.True(t, ok) + require.NotEmpty(t, sc.Route) + s := New(sc.Options(1, nil)) + + wp := sc.Route[0] + startBrg := bearing(s.Own.Lat, s.Own.Lon, wp[0], wp[1]) + assert.InDelta(t, startBrg, s.Own.Course, 1.0, "opening course should point at the first waypoint") + + startDist := dist(s.Own.Lat, s.Own.Lon, wp[0], wp[1]) + for range 60 { // one simulated minute + s.Step(1) + } + assert.Less(t, dist(s.Own.Lat, s.Own.Lon, wp[0], wp[1]), startDist, "own-ship should close on the waypoint") +} + +// The sailing scenario must make both COG and the heading-vs-COG gap vary widely +// over a few minutes (the case the heading line exists to show). +func TestScenarios_SailingVaries(t *testing.T) { + sc, ok := ScenarioByName("sailing") + require.True(t, ok) + require.True(t, sc.Sailing) + s := New(sc.Options(1, nil)) + + var cogMin, cogMax, gapMin, gapMax = 360.0, -360.0, 360.0, -360.0 + for range 240 { // four simulated minutes + s.Step(1) + nav := parseSim(t, s.NavSentences()).Navigation + require.NotNil(t, nav.COGTrue) + require.NotNil(t, nav.HeadingTrue) + cog := *nav.COGTrue + gap := math.Mod(*nav.HeadingTrue-cog+540, 360) - 180 // signed heading−COG + cogMin, cogMax = math.Min(cogMin, cog), math.Max(cogMax, cog) + gapMin, gapMax = math.Min(gapMin, gap), math.Max(gapMax, gap) + } + assert.Greater(t, cogMax-cogMin, 60.0, "COG should tack across a wide arc") + assert.Greater(t, gapMax-gapMin, 20.0, "heading−COG (leeway) should swing, and flip sign") + assert.Less(t, gapMin, 0.0) + assert.Greater(t, gapMax, 0.0) +} + +// --drop-gps's mechanism: env sentences must be position-free, nav sentences carry +// the position. (The command withholds NavSentences to simulate signal loss.) +func TestScenarios_NavEnvSplit(t *testing.T) { + s := New(Options{Lat: 38.978, Lon: -76.478, Course: 45, Speed: 6, Seed: 1}) + + env := parseSim(t, s.EnvSentences()) + assert.Nil(t, env.Navigation.Position, "env sentences must not carry a position") + require.NotNil(t, env.Environment.Depth.BelowTransducer, "env sentences carry depth") + + nav := parseSim(t, s.NavSentences()) + require.NotNil(t, nav.Navigation.Position, "nav sentences carry the position fix") +} diff --git a/internal/engine/nmea/sim/sim.go b/internal/engine/nmea/sim/sim.go index f3ae318..fe268bb 100644 --- a/internal/engine/nmea/sim/sim.go +++ b/internal/engine/nmea/sim/sim.go @@ -35,15 +35,24 @@ type Sim struct { Targets []*Vessel codec *aisnmea.NMEACodec depth float64 + // Own-ship heading model. leeway is the crab angle (heading = COG − leeway); + // fixed at 7° normally. In sailing mode own-ship weaves about baseCourse and + // leeway swings, so the heading line and COG vector visibly diverge. + leeway float64 + baseCourse float64 + sailing bool + phase float64 // seconds of elapsed sailing time, drives the weave } // Options configures a new Sim. type Options struct { - Lat, Lon float64 // own-ship start - Course float64 // own-ship course (deg true) - Speed float64 // own-ship speed (kn) - Targets int // number of AIS targets - Collision bool // make one target converge on own-ship + Lat, Lon float64 // own-ship start + Course float64 // own-ship course (deg true) + Speed float64 // own-ship speed (kn) + OwnRoute [][2]float64 // optional own-ship route (lat,lon waypoints) to steer through + Targets int // number of AIS targets + Collision bool // make one target converge on own-ship + Sailing bool // own-ship tacks (COG weaves) with a varying leeway (heading ≠ COG) Seed int64 Water *WaterMask // optional: constrain placement to navigable water from a cell } @@ -74,9 +83,18 @@ func New(o Options) *Sim { return destination(o.Lat, o.Lon, brg, dist) } s := &Sim{ - Own: Vessel{Name: "OWN", Lat: o.Lat, Lon: o.Lon, Course: o.Course, Speed: o.Speed}, - codec: aisnmea.NMEACodecNew(ais.CodecNew(false, false)), - depth: 12, + Own: Vessel{Name: "OWN", Lat: o.Lat, Lon: o.Lon, Course: o.Course, Speed: o.Speed}, + codec: aisnmea.NMEACodecNew(ais.CodecNew(false, false)), + depth: 12, + leeway: 7, // fixed crab; sailing mode overrides this each step + baseCourse: o.Course, + sailing: o.Sailing, + } + // Own-ship route: steer through the given waypoints (same machinery as targets), + // starting pointed at the first one so the opening fix already heads down-route. + if len(o.OwnRoute) > 0 { + s.Own.wps = o.OwnRoute + s.Own.Course = bearing(s.Own.Lat, s.Own.Lon, o.OwnRoute[0][0], o.OwnRoute[0][1]) } names := []string{"SEA BREEZE", "NORDIC STAR", "BAY TRADER", "MISS MOLLY", "EL TORO", "PACIFICA", "ORION", "KESTREL", "ARGO", "TIDEWATER"} types := []uint8{30, 36, 37, 52, 60, 70, 80} // fishing, sailing, pleasure, tug, passenger, cargo, tanker @@ -126,12 +144,28 @@ func New(o Options) *Sim { // Step advances every vessel by dt seconds (dead reckoning + gentle turns). func (s *Sim) Step(dt float64) { - advance(&s.Own, dt) + if s.sailing { + s.stepSail(dt) + } else { + advance(&s.Own, dt) + } for _, t := range s.Targets { advance(t, dt) } } +// stepSail weaves own-ship like a boat working to windward: COG tacks ±42° about +// the base course (≈110 s period) while leeway swings ±14° on a different period +// (≈55 s, so it beats against the tack) — heading = COG − leeway thus separates +// from COG and the gap keeps changing, the case the heading line exists to show. +func (s *Sim) stepSail(dt float64) { + s.phase += dt + s.Own.Course = math.Mod(s.baseCourse+42*math.Sin(s.phase*2*math.Pi/110)+360, 360) + s.leeway = 14 * math.Sin(s.phase*2*math.Pi/55) // flips sign with the tack + nm := s.Own.Speed * (dt / 3600) + s.Own.Lat, s.Own.Lon = destination(s.Own.Lat, s.Own.Lon, s.Own.Course, nm) +} + func advance(v *Vessel, dt float64) { switch { case len(v.wps) > 0: // route-follower: steer toward the current waypoint @@ -148,8 +182,16 @@ func advance(v *Vessel, dt float64) { v.Lat, v.Lon = destination(v.Lat, v.Lon, v.Course, nm) } -// OwnSentences returns the own-ship instrument sentences for this instant. +// OwnSentences returns all own-ship instrument sentences for this instant +// (position/motion + environment). func (s *Sim) OwnSentences() []string { + return append(s.NavSentences(), s.EnvSentences()...) +} + +// NavSentences returns the position/motion sentences (GGA/RMC/VTG/HDT/VHW). These +// are what stop when a GPS feed drops, so `cp simulate --drop-gps` withholds them +// while EnvSentences keep flowing — exactly what a real signal loss looks like. +func (s *Sim) NavSentences() []string { now := time.Now().UTC() hms := now.Format("150405.00") dmy := now.Format("020106") @@ -157,13 +199,20 @@ func (s *Sim) OwnSentences() []string { lon, ew := toNMEALon(s.Own.Lon) cog := s.Own.Course sog := s.Own.Speed - hdg := math.Mod(cog-7+360, 360) // a small crab angle so heading ≠ COG (head-up ≠ course-up) + hdg := math.Mod(cog-s.leeway+360, 360) // crab/leeway so heading ≠ COG (head-up ≠ course-up) return []string{ line(fmt.Sprintf("GPGGA,%s,%s,%s,%s,%s,1,10,0.8,2,M,-33.0,M,,", hms, lat, ns, lon, ew)), line(fmt.Sprintf("GPRMC,%s,A,%s,%s,%s,%s,%.1f,%.1f,%s,,,A", hms, lat, ns, lon, ew, sog, cog, dmy)), line(fmt.Sprintf("GPVTG,%.1f,T,,M,%.1f,N,,K,A", cog, sog)), line(fmt.Sprintf("HEHDT,%.1f,T", hdg)), line(fmt.Sprintf("IIVHW,%.1f,T,,M,%.1f,N,,K", cog, sog)), + } +} + +// EnvSentences returns the environment sentences (depth/wind/water temp), which +// keep flowing even when the GPS drops. +func (s *Sim) EnvSentences() []string { + return []string{ line(fmt.Sprintf("SDDPT,%.1f,0.5,", s.depth)), line(fmt.Sprintf("IIMWV,%.1f,R,%.1f,N,A", 45.0, 12.0)), line(fmt.Sprintf("IIMTW,%.1f,C", 18.0)), diff --git a/web/src/chart-canvas/chart-canvas.mjs b/web/src/chart-canvas/chart-canvas.mjs index fe6f682..f91fd20 100644 --- a/web/src/chart-canvas/chart-canvas.mjs +++ b/web/src/chart-canvas/chart-canvas.mjs @@ -766,6 +766,15 @@ export class ChartCanvas extends HTMLElement { const cam = { center: [fix.lng, fix.lat], duration, easing: LINEAR }; + // Look-ahead offset: in course-/head-up the chart rotates so the vessel's + // direction points up, so we sit the vessel ⅓ up from the bottom — most of the + // screen is water *ahead*. Screen y is down, so a positive y-offset drops the + // centre (the vessel) below the container middle; ⅓-from-bottom is ⅙ of the + // height below centre. North-up stays centred (offset 0). + const h = (this._followLookAhead !== false && (this._cameraMode === "course-up" || this._cameraMode === "head-up")) + ? (map.getContainer() && map.getContainer().clientHeight) || 0 : 0; + if (h) cam.offset = [0, h / 6]; + // Hold the mode's bearing on every fix; otherwise this centre-only ease would // cancel the one-shot bearing reset from setCameraMode (north-up gets stuck at // the previous course-up heading). Feed an UNWRAPPED target relative to the diff --git a/web/src/plugins/own-ship.mjs b/web/src/plugins/own-ship.mjs index 45ca3df..b2f5fbd 100644 --- a/web/src/plugins/own-ship.mjs +++ b/web/src/plugins/own-ship.mjs @@ -18,9 +18,19 @@ import { OWN_SHIP_MARKER, CENTER_ICON } from "../lib/openbridge-icons.mjs"; import { fmtLatLon } from "./target-info.mjs"; import { format } from "../lib/units.mjs"; -const SRC = "ownship-predictor"; +const SRC = "ownship-predictor"; // COG/SOG vector (dashed) const CASING = "ownship-predictor-casing"; const LINE = "ownship-predictor-line"; +const HSRC = "ownship-heading"; // heading line (solid, the vessel's nose) +const HCASING = "ownship-heading-casing"; +const HLINE = "ownship-heading-line"; + +// GPS freshness thresholds. A position that hasn't advanced for STALE_MS is shown +// greyed (sensor hiccup); past LOST_MS it's "GPS lost" and the vectors drop. The +// boat is never removed on a dropout — it freezes at the last known fix, which is +// the safe behaviour underway (a vanishing boat reads as "no hazard here"). +const STALE_MS = 6000; +const LOST_MS = 20000; const CHIP_STYLE = ` #ownship-recenter { @@ -38,6 +48,20 @@ const CHIP_STYLE = ` #ownship-recenter:active { transform: translateX(-50%) scale(.95); } #ownship-recenter svg { width: 17px; height: 17px; display: block; } #ownship-recenter[hidden] { display: none; } + #ownship-gps { + position: absolute; left: 50%; transform: translateX(-50%); + top: calc(var(--topbar-h, 0px) + 12px); z-index: 7; + display: inline-flex; align-items: center; gap: 7px; + padding: 6px 13px; border-radius: 20px; pointer-events: none; + font: 600 12px/1 system-ui, sans-serif; color: #1a1300; + box-shadow: 0 3px 14px rgba(0,0,0,.28); + } + #ownship-gps::before { + content: ""; width: 8px; height: 8px; border-radius: 50%; background: currentColor; + } + #ownship-gps.stale { background: #f5b301; } + #ownship-gps.lost { background: #e5484d; color: #fff; } + #ownship-gps[hidden] { display: none; } `; const EMPTY = { type: "FeatureCollection", features: [] }; @@ -62,6 +86,14 @@ export class OwnShip { this._predict = null; // {course, sog} for the predictor, from the latest fix this._poseRAF = 0; // in-flight pose tween this._lastFixTs = 0; // ms of the previous fix (to size the tween to the gap) + // GPS freshness: _freshWall is the wall-clock of the last position that actually + // advanced (moved, or its fix clock ticked). The watchdog ages it into + // live/stale/lost so a frozen feed is caught even when other sentences (depth, + // wind) keep the snapshot — and thus onChange — flowing. + this._freshWall = 0; + this._posKey = null; // last "lat,lon" seen, to spot a moving fix + this._fixClock = 0; // last navigation.datetime (ms), to spot a stationary-but-live fix + this._gps = "none"; // none | live | stale | lost this._el = document.createElement("div"); this._el.style.cssText = "pointer-events:auto;cursor:pointer;will-change:transform"; @@ -72,11 +104,20 @@ export class OwnShip { }); this._chip = this._makeChip(host); + this._gpsChip = this._makeGpsChip(host); + + // Pan and rotate both mean "I want to set my own view", so both break follow; + // pinch/wheel-zoom keeps it (vessel-anchored). We guard on originalEvent so only + // real user gestures count — our own programmatic eases (recenter, and the + // per-fix bearing hold in course-/head-up) fire rotate events WITHOUT one. + this._onGesture = (e) => { if (!e || e.originalEvent) this._setFollow(false); }; + map.on("dragstart", this._onGesture); + map.on("rotatestart", this._onGesture); - // A user pan breaks follow; programmatic eases (our own recenters) don't fire - // dragstart, so this only triggers on a real gesture. - this._onDrag = () => this._setFollow(false); - map.on("dragstart", this._onDrag); + // GPS-freshness watchdog: ages the last fix into live/stale/lost independent of + // the feed cadence (a frozen GPS still pushes depth/wind deltas, so we can't + // wait on onChange to notice the position stopped). + this._gpsTimer = setInterval(() => this._tickGps(), 1000); // Defer layer creation until the style is ready (see _ensureLayers); subscribe // first so a not-yet-loaded style can't abort the constructor before we do. @@ -107,11 +148,47 @@ export class OwnShip { return btn; } + // A non-interactive status pill (top-centre) shown only when the fix goes + // stale/lost. Mirrors the recenter chip's host-mounted pattern. + _makeGpsChip(host) { + if (!host) return null; + const el = document.createElement("div"); + el.id = "ownship-gps"; + el.hidden = true; + host.appendChild(el); + return el; + } + _setFollow(on) { this._follow = on; this._syncChip(); } + // Watchdog tick: age the last fresh fix into live/stale/lost and reflect it. + _tickGps() { + if (!this._freshWall || !this._fix) return; + const age = Date.now() - this._freshWall; + const next = age > LOST_MS ? "lost" : age > STALE_MS ? "stale" : "live"; + if (next !== this._gps) this._applyGps(next); + } + + // Reflect GPS freshness: grey/fade the glyph, drop the vectors once not live, and + // show the status pill. Never removes the boat — it stays frozen at the last fix. + _applyGps(status) { + this._gps = status; + this._el.style.filter = + status === "lost" ? "grayscale(1) opacity(.4)" + : status === "stale" ? "grayscale(1) opacity(.6)" + : ""; + if (status !== "live") this._clearVectors(); // a frozen heading/COG vector would mislead + if (this._gpsChip) { + const lost = status === "lost"; + this._gpsChip.hidden = status === "live" || status === "none"; + this._gpsChip.className = lost ? "lost" : status === "stale" ? "stale" : ""; + this._gpsChip.textContent = lost ? "GPS lost" : "Position stale"; + } + } + // Plugin contract (consumed by WheelZoom via the shell): the geographic point // wheel-zoom should keep fixed while zooming — the vessel while we're following // a fix, else null so it falls back to cursor-anchored zoom. @@ -149,15 +226,53 @@ export class OwnShip { paint: { "line-color": "#16324f", "line-width": 1.8, "line-dasharray": [2, 1.8] } }, { belowLabels: true }, ); + // Heading line (HDT, the vessel's nose): SOLID, distinct from the dashed COG + // vector. The gap between the two is what reveals being set by current/wind. + if (!map.getSource(HSRC)) { + map.addSource(HSRC, { type: "geojson", data: this._lastH || EMPTY }); + } + this._plotter.addOverlayLayer( + { id: HCASING, type: "line", source: HSRC, layout: { "line-cap": "round" }, + paint: { "line-color": "#fff", "line-width": 4, "line-opacity": 0.9 } }, + { belowLabels: true }, + ); + this._plotter.addOverlayLayer( + { id: HLINE, type: "line", source: HSRC, layout: { "line-cap": "round" }, + paint: { "line-color": "#16324f", "line-width": 1.8 } }, + { belowLabels: true }, + ); } _update(s) { - const pos = s && s.navigation && s.navigation.position; + const nav = s && s.navigation; + const pos = nav && nav.position; if (!pos || typeof pos.lat !== "number") { - this._hide(); + // No position in the feed. If we've never had one, there's nothing to show. + // If we had a fix, keep it frozen and let the watchdog age it to lost — don't + // make the boat disappear. + if (!this._fix) this._hide(); return; } - const nav = s.navigation; + + // Freshness: the fix is "fresh" if it moved, or its own clock (RMC/ZDA datetime) + // advanced — the latter catches a healthy GPS at anchor. Either way, stamp the + // wall clock the watchdog ages. + const key = pos.lat.toFixed(6) + "," + pos.lon.toFixed(6); + const clock = nav.datetime ? Date.parse(nav.datetime) : 0; + if (key !== this._posKey || (clock && clock !== this._fixClock) || !this._freshWall) { + this._freshWall = Date.now(); + if (this._gps !== "live") this._applyGps("live"); + } + this._posKey = key; + if (clock) this._fixClock = clock; + + // Self-heal the vector sources/layers each fix (~1 Hz, idempotent). The plugin + // is built on the canvas `ready` event — after the initial `style.load` has + // already fired — and the chart rebuilds its style with setStyle({diff:false}), + // which drops plugin layers. Relying on the style.load listener alone left the + // sources never (re)added, so the predictor + heading line never drew. + this._ensureLayers(); + const lng = pos.lon; const lat = pos.lat; // Heading priority: true heading (HDT) → magnetic heading + variation (most @@ -219,17 +334,39 @@ export class OwnShip { } this._marker.setLngLat([lng, lat]).setRotation(rot); if (!this._added) { this._marker.addTo(this._map); this._added = true; } + // Vectors are dropped while the fix is stale/lost (a frozen vector misleads). + const live = this._gps === "live" || this._gps === "none"; + // COG/SOG vector (dashed): along course, length = SOG × predictMin. let data = EMPTY; - if (this._predict) { + if (live && this._predict) { const end = destination(lat, lng, this._predict.course, this._predict.sog * (this._predictMin / 60)); - data = { type: "FeatureCollection", features: [{ type: "Feature", geometry: { type: "LineString", coordinates: [[lng, lat], end] } }] }; + data = seg([lng, lat], end); } - this._last = data; + // Heading line (solid): along the rendered heading (`rot`). Matches the COG + // vector's length under way so the crab angle is visible; a short fixed line at + // rest so the bow is still shown at anchor. + let hdata = EMPTY; + if (live) { + const hlen = this._predict ? this._predict.sog * (this._predictMin / 60) : 0.3; + hdata = seg([lng, lat], destination(lat, lng, rot, hlen)); + } + this._last = data; this._lastH = hdata; const src = this._map.getSource(SRC); if (src) src.setData(data); + const hsrc = this._map.getSource(HSRC); + if (hsrc) hsrc.setData(hdata); this._renderLng = lng; this._renderLat = lat; this._renderRot = rot; } + // Clear both vector sources (used when the fix goes stale/lost). + _clearVectors() { + this._last = EMPTY; this._lastH = EMPTY; + const src = this._map && this._map.getSource(SRC); + if (src) src.setData(EMPTY); + const hsrc = this._map && this._map.getSource(HSRC); + if (hsrc) hsrc.setData(EMPTY); + } + // Tween the drawn pose to the new fix over `dur` ms (linear). Bearing takes the // shortest angular path. dur<=0 (or no prior pose) snaps. A new fix mid-tween // cancels and re-tweens from the current interpolated pose. @@ -270,23 +407,33 @@ export class OwnShip { this._added = false; } this._fix = null; - this._last = EMPTY; - const src = this._map && this._map.getSource(SRC); - if (src) src.setData(EMPTY); + this._freshWall = 0; this._posKey = null; this._fixClock = 0; + this._gps = "none"; + this._el.style.filter = ""; + if (this._gpsChip) this._gpsChip.hidden = true; + this._clearVectors(); this._syncChip(); } destroy() { if (this._poseRAF) cancelAnimationFrame(this._poseRAF); + if (this._gpsTimer) clearInterval(this._gpsTimer); if (this._off) this._off(); if (this._onStyle) this._map.off("style.load", this._onStyle); - if (this._onDrag) this._map.off("dragstart", this._onDrag); + if (this._onGesture) { this._map.off("dragstart", this._onGesture); this._map.off("rotatestart", this._onGesture); } if (this._marker) this._marker.remove(); if (this._chip) this._chip.remove(); + if (this._gpsChip) this._gpsChip.remove(); this._plotter.removeOverlay([CASING, LINE], SRC); + this._plotter.removeOverlay([HCASING, HLINE], HSRC); } } +// seg builds a one-segment LineString FeatureCollection from a→b. +function seg(a, b) { + return { type: "FeatureCollection", features: [{ type: "Feature", geometry: { type: "LineString", coordinates: [a, b] } }] }; +} + // num coerces a finite number or returns null (so `?? fallback` works and 0 is kept). function num(v) { return typeof v === "number" && isFinite(v) ? v : null;