@meri-imperiumi/signalk-passage-briefing 0.5.2 → 0.6.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 +16 -0
- package/package.json +1 -1
- package/plugin/history-backfill.js +74 -5
- package/plugin/index.js +79 -52
- package/plugin/sqlite-db.js +27 -0
- package/public/components/backfill-controls.js +3 -0
- package/public/components/comfort-info.js +17 -0
- package/public/components/horizon-sparkline.js +4 -4
- package/public/components/models.mjs +43 -9
- package/public/components/passage-timeline.js +12 -0
- package/public/route-sim.mjs +288 -13
- package/public/sereno-physics.mjs +47 -0
- package/public/tack-gybe.js +10 -4
- package/public/vendor/tz-lookup/LICENSE +116 -0
- package/public/vendor/tz-lookup/tz-lookup.mjs +8 -0
- package/tests/history-backfill.test.js +146 -6
- package/tests/plugin.test.js +29 -7
- package/tests/route-sim.test.js +305 -0
- package/tests/sereno-physics.test.js +22 -0
- package/tests/source-status.test.js +1 -1
- package/tests/sqlite-db.test.js +80 -0
- package/tests/tack-gybe.test.js +12 -0
- package/tests/webapp.test.js +138 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.6.0] - 2026-10-04
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Timezone change events along the route (work doc #19): the briefing now says when the clock itself changes — the moment a crew sets watches and re-plans arrival in local time. Offshore, the simulated track detects crossings of the 15° zone meridians (short-arc direction from the step's longitude delta, so the antimeridian reads "180°" either way) and the unified timeline renders them as `time` events (◷ glyph): "Crossing 165°E — solar time 1 h ahead/behind — clock change due", advising but never asserting what the crew does with the clock. Crossings inside territorial waters stay quiet — the local zone governs there — using distance stints folded from the plugin's enter/leave transitions. Territorial transitions (work doc #17) are now timezone-annotated: the IANA zone at the crossing point comes from vendored tz-lookup (CC0, ~72 KB single ES module under `public/vendor/tz-lookup/` — resolved by position, which sidesteps the multi-zone-country problem entirely, and consistent with signalk-ships-time's answer) and its UTC offset at the crossing instant from the platform's `Intl` database (DST handled, no dependency); `mergeTimeline` adds the "time zone UTC+13" line when the crossing's offset differs from the vessel's current zone, or when none is published. Stamps themselves stay in the current ship's zone.
|
|
10
|
+
- No-sails semantics: slatting and survival regimes (work doc #26). On a cruising boat a logged `NO_SAILS` means one of three things the old pipeline could not tell apart — the slatting regime (apparent wind too low to keep flow attached: canvas down so it stops banging, the engine drives), the survival regime (wind very high: nothing set, nobody motors into it) — or a propulsion/non-passage state (motor-sailing nights, anchorage entries). The learning pipeline now enforces that reading. The regimes are defined in the physics module: `SAILS_FILL_MIN_AWS_KNOTS` (7 kt apparent, mirroring the upwind slatting gate in apparent rather than true wind — motoring into light air is exactly when canvas slats) and `SAILS_MAX_AWS_KNOTS` (45 kt apparent — Beaufort 9 territory, deliberately decoupled from the comfort bands whose 33 kt line marks seasickness, not the rig decision; the vessel carries working canvas standing well into the forties), with `noSailsRegime()` answering slatting / survival / null. The learning gate folds a `NO_SAILS` observation into a rig bin only when its wind window justifies it — the window's average TWS below the point-of-sail slatting gates (7 kt upwind, 12 kt downwind, from the comfort model) or its peak gust at the survival floor; a mid-scale bare-poles observation still caches its wind window (idempotency intact) and stays in the logbook record, but never wins the bin, and the backfill counts it in a new `skippedNoSails` the UI reports ("7 bare-poles entries kept out of the matrix"). The simulation only accepts a `NO_SAILS` suggestion when the step's real apparent wind (boat speed and heading included) sits in a regime, so already-poisoned learned cells cannot depower a passage. The drive under an accepted canvas-down follows one shared rule for rows and events: survival — no drive, the water moves the boat, and the timeline names the decision "Storm tactics" rather than claiming a tactic (heave to, drogue, run off — proper storm tactics are their own work document, #27); becalmed with drift mode enabled — drift on the current alone; otherwise the engine pushes at motor speed with honest motor hours and fuel. A plan holding `NO_SAILS` no longer makes phantom polar speed with zero motor hours. The one-time migration on plugin start clears the learned matrix, its wind-history cache and the recorded events (guarded by `PRAGMA user_version`, logbook files untouched) so the next backfill rebuilds every bin under the new rules — the recency rule had let single bare-poles entries erase a bin's real rig, and that loss is unrecoverable from the matrix alone; empty bins degrade safely to "keep the current rig". **Upgrading crews must run the Logbook Backfill once after starting this version** (the webapp card, or `POST /plugins/.../api/backfill`) — until then the learned plan is empty and the simulation keeps whatever rig the plan already had.
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- Slatting has a name wherever the comfort tier shows: timeline details at sail changes and sparkline titles read "slatting" instead of the escalated "rough" — a 10 kn afternoon in a residual swell was rendering as weather it isn't, sending someone looking for wind that isn't there. The comfort explainer (ℹ️) carries a SLATTING entry with its thresholds ("wind < 7–12 kn by point of sail · swell ≥ 0.6 m").
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- Phone timeline layout: the when-gutter (relative hours + date stamp + moon glyph) squeezed the event text to the side on narrow screens. On phone (below the app's 700 px layout breakpoint) each entry now renders on two lines — the date/time gutter on top at full width, glyph and event data beneath with the whole row's width available. Desktop layout unchanged.
|
|
19
|
+
- No more tacks and gybes with canvas down: the maneuver detector's sailing-step test now requires the plan's rig up — a course change under power or carried by the current is not a tack or gybe. This closes a real leak: drift rows carry `motoring: false` and can hold current-driven SOG above the sailing threshold, previously qualifying as sailing steps.
|
|
20
|
+
|
|
5
21
|
## [0.5.2] - 2026-10-04
|
|
6
22
|
|
|
7
23
|
### Fixed
|
package/package.json
CHANGED
|
@@ -334,17 +334,35 @@ function createHistoryWindStats({
|
|
|
334
334
|
* window's peak wind. The preferred sail of a bin is the most recent
|
|
335
335
|
* observation.
|
|
336
336
|
*
|
|
337
|
+
* Canvas-off observations are gated (work doc #26): a `NO_SAILS`
|
|
338
|
+
* observation folds into the rig matrix only when the weather
|
|
339
|
+
* justifies it — the two-regime decision the rig actually encodes.
|
|
340
|
+
* A mid-scale one is a propulsion decision (the engine drives) or a
|
|
341
|
+
* non-passage state (anchorage, marina); folding it would teach the
|
|
342
|
+
* plan to hold bare poles through sail-carrying weather.
|
|
343
|
+
*
|
|
337
344
|
* @param {object} params
|
|
338
345
|
* @param {import("./sqlite-db.js").PassageDatabase} params.db
|
|
339
346
|
* @param {{timestamp: string, eventType: string, sailState: string, night?: boolean, notes?: string}} params.event
|
|
340
347
|
* @param {WindWindowStats} params.stats
|
|
341
348
|
* @param {boolean} [params.night] - Day/night bucket of the event
|
|
342
349
|
* (defaults to the event's own flag, else day)
|
|
350
|
+
* @param {object} [params.physics] - The loaded sereno-physics module
|
|
351
|
+
* (constants for the regime gates). Omitted, the gate stays
|
|
352
|
+
* conservative: a `NO_SAILS` observation cannot be justified and is
|
|
353
|
+
* kept out of the rig matrix.
|
|
343
354
|
* @returns {{twsBin: number, twaBin: number, night: boolean,
|
|
344
355
|
* preferredSail: string, avgTwsTrigger: number, peakGustTrigger:
|
|
345
|
-
* number, sampleCount: number}}
|
|
356
|
+
* number, sampleCount: number}|null} The learned cell, or null when
|
|
357
|
+
* the observation was gated out of the rig matrix
|
|
346
358
|
*/
|
|
347
|
-
function applySailEvent({
|
|
359
|
+
function applySailEvent({
|
|
360
|
+
db,
|
|
361
|
+
event,
|
|
362
|
+
stats,
|
|
363
|
+
night = event.night ?? false,
|
|
364
|
+
physics = null,
|
|
365
|
+
}) {
|
|
348
366
|
db.upsertWindHistory({
|
|
349
367
|
timestamp: event.timestamp,
|
|
350
368
|
twsAvg: stats.twsAvg,
|
|
@@ -352,6 +370,13 @@ function applySailEvent({ db, event, stats, night = event.night ?? false }) {
|
|
|
352
370
|
twaAvg: stats.twaAvg,
|
|
353
371
|
});
|
|
354
372
|
|
|
373
|
+
if (
|
|
374
|
+
event.sailState === "NO_SAILS" &&
|
|
375
|
+
noSailsWindowRegime(stats, physics) == null
|
|
376
|
+
) {
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
|
|
355
380
|
const twsBin = twsBinIndex(stats.twsAvg);
|
|
356
381
|
const twaBin = twaBinIndex(stats.twaAvg);
|
|
357
382
|
const existing = db
|
|
@@ -377,6 +402,40 @@ function applySailEvent({ db, event, stats, night = event.night ?? false }) {
|
|
|
377
402
|
return cell;
|
|
378
403
|
}
|
|
379
404
|
|
|
405
|
+
/**
|
|
406
|
+
* The canvas-off regime a wind window justifies (work doc #26): the
|
|
407
|
+
* rig is already down in these observations, so apparent wind ≈ true
|
|
408
|
+
* wind and the regimes read off the window stats. Light air uses the
|
|
409
|
+
* comfort model's point-of-sail slatting gates (work doc #14: 7 kt
|
|
410
|
+
* upwind, 12 kt downwind); survival reads the window's peak gust
|
|
411
|
+
* against the top of the Sereno AWS bands.
|
|
412
|
+
*
|
|
413
|
+
* @param {WindWindowStats} stats - Window statistics
|
|
414
|
+
* @param {object|null} physics - The sereno-physics module (regime
|
|
415
|
+
* gate constants); null keeps the gate conservative
|
|
416
|
+
* @returns {"slatting"|"survival"|null}
|
|
417
|
+
*/
|
|
418
|
+
function noSailsWindowRegime(stats, physics) {
|
|
419
|
+
if (!stats || !physics) {
|
|
420
|
+
return null;
|
|
421
|
+
}
|
|
422
|
+
const { twsAvg, twsPeak, twaAvg } = stats;
|
|
423
|
+
if (!Number.isFinite(twsAvg)) {
|
|
424
|
+
return null;
|
|
425
|
+
}
|
|
426
|
+
const upwind = Math.abs(Number.isFinite(twaAvg) ? twaAvg : 0) < 90;
|
|
427
|
+
const lightFloor = upwind
|
|
428
|
+
? physics.SLATTING_TWS_UPWIND_KNOTS
|
|
429
|
+
: physics.SLATTING_TWS_DOWNWIND_KNOTS;
|
|
430
|
+
if (twsAvg < lightFloor) {
|
|
431
|
+
return "slatting";
|
|
432
|
+
}
|
|
433
|
+
if (Number.isFinite(twsPeak) && twsPeak >= physics.SAILS_MAX_AWS_KNOTS) {
|
|
434
|
+
return "survival";
|
|
435
|
+
}
|
|
436
|
+
return null;
|
|
437
|
+
}
|
|
438
|
+
|
|
380
439
|
/**
|
|
381
440
|
* Runs the backfill over a list of sail events (SPEC §4.2). Events are
|
|
382
441
|
* processed chronologically; an event already present in
|
|
@@ -399,7 +458,8 @@ function applySailEvent({ db, event, stats, night = event.night ?? false }) {
|
|
|
399
458
|
* skippedNoData: number, cells: object[]}>}
|
|
400
459
|
*/
|
|
401
460
|
async function backfillSailEvents({ db, events, getWindStats }) {
|
|
402
|
-
const
|
|
461
|
+
const physics = await loadPhysics();
|
|
462
|
+
const { isNight } = physics;
|
|
403
463
|
const sorted = [...events].sort(
|
|
404
464
|
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
|
|
405
465
|
);
|
|
@@ -407,6 +467,7 @@ async function backfillSailEvents({ db, events, getWindStats }) {
|
|
|
407
467
|
let learned = 0;
|
|
408
468
|
let skippedCached = 0;
|
|
409
469
|
let skippedNoData = 0;
|
|
470
|
+
let skippedNoSails = 0;
|
|
410
471
|
const cells = [];
|
|
411
472
|
|
|
412
473
|
for (const event of sorted) {
|
|
@@ -433,7 +494,15 @@ async function backfillSailEvents({ db, events, getWindStats }) {
|
|
|
433
494
|
event.position.longitude,
|
|
434
495
|
)
|
|
435
496
|
: false);
|
|
436
|
-
|
|
497
|
+
const cell = applySailEvent({ db, event, stats, night, physics });
|
|
498
|
+
if (cell) {
|
|
499
|
+
cells.push(cell);
|
|
500
|
+
learned++;
|
|
501
|
+
} else {
|
|
502
|
+
// Gated out of the rig matrix (work doc #26): still cached and
|
|
503
|
+
// recorded, but nothing was learned
|
|
504
|
+
skippedNoSails++;
|
|
505
|
+
}
|
|
437
506
|
db.recordSailEvent({
|
|
438
507
|
timestamp: event.timestamp,
|
|
439
508
|
eventType: event.eventType,
|
|
@@ -441,7 +510,6 @@ async function backfillSailEvents({ db, events, getWindStats }) {
|
|
|
441
510
|
night,
|
|
442
511
|
notes: event.notes,
|
|
443
512
|
});
|
|
444
|
-
learned++;
|
|
445
513
|
}
|
|
446
514
|
|
|
447
515
|
return {
|
|
@@ -449,6 +517,7 @@ async function backfillSailEvents({ db, events, getWindStats }) {
|
|
|
449
517
|
learned,
|
|
450
518
|
skippedCached,
|
|
451
519
|
skippedNoData,
|
|
520
|
+
skippedNoSails,
|
|
452
521
|
cells,
|
|
453
522
|
};
|
|
454
523
|
}
|
package/plugin/index.js
CHANGED
|
@@ -1443,6 +1443,17 @@ module.exports = (app) => {
|
|
|
1443
1443
|
return join(configPath, "plugin-config-data", "signalk-logbook");
|
|
1444
1444
|
}
|
|
1445
1445
|
|
|
1446
|
+
/** Startup grace (ms) before the delta-fed path sources are
|
|
1447
|
+
* recorded in the status registry (work doc #23, bergie session
|
|
1448
|
+
* note): the server replays current values to new subscriptions a
|
|
1449
|
+
* few seconds after start-up, and a snapshot taken before that
|
|
1450
|
+
* replay would record a transient failure/absence for paths that
|
|
1451
|
+
* are actually fine — navigation.state is the known case.
|
|
1452
|
+
* Non-path sources (resources, plugin-provided) record from the
|
|
1453
|
+
* first tick. */
|
|
1454
|
+
const PATH_SOURCE_GRACE_MS = 2 * 60 * 1000;
|
|
1455
|
+
let startedAt = Date.now();
|
|
1456
|
+
|
|
1446
1457
|
/**
|
|
1447
1458
|
* Records the Signal K source availability in the source status
|
|
1448
1459
|
* registry (work doc #23): the checklist also covers the server
|
|
@@ -1453,9 +1464,9 @@ module.exports = (app) => {
|
|
|
1453
1464
|
* there (companion plugin not installed, no route being sailed)
|
|
1454
1465
|
* records as absent — information, not an error.
|
|
1455
1466
|
*
|
|
1456
|
-
* Runs from the cron ticker:
|
|
1457
|
-
* grace period in which the server replays
|
|
1458
|
-
* new subscriptions.
|
|
1467
|
+
* Runs from the cron ticker: delta-fed path sources are skipped
|
|
1468
|
+
* during the startup grace period, in which the server replays
|
|
1469
|
+
* current values to the new subscriptions.
|
|
1459
1470
|
*
|
|
1460
1471
|
* @param {Date} [now]
|
|
1461
1472
|
*/
|
|
@@ -1469,55 +1480,66 @@ module.exports = (app) => {
|
|
|
1469
1480
|
typeof app.getSelfPath === "function"
|
|
1470
1481
|
? unwrap(app.getSelfPath(path))
|
|
1471
1482
|
: null;
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
:
|
|
1519
|
-
|
|
1520
|
-
|
|
1483
|
+
// Delta-fed paths: during the startup grace the server has not
|
|
1484
|
+
// replayed current values yet, so a snapshot here would lie about
|
|
1485
|
+
// sources that are about to populate (navigation.state arrives a
|
|
1486
|
+
// few seconds in). Skip them; they record from the next tick.
|
|
1487
|
+
if (now.getTime() - startedAt >= PATH_SOURCE_GRACE_MS) {
|
|
1488
|
+
const pathSources = [
|
|
1489
|
+
{
|
|
1490
|
+
id: "sk-internet-state",
|
|
1491
|
+
path: INTERNET_STATE_PATH,
|
|
1492
|
+
label: "Internet state (signalk-internet)",
|
|
1493
|
+
required: true,
|
|
1494
|
+
},
|
|
1495
|
+
{
|
|
1496
|
+
id: "sk-navigation-state",
|
|
1497
|
+
path: NAVIGATION_STATE_PATH,
|
|
1498
|
+
label: "Navigation state",
|
|
1499
|
+
// Optional: many boats never publish navigation.state, and
|
|
1500
|
+
// everything that reads it degrades gracefully — the state
|
|
1501
|
+
// machine fetches regardless (unset ≠ moored), and the
|
|
1502
|
+
// departure anchor treats unset as not underway (work doc
|
|
1503
|
+
// #15). A FAIL here would be a permanent false alarm.
|
|
1504
|
+
required: false,
|
|
1505
|
+
},
|
|
1506
|
+
{
|
|
1507
|
+
id: "sk-house-soc",
|
|
1508
|
+
path: HOUSE_SOC_PATH,
|
|
1509
|
+
label: "House battery SoC",
|
|
1510
|
+
required: true,
|
|
1511
|
+
},
|
|
1512
|
+
{
|
|
1513
|
+
id: "sk-active-route",
|
|
1514
|
+
path: ACTIVE_ROUTE_PATH,
|
|
1515
|
+
label: "Active route",
|
|
1516
|
+
required: false,
|
|
1517
|
+
},
|
|
1518
|
+
{
|
|
1519
|
+
id: "sk-energy-prediction",
|
|
1520
|
+
path: ENERGY_FORECAST_PATH,
|
|
1521
|
+
label: "Energy outlook (energy-predictor)",
|
|
1522
|
+
required: false,
|
|
1523
|
+
},
|
|
1524
|
+
];
|
|
1525
|
+
for (const source of pathSources) {
|
|
1526
|
+
const missing = observations[source.path] == null;
|
|
1527
|
+
sourceStatus.record({
|
|
1528
|
+
id: source.id,
|
|
1529
|
+
label: source.label,
|
|
1530
|
+
kind: "signalk",
|
|
1531
|
+
url: source.path,
|
|
1532
|
+
...(missing
|
|
1533
|
+
? source.required
|
|
1534
|
+
? {
|
|
1535
|
+
error: new Error("No value received since startup"),
|
|
1536
|
+
errorClass: "unavailable",
|
|
1537
|
+
}
|
|
1538
|
+
: { absent: true }
|
|
1539
|
+
: {}),
|
|
1540
|
+
now,
|
|
1541
|
+
});
|
|
1542
|
+
}
|
|
1521
1543
|
}
|
|
1522
1544
|
// Resource-side sources
|
|
1523
1545
|
const routesAvailable =
|
|
@@ -1600,6 +1622,11 @@ module.exports = (app) => {
|
|
|
1600
1622
|
description:
|
|
1601
1623
|
"Offshore passage daily briefing: multi-model weather outlook, comfort " +
|
|
1602
1624
|
"physics and learned sail preferences",
|
|
1625
|
+
// Test seam: shift the start-up instant so the source-status grace
|
|
1626
|
+
// period can be traversed deterministically
|
|
1627
|
+
__setStartedAt: (value) => {
|
|
1628
|
+
startedAt = value;
|
|
1629
|
+
},
|
|
1603
1630
|
|
|
1604
1631
|
schema: {
|
|
1605
1632
|
type: "object",
|
package/plugin/sqlite-db.js
CHANGED
|
@@ -193,6 +193,33 @@ class PassageDatabase {
|
|
|
193
193
|
PRIMARY KEY (tws_bin, twa_bin, night)
|
|
194
194
|
);
|
|
195
195
|
`);
|
|
196
|
+
this.resetLearnedMatrix();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* One-time migration (work doc #26): clears the learned matrix and
|
|
201
|
+
* its derived stores so the next backfill rebuilds everything under
|
|
202
|
+
* the new learning rules — the no-sails gate, and a learning engine
|
|
203
|
+
* whose recency rule no longer lets one bare-poles entry erase a
|
|
204
|
+
* bin's real rig. Classifying and dropping just the poisoned cells
|
|
205
|
+
* was considered, but whatever rig they had overwritten is
|
|
206
|
+
* unrecoverable from the matrix alone: the honest move is a full
|
|
207
|
+
* re-learn from the logbook. The logbook files themselves are
|
|
208
|
+
* untouched — this only empties the derived store. Until a backfill
|
|
209
|
+
* runs the matrix is empty and suggestions degrade to "keep the
|
|
210
|
+
* current rig".
|
|
211
|
+
*/
|
|
212
|
+
resetLearnedMatrix() {
|
|
213
|
+
const version = this.db.prepare("PRAGMA user_version").get().user_version;
|
|
214
|
+
if (version >= 1) {
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
this.db.exec(`
|
|
218
|
+
DELETE FROM learned_sail_matrix;
|
|
219
|
+
DELETE FROM wind_history_cache;
|
|
220
|
+
DELETE FROM logbook_sail_events;
|
|
221
|
+
`);
|
|
222
|
+
this.db.prepare("PRAGMA user_version = 1").run();
|
|
196
223
|
}
|
|
197
224
|
|
|
198
225
|
/**
|
|
@@ -83,6 +83,9 @@ class BackfillControls extends HTMLElement {
|
|
|
83
83
|
this._out.textContent =
|
|
84
84
|
`Learned ${summary.learned} of ${summary.total} sail changes` +
|
|
85
85
|
(skipped > 0 ? ` (skipped ${skipped})` : "") +
|
|
86
|
+
(summary.skippedNoSails > 0
|
|
87
|
+
? `, ${summary.skippedNoSails} bare-poles entries kept out of the matrix`
|
|
88
|
+
: "") +
|
|
86
89
|
`, ${summary.cells.length} matrix cells`;
|
|
87
90
|
} catch (error) {
|
|
88
91
|
this._out.classList.add("err");
|
|
@@ -84,6 +84,23 @@ class ComfortInfo extends HTMLElement {
|
|
|
84
84
|
row.appendChild(band);
|
|
85
85
|
this._pop.appendChild(row);
|
|
86
86
|
}
|
|
87
|
+
const slatting = document.createElement("div");
|
|
88
|
+
slatting.className = "row";
|
|
89
|
+
const slattingName = document.createElement("span");
|
|
90
|
+
slattingName.className = "name";
|
|
91
|
+
slattingName.textContent = "SLATTING";
|
|
92
|
+
const slattingText = document.createElement("span");
|
|
93
|
+
slattingText.textContent =
|
|
94
|
+
" — Light air over a residual swell: the sails slat and bang, " +
|
|
95
|
+
"snap-rolling the boat.";
|
|
96
|
+
slatting.appendChild(slattingName);
|
|
97
|
+
slatting.appendChild(slattingText);
|
|
98
|
+
const slattingBand = document.createElement("span");
|
|
99
|
+
slattingBand.className = "band";
|
|
100
|
+
slattingBand.textContent =
|
|
101
|
+
"wind < 7–12 kn by point of sail · swell ≥ 0.6 m";
|
|
102
|
+
slatting.appendChild(slattingBand);
|
|
103
|
+
this._pop.appendChild(slatting);
|
|
87
104
|
}
|
|
88
105
|
}
|
|
89
106
|
|
|
@@ -33,10 +33,10 @@ class HorizonSparkline extends HTMLElement {
|
|
|
33
33
|
<svg viewBox="0 0 240 96" preserveAspectRatio="none" role="img"
|
|
34
34
|
aria-label="24 hour comfort and wind outlook">
|
|
35
35
|
<defs>
|
|
36
|
-
<!-- Slatting hatch
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
36
|
+
<!-- Slatting hatch: rough comfort triggered by light air in
|
|
37
|
+
a residual swell renders striped — heavy-weather rough keeps
|
|
38
|
+
its solid colour. Transparent background lets the card
|
|
39
|
+
colour show between the stripes. -->
|
|
40
40
|
<pattern id="slatting-hatch" width="6" height="6"
|
|
41
41
|
patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
|
|
42
42
|
<rect width="6" height="6" fill="none"></rect>
|
|
@@ -306,7 +306,7 @@ export function sparklineColumns(hourlyComfort, hours = 24, options = {}) {
|
|
|
306
306
|
awsKnots: aws,
|
|
307
307
|
heightPct: Math.max(4, Math.round((aws / maxAws) * 100)),
|
|
308
308
|
color: comfortColor(b.comfortLevel),
|
|
309
|
-
title: `${stamp(b.hoursFromNow)}: ${fmtKn(aws)} AWS, ${b.
|
|
309
|
+
title: `${stamp(b.hoursFromNow)}: ${fmtKn(aws)} AWS, ${b.slatting ? "slatting" : (b.comfortLevel ?? "?")}`,
|
|
310
310
|
};
|
|
311
311
|
});
|
|
312
312
|
}
|
|
@@ -641,15 +641,22 @@ export function sailStateLabel(key) {
|
|
|
641
641
|
|
|
642
642
|
/**
|
|
643
643
|
* Label for one sail-change event: bare "No sails" says WHY the
|
|
644
|
-
* canvas is down — the plan drifts (`No sails - drifting`)
|
|
645
|
-
* engine pushes (`Motoring`)
|
|
644
|
+
* canvas is down — the plan drifts (`No sails - drifting`), the
|
|
645
|
+
* engine pushes (`Motoring`), or the weather demands the canvas-off
|
|
646
|
+
* regime (`Storm tactics`: at survival wind the tactic — heave to,
|
|
647
|
+
* drogue, run off — is the crew's call, not the simulator's; work
|
|
648
|
+
* doc #27 will pick it up) — instead of implying bare-pole sailing.
|
|
646
649
|
*
|
|
647
|
-
* @param {{sailState: string|null, propulsion?: string|null
|
|
650
|
+
* @param {{sailState: string|null, propulsion?: string|null,
|
|
651
|
+
* canvasOffRegime?: string|null}} event
|
|
648
652
|
* @returns {string} Human label
|
|
649
653
|
*/
|
|
650
654
|
function sailEventLabel(event) {
|
|
651
655
|
const label = sailStateLabel(event.sailState);
|
|
652
656
|
if (label === "No sails") {
|
|
657
|
+
if (event.canvasOffRegime === "survival") {
|
|
658
|
+
return "Storm tactics";
|
|
659
|
+
}
|
|
653
660
|
if (event.propulsion === "adrift") {
|
|
654
661
|
return "No sails - drifting";
|
|
655
662
|
}
|
|
@@ -707,8 +714,9 @@ function episodeDetail(episode, parts) {
|
|
|
707
714
|
* to one chronological shape — `{hoursFromNow, timestamp, stamp,
|
|
708
715
|
* kind, severity, label, detail}` — sorted by time, undated last.
|
|
709
716
|
* Kinds: `sail`, `maneuver`, `convective`, `sea`, `zone`, `space`,
|
|
710
|
-
* `hazard`; new event sources become new kinds instead of
|
|
711
|
-
* (work doc #1's lines of interest will ride in as
|
|
717
|
+
* `hazard`, `time`; new event sources become new kinds instead of
|
|
718
|
+
* new blocks (work doc #1's lines of interest will ride in as
|
|
719
|
+
* `line`).
|
|
712
720
|
* Severities: `info`, `warn`, `severe` — the renderer's colour
|
|
713
721
|
* scale. The tactical dashboard renders the 24 h slice of this list,
|
|
714
722
|
* the strategic outlook the whole passage.
|
|
@@ -724,7 +732,7 @@ function episodeDetail(episode, parts) {
|
|
|
724
732
|
* @param {object|null} exceptions - Worker exception view
|
|
725
733
|
* @param {object|null} payload - Briefing payload
|
|
726
734
|
* (UnifiedWeatherPayload; optional `spaceEvents`,
|
|
727
|
-
* `zoneTransitions`, `celestialNights`)
|
|
735
|
+
* `zoneTransitions`, `celestialNights`, `timeZoneChanges`)
|
|
728
736
|
* @returns {Array<{hoursFromNow: number|null, timestamp: string|null, stamp: string, kind: string, severity: string, label: string, detail: string, night: boolean, moon: string|null}>}
|
|
729
737
|
*/
|
|
730
738
|
/**
|
|
@@ -982,7 +990,10 @@ export function mergeTimeline(exceptions, payload = null) {
|
|
|
982
990
|
// Conditions at the change point (work doc #18): what the crew
|
|
983
991
|
// is rigging into — true wind, sea state, comfort tier. Falls
|
|
984
992
|
// back to the bare maneuver TWS for payloads without the
|
|
985
|
-
// enriched conditions block.
|
|
993
|
+
// enriched conditions block. When the tier came from the
|
|
994
|
+
// slatting penalty (work doc #14: light air over a residual
|
|
995
|
+
// swell), the detail says so — "rough" alone would send someone
|
|
996
|
+
// looking for wind that isn't there.
|
|
986
997
|
const conditions = e.conditions ?? null;
|
|
987
998
|
const tws = conditions?.twsKnots ?? e.twsAtManeuver ?? null;
|
|
988
999
|
// Why the change sits where it sits: watch handover when a watch
|
|
@@ -1009,7 +1020,7 @@ export function mergeTimeline(exceptions, payload = null) {
|
|
|
1009
1020
|
conditions?.hsMeters != null
|
|
1010
1021
|
? `Hs ${conditions.hsMeters.toFixed(1)} m`
|
|
1011
1022
|
: null,
|
|
1012
|
-
conditions?.comfortLevel ?? null,
|
|
1023
|
+
conditions?.slatting ? "slatting" : (conditions?.comfortLevel ?? null),
|
|
1013
1024
|
anchorText,
|
|
1014
1025
|
]
|
|
1015
1026
|
.filter(Boolean)
|
|
@@ -1105,6 +1116,9 @@ export function mergeTimeline(exceptions, payload = null) {
|
|
|
1105
1116
|
// (summary), falling back to the payload's undated transitions;
|
|
1106
1117
|
// the timeline kind is always `zone`. Leaving territorial waters
|
|
1107
1118
|
// carries the connectivity note — the metered-ocean boundary.
|
|
1119
|
+
// When the crossing lands in another timezone than the vessel's
|
|
1120
|
+
// current one, the event says so (work doc #19) — exactly the
|
|
1121
|
+
// moment a crew sets watches and re-plans arrival in local time.
|
|
1108
1122
|
for (const z of summary.zoneTransitions ?? payload?.zoneTransitions ?? []) {
|
|
1109
1123
|
const territory = z.territory?.name ?? "?";
|
|
1110
1124
|
push({
|
|
@@ -1120,12 +1134,32 @@ export function mergeTimeline(exceptions, payload = null) {
|
|
|
1120
1134
|
z.distanceFromStartNm != null
|
|
1121
1135
|
? `${Math.round(z.distanceFromStartNm)} nm from departure`
|
|
1122
1136
|
: null,
|
|
1137
|
+
z.zoneOffsetMinutes != null &&
|
|
1138
|
+
(shipTime == null || z.zoneOffsetMinutes !== shipTime.offsetMinutes)
|
|
1139
|
+
? `time zone ${offsetLabel(z.zoneOffsetMinutes)}`
|
|
1140
|
+
: null,
|
|
1123
1141
|
]
|
|
1124
1142
|
.filter(Boolean)
|
|
1125
1143
|
.join(" · "),
|
|
1126
1144
|
});
|
|
1127
1145
|
}
|
|
1128
1146
|
|
|
1147
|
+
// Zone meridian crossings offshore (work doc #19): solar time
|
|
1148
|
+
// drifts 1 h per 15° of longitude, so watch schedules quietly
|
|
1149
|
+
// desynchronize from the sun unless the clock follows. The event
|
|
1150
|
+
// advises; what the crew does with the clock is the master's
|
|
1151
|
+
// discretion.
|
|
1152
|
+
for (const c of summary.timeZoneChanges ?? payload?.timeZoneChanges ?? []) {
|
|
1153
|
+
push({
|
|
1154
|
+
hoursFromNow: c.hoursFromNow ?? relHours(c.timestamp, fetchMs),
|
|
1155
|
+
timestamp: c.timestamp ?? null,
|
|
1156
|
+
kind: "time",
|
|
1157
|
+
severity: "info",
|
|
1158
|
+
label: `Crossing ${c.meridian ?? "a zone meridian"}`,
|
|
1159
|
+
detail: `solar time 1 h ${c.eastbound ? "ahead" : "behind"} — clock change due`,
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1129
1163
|
// Night indicators for every dated item (work doc #17 session
|
|
1130
1164
|
// feedback): interpolate the crew's position at the item's hour
|
|
1131
1165
|
// from the simulated track and test the sun — one uniform answer
|
|
@@ -31,6 +31,7 @@ const GLYPHS = {
|
|
|
31
31
|
line: "⌀",
|
|
32
32
|
energy: "🔋",
|
|
33
33
|
departure: "⚓",
|
|
34
|
+
time: "◷",
|
|
34
35
|
};
|
|
35
36
|
|
|
36
37
|
/**
|
|
@@ -71,6 +72,16 @@ class PassageTimeline extends HTMLElement {
|
|
|
71
72
|
.sev-severe .glyph, .sev-severe .label { color: var(--color-red); }
|
|
72
73
|
.sev-info .glyph { color: var(--color-teal); }
|
|
73
74
|
.none { color: var(--text-muted); font-size: 0.85rem; }
|
|
75
|
+
/* Phone: the when gutter is too wide and squeezes the event
|
|
76
|
+
* text, so the date line goes on top at full width and the
|
|
77
|
+
* event data flows beneath it. Matches the 700px layout
|
|
78
|
+
* breakpoint used across the app. */
|
|
79
|
+
@media (max-width: 699px) {
|
|
80
|
+
.entry { grid-template-columns: auto 1fr; }
|
|
81
|
+
.when { grid-area: 1 / 1 / 2 / 3; }
|
|
82
|
+
.glyph { grid-area: 2 / 1 / 3 / 2; }
|
|
83
|
+
.body { grid-area: 2 / 2 / 3 / 3; }
|
|
84
|
+
}
|
|
74
85
|
</style>
|
|
75
86
|
<div id="list"></div>
|
|
76
87
|
`;
|
|
@@ -117,6 +128,7 @@ class PassageTimeline extends HTMLElement {
|
|
|
117
128
|
glyph.title = item.kind ?? "";
|
|
118
129
|
|
|
119
130
|
const body = document.createElement("span");
|
|
131
|
+
body.className = "body";
|
|
120
132
|
const label = document.createElement("span");
|
|
121
133
|
label.className = "label";
|
|
122
134
|
label.textContent = item.label ?? "?";
|