@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.
- package/CHANGELOG.md +20 -0
- package/README.md +1 -0
- package/package.json +4 -2
- package/plugin/index.js +107 -3
- package/public/components/conditions-here.js +23 -0
- package/public/components/departure-control.js +11 -82
- package/public/components/map-model.mjs +32 -0
- package/public/components/map-view.js +131 -21
- package/public/components/models.mjs +81 -77
- package/public/components/passage-outlook.js +225 -36
- package/public/components/strategic-outlook.js +51 -8
- package/public/components/tactical-dashboard.js +48 -17
- package/public/landfall-estimator.mjs +548 -0
- package/public/landfall-tiles.mjs +384 -0
- package/public/polar.mjs +82 -107
- package/public/route-sim.mjs +146 -18
- package/public/sereno-physics.mjs +68 -0
- package/public/vendor/mvt/LICENSE.pbf.txt +27 -0
- package/public/vendor/mvt/LICENSE.point-geometry.txt +13 -0
- package/public/vendor/mvt/LICENSE.vector-tile.txt +27 -0
- package/public/vendor/mvt/pbf.js +867 -0
- package/public/vendor/mvt/point-geometry.js +324 -0
- package/public/vendor/mvt/vector-tile.js +371 -0
- package/public/vendor/polar/LICENSE.polar-format.txt +201 -0
- package/public/vendor/polar/LICENSE.polar-math.txt +201 -0
- package/public/vendor/polar/polar-format.js +321 -0
- package/public/vendor/polar/polar-math.js +466 -0
- package/public/worker.js +56 -3
- package/tests/departure-candidates.test.js +77 -0
- package/tests/landfall.test.js +428 -0
- package/tests/map-model.test.js +76 -2
- package/tests/map-view.test.js +49 -0
- package/tests/route-sim.test.js +61 -26
- 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.
|
|
4
|
-
"description": "Offshore passage daily briefing
|
|
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 =
|
|
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: "
|
|
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
|
-
:
|
|
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
|
|
3
|
-
*
|
|
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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
<
|
|
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.
|
|
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
|