@meri-imperiumi/signalk-passage-briefing 0.13.3 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.14.0] - 2026-10-19
6
+
7
+ ### Changed
8
+ - The departure mode pulldown (Auto / Now / First light / In 1 hour / In 2 hours / Custom) is gone from the departure chip: the daylight anchor (work doc #15) decides the timing, the route switcher's departure-day variants (work doc #34) are the crew's explicit choices, and the chip stays as the honest statement of the effective departure — "First light 05:48" or "Underway — from now". The manual presets had become a second, weaker copy of the variant browsing; `effectiveDeparture`'s mode machinery left with them (`departureCandidates` is the one departure-decision path now).
9
+ - The polar consumption is rewritten on the vendored polar-math engine (work doc #25): monotone PCHIP interpolation over the *observed* cells only — a learned table's 0.0 cells mean "never sailed there", not zero speed, and no longer drag whole angle bands toward zero (the crew's own table read ~0.4 kn at 45°/6 kn under bilinear); below the modeled pinch boundary the lookup returns "in irons" instead of a fabricated speed; the dead-run band is modeled on VMG instead of wobbling between isolated cells. With it, the propulsion decision becomes speed-based: sail what the polar allows, and motor only when the achieved speed — on course, or VMG while beating — drops below the new `motoring_speed_threshold` (2 kn); `motoring_tws_threshold` now only gates the becalmed drift. In irons the plan beats the best angle the crew's own data supports: progress at VMG, comfort and rig read at the beat angle (the boat makes its own apparent wind, so beating works into light air — the fill floor checks AWS), and the timeline carries a "Beating — N h tacking · VMG" episode (⇄) per continuous stretch. Whole-passage plans shift with the more honest speeds, deliberately.
10
+ - The conditions-here map centers on the vessel's live `navigation.position` instead of the briefing payload's fetch-time waypoint (work doc #34 session): seeded with the briefing's own fix at render, refined by the stream's live one, and refitting only when the boat materially moves — a fix at anchor never yanks the view. The map also draws the vessel marker on every surface now — a static teal "you are here" dot at the boat on the here, tactical and strategic maps, riding along the route.
11
+
12
+ ### Added
13
+ - Arrival as a timeline event: the passage's payoff moment was only numbers on the ETA card while the timeline ran from departure to — nothing. The simulated track's final row is the nominal arrival, so the milestone lands on the same schedule the rest of the timeline reads: 🏁 row at the destination, night context and map pin included, distance from departure in the detail. It only appears when the plan actually reached the destination inside the forecast horizon — a horizon-cap end is no arrival, and the ETA card's percentiles already say why.
14
+ - Departure-day variants in the route switcher (work doc #34, before-departure half): while the boat is stationary, the active route's entry gains "«route», +24 h", "+48 h" and — with the default `departure_days` of 4 — "+72 h" options, each the daylight-anchored departure of its candidate day, the same work doc #15 rules (dawn anchor, prep delay) applied one day later, not a raw clock offset. Selecting one re-anchors the whole briefing to that day: departure chip, timeline, ETA percentiles and the simulated track all recompute from the same cached weather array — a comparison costs worker CPU, not fetches. Candidates departing beyond the payload's forecast horizon stay listed but disabled ("beyond forecast") rather than simulating on clamped weather; underway the variants collapse to the single now-departure, and touching the departure control overrides the browsed variant until it is re-picked. Two knobs back it: `departure_days` (candidate count, 1–7) and `forecast_days` (the route-briefing fetch horizon, default 7, up to 16) — a late variant is only simulable when the horizon reaches past its arrival, and every extra fetched day costs a fetch per waypoint per refresh.
15
+ - Landfall sightings in the briefing (work doc #6): the simulation now raycasts the planned course against the corridor DEM and reports where and when topography first breaks the visual horizon — "At 14:00 assumed landfall, Neiafu visible at 230° True." — named from the on-board vector chart's Shortbread place labels. Multiple sightings per passage are episodes: island chains pass through the same list, separated by an out-of-sight gate (`landfall_min_offshore_hours`, 12 h — nothing within the horizon for that long before a sighting counts as new, and none reported until the departure land has disappeared), the arrival coast flagged final and shown with the ETA block in the strategic outlook, every episode riding the unified timeline (⛰ glyph, also on the map pins) and the tactical 24 h slice. Configuration: `landfall_enabled`, `height_of_eye_m` (3 m eye height in the 2.08·(√h+√H) horizon), `landfall_max_range_nm` (120), `landfall_min_offshore_hours` (12). The estimator (`public/landfall-estimator.mjs`) is pure with injectable elevation and gazetteer; the browser readers (`public/landfall-tiles.mjs`) fetch the corridor downloader's elevation MBTiles (Mapterhorn GLO-30 lossless-WebP Terrarium tiles, decoded natively) and the Shortbread base vector tiles (`place_labels`) over the same chart endpoints the map already uses — no new online fetches, tiles are content the boat already carries.
16
+
17
+ ### Fixed
18
+ - The tactical screen's warnings map drew timeline event pins only for the 24 h slice while the strategic map drew the whole passage's — mid-passage the pins just stopped. The map now gets the full merged timeline like the strategic screen's does, so every placeable event shows where it happens; the timeline list stays the tactical 24 h slice, and the pin↔row click-through translates between the two index spaces through the shared event objects.
5
19
  ## [0.13.3] - 2026-10-19
6
20
 
7
21
  ### Fixed
package/README.md CHANGED
@@ -11,6 +11,7 @@ Offshore passage daily briefing webapp for Signal K: a plugin that plans and rev
11
11
  - Fetches bulletins, official structured alerts, global disaster events and synoptic charts for the waters the route actually crosses
12
12
  - Keeps working offline: everything fetches in the online window and survives the other 23 hours from the cache
13
13
  - Shows a mini summary on the chart plotter and learns from every logbook entry the crew writes
14
+ - Publishes weather warning areas as Signal K regions and notes for chartplotter apps to visualize
14
15
 
15
16
  The plugin fetches weather along the planned route — from the server's Weather API when a provider answers, Open-Meteo otherwise; fetched online, or via a GRIB/text spool when offline offshore. It runs a step-forward isochrone simulation with a monohull comfort model, learns the crew's sail preferences from the electronic logbook, and serves a two-screen webapp: a 24-hour tactical dashboard and a strategic passage summary. See [SPEC.md](SPEC.md) for the full design.
16
17
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@meri-imperiumi/signalk-passage-briefing",
3
- "version": "0.13.3",
4
- "description": "Offshore passage daily briefing webapp for Signal K",
3
+ "version": "0.14.0",
4
+ "description": "Offshore passage daily briefing including weather warnings for Signal K",
5
5
  "main": "plugin/index.js",
6
6
  "scripts": {
7
7
  "format": "npx @biomejs/biome check --use-editorconfig=true --write plugin/ tests/ bin/ public/",
@@ -45,8 +45,10 @@
45
45
  "node": ">=22.12.0"
46
46
  },
47
47
  "dependencies": {
48
+ "@mapbox/vector-tile": "^3.0.0",
48
49
  "@openwaters/almanac": "^0.10.0",
49
50
  "@openwaters/maritime-zones": "^0.2.1",
51
+ "pbf": "^5.1.2",
50
52
  "satellite.js": "^7.1.0"
51
53
  },
52
54
  "devDependencies": {
package/plugin/index.js CHANGED
@@ -189,6 +189,10 @@ const HERE_TTL_MS = 3 * 60 * 60 * 1000;
189
189
  */
190
190
  const DEFAULTS = {
191
191
  motoring_tws_threshold: 3.5,
192
+ // The engine's fallback bar: achieved speed (polar on course, or
193
+ // VMG while beating in irons) below this and the motor comes on
194
+ // (work doc #49)
195
+ motoring_speed_threshold: 2,
192
196
  // Downwind-sail night policy (work doc #47 session): "break" is
193
197
  // the default — no downwind sail at night; dusk strikes the sail
194
198
  // and reefable white sails (poled out) carry the dark period, dawn
@@ -203,6 +207,20 @@ const DEFAULTS = {
203
207
  k_heel: 0.35,
204
208
  k_pitch: 0.4,
205
209
  lines_of_interest_enabled: true,
210
+ /** Departure-day variants (work doc #34): how many candidate days
211
+ * the route switcher offers — today, +24 h, +48 h, +72 h at 4. */
212
+ departure_days: 4,
213
+ /** Forecast horizon for route briefings (days). The departure-day
214
+ * variants need the horizon to reach past the *latest* candidate's
215
+ * arrival — a +72 h variant of a week-long passage wants ~11 days.
216
+ * Each extra day is fetched for every waypoint on every refresh. */
217
+ forecast_days: DEFAULT_FORECAST_DAYS,
218
+ /** Landfall estimator (work doc #6): where and when topography
219
+ * first breaks the visual horizon along the planned course. */
220
+ landfall_enabled: true,
221
+ height_of_eye_m: 3,
222
+ landfall_max_range_nm: 120,
223
+ landfall_min_offshore_hours: 12,
206
224
  hazard_events_enabled: true,
207
225
  hazard_min_alert_level: "orange",
208
226
  hazard_radius_offroute_nm: 500,
@@ -302,6 +320,9 @@ module.exports = (app) => {
302
320
  k_heel: DEFAULTS.k_heel,
303
321
  k_pitch: DEFAULTS.k_pitch,
304
322
  };
323
+ /** Configured route-briefing fetch horizon in days (the default
324
+ * `forecastDays` every refresh path falls back to). */
325
+ let simulationForecastDays = DEFAULT_FORECAST_DAYS;
305
326
  /** @type {NodeJS.Timeout|null} */
306
327
  let cronTimer = null;
307
328
  /** Last known values of the watched paths. */
@@ -1596,7 +1617,7 @@ module.exports = (app) => {
1596
1617
  */
1597
1618
  async function refreshBriefingQueued(
1598
1619
  routeId,
1599
- forecastDays = DEFAULT_FORECAST_DAYS,
1620
+ forecastDays = simulationForecastDays,
1600
1621
  ) {
1601
1622
  if (!isOnline()) {
1602
1623
  sourceStatus?.record({ ...WEATHER_SOURCE, skip: true });
@@ -2525,9 +2546,22 @@ module.exports = (app) => {
2525
2546
  properties: {
2526
2547
  motoring_tws_threshold: {
2527
2548
  type: "number",
2528
- title: "Motoring TWS Threshold (knots)",
2549
+ title: "Becalmed Wind Threshold (knots)",
2550
+ description:
2551
+ "True wind below which the plan drifts (drift mode on) or " +
2552
+ "motors — the becalmed case. Above it, the engine decision " +
2553
+ "is speed-based, not wind-based.",
2529
2554
  default: DEFAULTS.motoring_tws_threshold,
2530
2555
  },
2556
+ motoring_speed_threshold: {
2557
+ type: "number",
2558
+ title: "Motoring Speed Threshold (knots)",
2559
+ description:
2560
+ "The engine's fallback bar: when the achieved speed — polar " +
2561
+ "speed on course, or VMG while beating in irons — drops " +
2562
+ "below this, the motor comes on (work doc #49).",
2563
+ default: DEFAULTS.motoring_speed_threshold,
2564
+ },
2531
2565
  downwind_night_policy: {
2532
2566
  type: "string",
2533
2567
  title: "Downwind Sail Night Policy",
@@ -2580,6 +2614,37 @@ module.exports = (app) => {
2580
2614
  "antimeridian crossings along the planned route.",
2581
2615
  default: DEFAULTS.lines_of_interest_enabled,
2582
2616
  },
2617
+ landfall_enabled: {
2618
+ type: "boolean",
2619
+ title: "Landfall sightings (visual horizon)",
2620
+ description:
2621
+ "Where and when topography first breaks the visual horizon " +
2622
+ "along the planned course, named from the corridor DEM and " +
2623
+ "its vector place names.",
2624
+ default: DEFAULTS.landfall_enabled,
2625
+ },
2626
+ height_of_eye_m: {
2627
+ type: "number",
2628
+ title: "Landfall Height of Eye (meters)",
2629
+ description:
2630
+ "Observer height above the water for the visual-horizon " +
2631
+ "distance: land at height H appears at 2.08·(√h+√H) nm.",
2632
+ default: DEFAULTS.height_of_eye_m,
2633
+ },
2634
+ landfall_max_range_nm: {
2635
+ type: "number",
2636
+ title: "Landfall Scan Range (nm)",
2637
+ default: DEFAULTS.landfall_max_range_nm,
2638
+ },
2639
+ landfall_min_offshore_hours: {
2640
+ type: "number",
2641
+ title: "Landfall Minimum Offshore Hours",
2642
+ description:
2643
+ "Land becomes a reported sighting only after this many hours " +
2644
+ "with nothing within the visual horizon — coasting an island " +
2645
+ "does not repeat the announcement.",
2646
+ default: DEFAULTS.landfall_min_offshore_hours,
2647
+ },
2583
2648
  hazard_events_enabled: {
2584
2649
  type: "boolean",
2585
2650
  title: "GDACS Hazard Events (earthquakes, cyclones, floods)",
@@ -2648,6 +2713,27 @@ module.exports = (app) => {
2648
2713
  "(−6° = civil dawn).",
2649
2714
  default: DEFAULTS.departure_dawn_altitude_deg,
2650
2715
  },
2716
+ departure_days: {
2717
+ type: "number",
2718
+ title: "Departure Variant Days",
2719
+ description:
2720
+ "How many departure days the route switcher offers while " +
2721
+ "stationary: today plus the +24 h/+48 h/… candidates, each " +
2722
+ "the daylight-anchored departure of its own day. The latest " +
2723
+ "candidate also needs the forecast horizon to cover its " +
2724
+ "arrival.",
2725
+ default: DEFAULTS.departure_days,
2726
+ },
2727
+ forecast_days: {
2728
+ type: "number",
2729
+ title: "Forecast Horizon (days)",
2730
+ description:
2731
+ "How many days of weather each route briefing fetches. Every " +
2732
+ "extra day costs a fetch per waypoint per refresh — and is " +
2733
+ "what makes late departure variants (work doc #34) simulable " +
2734
+ "instead of disabled beyond forecast.",
2735
+ default: DEFAULTS.forecast_days,
2736
+ },
2651
2737
  celestial_eye_height_m: {
2652
2738
  type: "number",
2653
2739
  title: "Eye Height Above Water (meters)",
@@ -2884,14 +2970,32 @@ module.exports = (app) => {
2884
2970
  typeof station.station === "string",
2885
2971
  )
2886
2972
  : [];
2973
+ // Departure-day variants (work doc #34): the candidate-day count
2974
+ // the route switcher offers, clamped to a sane range
2975
+ const departureDays = Math.min(
2976
+ Math.max(Math.round(Number(config.departure_days) || 4), 1),
2977
+ 7,
2978
+ );
2979
+ // Forecast horizon (days): the fetch weight knob. The default
2980
+ // stays the historical 7 — a late departure variant wants more
2981
+ simulationForecastDays = Math.min(
2982
+ Math.max(Math.round(Number(config.forecast_days) || 7), 1),
2983
+ 16,
2984
+ );
2887
2985
  simulationConfig = {
2888
2986
  motoring_tws_threshold: config.motoring_tws_threshold,
2987
+ motoring_speed_threshold: config.motoring_speed_threshold,
2889
2988
  drift_mode_enabled: config.drift_mode_enabled,
2890
2989
  motor_fuel_l_per_hour: config.motor_fuel_l_per_hour,
2891
2990
  waterline_length_m: config.waterline_length_m,
2892
2991
  k_heel: config.k_heel,
2893
2992
  k_pitch: config.k_pitch,
2894
2993
  lines_of_interest_enabled: config.lines_of_interest_enabled,
2994
+ landfall_enabled: config.landfall_enabled !== false,
2995
+ height_of_eye_m: config.height_of_eye_m,
2996
+ landfall_max_range_nm: config.landfall_max_range_nm,
2997
+ landfall_min_offshore_hours: config.landfall_min_offshore_hours,
2998
+ departure_days: departureDays,
2895
2999
  // Downwind sail inventory for the deployment advisor (work
2896
3000
  // doc #41): empty until the start-time read lands — the
2897
3001
  // advisor stays silent rather than recommending canvas the
@@ -3193,7 +3297,7 @@ module.exports = (app) => {
3193
3297
  const days =
3194
3298
  typeof req.query.days === "string" && Number(req.query.days) > 0
3195
3299
  ? Number(req.query.days)
3196
- : DEFAULT_FORECAST_DAYS;
3300
+ : simulationForecastDays;
3197
3301
  res.json(await refreshBriefing(routeId, days));
3198
3302
  } catch (error) {
3199
3303
  app.error(`Briefing refresh failed: ${error.message}`);
@@ -366,6 +366,17 @@ class ConditionsHere extends HTMLElement {
366
366
  map.setPayload(payload);
367
367
  map.setBulletin(payload?.metareaBulletin ?? null);
368
368
  map.hidden = !map.hasShapes();
369
+ // Seed the center with the briefing's own fix: the payload's
370
+ // waypoint is the position the briefing was compiled at, and a
371
+ // real center beats the birth view even before the stream
372
+ // delivers the live one (the race that stranded the map at 0,0)
373
+ const seeded = payload?.waypoints?.[0];
374
+ if (Number.isFinite(seeded?.lat) && Number.isFinite(seeded?.lon)) {
375
+ map.setVesselPosition({ lat: seeded.lat, lon: seeded.lon });
376
+ }
377
+ if (this._vesselPosition != null) {
378
+ map.setVesselPosition(this._vesselPosition);
379
+ }
369
380
  }
370
381
  this._map = map;
371
382
 
@@ -515,6 +526,18 @@ class ConditionsHere extends HTMLElement {
515
526
  }
516
527
  }
517
528
 
529
+ /**
530
+ * The vessel's live position (work doc #34 session): forwards to
531
+ * the map so the here view centers on `navigation.position`, not
532
+ * the briefing payload's fetch-time waypoint.
533
+ *
534
+ * @param {{lat: number, lon: number}|null} position
535
+ */
536
+ setVesselPosition(position) {
537
+ this._vesselPosition = position ?? null;
538
+ this._map?.setVesselPosition?.(position);
539
+ }
540
+
518
541
  /**
519
542
  * Click-through target (work doc #47): scroll to and flash one
520
543
  * warning block's text inside the console box.
@@ -1,17 +1,13 @@
1
1
  /**
2
- * `<departure-control>` — the departure-time control shared by the
3
- * tactical and strategic views (work doc #15, SPEC §6.3 element
4
- * conventions).
2
+ * `<departure-control>` — the departure chip shared by the tactical
3
+ * and strategic views (work doc #15, SPEC §6.3 element conventions).
5
4
  *
6
- * Auto mode anchors to daylight (next civil dawn at night, prep delay
7
- * during the day, next dawn when the sun would set within the prep
8
- * window); the manual presets — Now, First light, +1 h, +2 h, and a
9
- * custom instant — are the crew's word and override auto until it is
10
- * put back to Auto. The root component owns the state; this element
11
- * renders it and reports changes, so both views always agree.
12
- *
13
- * The chip line states the assumption instead of silently shifting
14
- * numbers: "First light 05:48 (+7 h)" or "Underway — from now".
5
+ * Displays the effective departure instead of silently shifting
6
+ * numbers: "First light 05:48 (+7 h)" or "Underway — from now". The
7
+ * timing itself is decided in two places: the daylight anchor (auto,
8
+ * work doc #15) and the route switcher's departure-day variants
9
+ * (work doc #34) — the old mode pulldown went away once the variants
10
+ * made the manual presets redundant.
15
11
  *
16
12
  * DOM-free import safe: the element registers only in the browser.
17
13
  *
@@ -20,15 +16,6 @@
20
16
 
21
17
  import { fmtShip } from "./models.mjs";
22
18
 
23
- const MODES = [
24
- ["auto", "Auto (daylight)"],
25
- ["now", "Now"],
26
- ["dawn", "First light"],
27
- ["+1h", "In 1 hour"],
28
- ["+2h", "In 2 hours"],
29
- ["custom", "Custom…"],
30
- ];
31
-
32
19
  /**
33
20
  * Reason → chip text, the part the departure anchor explains.
34
21
  *
@@ -64,52 +51,17 @@ class DepartureControl extends HTMLElement {
64
51
  this.shadowRoot.innerHTML = `
65
52
  <style>
66
53
  :host { display: block; }
67
- .row {
68
- display: flex; flex-wrap: wrap; gap: 8px;
69
- align-items: center; margin: 8px 0 0;
70
- }
71
54
  .chip {
72
55
  font-family: var(--font-data, ui-monospace, monospace);
73
56
  font-size: 0.8rem; font-weight: 700;
74
57
  letter-spacing: 0.08em; text-transform: uppercase;
75
58
  color: var(--color-grey);
59
+ margin: 8px 0 0;
76
60
  }
77
- select, input {
78
- min-height: 36px; padding: 4px 8px;
79
- }
80
- input[hidden] { display: none; }
81
61
  </style>
82
- <div class="row">
83
- <span class="chip" id="chip"></span>
84
- <select id="mode" aria-label="Departure time"></select>
85
- <input type="datetime-local" id="custom" hidden
86
- aria-label="Custom departure time"></input>
87
- </div>
62
+ <span class="chip" id="chip"></span>
88
63
  `;
89
64
  this._chip = this.shadowRoot.getElementById("chip");
90
- this._select = this.shadowRoot.getElementById("mode");
91
- this._custom = this.shadowRoot.getElementById("custom");
92
- for (const [value, label] of MODES) {
93
- const option = document.createElement("option");
94
- option.value = value;
95
- option.textContent = label;
96
- this._select.appendChild(option);
97
- }
98
- this._select.addEventListener("change", () => {
99
- const mode = this._select.value;
100
- this._custom.hidden = mode !== "custom";
101
- if (mode !== "custom") {
102
- this._customTime = null;
103
- }
104
- this.emit({ mode, customTime: this._customTime });
105
- });
106
- this._custom.addEventListener("change", () => {
107
- if (this._custom.value) {
108
- // datetime-local is naive local time; convert to epoch
109
- this._customTime = new Date(this._custom.value).toISOString();
110
- this.emit({ mode: "custom", customTime: this._customTime });
111
- }
112
- });
113
65
  this._rendered = true;
114
66
  }
115
67
  this.render(this._state ?? {});
@@ -119,31 +71,14 @@ class DepartureControl extends HTMLElement {
119
71
  * Renders the current departure state.
120
72
  *
121
73
  * @param {object} state
122
- * @param {string} state.mode - Selected mode
123
74
  * @param {{time: string|Date, reason: string, assumed: boolean}|
124
75
  * null} state.departure - Effective departure
125
- * @param {string|null} state.customTime - Custom instant (ISO)
126
76
  */
127
77
  render(state) {
128
78
  this._state = state ?? {};
129
- if (!this._select) {
79
+ if (!this._chip) {
130
80
  return; // Not yet connected: rendered on connect
131
81
  }
132
- if (this._select.value !== this._state.mode) {
133
- this._select.value = this._state.mode ?? "auto";
134
- this._custom.hidden = this._select.value !== "custom";
135
- }
136
- if (this._state.customTime && this._state.mode === "custom") {
137
- const custom = this._state.customTime;
138
- if (this._custom.value !== custom) {
139
- // datetime-local wants local wall time; offset from ISO
140
- const date = new Date(custom);
141
- const pad = (n) => String(n).padStart(2, "0");
142
- this._custom.value =
143
- `${date.getFullYear()}-${pad(date.getMonth() + 1)}-` +
144
- `${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
145
- }
146
- }
147
82
  const departure = this._state.departure;
148
83
  if (!departure?.time) {
149
84
  this._chip.textContent = "";
@@ -155,12 +90,6 @@ class DepartureControl extends HTMLElement {
155
90
  ? `${reason} ${stamp}`
156
91
  : `Departure ${stamp}`;
157
92
  }
158
-
159
- emit(detail) {
160
- this.dispatchEvent(
161
- new CustomEvent("departurechange", { detail, bubbles: true }),
162
- );
163
- }
164
93
  }
165
94
 
166
95
  if (typeof customElements !== "undefined") {
@@ -32,6 +32,7 @@
32
32
 
33
33
  /* global L */
34
34
 
35
+ import { greatCircleNm } from "../sereno-physics.mjs";
35
36
  import {
36
37
  blockShapes,
37
38
  chartAssetsFromManifest,
@@ -63,6 +64,9 @@ class PbMapView extends HTMLElement {
63
64
  this._blocks = [];
64
65
  this._payload = null;
65
66
  this._vessel = null;
67
+ this._liveFix = null;
68
+ this._vesselMarker = null;
69
+ this._fitted = false;
66
70
  this._ribbonRows = [];
67
71
  this._events = [];
68
72
  this._resourceRegions = {};
@@ -107,6 +111,25 @@ class PbMapView extends HTMLElement {
107
111
  0 0 3px var(--bg-base, #080a0c),
108
112
  0 0 3px var(--bg-base, #080a0c);
109
113
  }
114
+ /* The vessel herself (work doc #34 session): a live "you are
115
+ here" marker at navigation.position, the way
116
+ signalk-dead-reckoning marks the boat — a pulsing dot the
117
+ crew finds instantly on every map */
118
+ .pb-vessel {
119
+ background: transparent;
120
+ border: none;
121
+ }
122
+ .pb-vessel-dot {
123
+ display: block;
124
+ width: 10px;
125
+ height: 10px;
126
+ border-radius: 50%;
127
+ background: var(--color-teal, #4ec9b0);
128
+ border: 2px solid rgba(255, 255, 255, 0.55);
129
+ box-shadow:
130
+ 0 0 6px rgba(78, 201, 176, 0.8),
131
+ 0 0 2px rgba(78, 201, 176, 0.9);
132
+ }
110
133
  .pb-pin.sev-warn { filter: sepia(1) saturate(4) hue-rotate(-25deg); }
111
134
  .pb-pin.sev-severe { filter: sepia(1) saturate(6) hue-rotate(-40deg); }
112
135
  .pb-pin:focus { outline: none; }
@@ -449,7 +472,9 @@ class PbMapView extends HTMLElement {
449
472
  setPayload(payload) {
450
473
  this._payload = payload ?? null;
451
474
  // The vessel anchors the map when no route does (here mode: the
452
- // payload carries the vessel's position as its single waypoint)
475
+ // payload carries the vessel's position as its single waypoint).
476
+ // A live fix already received outranks the fetch-time waypoint —
477
+ // setVesselPosition keeps it current.
453
478
  const waypoints = this._payload?.waypoints ?? [];
454
479
  this._vessel =
455
480
  waypoints.length === 1 &&
@@ -457,10 +482,60 @@ class PbMapView extends HTMLElement {
457
482
  Number.isFinite(waypoints[0]?.lon)
458
483
  ? waypoints[0]
459
484
  : null;
485
+ if (this._liveFix != null) {
486
+ this._vessel = this._liveFix;
487
+ }
460
488
  this._render();
461
489
  this._fit();
462
490
  }
463
491
 
492
+ /**
493
+ * The vessel's live position (work doc #34 session): the here-mode
494
+ * map centers on `navigation.position`, not the briefing payload's
495
+ * fetch-time waypoint. Refits only when the boat materially moved —
496
+ * a fix at anchor must not yank the view the crew is reading; the
497
+ * distance test is against the position the current fit was built
498
+ * on.
499
+ *
500
+ * @param {{lat: number, lon: number}|null} position
501
+ */
502
+ setVesselPosition(position) {
503
+ if (
504
+ !position ||
505
+ !Number.isFinite(position.lat) ||
506
+ !Number.isFinite(position.lon)
507
+ ) {
508
+ return;
509
+ }
510
+ this._liveFix = position;
511
+ const previous = this._vessel;
512
+ this._vessel = position;
513
+ const moved =
514
+ previous != null &&
515
+ Number.isFinite(previous.lat) &&
516
+ Number.isFinite(previous.lon)
517
+ ? greatCircleNm(previous.lat, previous.lon, position.lat, position.lon)
518
+ : Infinity;
519
+ // The direct center applies only when the vessel anchors the view
520
+ // (here mode, no route ribbon) — route maps keep their route fit
521
+ // and draw the marker along it.
522
+ const anchors = (this._ribbon ?? []).length === 0;
523
+ if (anchors && (previous == null || moved > 5 || !this._fitted)) {
524
+ this._fit();
525
+ // Belt and braces: the fit's bounds arithmetic can bail mid-reveal
526
+ // (hidden container, unmeasured tick), and the observer then has
527
+ // no size change left to refit on. The vessel center itself is
528
+ // always finite — land on it directly when the map is live.
529
+ if (!this._fitted && this.map != null && this.mapEl.clientWidth > 50) {
530
+ this.map.setView([position.lat, position.lon], 6);
531
+ this._fitted = true;
532
+ }
533
+ }
534
+ if (this._vesselMarker != null) {
535
+ this._vesselMarker.setLatLng([position.lat, position.lon]);
536
+ }
537
+ }
538
+
464
539
  /**
465
540
  * The simulated passage rows (work doc #47): they colorize the real
466
541
  * route geometry — comfort tier colors, motoring stretches dashed,
@@ -792,6 +867,26 @@ class PbMapView extends HTMLElement {
792
867
  .addTo(this._overlayGroup);
793
868
  this._eventMarkers.set(pin.index, marker);
794
869
  }
870
+ // The vessel herself (work doc #34 session): a live "you are
871
+ // here" marker at navigation.position, dead-reckoning style.
872
+ // Drawn on every map — here mode centers on it, route mode draws
873
+ // it along the route.
874
+ if (this._vesselMarker) {
875
+ this._vesselMarker.remove();
876
+ this._vesselMarker = null;
877
+ }
878
+ if (this._vessel != null && this.map != null) {
879
+ this._vesselMarker = L.marker([this._vessel.lat, this._vessel.lon], {
880
+ interactive: false,
881
+ zIndexOffset: 1000,
882
+ icon: L.divIcon({
883
+ className: "pb-vessel",
884
+ html: '<span class="pb-vessel-dot"></span>',
885
+ iconSize: [14, 14],
886
+ iconAnchor: [7, 7],
887
+ }),
888
+ }).addTo(this.map);
889
+ }
795
890
  }
796
891
 
797
892
  /** Click-through: hand the console the block index. */
@@ -885,17 +980,28 @@ class PbMapView extends HTMLElement {
885
980
  // under 50 px is unmeasured — the resize tick refits once the
886
981
  // card reveals.
887
982
  if (this.mapEl.clientWidth < 50 || this.mapEl.clientHeight < 50) {
983
+ console.info(
984
+ "pb-map-view: fit deferred — container unmeasured (hidden or mid-layout)",
985
+ );
888
986
  this._needsFit = true;
889
987
  return;
890
988
  }
891
989
  const all = this._drawablePoints();
892
- // No route: the vessel anchors the view — a couple hundred miles
893
- // of water around her, a fabricated square so the span holds
894
- // across screen sizes instead of leaning on a zoom level
895
- const vesselBounds =
896
- all.length === 1 && this._vessel ? vesselFitBounds(this._vessel) : null;
990
+ // The vessel anchors the view — a couple hundred miles of water
991
+ // around her, a fabricated square so the span holds across screen
992
+ // sizes instead of leaning on a zoom level. Keyed on the vessel's
993
+ // presence, not on what else is drawable: the here map's shapes
994
+ // and regions arrive asynchronously, and a fit that waits for
995
+ // them never runs at all — the map stays on its birth view (the
996
+ // 0,0 the crew stared at).
997
+ const vesselAnchors =
998
+ this._vessel != null && (this._ribbon ?? []).length === 0;
999
+ const vesselBounds = vesselAnchors ? vesselFitBounds(this._vessel) : null;
897
1000
  const bounds = vesselBounds ?? frameBounds(all);
898
1001
  if (!bounds) {
1002
+ console.info(
1003
+ "pb-map-view: fit skipped — nothing drawable yet (vessel and shapes pending)",
1004
+ );
899
1005
  return;
900
1006
  }
901
1007
  const [minLon, minLat, maxLon, maxLat] = bounds;
@@ -938,7 +1044,9 @@ class PbMapView extends HTMLElement {
938
1044
  // tick refits
939
1045
  this.map.setView([0, 0], 3);
940
1046
  this._needsFit = true;
1047
+ return;
941
1048
  }
1049
+ this._fitted = true;
942
1050
  }
943
1051
  }
944
1052