@meri-imperiumi/signalk-passage-briefing 0.13.2 → 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.
Files changed (34) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +1 -0
  3. package/package.json +4 -2
  4. package/plugin/index.js +107 -3
  5. package/public/components/conditions-here.js +23 -0
  6. package/public/components/departure-control.js +11 -82
  7. package/public/components/map-model.mjs +32 -0
  8. package/public/components/map-view.js +131 -21
  9. package/public/components/models.mjs +81 -77
  10. package/public/components/passage-outlook.js +225 -36
  11. package/public/components/strategic-outlook.js +51 -8
  12. package/public/components/tactical-dashboard.js +48 -17
  13. package/public/landfall-estimator.mjs +548 -0
  14. package/public/landfall-tiles.mjs +384 -0
  15. package/public/polar.mjs +82 -107
  16. package/public/route-sim.mjs +146 -18
  17. package/public/sereno-physics.mjs +68 -0
  18. package/public/vendor/mvt/LICENSE.pbf.txt +27 -0
  19. package/public/vendor/mvt/LICENSE.point-geometry.txt +13 -0
  20. package/public/vendor/mvt/LICENSE.vector-tile.txt +27 -0
  21. package/public/vendor/mvt/pbf.js +867 -0
  22. package/public/vendor/mvt/point-geometry.js +324 -0
  23. package/public/vendor/mvt/vector-tile.js +371 -0
  24. package/public/vendor/polar/LICENSE.polar-format.txt +201 -0
  25. package/public/vendor/polar/LICENSE.polar-math.txt +201 -0
  26. package/public/vendor/polar/polar-format.js +321 -0
  27. package/public/vendor/polar/polar-math.js +466 -0
  28. package/public/worker.js +56 -3
  29. package/tests/departure-candidates.test.js +77 -0
  30. package/tests/landfall.test.js +428 -0
  31. package/tests/map-model.test.js +76 -2
  32. package/tests/map-view.test.js +49 -0
  33. package/tests/route-sim.test.js +61 -26
  34. package/tests/webapp.test.js +119 -67
package/CHANGELOG.md CHANGED
@@ -2,6 +2,26 @@
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.
19
+ ## [0.13.3] - 2026-10-19
20
+
21
+ ### Fixed
22
+ - Every map overlay — route, timeline pins, warning regions, notes, cyclone graphics — vanished mid-pan when the view crossed the antimeridian: `worldCopyJump` silently teleports a drag across the ±180° seam back into the primary world copy, where the basemap repeats (tiles wrap every world) but the overlays do not — they are unwrapped into the boat's own world copy (work doc #47), which on a Tonga→New Zealand passage lies west of the seam, so one pan across it left everything drawn 360° off-screen until the next data-driven fit. The option stays off now; overlays slide naturally with the pan instead of disappearing from a map that looks unchanged.
23
+ - The map drew the simplified simulated track instead of the route's own geometry: the "one route line" refactor computed the colorized route ribbon and then a leftover assignment overwrote it with the sim-row ribbon (shipped in v0.12.8), so the drawn line drifted a mile or two off the actual route — the exact drift the refactor was meant to remove. The row-or-route selection moved into the model (`ribbonSpec`) and the view renders it in one assignment.
24
+
5
25
  ## [0.13.2] - 2026-10-19
6
26
 
7
27
  ### 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.2",
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") {
@@ -870,6 +870,38 @@ export function routeRibbon(waypoints, track, referenceLon = null) {
870
870
  return segments.map(({ key, ...segment }) => segment);
871
871
  }
872
872
 
873
+ /**
874
+ * The one route line the map draws: the payload's real geometry
875
+ * colorized by the plan ({@link routeRibbon}) when it exists, the
876
+ * simulated rows ({@link ribbonSegments}) only without it (here mode
877
+ * and pre-compile views). The selection lives here — not in the view
878
+ * — so the row fallback cannot clobber the route line again (the
879
+ * discard regression of 2026-10: the view computed the route ribbon
880
+ * and then overwrote it with the rows).
881
+ *
882
+ * @param {Array<{lat: number, lon: number}>} waypoints - The viewed
883
+ * route's sampled geometry (empty in here mode)
884
+ * @param {Array<{distanceFromStartNm: number, lat: number, lon: number,
885
+ * comfortLevel: string|null, slatting: boolean, motoring: boolean,
886
+ * night: boolean}>} track - summary.track rows
887
+ * @param {number|null} referenceLon - The map's longitude frame
888
+ * @returns {Array<{points: number[][], comfortLevel: string|null,
889
+ * slatting: boolean, motoring: boolean, night: boolean}>}
890
+ */
891
+ export function ribbonSpec(waypoints, track, referenceLon = null) {
892
+ const hasGeometry =
893
+ (waypoints ?? []).filter(
894
+ (w) => Number.isFinite(w?.lat) && Number.isFinite(w?.lon),
895
+ ).length >= 2;
896
+ if (!hasGeometry) {
897
+ return ribbonSegments(track ?? [], referenceLon);
898
+ }
899
+ return (
900
+ routeRibbon(waypoints, track ?? [], referenceLon) ??
901
+ ribbonSegments(track ?? [], referenceLon)
902
+ );
903
+ }
904
+
873
905
  /**
874
906
  * The coordinates the map's initial fit frames (work doc #47 session:
875
907
  * the fit is the route's, not the warnings'). When route geometry