@meri-imperiumi/signalk-passage-briefing 0.9.0 → 0.10.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 +15 -0
- package/README.md +6 -6
- package/SPEC.md +17 -7
- package/package.json +1 -1
- package/plugin/fetch-engine.js +10 -3
- package/plugin/weather-source.js +4 -0
- package/public/brief-ext-widget.js +2 -2
- package/public/components/comfort-info.js +61 -2
- package/public/components/conditions-here.js +4 -1
- package/public/components/models.mjs +63 -8
- package/public/components/passage-outlook.js +11 -9
- package/public/components/passage-timeline.js +1 -0
- package/public/components/tactical-dashboard.js +5 -4
- package/public/css/visuals.css +4 -4
- package/public/route-sim.mjs +70 -1
- package/public/sereno-physics.mjs +20 -10
- package/public/tack-gybe.js +31 -0
- package/status-tiles-examples.json +2 -2
- package/tests/backtest.test.js +1 -1
- package/tests/fetch-engine.test.js +8 -0
- package/tests/openmeteo-mock.js +2 -0
- package/tests/plugin.test.js +1 -1
- package/tests/route-sim.test.js +48 -1
- package/tests/sereno-physics.test.js +8 -8
- package/tests/statustilesexamples.test.js +1 -1
- package/tests/tack-gybe.test.js +15 -0
- package/tests/weather-source.test.js +3 -0
- package/tests/webapp.test.js +80 -15
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.10.0] - 2026-10-14
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- Rain-shower episodes on the unified timeline (probable squalls): Open-Meteo `precipitation` (hourly mm/h) and `precipitation_probability` join the fetch, ride `weatherAt`'s interpolation under the same unknown-stays-null rule as cloud cover, and hours at or over the shower bar (≥ 0.5 mm/h rate or ≥ 50 % probability — whichever the model still publishes at that horizon) merge into contiguous `rainEpisodes` carrying the peak rate and probability. They render as info-level 🌧 timeline entries (“Rain showers · 2.5 mm/h · 80 % · until …”) — tropical rain rarely arrives without squalls, so probable rain is the crew's probable-squall advisory without claiming convection's warning weight. The sky line on tactical and here gains the now rain segment above the same bars (🌧 65 %, or 🌧 0.8 mm/h when the model publishes only a rate — drier hours stay quiet); Weather API provider mode publishes no precipitation fields and degrades to absent rather than inventing them.
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
- Two comfort tiers renamed for the project's modified Sereno scale — `coffee` is now `sporty` ("Lively enough that a hot coffee needs a firm grip.") and `sick` is now `miserable` ("Nobody is enjoying this. Seasickness very likely.") — the canonical identifiers everywhere: physics module, CSS tier tokens (`--comfort-sporty`/`--comfort-miserable`), the embed widget's color map, the status-tile example mapping, and the Signal K value the plugin publishes. Briefings compiled before the rename serve raw old tier names — unstyled, never wrong-colored — until their next compile rewrites them; no compatibility mapping, one unambiguous scale.
|
|
12
|
+
- Sail-change and maneuver timeline entries name the point of sail: `Genoa 1 + Main — broad reach · 9.5 kn TWS · Hs 1.1 m · coffee · at dawn`. The signed TWA at the change point (nearest hourly step, wind-from-starboard positive, same convention as the maneuver tack calls) rides the event's conditions block as `twaDeg`; the timeline renders it as the standard angle-band text (close-hauled / close reach / beam reach / broad reach / running, sign-blind since the maneuver label already calls the tack). Payloads compiled before the field degrade to the previous detail line without inventing an angle.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
- Conditions here showed two "Update now" buttons stacked when no cached conditions existed: the no-cached-conditions strip carried its own button while the conditions card head directly below always carries one. The strip now stays text-only in here mode (the card-head button is the refresh affordance there, present with and without cached data); the route-mode strip keeps its button, as the tab views have no other on-screen refresh.
|
|
16
|
+
- The comfort-scale legend (the ℹ️ popover) opened past the right edge of a phone screen, dragging the page into a sideways scroll with half the legend unreachable. While open it now pins to the viewport — clamped inside the window on both axes, flipping above the summary when the space below runs out, internally scrollable when taller than the screen, and repositioning on scroll, resize and tier-highlight changes.
|
|
17
|
+
- The conditions-here celestial & space events card rendered raw ISO timestamps (`2026-10-12T07:06:12.650Z Tiangong pass: …`) instead of the app's formatted stamps: the renderer read a `stamp` field the plugin never sets, falling through to the raw event timestamp. Events now render through the shared ship's-time formatter like every other view (`MM-DD HH:MM` with the zone offset, `MM-DD HH:MMZ` in UTC when the vessel publishes no timezone), per the Signal K visuals time-formatting rules.
|
|
18
|
+
- Energy timeline events rendered their detail stamps in UTC (`battery depleted by 06-22 03:00Z`) while the same rows' timeline stamp renders in ship's time — a leftover from before the ship's-time work, forcing the crew to do arithmetic between two renderings of the same instant. The `until`/`battery depleted by` details now use the shared ship's-time formatter like every other stamp.
|
|
19
|
+
|
|
5
20
|
## [0.9.0] - 2026-10-12
|
|
6
21
|
|
|
7
22
|
### Added
|
package/README.md
CHANGED
|
@@ -7,14 +7,14 @@ Offshore passage daily briefing webapp for Signal K: a plugin that plans and rev
|
|
|
7
7
|
- Simulates the passage hourly from the boat's actual position: speed from the vessel's polar, current added, ETA percentiles for the remaining route
|
|
8
8
|
- Recommends sail changes hour by hour from what the crew actually did, learned from the electronic logbook
|
|
9
9
|
- Rates every hour on the Sereno comfort scale and flags when light air in a residual swell will be worse than a gale
|
|
10
|
-
- Renders one unified timeline: sail work, tacks and gybes, clock changes, territorial waters, official alerts, sky events and hazards in order
|
|
10
|
+
- Renders one unified timeline: sail work, tacks and gybes, clock changes, territorial waters, official alerts, sky events, rain showers and hazards in order
|
|
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
14
|
|
|
15
15
|
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
16
|
|
|
17
|
-
Part of the Lille Ø offshore suite, alongside [@meri-imperiumi/signalk-energy-predictor](https://github.com/meri-imperiumi/signalk-energy-
|
|
17
|
+
Part of the Lille Ø offshore suite, alongside [@meri-imperiumi/signalk-energy-predictor](https://github.com/meri-imperiumi/signalk-energy-predictor) and [@meri-imperiumi/signalk-logbook](https://github.com/meri-imperiumi/signalk-logbook).
|
|
18
18
|
|
|
19
19
|
## Data sources
|
|
20
20
|
|
|
@@ -32,13 +32,13 @@ Part of the Lille Ø offshore suite, alongside [@meri-imperiumi/signalk-energy-p
|
|
|
32
32
|
- [signalk-ships-time](https://github.com/meri-imperiumi/signalk-ships-time) — `environment.time.timezoneOffset` / `.timezoneRegion`, the vessel's published timezone: briefing stamps render in ship's time when available (the offset rides on every stamp), falling back to UTC `Z` when nothing is published
|
|
33
33
|
- [signalk-watch-schedule](https://github.com/hoeken/signalk-watch-schedule) — `watch.state.onWatch`, `watch.state.startedAt`, `watch.system` and `watch.schedule`: the running watch rotation. While a watch is running, planned sail changes anchor to the previous watch handover (both teams awake) instead of sunrise/sunset; boundaries are extrapolated across the forecast horizon from the rotation cycle
|
|
34
34
|
- [@signalk/sailsconfiguration](https://www.npmjs.com/package/@signalk/sailsconfiguration) — sail inventory used to filter free-text noise out of log entries
|
|
35
|
-
- Weather API (`app.weatherApi`) — waypoint forecasts from the registered provider, the preferred source (`weather_source: auto`) whenever one answers; [signalk-weather-router-plus](https://github.com/motamman/signalk-weather-router-plus) serves it from its decoded ECMWF run, so briefing numbers match what the router planned with. That source carries combined sea only: swell/wind-sea partitions
|
|
35
|
+
- Weather API (`app.weatherApi`) — waypoint forecasts from the registered provider, the preferred source (`weather_source: auto`) whenever one answers; [signalk-weather-router-plus](https://github.com/motamman/signalk-weather-router-plus) serves it from its decoded ECMWF run, so briefing numbers match what the router planned with. That source carries combined sea only: swell/wind-sea partitions, the upper-air fields behind the convective warnings and the precipitation fields behind the rain-shower episodes degrade to absent rather than being invented
|
|
36
36
|
|
|
37
37
|
### External
|
|
38
38
|
|
|
39
|
-
The Open-Meteo entries below are the fallback weather source: used when no Weather API provider answers (or `weather_source` is `open-meteo`), and the only source of the partition
|
|
39
|
+
The Open-Meteo entries below are the fallback weather source: used when no Weather API provider answers (or `weather_source` is `open-meteo`), and the only source of the partition, upper-air and precipitation fields the Weather API providers don't publish.
|
|
40
40
|
|
|
41
|
-
- [Open-Meteo Forecast API](https://open-meteo.com/en/docs) — surface wind, gusts, MSL pressure, CAPE and the pressure-layer fields behind the K-index (best-match model)
|
|
41
|
+
- [Open-Meteo Forecast API](https://open-meteo.com/en/docs) — surface wind, gusts, MSL pressure, CAPE, precipitation (hourly rate and probability) and the pressure-layer fields behind the K-index (best-match model)
|
|
42
42
|
- [NOAA SWPC planetary K-index forecast](https://services.swpc.noaa.gov/products/noaa-planetary-k-index-forecast.json) — geomagnetic activity behind the aurora advisories
|
|
43
43
|
- [JPL SBDB query API](https://ssd-api.jpl.nasa.gov/doc/sbdb_query.html) — comet brightness parameters (M1/K1) behind the Sky Notes comets
|
|
44
44
|
- [CelesTrak](https://celestrak.org/NORAD/elements/gp.php?GROUP=stations&FORMAT=tle) — two-line elements for the crewed stations (ISS, Tiangong); bright satellite passes are propagated locally from the fetched TLEs with SGP4, gated to nautical night, clear sky and a 15° haze line. The same service's per-catalog queries (`gp.php?CATNR=…`) supply the current elements of decaying satellites named by Space-Track's reentry predictions
|
|
@@ -64,7 +64,7 @@ All external fetches are online-gated and cached to the plugin data directory, s
|
|
|
64
64
|
|
|
65
65
|
## Acknowledgments
|
|
66
66
|
|
|
67
|
-
The comfort model and the whole idea of "what will each departure actually feel like" come from SV Sabado's [passage-weather](https://github.com/sailing12388/passage-weather) ensemble departure planner by Ray Hendricks.
|
|
67
|
+
The comfort model and the whole idea of "what will each departure actually feel like" come from SV Sabado's [passage-weather](https://github.com/sailing12388/passage-weather) ensemble departure planner by Ray Hendricks. Ours is a modified Sereno comfort scale, not a direct copy: the thresholds and the encounter-period motion math are adapted for a monohull (heel and waterline-pitch resonance instead of catamaran beam-roll and bridgedeck slam), the tier names with their meanings are our own — Champagne, Easy, Sporty, Rough, Miserable — and the slatting penalty (light air over a residual swell snap-rolling the boat, which raw acceleration scores as calm) is a vector upstream lacks entirely; a catamaran slats far less, and few models bother with light-air discomfort. The ISO 2631-1 comfort bands complete the model.
|
|
68
68
|
|
|
69
69
|
## License
|
|
70
70
|
|
package/SPEC.md
CHANGED
|
@@ -287,6 +287,8 @@ interface TimeStepForecast {
|
|
|
287
287
|
twd: number; // degrees true
|
|
288
288
|
mslp: number; // hPa
|
|
289
289
|
cloudCover: number | null; // percent (0-100); null when the source carries none
|
|
290
|
+
precip: number | null; // mm/h; null when the source carries none
|
|
291
|
+
precipProbability: number | null; // percent (0-100); null when unknown
|
|
290
292
|
};
|
|
291
293
|
marine: {
|
|
292
294
|
hsCombined: number; // meters
|
|
@@ -439,15 +441,17 @@ Executed inside `public/worker.js` for time steps $\Delta t = 1.0\text{ hour}$:
|
|
|
439
441
|
|
|
440
442
|
### 5.2 Monohull Sereno Comfort Index Formulation
|
|
441
443
|
|
|
444
|
+
A modified Sereno comfort scale, not a direct copy of the upstream planner's: the band thresholds and motion math derive from passage-weather, the tier names and their one-line meanings are this project's own, and the slatting & roll-dampening penalty below is a vector upstream has nothing of — a catamaran slats far less, and light-air discomfort rarely gets modeled.
|
|
445
|
+
|
|
442
446
|
#### Vector 1: Apparent Wind Speed Rating
|
|
443
447
|
|
|
444
448
|
$$AWS = \sqrt{TWS^2 + STW^2 + 2 \cdot TWS \cdot STW \cdot \cos(TWA)}$$
|
|
445
449
|
|
|
446
450
|
* $AWS < 12\text{ kt} \implies \mathbf{Champagne}$
|
|
447
451
|
* $12\text{ kt} \le AWS < 18\text{ kt} \implies \mathbf{Easy}$
|
|
448
|
-
* $18\text{ kt} \le AWS < 23\text{ kt} \implies \mathbf{
|
|
452
|
+
* $18\text{ kt} \le AWS < 23\text{ kt} \implies \mathbf{Sporty}$
|
|
449
453
|
* $23\text{ kt} \le AWS < 33\text{ kt} \implies \mathbf{Rough}$
|
|
450
|
-
* $AWS \ge 33\text{ kt} \implies \mathbf{
|
|
454
|
+
* $AWS \ge 33\text{ kt} \implies \mathbf{Miserable}$
|
|
451
455
|
|
|
452
456
|
#### Vector 2: Monohull Vertical Acceleration ($a_z$)
|
|
453
457
|
|
|
@@ -482,9 +486,9 @@ $$a_z = a_{z,\text{base}} \cdot \mu_{\text{heel}} \cdot \mu_{\text{pitch}}$$
|
|
|
482
486
|
|
|
483
487
|
* $a_z < 0.15\text{ m/s}^2 \implies \mathbf{Champagne}$
|
|
484
488
|
* $0.15 \le a_z < 0.315\text{ m/s}^2 \implies \mathbf{Easy}$
|
|
485
|
-
* $0.315 \le a_z < 0.630\text{ m/s}^2 \implies \mathbf{
|
|
489
|
+
* $0.315 \le a_z < 0.630\text{ m/s}^2 \implies \mathbf{Sporty}$
|
|
486
490
|
* $0.630 \le a_z < 1.250\text{ m/s}^2 \implies \mathbf{Rough}$
|
|
487
|
-
* $a_z \ge 1.250\text{ m/s}^2 \implies \mathbf{
|
|
491
|
+
* $a_z \ge 1.250\text{ m/s}^2 \implies \mathbf{Miserable}$
|
|
488
492
|
|
|
489
493
|
#### Slatting & Roll-Dampening Penalty (work doc #14)
|
|
490
494
|
|
|
@@ -527,9 +531,9 @@ $$\text{IntersectCount} = \sum_{j=1}^{k} \text{RayCrossesSegment}(P_{\text{inter
|
|
|
527
531
|
--color-fg: #ffcc00;
|
|
528
532
|
--color-champagne: #00ffcc;
|
|
529
533
|
--color-easy: #00ff00;
|
|
530
|
-
--color-
|
|
534
|
+
--color-sporty: #ffcc00;
|
|
531
535
|
--color-rough: #ff6600;
|
|
532
|
-
--color-
|
|
536
|
+
--color-miserable: #ff0000;
|
|
533
537
|
--font-family: 'Courier New', Courier, monospace;
|
|
534
538
|
}
|
|
535
539
|
|
|
@@ -565,6 +569,12 @@ function filterExceptions(simulationResult) {
|
|
|
565
569
|
totalFuelGal: simulationResult.fuelConsumptionGal,
|
|
566
570
|
macroSeaAnomalies: simulationResult.seaStateAnomalies.filter(a => a.steepnessRatio < 3.28),
|
|
567
571
|
convectiveWarnings: simulationResult.upperAirAnomalies.filter(u => u.cape > 1000 || u.kIndex > 28),
|
|
572
|
+
// Rain-shower episodes: hours at or over the shower bar
|
|
573
|
+
// (precip ≥ 0.5 mm/h or probability ≥ 50 %) merged into one
|
|
574
|
+
// entry each with the peak rate and probability. The crew reads
|
|
575
|
+
// probable rain as probable squalls in the tropics — info-level
|
|
576
|
+
// timeline entries, not warnings
|
|
577
|
+
rainEpisodes: mergeEpisodes(simulationResult.rainHours),
|
|
568
578
|
// Meteor-shower nights evaluated along the track (work doc #37):
|
|
569
579
|
// per (shower, night) spaceEvents gated at the night's nautical-dusk
|
|
570
580
|
// anchor, the radiant checked at the track's forecasted position;
|
|
@@ -605,7 +615,7 @@ Displays:
|
|
|
605
615
|
|
|
606
616
|
#### `<horizon-sparkline>` (SVG Renderer)
|
|
607
617
|
|
|
608
|
-
Renders a 24-column SVG bar chart where bar background color maps to `comfortLevel` (Champagne, Easy,
|
|
618
|
+
Renders a 24-column SVG bar chart where bar background color maps to `comfortLevel` (Champagne, Easy, Sporty, Rough, Miserable) and bar height represents $AWS$.
|
|
609
619
|
|
|
610
620
|
---
|
|
611
621
|
|
package/package.json
CHANGED
package/plugin/fetch-engine.js
CHANGED
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
* Open-Meteo, the same source SV Sabado's passage-weather vetted
|
|
6
6
|
* against raw ECMWF/NOAA GRIB:
|
|
7
7
|
*
|
|
8
|
-
* - forecast API: surface wind (kn), gusts, MSL pressure, CAPE,
|
|
9
|
-
*
|
|
10
|
-
* T850/T700/T500 and
|
|
8
|
+
* - forecast API: surface wind (kn), gusts, MSL pressure, CAPE,
|
|
9
|
+
* precipitation (rate and probability), and the pressure-layer
|
|
10
|
+
* fields behind the K-index (computed here from T850/T700/T500 and
|
|
11
|
+
* dewpoints via Magnus);
|
|
11
12
|
* - marine API (`ncep_gfswave025`): combined sea plus wind sea and
|
|
12
13
|
* swell partitions (the SPEC §3.1 marine block needs the split;
|
|
13
14
|
* ECMWF's split fields come back empty per passage-weather's
|
|
@@ -74,6 +75,8 @@ const FORECAST_HOURLY = [
|
|
|
74
75
|
"relative_humidity_700hPa",
|
|
75
76
|
"wind_speed_850hPa",
|
|
76
77
|
"cloud_cover",
|
|
78
|
+
"precipitation",
|
|
79
|
+
"precipitation_probability",
|
|
77
80
|
].join(",");
|
|
78
81
|
|
|
79
82
|
const WAVE_HOURLY = [
|
|
@@ -445,6 +448,8 @@ function buildTimeSteps(surfaceMap, marineMap, currentMap) {
|
|
|
445
448
|
mslp: num(surface.pressure_msl),
|
|
446
449
|
gust,
|
|
447
450
|
cloudCover: num(surface.cloud_cover),
|
|
451
|
+
precip: num(surface.precipitation),
|
|
452
|
+
precipProbability: num(surface.precipitation_probability),
|
|
448
453
|
},
|
|
449
454
|
marine: {
|
|
450
455
|
hsCombined: num(marine.wave_height),
|
|
@@ -533,6 +538,8 @@ async function fetchWeatherAlongTrack({
|
|
|
533
538
|
"relative_humidity_700hPa",
|
|
534
539
|
"wind_speed_850hPa",
|
|
535
540
|
"cloud_cover",
|
|
541
|
+
"precipitation",
|
|
542
|
+
"precipitation_probability",
|
|
536
543
|
]);
|
|
537
544
|
const marineMap = hourlyLookup(waveBlocks[index], [
|
|
538
545
|
"wave_height",
|
package/plugin/weather-source.js
CHANGED
|
@@ -98,6 +98,10 @@ function weatherDataToTimeStep(item) {
|
|
|
98
98
|
// The provider publishes no cloud cover; the celestial
|
|
99
99
|
// visibility gate stays open (work doc #3 Phase 2)
|
|
100
100
|
cloudCover: null,
|
|
101
|
+
// Nor any precipitation fields; rain-shower episodes degrade
|
|
102
|
+
// to absent rather than being invented
|
|
103
|
+
precip: null,
|
|
104
|
+
precipProbability: null,
|
|
101
105
|
},
|
|
102
106
|
marine: {
|
|
103
107
|
hsCombined: Number.isFinite(item.water?.waveSignificantHeight)
|
|
@@ -279,9 +279,9 @@ class BriefExtWidget extends HTMLElement {
|
|
|
279
279
|
{
|
|
280
280
|
champagne: "#8dfcbb",
|
|
281
281
|
easy: "#8dfcbb",
|
|
282
|
-
|
|
282
|
+
sporty: "#fca847",
|
|
283
283
|
rough: "#fca847",
|
|
284
|
-
|
|
284
|
+
miserable: "#ff5e5e",
|
|
285
285
|
}[String(comfort).toLowerCase()] ?? "var(--text-main, #c4c4c4)";
|
|
286
286
|
const stale = this.values["navigation.briefing.stale"] === true;
|
|
287
287
|
const ageEl = root.querySelector("#age");
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The ℹ️ next to the comfort tier: an expanding explainer for the
|
|
3
|
-
* Sereno comfort scale (champagne →
|
|
4
|
-
* tier via the `tier` attribute. Used by the tactical dashboard and
|
|
3
|
+
* project's modified Sereno comfort scale (champagne → miserable),
|
|
4
|
+
* highlighting the current tier via the `tier` attribute. Used by the tactical dashboard and
|
|
5
5
|
* the conditions-here view.
|
|
6
6
|
*
|
|
7
7
|
* @file components/comfort-info.js
|
|
@@ -32,6 +32,7 @@ class ComfortInfo extends HTMLElement {
|
|
|
32
32
|
border: 1px solid var(--color-grey);
|
|
33
33
|
font-family: var(--font-data, ui-monospace, monospace);
|
|
34
34
|
font-size: 0.75rem; text-transform: none; letter-spacing: normal;
|
|
35
|
+
max-height: calc(100vh - 16px); overflow-y: auto;
|
|
35
36
|
}
|
|
36
37
|
.note { color: var(--color-grey); margin: 0 0 6px; }
|
|
37
38
|
.row { margin: 4px 0; }
|
|
@@ -45,10 +46,66 @@ class ComfortInfo extends HTMLElement {
|
|
|
45
46
|
</details>
|
|
46
47
|
`;
|
|
47
48
|
this._details = this.shadowRoot.querySelector("details");
|
|
49
|
+
this._summary = this.shadowRoot.querySelector("summary");
|
|
48
50
|
this._pop = this.shadowRoot.getElementById("pop");
|
|
51
|
+
// Mobile-safe popover (viewport-clamped): anchored absolute it
|
|
52
|
+
// runs off the right edge of a phone screen and drags the page
|
|
53
|
+
// into a sideways scroll. While open it is pinned to the
|
|
54
|
+
// viewport instead, repositioning on scroll and resize.
|
|
55
|
+
this._details.addEventListener("toggle", () => {
|
|
56
|
+
if (this._details.open) {
|
|
57
|
+
requestAnimationFrame(() => this._positionPop());
|
|
58
|
+
window.addEventListener("scroll", this, true);
|
|
59
|
+
window.addEventListener("resize", this);
|
|
60
|
+
} else {
|
|
61
|
+
window.removeEventListener("scroll", this, true);
|
|
62
|
+
window.removeEventListener("resize", this);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
49
65
|
this.renderPop();
|
|
50
66
|
}
|
|
51
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Repositions the open popover as a viewport-fixed overlay clamped
|
|
70
|
+
* inside the window (handleEvent target for scroll/resize).
|
|
71
|
+
*/
|
|
72
|
+
handleEvent() {
|
|
73
|
+
this._positionPop();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
disconnectedCallback() {
|
|
77
|
+
window.removeEventListener("scroll", this, true);
|
|
78
|
+
window.removeEventListener("resize", this);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Pins the open popover inside the viewport, flipping above the
|
|
82
|
+
* summary when the space below runs out. */
|
|
83
|
+
_positionPop() {
|
|
84
|
+
if (!this._details?.open || !this._pop) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const rect = this._summary.getBoundingClientRect();
|
|
88
|
+
const margin = 8;
|
|
89
|
+
const gap = 6;
|
|
90
|
+
const width = this._pop.offsetWidth;
|
|
91
|
+
const height = this._pop.offsetHeight;
|
|
92
|
+
const left = Math.min(
|
|
93
|
+
Math.max(rect.left, margin),
|
|
94
|
+
Math.max(window.innerWidth - width - margin, margin),
|
|
95
|
+
);
|
|
96
|
+
let top = rect.bottom + gap;
|
|
97
|
+
if (top + height > window.innerHeight - margin) {
|
|
98
|
+
top = rect.top - height - gap;
|
|
99
|
+
}
|
|
100
|
+
top = Math.min(
|
|
101
|
+
Math.max(top, margin),
|
|
102
|
+
Math.max(window.innerHeight - height - margin, margin),
|
|
103
|
+
);
|
|
104
|
+
this._pop.style.position = "fixed";
|
|
105
|
+
this._pop.style.left = `${left}px`;
|
|
106
|
+
this._pop.style.top = `${top}px`;
|
|
107
|
+
}
|
|
108
|
+
|
|
52
109
|
attributeChangedCallback() {
|
|
53
110
|
this.renderPop();
|
|
54
111
|
}
|
|
@@ -101,6 +158,8 @@ class ComfortInfo extends HTMLElement {
|
|
|
101
158
|
"wind < 7–12 kn by point of sail · swell ≥ 0.6 m";
|
|
102
159
|
slatting.appendChild(slattingBand);
|
|
103
160
|
this._pop.appendChild(slatting);
|
|
161
|
+
// Content changes (tier highlight) can resize the open popover
|
|
162
|
+
this._positionPop();
|
|
104
163
|
}
|
|
105
164
|
}
|
|
106
165
|
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import {
|
|
16
16
|
fmtKn,
|
|
17
|
+
fmtShip,
|
|
17
18
|
fmtUtc,
|
|
18
19
|
hereEnergySummary,
|
|
19
20
|
hereHourly,
|
|
@@ -318,6 +319,8 @@ class ConditionsHere extends HTMLElement {
|
|
|
318
319
|
this._skyEl.innerHTML = "";
|
|
319
320
|
for (const segment of skySegments(payload ?? null, {
|
|
320
321
|
cloudCover: now.cloudCover ?? null,
|
|
322
|
+
precip: now.precipMmH ?? null,
|
|
323
|
+
precipProbability: now.precipProbability ?? null,
|
|
321
324
|
})) {
|
|
322
325
|
const span = document.createElement("span");
|
|
323
326
|
span.textContent = segment.text;
|
|
@@ -353,7 +356,7 @@ class ConditionsHere extends HTMLElement {
|
|
|
353
356
|
for (const event of events) {
|
|
354
357
|
const el = document.createElement("div");
|
|
355
358
|
el.textContent =
|
|
356
|
-
`${event.
|
|
359
|
+
`${fmtShip(event.timestamp)} ${event.description ?? event.kind ?? ""}`.trim();
|
|
357
360
|
// Provenance (work doc #31): satellite passes carry the live
|
|
358
361
|
// tracker link; computed ephemeris carries none
|
|
359
362
|
const provUrl = event.provenance?.url;
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
serenoComfort,
|
|
15
15
|
travelDirection,
|
|
16
16
|
} from "../sereno-physics.mjs";
|
|
17
|
+
import { pointOfSail } from "../tack-gybe.js";
|
|
17
18
|
|
|
18
19
|
const DEG = Math.PI / 180;
|
|
19
20
|
|
|
@@ -24,9 +25,9 @@ const DEG = Math.PI / 180;
|
|
|
24
25
|
export const COMFORT_TIERS = [
|
|
25
26
|
{ level: "champagne", css: "var(--comfort-champagne)" },
|
|
26
27
|
{ level: "easy", css: "var(--comfort-easy)" },
|
|
27
|
-
{ level: "
|
|
28
|
+
{ level: "sporty", css: "var(--comfort-sporty)" },
|
|
28
29
|
{ level: "rough", css: "var(--comfort-rough)" },
|
|
29
|
-
{ level: "
|
|
30
|
+
{ level: "miserable", css: "var(--comfort-miserable)" },
|
|
30
31
|
];
|
|
31
32
|
|
|
32
33
|
const TIER_BY_LEVEL = new Map(COMFORT_TIERS.map((t) => [t.level, t.css]));
|
|
@@ -538,6 +539,8 @@ export function hereNow(payload, rows = []) {
|
|
|
538
539
|
mslpHpa: null,
|
|
539
540
|
mslpTrend: null,
|
|
540
541
|
cloudCover: null,
|
|
542
|
+
precipMmH: null,
|
|
543
|
+
precipProbability: null,
|
|
541
544
|
stamp: "",
|
|
542
545
|
};
|
|
543
546
|
}
|
|
@@ -561,6 +564,8 @@ export function hereNow(payload, rows = []) {
|
|
|
561
564
|
mslpHpa: mslpNow,
|
|
562
565
|
mslpTrend,
|
|
563
566
|
cloudCover: step.surface?.cloudCover ?? null,
|
|
567
|
+
precipMmH: step.surface?.precip ?? null,
|
|
568
|
+
precipProbability: step.surface?.precipProbability ?? null,
|
|
564
569
|
stamp: fmtShip(step.timestamp),
|
|
565
570
|
};
|
|
566
571
|
}
|
|
@@ -866,12 +871,21 @@ function moonGlyphFor(timestamp, payload) {
|
|
|
866
871
|
* picked for (default: the payload's fetch time)
|
|
867
872
|
* @param {number|null} [options.cloudCover] - Cloud cover percent at
|
|
868
873
|
* that instant
|
|
874
|
+
* @param {number|null} [options.precip] - Precipitation rate (mm/h) at
|
|
875
|
+
* that instant
|
|
876
|
+
* @param {number|null} [options.precipProbability] - Precipitation
|
|
877
|
+
* probability percent at that instant
|
|
869
878
|
* @returns {Array<{text: string, title: string}>} Empty when the
|
|
870
879
|
* payload predates the sky data
|
|
871
880
|
*/
|
|
872
881
|
export function skySegments(
|
|
873
882
|
payload,
|
|
874
|
-
{
|
|
883
|
+
{
|
|
884
|
+
timestamp = null,
|
|
885
|
+
cloudCover = null,
|
|
886
|
+
precip = null,
|
|
887
|
+
precipProbability = null,
|
|
888
|
+
} = {},
|
|
875
889
|
) {
|
|
876
890
|
const night = nearestNight(
|
|
877
891
|
timestamp ?? payload?.metadata?.fetchedAt ?? null,
|
|
@@ -922,6 +936,26 @@ export function skySegments(
|
|
|
922
936
|
title: `cloud cover ${Math.round(cloudCover)} %`,
|
|
923
937
|
});
|
|
924
938
|
}
|
|
939
|
+
// Rain rides the same line as the cloud it falls from — but only
|
|
940
|
+
// when it is probable: the segment speaks at the same shower bars
|
|
941
|
+
// the timeline's rain episodes use (route-sim.mjs RAIN_MM_PER_H /
|
|
942
|
+
// RAIN_PROBABILITY_PERCENT; mirrored here to keep this module
|
|
943
|
+
// dependency-light). A 10 % chance is noise, not weather.
|
|
944
|
+
const rainyRate = precip != null && Number.isFinite(precip) && precip >= 0.5;
|
|
945
|
+
const rainyProb =
|
|
946
|
+
precipProbability != null &&
|
|
947
|
+
Number.isFinite(precipProbability) &&
|
|
948
|
+
precipProbability >= 50;
|
|
949
|
+
if (rainyProb || rainyRate) {
|
|
950
|
+
segments.push({
|
|
951
|
+
text: rainyProb
|
|
952
|
+
? `🌧 ${Math.round(precipProbability)}%`
|
|
953
|
+
: `🌧 ${precip.toFixed(1)}mm`,
|
|
954
|
+
title: rainyProb
|
|
955
|
+
? `precipitation probability ${Math.round(precipProbability)} %`
|
|
956
|
+
: `precipitation ${precip.toFixed(1)} mm/h`,
|
|
957
|
+
});
|
|
958
|
+
}
|
|
925
959
|
return segments;
|
|
926
960
|
}
|
|
927
961
|
|
|
@@ -1271,15 +1305,16 @@ export function mergeTimeline(exceptions, payload = null) {
|
|
|
1271
1305
|
const label = maneuver
|
|
1272
1306
|
? `${e.maneuver === "tack" ? "Tack" : "Gybe"} to ${e.toTack ?? tack ?? "?"}`
|
|
1273
1307
|
: sailEventLabel(e) || state;
|
|
1274
|
-
// Conditions at the change point (work doc #18):
|
|
1275
|
-
//
|
|
1276
|
-
// back to the bare maneuver TWS for payloads without the
|
|
1308
|
+
// Conditions at the change point (work doc #18): how the boat
|
|
1309
|
+
// sits to the wind, then true wind, sea state, comfort tier.
|
|
1310
|
+
// Falls back to the bare maneuver TWS for payloads without the
|
|
1277
1311
|
// enriched conditions block. When the tier came from the
|
|
1278
1312
|
// slatting penalty (work doc #14: light air over a residual
|
|
1279
1313
|
// swell), the detail says so — "rough" alone would send someone
|
|
1280
1314
|
// looking for wind that isn't there.
|
|
1281
1315
|
const conditions = e.conditions ?? null;
|
|
1282
1316
|
const tws = conditions?.twsKnots ?? e.twsAtManeuver ?? null;
|
|
1317
|
+
const posText = pointOfSail(conditions?.twaDeg ?? null);
|
|
1283
1318
|
// Why the change sits where it sits: watch handover when a watch
|
|
1284
1319
|
// schedule runs, else the sunrise/sunset it waits for
|
|
1285
1320
|
const anchorText =
|
|
@@ -1300,6 +1335,7 @@ export function mergeTimeline(exceptions, payload = null) {
|
|
|
1300
1335
|
e.distanceFromStartNm != null
|
|
1301
1336
|
? `${Math.round(e.distanceFromStartNm)} nm`
|
|
1302
1337
|
: null,
|
|
1338
|
+
posText,
|
|
1303
1339
|
tws != null ? `${fmtKn(tws)} TWS` : null,
|
|
1304
1340
|
conditions?.hsMeters != null
|
|
1305
1341
|
? `Hs ${conditions.hsMeters.toFixed(1)} m`
|
|
@@ -1352,6 +1388,25 @@ export function mergeTimeline(exceptions, payload = null) {
|
|
|
1352
1388
|
});
|
|
1353
1389
|
}
|
|
1354
1390
|
|
|
1391
|
+
// Rain-shower episodes (probable squalls in the tropics): peak
|
|
1392
|
+
// hourly rate and probability over the range, info-level — rain
|
|
1393
|
+
// informs where convection warns
|
|
1394
|
+
for (const r of summary.rainEpisodes ?? []) {
|
|
1395
|
+
push({
|
|
1396
|
+
hoursFromNow: r.hoursFromNow ?? null,
|
|
1397
|
+
timestamp: r.timestamp ?? null,
|
|
1398
|
+
kind: "rain",
|
|
1399
|
+
severity: "info",
|
|
1400
|
+
label: "Rain showers",
|
|
1401
|
+
detail: episodeDetail(r, [
|
|
1402
|
+
r.precip != null ? `${r.precip.toFixed(1)} mm/h` : null,
|
|
1403
|
+
r.precipProbability != null
|
|
1404
|
+
? `${Math.round(r.precipProbability)} %`
|
|
1405
|
+
: null,
|
|
1406
|
+
]),
|
|
1407
|
+
});
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1355
1410
|
// Hazard notes along the track (whole passage; the tactical slice
|
|
1356
1411
|
// picks up its share by hoursFromNow). Provenance (work doc #31):
|
|
1357
1412
|
// the note's url is the GDACS event page the note was built from
|
|
@@ -1582,10 +1637,10 @@ export function mergeTimeline(exceptions, payload = null) {
|
|
|
1582
1637
|
? `${e.netWh >= 0 ? "+" : "−"}${(Math.abs(e.netWh) / 1000).toFixed(1)} kWh`
|
|
1583
1638
|
: null,
|
|
1584
1639
|
e.timestamp != null && e.endTimestamp != null
|
|
1585
|
-
? `until ${
|
|
1640
|
+
? `until ${fmtShip(e.endTimestamp)}`
|
|
1586
1641
|
: null,
|
|
1587
1642
|
deficit && e.timeToEmpty != null
|
|
1588
|
-
? `battery depleted by ${
|
|
1643
|
+
? `battery depleted by ${fmtShip(e.timeToEmpty)}`
|
|
1589
1644
|
: null,
|
|
1590
1645
|
e.type === "surplus" ? "run opportunistic loads" : null,
|
|
1591
1646
|
]
|
|
@@ -671,15 +671,17 @@ class PassageOutlook extends HTMLElement {
|
|
|
671
671
|
mode === "here"
|
|
672
672
|
? "No cached conditions yet"
|
|
673
673
|
: "No cached briefing for this route";
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
//
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
674
|
+
strip.append(text);
|
|
675
|
+
// Route mode has no other refresh affordance on screen; here mode
|
|
676
|
+
// carries its own Update now in the conditions card head (work
|
|
677
|
+
// doc #7) — a second button on the strip read as a duplicate
|
|
678
|
+
if (mode !== "here") {
|
|
679
|
+
const button = document.createElement("button");
|
|
680
|
+
button.textContent = "Update now";
|
|
681
|
+
button.disabled = this._status?.online !== true;
|
|
682
|
+
button.addEventListener("click", () => this.refreshBriefing());
|
|
683
|
+
strip.append(button);
|
|
684
|
+
}
|
|
683
685
|
this._view.before(strip);
|
|
684
686
|
}
|
|
685
687
|
|
|
@@ -259,11 +259,12 @@ strong.sev {
|
|
|
259
259
|
return;
|
|
260
260
|
}
|
|
261
261
|
el.innerHTML = "";
|
|
262
|
-
const
|
|
263
|
-
this._payload?.waypoints?.[0]?.forecasts?.[0]?.surface
|
|
264
|
-
null;
|
|
262
|
+
const nowSurface =
|
|
263
|
+
this._payload?.waypoints?.[0]?.forecasts?.[0]?.surface ?? null;
|
|
265
264
|
const segments = skySegments(this._payload ?? null, {
|
|
266
|
-
cloudCover:
|
|
265
|
+
cloudCover: nowSurface?.cloudCover ?? null,
|
|
266
|
+
precip: nowSurface?.precip ?? null,
|
|
267
|
+
precipProbability: nowSurface?.precipProbability ?? null,
|
|
267
268
|
});
|
|
268
269
|
for (const segment of segments) {
|
|
269
270
|
const span = document.createElement("span");
|
package/public/css/visuals.css
CHANGED
|
@@ -18,9 +18,9 @@
|
|
|
18
18
|
/* Comfort tiers (Sereno physics, SPEC §5.2) — night-lean defaults */
|
|
19
19
|
--comfort-champagne: #33616b;
|
|
20
20
|
--comfort-easy: #4a7555;
|
|
21
|
-
--comfort-
|
|
21
|
+
--comfort-sporty: #8a5318;
|
|
22
22
|
--comfort-rough: #a05218;
|
|
23
|
-
--comfort-
|
|
23
|
+
--comfort-miserable: #8f3333;
|
|
24
24
|
--comfort-unknown: #333333;
|
|
25
25
|
|
|
26
26
|
--color-green: #4a7555;
|
|
@@ -39,9 +39,9 @@
|
|
|
39
39
|
:root[data-mode="day"] {
|
|
40
40
|
--comfort-champagne: #66c6db;
|
|
41
41
|
--comfort-easy: #8dfcbb;
|
|
42
|
-
--comfort-
|
|
42
|
+
--comfort-sporty: #fca847;
|
|
43
43
|
--comfort-rough: #ff8847;
|
|
44
|
-
--comfort-
|
|
44
|
+
--comfort-miserable: #ff5e5e;
|
|
45
45
|
--comfort-unknown: #666666;
|
|
46
46
|
|
|
47
47
|
--color-green: #8dfcbb;
|
package/public/route-sim.mjs
CHANGED
|
@@ -39,7 +39,7 @@ import {
|
|
|
39
39
|
suggestSailState,
|
|
40
40
|
travelDirection,
|
|
41
41
|
} from "./sereno-physics.mjs";
|
|
42
|
-
import { detectManeuvers } from "./tack-gybe.js";
|
|
42
|
+
import { detectManeuvers, wrap180 } from "./tack-gybe.js";
|
|
43
43
|
import tzLookup from "./vendor/tz-lookup/tz-lookup.mjs";
|
|
44
44
|
|
|
45
45
|
/**
|
|
@@ -389,6 +389,16 @@ export function weatherAt(forecasts, timestamp) {
|
|
|
389
389
|
sa.cloudCover == null || sb.cloudCover == null
|
|
390
390
|
? null
|
|
391
391
|
: mix(sa.cloudCover, sb.cloudCover),
|
|
392
|
+
// Rain fields follow the same honesty rule: unknown on either
|
|
393
|
+
// side of the span interpolates to null, never invented
|
|
394
|
+
precip:
|
|
395
|
+
sa.precip == null || sb.precip == null
|
|
396
|
+
? null
|
|
397
|
+
: mix(sa.precip, sb.precip),
|
|
398
|
+
precipProbability:
|
|
399
|
+
sa.precipProbability == null || sb.precipProbability == null
|
|
400
|
+
? null
|
|
401
|
+
: mix(sa.precipProbability, sb.precipProbability),
|
|
392
402
|
},
|
|
393
403
|
marine: {
|
|
394
404
|
hsCombined: mix(ma.hsCombined, mb.hsCombined),
|
|
@@ -492,6 +502,22 @@ const CONVECTIVE_K_INDEX = 30;
|
|
|
492
502
|
*/
|
|
493
503
|
const CONVECTIVE_K_INDEX_MARGINAL = 28;
|
|
494
504
|
|
|
505
|
+
/**
|
|
506
|
+
* Hourly precipitation rate at which a rain-shower hour is captured
|
|
507
|
+
* (mm/h). Tropical rain rarely arrives without squalls, so the crew
|
|
508
|
+
* reads a shower band as probable squalls; below the bar an hour is a
|
|
509
|
+
* stray drop, not weather.
|
|
510
|
+
*/
|
|
511
|
+
const RAIN_MM_PER_H = 0.5;
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Precipitation probability at which a rain-shower hour is captured
|
|
515
|
+
* (%). The probability field extends detection into the later forecast
|
|
516
|
+
* days, where some models publish it long after they stop being
|
|
517
|
+
* confident about hourly rates.
|
|
518
|
+
*/
|
|
519
|
+
const RAIN_PROBABILITY_PERCENT = 50;
|
|
520
|
+
|
|
495
521
|
/**
|
|
496
522
|
* Maximum gap in hours between two hourly anomalies that still counts
|
|
497
523
|
* as one continuing episode (covers hourly forecast steps and the
|
|
@@ -876,6 +902,7 @@ export function simulateRun({
|
|
|
876
902
|
const positions = [];
|
|
877
903
|
const seaStateAnomalies = [];
|
|
878
904
|
const upperAirAnomalies = [];
|
|
905
|
+
const rainHours = [];
|
|
879
906
|
const timeEvents = [];
|
|
880
907
|
let arrivedHours = null;
|
|
881
908
|
let distanceMadeGoodNm = 0;
|
|
@@ -1110,6 +1137,24 @@ export function simulateRun({
|
|
|
1110
1137
|
});
|
|
1111
1138
|
}
|
|
1112
1139
|
|
|
1140
|
+
// Rain-shower hours (probable squalls): either the model's hourly
|
|
1141
|
+
// rate crosses the shower bar or its probability does — the two
|
|
1142
|
+
// fields have independent availability across the horizon
|
|
1143
|
+
const precip = weather?.surface?.precip;
|
|
1144
|
+
const precipProbability = weather?.surface?.precipProbability;
|
|
1145
|
+
if (
|
|
1146
|
+
(precip != null && precip >= RAIN_MM_PER_H) ||
|
|
1147
|
+
(precipProbability != null &&
|
|
1148
|
+
precipProbability >= RAIN_PROBABILITY_PERCENT)
|
|
1149
|
+
) {
|
|
1150
|
+
rainHours.push({
|
|
1151
|
+
hoursFromNow: Math.round(hours * 10) / 10,
|
|
1152
|
+
timestamp: t.toISOString(),
|
|
1153
|
+
precip,
|
|
1154
|
+
precipProbability,
|
|
1155
|
+
});
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1113
1158
|
// Waypoint progress
|
|
1114
1159
|
if (arrivedNow) {
|
|
1115
1160
|
if (targetIndex === track.length - 1) {
|
|
@@ -1175,6 +1220,7 @@ export function simulateRun({
|
|
|
1175
1220
|
positions,
|
|
1176
1221
|
seaStateAnomalies,
|
|
1177
1222
|
upperAirAnomalies,
|
|
1223
|
+
rainHours,
|
|
1178
1224
|
timeEvents,
|
|
1179
1225
|
hazardAlerts: hazardAlerts(notes, positions),
|
|
1180
1226
|
};
|
|
@@ -1484,6 +1530,15 @@ export function simulatePassage({
|
|
|
1484
1530
|
conditions: {
|
|
1485
1531
|
twsKnots: row.twsKnots ?? null,
|
|
1486
1532
|
awsKnots: row.awsKnots ?? null,
|
|
1533
|
+
// Signed TWA at the change point (positive = wind
|
|
1534
|
+
// from starboard, same convention as the maneuvers):
|
|
1535
|
+
// the timeline names the point of sail, tiles and
|
|
1536
|
+
// widgets read the precise angle
|
|
1537
|
+
twaDeg:
|
|
1538
|
+
typeof row.headingDeg === "number" &&
|
|
1539
|
+
typeof row.twdDeg === "number"
|
|
1540
|
+
? Math.round(wrap180(row.twdDeg - row.headingDeg) * 10) / 10
|
|
1541
|
+
: null,
|
|
1487
1542
|
hsMeters: row.hsMeters ?? null,
|
|
1488
1543
|
tpSeconds: row.tpSeconds ?? null,
|
|
1489
1544
|
comfortLevel: row.comfortLevel ?? null,
|
|
@@ -1509,6 +1564,7 @@ export function simulatePassage({
|
|
|
1509
1564
|
hazardAlerts: nominal.hazardAlerts,
|
|
1510
1565
|
seaStateAnomalies: nominal.seaStateAnomalies,
|
|
1511
1566
|
upperAirAnomalies: nominal.upperAirAnomalies,
|
|
1567
|
+
rainHours: nominal.rainHours,
|
|
1512
1568
|
energy: energySummary(energyHourly, startTime),
|
|
1513
1569
|
runs: runs.map((run, i) => ({
|
|
1514
1570
|
twsFactor: ETA_FACTORS[i],
|
|
@@ -1687,6 +1743,19 @@ export function filterExceptions(simulationResult, simParams = {}) {
|
|
|
1687
1743
|
(episode.kIndex != null && episode.kIndex >= CONVECTIVE_K_INDEX)
|
|
1688
1744
|
),
|
|
1689
1745
|
})),
|
|
1746
|
+
// Rain-shower episodes (probable squalls): contiguous wet hours
|
|
1747
|
+
// merge into one entry carrying the episode's peak rate and
|
|
1748
|
+
// probability; always info-level — rain informs, CAPE warns
|
|
1749
|
+
rainEpisodes: mergeEpisodes(
|
|
1750
|
+
simulationResult.rainHours ?? [],
|
|
1751
|
+
(episode, r) => {
|
|
1752
|
+
episode.precip = maxOf(episode.precip, r.precip);
|
|
1753
|
+
episode.precipProbability = maxOf(
|
|
1754
|
+
episode.precipProbability,
|
|
1755
|
+
r.precipProbability,
|
|
1756
|
+
);
|
|
1757
|
+
},
|
|
1758
|
+
),
|
|
1690
1759
|
},
|
|
1691
1760
|
};
|
|
1692
1761
|
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* consumes the same file with `import()`.
|
|
8
8
|
*
|
|
9
9
|
* The comfort model rates a passage hour on the Sereno scale
|
|
10
|
-
* (Champagne > Easy >
|
|
10
|
+
* (Champagne > Easy > Sporty > Rough > Miserable) from two independent
|
|
11
11
|
* vectors, the worse of which sets the level:
|
|
12
12
|
*
|
|
13
13
|
* 1. **Wind over the deck** — apparent wind speed bands derived from
|
|
@@ -23,8 +23,12 @@
|
|
|
23
23
|
* (https://github.com/sailing12388/passage-weather, MIT), adapted for a
|
|
24
24
|
* monohull: the catamaran beam-roll and bridgedeck-slam factors are
|
|
25
25
|
* replaced by a heel multiplier and a waterline pitching resonance
|
|
26
|
-
* term.
|
|
27
|
-
*
|
|
26
|
+
* term. This is a modified Sereno scale, not a direct copy — the
|
|
27
|
+
* slatting penalty is this project's own addition, upstream has
|
|
28
|
+
* nothing like it (a catamaran slats far less, and light-air
|
|
29
|
+
* discomfort rarely gets modeled at all). Not calibrated against a
|
|
30
|
+
* logged passage until the backtest CLI (SPEC §7) tunes k_heel/k_pitch
|
|
31
|
+
* against the vessel's own history.
|
|
28
32
|
*
|
|
29
33
|
* Conventions:
|
|
30
34
|
* - wind speeds and boat speeds in knots, angles in radians unless a
|
|
@@ -53,7 +57,13 @@ export const KN_TO_MS = 0.514444;
|
|
|
53
57
|
* Comfort tiers, best first. The index in this array is the severity
|
|
54
58
|
* rank used when combining vectors.
|
|
55
59
|
*/
|
|
56
|
-
export const COMFORT_TIERS = [
|
|
60
|
+
export const COMFORT_TIERS = [
|
|
61
|
+
"champagne",
|
|
62
|
+
"easy",
|
|
63
|
+
"sporty",
|
|
64
|
+
"rough",
|
|
65
|
+
"miserable",
|
|
66
|
+
];
|
|
57
67
|
|
|
58
68
|
/**
|
|
59
69
|
* One-line meaning per comfort tier, display order best first, with
|
|
@@ -74,10 +84,10 @@ export const COMFORT_SCALE_INFO = [
|
|
|
74
84
|
text: "Comfortable: moving around below is easy.",
|
|
75
85
|
},
|
|
76
86
|
{
|
|
77
|
-
tier: "
|
|
87
|
+
tier: "sporty",
|
|
78
88
|
maxAws: 23,
|
|
79
89
|
maxAz: 0.63,
|
|
80
|
-
text: "
|
|
90
|
+
text: "Lively enough that a hot coffee needs a firm grip.",
|
|
81
91
|
},
|
|
82
92
|
{
|
|
83
93
|
tier: "rough",
|
|
@@ -86,22 +96,22 @@ export const COMFORT_SCALE_INFO = [
|
|
|
86
96
|
text: "One hand for the boat — loose objects go flying.",
|
|
87
97
|
},
|
|
88
98
|
{
|
|
89
|
-
tier: "
|
|
99
|
+
tier: "miserable",
|
|
90
100
|
maxAws: null,
|
|
91
101
|
maxAz: null,
|
|
92
|
-
text: "
|
|
102
|
+
text: "Nobody is enjoying this. Seasickness very likely.",
|
|
93
103
|
},
|
|
94
104
|
];
|
|
95
105
|
|
|
96
106
|
/**
|
|
97
107
|
* Apparent wind speed (knots) at or above which each tier is lost
|
|
98
|
-
* (Champagne, Easy,
|
|
108
|
+
* (Champagne, Easy, Sporty, Rough). SPEC §5.2 vector 1.
|
|
99
109
|
*/
|
|
100
110
|
export const AWS_BANDS_KNOTS = [12, 18, 23, 33];
|
|
101
111
|
|
|
102
112
|
/**
|
|
103
113
|
* RMS vertical acceleration (m/s²) at or above which each tier is lost
|
|
104
|
-
* (Champagne, Easy,
|
|
114
|
+
* (Champagne, Easy, Sporty, Rough). SPEC §5.2 vector 2, ISO 2631-1.
|
|
105
115
|
*/
|
|
106
116
|
export const AZ_BANDS_MS2 = [0.15, 0.315, 0.63, 1.25];
|
|
107
117
|
|
package/public/tack-gybe.js
CHANGED
|
@@ -51,6 +51,37 @@ export function tackSide(signedTwaDeg) {
|
|
|
51
51
|
return signedTwaDeg >= 0 ? "starboard" : "port";
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Point of sail for a signed TWA, by the standard true-wind-angle
|
|
56
|
+
* bands: close-hauled (≤ 60°), close reach (≤ 80°), beam reach
|
|
57
|
+
* (≤ 100°), broad reach (≤ 150°), running beyond. The sign is
|
|
58
|
+
* deliberately ignored — tack side is `tackSide`'s business — so a
|
|
59
|
+
* timeline detail can name how the boat sits to the wind without
|
|
60
|
+
* duplicating the maneuver label's tack call.
|
|
61
|
+
*
|
|
62
|
+
* @param {number|null} signedTwaDeg
|
|
63
|
+
* @returns {string|null} Band name, or null without usable input
|
|
64
|
+
*/
|
|
65
|
+
export function pointOfSail(signedTwaDeg) {
|
|
66
|
+
if (typeof signedTwaDeg !== "number" || !Number.isFinite(signedTwaDeg)) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
const abs = Math.abs(signedTwaDeg);
|
|
70
|
+
if (abs <= 60) {
|
|
71
|
+
return "close-hauled";
|
|
72
|
+
}
|
|
73
|
+
if (abs <= 80) {
|
|
74
|
+
return "close reach";
|
|
75
|
+
}
|
|
76
|
+
if (abs <= 100) {
|
|
77
|
+
return "beam reach";
|
|
78
|
+
}
|
|
79
|
+
if (abs <= 150) {
|
|
80
|
+
return "broad reach";
|
|
81
|
+
}
|
|
82
|
+
return "running";
|
|
83
|
+
}
|
|
84
|
+
|
|
54
85
|
/**
|
|
55
86
|
* Whether a simulation hour is a sailing step: not motoring, actually
|
|
56
87
|
* making way (drift-mode hours sit at SOG 0) and the plan's rig up —
|
|
@@ -19,9 +19,9 @@
|
|
|
19
19
|
"map": [
|
|
20
20
|
{ "value": "champagne", "state": "green" },
|
|
21
21
|
{ "value": "easy", "state": "green" },
|
|
22
|
-
{ "value": "
|
|
22
|
+
{ "value": "sporty", "state": "amber" },
|
|
23
23
|
{ "value": "rough", "state": "amber" },
|
|
24
|
-
{ "value": "
|
|
24
|
+
{ "value": "miserable", "state": "red" }
|
|
25
25
|
],
|
|
26
26
|
"default": "neutral",
|
|
27
27
|
"reason": "Comfort tier unknown",
|
package/tests/backtest.test.js
CHANGED
|
@@ -404,7 +404,7 @@ test("backtest CLI runs against a local history server", async () => {
|
|
|
404
404
|
|
|
405
405
|
// Matrix rendering
|
|
406
406
|
const rendered = renderMatrix({
|
|
407
|
-
tiers: ["champagne", "easy", "
|
|
407
|
+
tiers: ["champagne", "easy", "sporty", "rough", "miserable"],
|
|
408
408
|
matrix: [
|
|
409
409
|
[3, 1, 0, 0, 0],
|
|
410
410
|
[0, 4, 0, 0, 0],
|
|
@@ -166,7 +166,15 @@ describe("fetchWeatherAlongTrack", () => {
|
|
|
166
166
|
mslp: 1013,
|
|
167
167
|
gust: 18,
|
|
168
168
|
cloudCover: 25,
|
|
169
|
+
precip: 0,
|
|
170
|
+
precipProbability: 10,
|
|
169
171
|
});
|
|
172
|
+
// Second step carries the wet hour of the fixture
|
|
173
|
+
assert.equal(payload.waypoints[0].forecasts[1].surface.precip, 2.4);
|
|
174
|
+
assert.equal(
|
|
175
|
+
payload.waypoints[0].forecasts[1].surface.precipProbability,
|
|
176
|
+
80,
|
|
177
|
+
);
|
|
170
178
|
assert.deepEqual(step.marine, {
|
|
171
179
|
hsCombined: 1.2,
|
|
172
180
|
tpCombined: 7,
|
package/tests/openmeteo-mock.js
CHANGED
package/tests/plugin.test.js
CHANGED
|
@@ -1170,7 +1170,7 @@ describe("plugin", () => {
|
|
|
1170
1170
|
|
|
1171
1171
|
// Brief meta serves the same view the tile publishes
|
|
1172
1172
|
const meta = await call("/api/brief-meta");
|
|
1173
|
-
assert.equal(meta.payload.comfort, "
|
|
1173
|
+
assert.equal(meta.payload.comfort, "sporty");
|
|
1174
1174
|
assert.equal(meta.payload.stale, false);
|
|
1175
1175
|
assert.equal(meta.payload.ageHours, 0);
|
|
1176
1176
|
|
package/tests/route-sim.test.js
CHANGED
|
@@ -33,6 +33,7 @@ function buildPayload({
|
|
|
33
33
|
set = 0,
|
|
34
34
|
steepHour = null,
|
|
35
35
|
convectiveHour = null,
|
|
36
|
+
rainHour = null,
|
|
36
37
|
} = {}) {
|
|
37
38
|
const track = sampleRoutePoints(route);
|
|
38
39
|
const waypoints = track.map((w) => ({
|
|
@@ -47,6 +48,8 @@ function buildPayload({
|
|
|
47
48
|
twd,
|
|
48
49
|
mslp: 1013,
|
|
49
50
|
gust: tws + 5,
|
|
51
|
+
precip: h === rainHour ? 2.5 : 0,
|
|
52
|
+
precipProbability: h === rainHour ? 80 : 10,
|
|
50
53
|
},
|
|
51
54
|
marine: {
|
|
52
55
|
hsCombined: steep ? 3 : hs,
|
|
@@ -175,7 +178,7 @@ describe("simulateRun", () => {
|
|
|
175
178
|
// Comfort evaluated every hour
|
|
176
179
|
for (const block of run.hourly) {
|
|
177
180
|
assert.ok(
|
|
178
|
-
["champagne", "easy", "
|
|
181
|
+
["champagne", "easy", "sporty", "rough", "miserable"].includes(
|
|
179
182
|
block.comfortLevel,
|
|
180
183
|
),
|
|
181
184
|
);
|
|
@@ -293,6 +296,26 @@ describe("simulateRun", () => {
|
|
|
293
296
|
assert.ok(run.upperAirAnomalies.some((a) => a.kIndex > 28));
|
|
294
297
|
});
|
|
295
298
|
|
|
299
|
+
test("collects rain-shower hours over the shower bar", async () => {
|
|
300
|
+
const { simulateRun } = await simPromise;
|
|
301
|
+
// START is 06:00Z, so fixture hour 7 is simulated hour 1
|
|
302
|
+
const run = simulateRun({
|
|
303
|
+
payload: buildPayload({ rainHour: 7 }),
|
|
304
|
+
startTime: START,
|
|
305
|
+
maxHours: 12,
|
|
306
|
+
});
|
|
307
|
+
assert.equal(run.rainHours.length, 1);
|
|
308
|
+
assert.equal(run.rainHours[0].precip, 2.5);
|
|
309
|
+
assert.equal(run.rainHours[0].precipProbability, 80);
|
|
310
|
+
// Dry fixture hours stay under the bar
|
|
311
|
+
const dry = simulateRun({
|
|
312
|
+
payload: buildPayload({}),
|
|
313
|
+
startTime: START,
|
|
314
|
+
maxHours: 12,
|
|
315
|
+
});
|
|
316
|
+
assert.equal(dry.rainHours.length, 0);
|
|
317
|
+
});
|
|
318
|
+
|
|
296
319
|
test("current pushes the boat off the heading line", async () => {
|
|
297
320
|
const { simulateRun } = await simPromise;
|
|
298
321
|
const withCurrent = simulateRun({
|
|
@@ -419,6 +442,14 @@ describe("simulatePassage", () => {
|
|
|
419
442
|
for (const change of exceptions.passageSummary.sailChanges) {
|
|
420
443
|
if (change.conditions) {
|
|
421
444
|
assert.equal(change.conditions.cloudCover, null);
|
|
445
|
+
// Signed TWA at the change point (work doc #5): the angle
|
|
446
|
+
// behind the timeline's point-of-sail text — within ±180°
|
|
447
|
+
assert.ok(
|
|
448
|
+
change.conditions.twaDeg == null ||
|
|
449
|
+
(change.conditions.twaDeg > -180 &&
|
|
450
|
+
change.conditions.twaDeg <= 180),
|
|
451
|
+
`twaDeg ${change.conditions.twaDeg} out of range`,
|
|
452
|
+
);
|
|
422
453
|
}
|
|
423
454
|
}
|
|
424
455
|
assert.equal(
|
|
@@ -427,6 +458,7 @@ describe("simulatePassage", () => {
|
|
|
427
458
|
);
|
|
428
459
|
assert.ok(exceptions.passageSummary.macroSeaAnomalies.length === 0);
|
|
429
460
|
assert.ok(exceptions.passageSummary.convectiveWarnings.length === 0);
|
|
461
|
+
assert.ok(exceptions.passageSummary.rainEpisodes.length === 0);
|
|
430
462
|
assert.ok(Array.isArray(exceptions.passageSummary.hazards));
|
|
431
463
|
});
|
|
432
464
|
|
|
@@ -492,6 +524,21 @@ describe("simulatePassage", () => {
|
|
|
492
524
|
assert.equal(exceptions.passageSummary.convectiveWarnings[0].cape, 1800);
|
|
493
525
|
});
|
|
494
526
|
|
|
527
|
+
test("rain episodes surface in the passage summary", async () => {
|
|
528
|
+
const { filterExceptions, simulatePassage } = await simPromise;
|
|
529
|
+
const result = simulatePassage({
|
|
530
|
+
payload: buildPayload({ rainHour: 7 }),
|
|
531
|
+
startTime: new Date("2026-06-21T06:00:00Z"),
|
|
532
|
+
});
|
|
533
|
+
const exceptions = filterExceptions(result);
|
|
534
|
+
assert.ok(exceptions.passageSummary.rainEpisodes.length >= 1);
|
|
535
|
+
assert.equal(exceptions.passageSummary.rainEpisodes[0].precip, 2.5);
|
|
536
|
+
assert.equal(
|
|
537
|
+
exceptions.passageSummary.rainEpisodes[0].precipProbability,
|
|
538
|
+
80,
|
|
539
|
+
);
|
|
540
|
+
});
|
|
541
|
+
|
|
495
542
|
test("anomalies merge into episodes with peak values, marginal band drops", async () => {
|
|
496
543
|
const { filterExceptions } = await simPromise;
|
|
497
544
|
const result = {
|
|
@@ -18,9 +18,9 @@ describe("comfort bands (SPEC §5.2)", () => {
|
|
|
18
18
|
assert.deepEqual(p.COMFORT_TIERS, [
|
|
19
19
|
"champagne",
|
|
20
20
|
"easy",
|
|
21
|
-
"
|
|
21
|
+
"sporty",
|
|
22
22
|
"rough",
|
|
23
|
-
"
|
|
23
|
+
"miserable",
|
|
24
24
|
]);
|
|
25
25
|
assert.deepEqual(p.STEEPNESS_RATIO_THRESHOLD, 3.28);
|
|
26
26
|
});
|
|
@@ -29,18 +29,18 @@ describe("comfort bands (SPEC §5.2)", () => {
|
|
|
29
29
|
const { rateApparentWind } = await physics();
|
|
30
30
|
assert.equal(rateApparentWind(11.9), "champagne");
|
|
31
31
|
assert.equal(rateApparentWind(12), "easy");
|
|
32
|
-
assert.equal(rateApparentWind(18), "
|
|
32
|
+
assert.equal(rateApparentWind(18), "sporty");
|
|
33
33
|
assert.equal(rateApparentWind(23), "rough");
|
|
34
|
-
assert.equal(rateApparentWind(33), "
|
|
34
|
+
assert.equal(rateApparentWind(33), "miserable");
|
|
35
35
|
});
|
|
36
36
|
|
|
37
37
|
test("acceleration bands", async () => {
|
|
38
38
|
const { rateAcceleration } = await physics();
|
|
39
39
|
assert.equal(rateAcceleration(0.149), "champagne");
|
|
40
40
|
assert.equal(rateAcceleration(0.15), "easy");
|
|
41
|
-
assert.equal(rateAcceleration(0.315), "
|
|
41
|
+
assert.equal(rateAcceleration(0.315), "sporty");
|
|
42
42
|
assert.equal(rateAcceleration(0.63), "rough");
|
|
43
|
-
assert.equal(rateAcceleration(1.25), "
|
|
43
|
+
assert.equal(rateAcceleration(1.25), "miserable");
|
|
44
44
|
});
|
|
45
45
|
});
|
|
46
46
|
|
|
@@ -241,8 +241,8 @@ describe("serenoComfort", () => {
|
|
|
241
241
|
{ twsKnots: 6, twaRad: 0.3 },
|
|
242
242
|
);
|
|
243
243
|
assert.ok(
|
|
244
|
-
["rough", "
|
|
245
|
-
`expected rough/
|
|
244
|
+
["rough", "miserable"].includes(rough.comfort),
|
|
245
|
+
`expected rough/miserable, got ${rough.comfort}`,
|
|
246
246
|
);
|
|
247
247
|
// Champagne glass conditions: light following breeze, gentle swell
|
|
248
248
|
const idyllic = serenoComfort(
|
|
@@ -29,7 +29,7 @@ test("bundled example set parses and references published paths", () => {
|
|
|
29
29
|
const comfortCheck = tile.checks[0];
|
|
30
30
|
assert.deepEqual(
|
|
31
31
|
comfortCheck.map.map((m) => m.value),
|
|
32
|
-
["champagne", "easy", "
|
|
32
|
+
["champagne", "easy", "sporty", "rough", "miserable"],
|
|
33
33
|
);
|
|
34
34
|
const footerPaths = tile.footer.map((f) => f.path);
|
|
35
35
|
assert.ok(footerPaths.includes("navigation.briefing.ageHours"));
|
package/tests/tack-gybe.test.js
CHANGED
|
@@ -220,4 +220,19 @@ describe("tack & gybe detection", () => {
|
|
|
220
220
|
assert.equal(events.length, 1);
|
|
221
221
|
assert.equal(events[0].sailState, "@port");
|
|
222
222
|
});
|
|
223
|
+
|
|
224
|
+
test("pointOfSail names the true-wind-angle band, sign-blind", async () => {
|
|
225
|
+
const { pointOfSail } = await tgPromise;
|
|
226
|
+
assert.equal(pointOfSail(45), "close-hauled");
|
|
227
|
+
assert.equal(pointOfSail(-60), "close-hauled");
|
|
228
|
+
assert.equal(pointOfSail(70), "close reach");
|
|
229
|
+
assert.equal(pointOfSail(-90), "beam reach");
|
|
230
|
+
assert.equal(pointOfSail(120), "broad reach");
|
|
231
|
+
assert.equal(pointOfSail(-170), "running");
|
|
232
|
+
assert.equal(pointOfSail(180), "running");
|
|
233
|
+
// Without usable input there is nothing to say — old payloads
|
|
234
|
+
// without a conditions TWA degrade to the bare detail line
|
|
235
|
+
assert.equal(pointOfSail(null), null);
|
|
236
|
+
assert.equal(pointOfSail(undefined), null);
|
|
237
|
+
});
|
|
223
238
|
});
|
|
@@ -50,6 +50,9 @@ describe("weatherDataToTimeStep", () => {
|
|
|
50
50
|
assert.equal(step.surface.twd, 45);
|
|
51
51
|
assert.equal(step.surface.mslp, 1013);
|
|
52
52
|
assert.equal(step.surface.gust, 18);
|
|
53
|
+
// Provider mode carries no precipitation fields — degrade, never invent
|
|
54
|
+
assert.equal(step.surface.precip, null);
|
|
55
|
+
assert.equal(step.surface.precipProbability, null);
|
|
53
56
|
assert.equal(step.marine.hsCombined, 1.2);
|
|
54
57
|
assert.equal(step.marine.tpCombined, 7);
|
|
55
58
|
assert.equal(step.marine.dirCombined, 160);
|
package/tests/webapp.test.js
CHANGED
|
@@ -154,7 +154,7 @@ test("webapp view models", async (t) => {
|
|
|
154
154
|
const blocks = [
|
|
155
155
|
{ hoursFromNow: 0, comfortLevel: "champagne", awsKnots: 10 },
|
|
156
156
|
{ hoursFromNow: 1, comfortLevel: "rough", awsKnots: 20 },
|
|
157
|
-
{ hoursFromNow: 2, comfortLevel: "
|
|
157
|
+
{ hoursFromNow: 2, comfortLevel: "miserable", awsKnots: 0 },
|
|
158
158
|
];
|
|
159
159
|
const cols = sparklineColumns(blocks);
|
|
160
160
|
assert.equal(cols.length, 3);
|
|
@@ -162,7 +162,7 @@ test("webapp view models", async (t) => {
|
|
|
162
162
|
assert.equal(cols[1].heightPct, 100);
|
|
163
163
|
assert.equal(cols[2].heightPct, 4); // Floor
|
|
164
164
|
assert.match(cols[0].color, /champagne/);
|
|
165
|
-
assert.match(cols[2].color, /
|
|
165
|
+
assert.match(cols[2].color, /miserable/);
|
|
166
166
|
assert.ok(cols[1].title.includes("20.0 kn"));
|
|
167
167
|
},
|
|
168
168
|
);
|
|
@@ -192,7 +192,7 @@ test("webapp view models", async (t) => {
|
|
|
192
192
|
await t.test("comfort tiers cover the Sereno scale", () => {
|
|
193
193
|
assert.deepEqual(
|
|
194
194
|
COMFORT_TIERS.map((tier) => tier.level),
|
|
195
|
-
["champagne", "easy", "
|
|
195
|
+
["champagne", "easy", "sporty", "rough", "miserable"],
|
|
196
196
|
);
|
|
197
197
|
assert.match(comfortColor("unknown"), /unknown/);
|
|
198
198
|
});
|
|
@@ -203,7 +203,7 @@ test("webapp view models", async (t) => {
|
|
|
203
203
|
comfortBlocks: [
|
|
204
204
|
{
|
|
205
205
|
hoursFromNow: 0,
|
|
206
|
-
comfortLevel: "
|
|
206
|
+
comfortLevel: "sporty",
|
|
207
207
|
awsKnots: 14.2,
|
|
208
208
|
timestamp: "2026-06-21T06:00:00Z",
|
|
209
209
|
},
|
|
@@ -211,10 +211,10 @@ test("webapp view models", async (t) => {
|
|
|
211
211
|
},
|
|
212
212
|
};
|
|
213
213
|
const now = tacticalNow(exceptions);
|
|
214
|
-
assert.equal(now.comfortLevel, "
|
|
214
|
+
assert.equal(now.comfortLevel, "sporty");
|
|
215
215
|
assert.equal(now.awsKnots, 14.2);
|
|
216
216
|
assert.equal(now.stamp, "06-21 06:00Z");
|
|
217
|
-
assert.match(now.color, /
|
|
217
|
+
assert.match(now.color, /sporty/);
|
|
218
218
|
|
|
219
219
|
assert.equal(tacticalNow(null).comfortLevel, null);
|
|
220
220
|
assert.match(tacticalNow(null).color, /unknown/);
|
|
@@ -280,7 +280,14 @@ test("webapp view models", async (t) => {
|
|
|
280
280
|
timestamp: new Date(
|
|
281
281
|
new Date("2026-09-27T00:00:00.000Z").getTime() + i * 3600000,
|
|
282
282
|
).toISOString(),
|
|
283
|
-
surface: {
|
|
283
|
+
surface: {
|
|
284
|
+
tws: 12,
|
|
285
|
+
twd: 45,
|
|
286
|
+
mslp: 1013,
|
|
287
|
+
gust: 18,
|
|
288
|
+
precip: 1.2,
|
|
289
|
+
precipProbability: 65,
|
|
290
|
+
},
|
|
284
291
|
marine: {
|
|
285
292
|
hsCombined: 1.2,
|
|
286
293
|
tpCombined: 7,
|
|
@@ -311,6 +318,9 @@ test("webapp view models", async (t) => {
|
|
|
311
318
|
assert.equal(now.comfortLevel, rows[0].comfortLevel);
|
|
312
319
|
// Now cloud cover (work doc #33): from the first forecast step
|
|
313
320
|
assert.equal(now.cloudCover, null);
|
|
321
|
+
// Now rain fields ride the same step
|
|
322
|
+
assert.equal(now.precipMmH, 1.2);
|
|
323
|
+
assert.equal(now.precipProbability, 65);
|
|
314
324
|
});
|
|
315
325
|
|
|
316
326
|
await t.test("hereNow and hereHourly handle empty payloads", () => {
|
|
@@ -450,6 +460,24 @@ test("webapp view models", async (t) => {
|
|
|
450
460
|
// No cloud field (provider mode): no cloud segment, never invented
|
|
451
461
|
const clearSky = skySegments(payload, { cloudCover: null });
|
|
452
462
|
assert.ok(!clearSky.some((s) => s.text.startsWith("☁")));
|
|
463
|
+
// Rain rides the line as probability, or as the hourly rate when
|
|
464
|
+
// the model publishes no probability
|
|
465
|
+
const wetSky = skySegments(payload, {
|
|
466
|
+
cloudCover: 40,
|
|
467
|
+
precipProbability: 65,
|
|
468
|
+
});
|
|
469
|
+
assert.ok(
|
|
470
|
+
wetSky.some((s) => s.text === "🌧 65%"),
|
|
471
|
+
wetSky.map((s) => s.text).join(" "),
|
|
472
|
+
);
|
|
473
|
+
const rateSky = skySegments(payload, { precip: 0.8 });
|
|
474
|
+
assert.ok(
|
|
475
|
+
rateSky.some((s) => s.text === "🌧 0.8mm"),
|
|
476
|
+
rateSky.map((s) => s.text).join(" "),
|
|
477
|
+
);
|
|
478
|
+
// Dry hours stay quiet
|
|
479
|
+
const drySky = skySegments(payload, { precip: 0.1, precipProbability: 10 });
|
|
480
|
+
assert.ok(!drySky.some((s) => s.text.startsWith("🌧")));
|
|
453
481
|
// A payload predating the sky data degrades to empty
|
|
454
482
|
assert.deepEqual(skySegments({}, {}), []);
|
|
455
483
|
assert.deepEqual(skySegments(null, {}), []);
|
|
@@ -534,6 +562,32 @@ test("webapp view models", async (t) => {
|
|
|
534
562
|
},
|
|
535
563
|
);
|
|
536
564
|
|
|
565
|
+
await t.test("mergeTimeline carries rain episodes as info items", () => {
|
|
566
|
+
const timeline = mergeTimeline(
|
|
567
|
+
{
|
|
568
|
+
passageSummary: {
|
|
569
|
+
rainEpisodes: [
|
|
570
|
+
{
|
|
571
|
+
hoursFromNow: 4,
|
|
572
|
+
timestamp: "2026-06-21T10:00:00.000Z",
|
|
573
|
+
untilHoursFromNow: 7,
|
|
574
|
+
untilTimestamp: "2026-06-21T13:00:00.000Z",
|
|
575
|
+
precip: 2.5,
|
|
576
|
+
precipProbability: 80,
|
|
577
|
+
},
|
|
578
|
+
],
|
|
579
|
+
},
|
|
580
|
+
},
|
|
581
|
+
null,
|
|
582
|
+
);
|
|
583
|
+
const rain = timeline.filter((i) => i.kind === "rain");
|
|
584
|
+
assert.equal(rain.length, 1);
|
|
585
|
+
assert.equal(rain[0].severity, "info");
|
|
586
|
+
assert.equal(rain[0].label, "Rain showers");
|
|
587
|
+
assert.match(rain[0].detail, /2\.5 mm\/h/);
|
|
588
|
+
assert.match(rain[0].detail, /80 %/);
|
|
589
|
+
});
|
|
590
|
+
|
|
537
591
|
await t.test(
|
|
538
592
|
"mergeTimeline passes source provenance through (doc #31)",
|
|
539
593
|
() => {
|
|
@@ -708,9 +762,10 @@ test("webapp view models", async (t) => {
|
|
|
708
762
|
conditions: {
|
|
709
763
|
twsKnots: 14.2,
|
|
710
764
|
awsKnots: 18.4,
|
|
765
|
+
twaDeg: 75,
|
|
711
766
|
hsMeters: 1.5,
|
|
712
767
|
tpSeconds: 8,
|
|
713
|
-
comfortLevel: "
|
|
768
|
+
comfortLevel: "sporty",
|
|
714
769
|
},
|
|
715
770
|
},
|
|
716
771
|
{
|
|
@@ -722,8 +777,14 @@ test("webapp view models", async (t) => {
|
|
|
722
777
|
],
|
|
723
778
|
},
|
|
724
779
|
});
|
|
725
|
-
|
|
726
|
-
//
|
|
780
|
+
// The point of sail leads the conditions: how the boat sits to
|
|
781
|
+
// the wind is the first thing the crew reads when rigging
|
|
782
|
+
assert.equal(
|
|
783
|
+
timeline[0].detail,
|
|
784
|
+
"close reach · 14.2 kn TWS · Hs 1.5 m · sporty",
|
|
785
|
+
);
|
|
786
|
+
// Missing sea state degrades without inventing values; a
|
|
787
|
+
// pre-TWA payload just loses the point of sail, not the line
|
|
727
788
|
assert.equal(timeline[1].detail, "8.0 kn TWS · champagne");
|
|
728
789
|
|
|
729
790
|
// The slatting regime names itself: "rough" alone would send
|
|
@@ -737,6 +798,7 @@ test("webapp view models", async (t) => {
|
|
|
737
798
|
sailState: "GENOA_1",
|
|
738
799
|
conditions: {
|
|
739
800
|
twsKnots: 10,
|
|
801
|
+
twaDeg: -170,
|
|
740
802
|
hsMeters: 1.7,
|
|
741
803
|
comfortLevel: "rough",
|
|
742
804
|
slatting: true,
|
|
@@ -745,7 +807,10 @@ test("webapp view models", async (t) => {
|
|
|
745
807
|
],
|
|
746
808
|
},
|
|
747
809
|
});
|
|
748
|
-
assert.equal(
|
|
810
|
+
assert.equal(
|
|
811
|
+
slatting[0].detail,
|
|
812
|
+
"running · 10.0 kn TWS · Hs 1.7 m · slatting",
|
|
813
|
+
);
|
|
749
814
|
});
|
|
750
815
|
|
|
751
816
|
await t.test("mergeTimeline carries the conditions tier for the tab", () => {
|
|
@@ -766,7 +831,7 @@ test("webapp view models", async (t) => {
|
|
|
766
831
|
hoursFromNow: 10,
|
|
767
832
|
lat: -18.9,
|
|
768
833
|
lon: 174.5,
|
|
769
|
-
comfortLevel: "
|
|
834
|
+
comfortLevel: "sporty",
|
|
770
835
|
slatting: false,
|
|
771
836
|
},
|
|
772
837
|
{
|
|
@@ -792,18 +857,18 @@ test("webapp view models", async (t) => {
|
|
|
792
857
|
hoursFromNow: 19,
|
|
793
858
|
timestamp: "2026-06-22T01:00:00.000Z",
|
|
794
859
|
sailState: "MAIN_2_REEF",
|
|
795
|
-
conditions: { twsKnots: 22, comfortLevel: "
|
|
860
|
+
conditions: { twsKnots: 22, comfortLevel: "miserable" },
|
|
796
861
|
},
|
|
797
862
|
],
|
|
798
863
|
},
|
|
799
864
|
});
|
|
800
865
|
const sea = timeline.find((item) => item.kind === "sea");
|
|
801
|
-
assert.equal(sea.comfortLevel, "
|
|
866
|
+
assert.equal(sea.comfortLevel, "sporty");
|
|
802
867
|
assert.equal(sea.slatting, false);
|
|
803
868
|
// The sail change's own enriched conditions block wins over the
|
|
804
869
|
// nearest track hour — it is the reefing logic's tier
|
|
805
870
|
const sail = timeline.find((item) => item.kind === "sail");
|
|
806
|
-
assert.equal(sail.comfortLevel, "
|
|
871
|
+
assert.equal(sail.comfortLevel, "miserable");
|
|
807
872
|
// No track: the tier degrades to unknown, not invented
|
|
808
873
|
const bare = mergeTimeline({
|
|
809
874
|
passageSummary: {
|