signalk-pypilot-newui 2.0.2 → 2.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,44 +1,16 @@
1
1
  # Changelog
2
2
 
3
- ## 2.0.2 — 2026-08-20 — Rev65
4
-
5
- Bug fix + UI polish batch.
6
-
7
- ### Fixed
8
-
9
- - **Target diamond drifted after tapping AP on laggy links** (Tailscale,
10
- 4G). We were re-reading `state.heading` inside the parallel promise
11
- chain, so between the click and the actual POST the heading could have
12
- moved a few degrees. Consequence: we sent one target to the server,
13
- asigned another locally, and the diamond jumped to a third when the
14
- real delta arrived. Now the heading is captured ONCE at the click and
15
- reused for the POST, the optimistic assign and any later reference in
16
- the handler.
17
-
18
- ### Changed - compass rose
19
-
20
- - HDG now shown in a small dark chip that straddles the top of the ring
21
- (asoma un poco por encima) instead of floating text. Font shrunk from
22
- 18 to 14 to fit inside the chip.
23
- - Wind arrows redrawn: both solid (no more dashed shaft on TWA), amber
24
- AWA keeps a big head, sea-green (#5ce8c0) TWA gets a smaller head, and
25
- each carries its own `AWA` / `TWA` text label rotating with the arrow.
26
- The magenta is gone.
27
- - Bottom of rose: `AWS` / `TWS` label small on top and the number large
28
- below, split into two lines with the S cardinal reubicated so the
29
- gap between them stays visible for the rose itself.
30
- - Short tap on a corner mapped to "wind" now flips the label between
31
- AWA/AWS and TWA/TWS and pins that choice. When the AP mode changes to
32
- a true-wind mode the label auto-follows to TWS (unless the user has
33
- already pinned it manually).
34
-
35
- ### Added - rudder angle indicator in the swipe-hint bar
36
-
37
- The bar under the Control tab now carries a horizontal red-to-green
38
- gradient (port → stbd) with a moving white marker showing the current
39
- rudder angle. The central yellow nav-toggle triangle sits at 0 deg,
40
- doubling as the amidships reference. Scale comes from the pypilot
41
- catalog's `rudder.range` (default 45 deg when unavailable).
3
+ ## 2.0.3 — 2026-08-20 — Rev66 - rollback of 2.0.2
4
+
5
+ Codebase is functionally identical to 2.0.1. Version 2.0.2 introduced
6
+ regressions in the socket auto-reconnect and the new "escalated restart"
7
+ triad that could leave the plugin showing `connected: false` against a
8
+ pypilot_web that is in fact alive and serving other consumers. Rolling
9
+ back to the 2.0.1 behaviour and reintroducing the 2.0.2 UI polish
10
+ (target diamond snapshot, redesigned AWA/TWA arrows, HDG chip, wind
11
+ speed chip, tap wind toggle, rudder bar, debug console, escalated
12
+ restarts) in a later 2.0.4+ release, one item at a time with proper QA
13
+ before publish. 2.0.2 has been deprecated on npm.
42
14
 
43
15
  ## 2.0.1 — 2026-08-20 — Rev64
44
16
 
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@ const scanner_1 = require("./scanner");
6
6
  const autopilot_provider_1 = require("./autopilot-provider");
7
7
  // Rev counter bumped on every build so the user can distinguish deploys
8
8
  // from the webapp header (feedback_revision_bump_each_build).
9
- const PLUGIN_REVISION = "Rev65";
9
+ const PLUGIN_REVISION = "Rev66";
10
10
  // Rev59: read package.json once at load time so /status can report the
11
11
  // npm package version alongside the internal Rev counter.
12
12
  // eslint-disable-next-line @typescript-eslint/no-require-imports
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-pypilot-newui",
3
- "version": "2.0.2",
3
+ "version": "2.0.3",
4
4
  "description": "Signal K friendly UI for the pypilot autopilot, with the capability to spread its functions and buttons to other programs like KIP, WilhelmSK and freeboard. Touch-first mobile visor + full pypilot data (servo, gains, calibration, tack, warnings) as first-class Signal K paths and Simple Switches. Coexists with pypilot-autopilot-provider.",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {
package/public/app.js CHANGED
@@ -534,20 +534,6 @@
534
534
  windAngleTrue: null,
535
535
  windSpeedTrue: null,
536
536
  sog: null, // m/s
537
- // Rev65 / 2.0.2 - wind-corner display preference. "awa" or "twa".
538
- // Initial value derives from the browser-stored preference; if
539
- // absent, defaults to "awa" and later auto-follows the AP mode
540
- // (see _syncWindCornerFromMode).
541
- windCornerShow: (() => {
542
- try { return localStorage.getItem("pypilot-newui.windCornerShow") || "awa"; }
543
- catch { return "awa"; }
544
- })(),
545
- windCornerUserPinned: (() => {
546
- // User's manual tap "pins" the choice so mode changes stop
547
- // overwriting it. Persistent per browser.
548
- try { return localStorage.getItem("pypilot-newui.windCornerPinned") === "1"; }
549
- catch { return false; }
550
- })(),
551
537
  };
552
538
 
553
539
  const RAD2DEG = 180 / Math.PI;
@@ -686,7 +672,7 @@
686
672
  function applyValue(path, value) {
687
673
  switch (path) {
688
674
  case "steering.autopilot.state": state.apState = value; break;
689
- case "steering.autopilot.mode": state.mode = value; setSelect("#mode-select", value); _syncWindCornerFromMode(value); break;
675
+ case "steering.autopilot.mode": state.mode = value; setSelect("#mode-select", value); break;
690
676
  case "steering.autopilot.target": state.target = numericOrNull(value); renderTargetArrow(); break;
691
677
  case "steering.autopilot.engaged": state.engaged = !!value; renderEngage(); renderTargetArrow(); break;
692
678
  case "steering.autopilot.availableActions": state.availableActions = value || []; break;
@@ -794,12 +780,9 @@
794
780
  // getComputedTextLength() only works on rendered SVG text nodes.
795
781
  // Fall back to a heuristic width if the SVG has not laid out yet
796
782
  // (e.g. tab is not visible).
797
- // Fallback width when SVG has not laid out yet (tab not visible).
798
- // Rev65: HDG font shrunk from 18 to 14 for the new chip, so the
799
- // per-char heuristic drops from 12 to 9 units.
800
783
  let w;
801
- try { w = hdgTxt.getComputedTextLength(); } catch { w = s.length * 9; }
802
- if (!isFinite(w) || w <= 0) w = s.length * 9;
784
+ try { w = hdgTxt.getComputedTextLength(); } catch { w = s.length * 12; }
785
+ if (!isFinite(w) || w <= 0) w = s.length * 12;
803
786
  hdgDeg.setAttribute("x", (w / 2 + 1).toFixed(1));
804
787
  }
805
788
  }
@@ -821,15 +804,9 @@
821
804
  twa: { label: "TWA", read: () => state.windAngleTrue == null ? "---" : _sgn(state.windAngleTrue * RAD2DEG) + "°" },
822
805
  tws: { label: "TWS", read: () => state.windSpeedTrue == null ? "---" : (state.windSpeedTrue * 1.94384).toFixed(1) + " kn" },
823
806
  twd: { label: "TWD", read: () => state.windDirTrue == null ? "---" : Math.round(state.windDirTrue * RAD2DEG) + "°" },
824
- // Rev65 / 2.0.2: the "wind" corner now honours a user-toggleable
825
- // preference (state.windCornerShow: "awa" or "twa"), overridable by
826
- // a short tap on the corner. When the AP mode changes TO true-wind,
827
- // the preference auto-flips to TWA/TWS; when it changes AWAY from
828
- // true-wind, it flips back to AWA/AWS. Manual taps after that stick
829
- // until the mode flips again (see _syncWindCornerFromMode).
830
- wind: { label: () => (state.windCornerShow === "twa" ? "TWA/TWS" : "AWA/AWS"),
807
+ wind: { label: () => (String(state.mode || "").includes("true") ? "TWA/TWS" : "AWA/AWS"),
831
808
  read: () => {
832
- const useTrue = state.windCornerShow === "twa";
809
+ const useTrue = String(state.mode || "").includes("true");
833
810
  const ang = useTrue ? state.windAngleTrue : state.windAngle;
834
811
  const spd = useTrue ? state.windSpeedTrue : state.windSpeed;
835
812
  const a = ang == null ? "---" : _sgn(ang * RAD2DEG) + "°";
@@ -908,32 +885,6 @@
908
885
  });
909
886
  }
910
887
  _refreshCustomDashEntries();
911
- // Rev65 / 2.0.2: short tap on the "wind" corner flips the preference
912
- // between AWA/AWS and TWA/TWS, and pins it so mode auto-changes stop
913
- // overwriting it until the user opts back into auto-follow.
914
- function _toggleWindCornerShow() {
915
- state.windCornerShow = (state.windCornerShow === "twa") ? "awa" : "twa";
916
- state.windCornerUserPinned = true;
917
- try {
918
- localStorage.setItem("pypilot-newui.windCornerShow", state.windCornerShow);
919
- localStorage.setItem("pypilot-newui.windCornerPinned", "1");
920
- } catch {}
921
- _dashRenderCorners();
922
- renderWindRose();
923
- }
924
- // Called from applyValue whenever the AP mode changes. If the user has
925
- // not pinned the wind-corner preference, follow the mode: true-wind
926
- // modes show TWA/TWS, everything else shows AWA/AWS.
927
- function _syncWindCornerFromMode(mode) {
928
- if (state.windCornerUserPinned) return;
929
- const wantTrue = String(mode || "").toLowerCase().includes("true");
930
- const next = wantTrue ? "twa" : "awa";
931
- if (state.windCornerShow === next) return;
932
- state.windCornerShow = next;
933
- try { localStorage.setItem("pypilot-newui.windCornerShow", next); } catch {}
934
- _dashRenderCorners();
935
- renderWindRose();
936
- }
937
888
  function _sgn(n) { return (n > 0 ? "+" : "") + Math.round(n); }
938
889
  function _dashLoadCfg() {
939
890
  try {
@@ -1111,10 +1062,9 @@
1111
1062
  }
1112
1063
 
1113
1064
  // SVG compass rose. The card (cardinals + ticks) rotates so N points to
1114
- // magnetic north regardless of boat heading; the boat is fixed pointing
1115
- // up; TWO independent wind arrows (AWA amber solid, TWA sea-green solid)
1116
- // rotate to their respective angles; wedges are boat-fixed helm guides;
1117
- // the bottom AWS/TWS readout follows the user's wind-corner preference.
1065
+ // magnetic north regardless of boat heading; the boat is fixed pointing up;
1066
+ // TWO independent wind arrows (AWA amber solid, TWA magenta dashed) rotate
1067
+ // to their respective angles; wedges are boat-fixed helm guides.
1118
1068
  function renderWindRose() {
1119
1069
  const card = document.getElementById("rose-card");
1120
1070
  if (card && state.heading != null) {
@@ -1140,29 +1090,22 @@
1140
1090
  }
1141
1091
  }
1142
1092
 
1143
- // Rev65 / 2.0.2: bottom AWS/TWS readout now uses TWO separate text
1144
- // nodes (small dim label on top, big white number below) and follows
1145
- // `state.windCornerShow` so a tap on the wind corner also swaps the
1146
- // readout at the bottom of the rose. Falls back gracefully if the
1147
- // chosen source has no value yet.
1148
- const useTrue = state.windCornerShow === "twa";
1149
- const primarySpd = useTrue ? state.windSpeedTrue : state.windSpeed;
1150
- const primaryTag = useTrue ? "TWS" : "AWS";
1151
- let windSpeed = primarySpd, tag = primaryTag;
1152
- if (windSpeed == null) {
1153
- // Fall back to the other source so the number is not "---" when only
1154
- // one wind flavour is available (typical on boats without SOG).
1155
- const altSpd = useTrue ? state.windSpeed : state.windSpeedTrue;
1156
- const altTag = useTrue ? "AWS" : "TWS";
1157
- if (altSpd != null) { windSpeed = altSpd; tag = altTag; }
1158
- }
1159
- const speedLabel = document.getElementById("rose-speed-label");
1160
- const speedText = document.getElementById("rose-speed");
1161
- if (speedLabel) speedLabel.textContent = windSpeed == null ? "" : tag;
1093
+ // Speed readout at the bottom: prefer the source that is enabled; if
1094
+ // both are enabled, show apparent (the one the crew feels).
1095
+ const showAws = _windArrowShow.awa && state.windSpeed != null;
1096
+ const showTws = _windArrowShow.twa && state.windSpeedTrue != null;
1097
+ let windSpeed = null, tag = "";
1098
+ if (showAws) { windSpeed = state.windSpeed; tag = "AWS"; }
1099
+ else if (showTws) { windSpeed = state.windSpeedTrue; tag = "TWS"; }
1100
+
1101
+ // Rev56: sailing zones (green stbd / red port) are DRAWN STATIC in
1102
+ // the SVG at fixed boat-centerline angles +/-30..60, so nothing to
1103
+ // update here.
1104
+ const speedText = document.getElementById("rose-speed");
1162
1105
  if (speedText) {
1163
1106
  speedText.textContent = windSpeed == null
1164
1107
  ? "--- kn"
1165
- : (windSpeed * 1.94384).toFixed(1) + " kn";
1108
+ : (windSpeed * 1.94384).toFixed(1) + " kn" + (tag ? " " + tag : "");
1166
1109
  }
1167
1110
  }
1168
1111
 
@@ -1257,36 +1200,14 @@
1257
1200
  }
1258
1201
 
1259
1202
  function renderRudder(rad) {
1260
- // Legacy widget (still hidden by default) - kept in case someone wires it.
1261
- const w = $("#rudder-widget");
1262
- if (rad == null) {
1263
- if (w) w.setAttribute("hidden", "");
1264
- // Also center the swipe-hint rudder marker so it looks amidships
1265
- // when data is missing rather than sticking to some old value.
1266
- const m = $("#rudder-marker");
1267
- if (m) m.style.left = "50%";
1268
- return;
1269
- }
1270
- if (w) w.removeAttribute("hidden");
1203
+ if (rad == null) { $("#rudder-widget").setAttribute("hidden", ""); return; }
1204
+ $("#rudder-widget").removeAttribute("hidden");
1271
1205
  const deg = rad * RAD2DEG;
1272
- const pct45 = Math.max(-1, Math.min(1, deg / 45));
1206
+ const pct = Math.max(-1, Math.min(1, deg / 45));
1273
1207
  const fill = $("#rudder-fill");
1274
- if (fill) {
1275
- fill.style.width = Math.abs(pct45 * 50) + "%";
1276
- fill.style.transform = pct45 < 0 ? "translateX(-100%)" : "translateX(0%)";
1277
- }
1278
- const val = $("#rudder-value");
1279
- if (val) val.textContent = deg.toFixed(1) + " deg";
1280
- // Rev65 / 2.0.2: paint the rudder marker on the swipe-hint bar. Range
1281
- // comes from the pypilot catalog (`rudder.range` in degrees) when
1282
- // available, else defaults to 45 deg either side.
1283
- const marker = $("#rudder-marker");
1284
- if (marker) {
1285
- const rangeDeg = Number(state.values?.["steering.autopilot.pypilot.rudder.range"]) || 45;
1286
- const pct = Math.max(-1, Math.min(1, deg / rangeDeg));
1287
- // pct = -1 -> left edge (0%), 0 -> center (50%), +1 -> right edge (100%).
1288
- marker.style.left = (50 + pct * 50).toFixed(1) + "%";
1289
- }
1208
+ fill.style.width = Math.abs(pct * 50) + "%";
1209
+ fill.style.transform = pct < 0 ? "translateX(-100%)" : "translateX(0%)";
1210
+ $("#rudder-value").textContent = deg.toFixed(1) + " deg";
1290
1211
  }
1291
1212
 
1292
1213
  // Rev36 helper: build a slider row with nudge buttons -10/-1/+1/+10.
@@ -1746,16 +1667,6 @@
1746
1667
  _apPending = true;
1747
1668
  eng.classList.add("pending");
1748
1669
  const wasEngaged = state.engaged;
1749
- // Rev65 / 2.0.2: snapshot the heading the USER IS SEEING at the exact
1750
- // moment of the click, and use that same value for (a) the target we
1751
- // send to the server, (b) the optimistic local assign, (c) any later
1752
- // reference in this handler. Previously we re-read state.heading on
1753
- // each await point - on a laggy Tailscale link (200-800 ms RTT) the
1754
- // heading could have drifted a few degrees by the time the POST
1755
- // executed, so we sent one value to the server, assigned another
1756
- // locally, and the diamond jumped to a third when the real delta
1757
- // arrived. Carlos's report: "el diamante no se fija bien".
1758
- const capturedHeading = state.heading;
1759
1670
  // Optimistic: flip the visual state before waiting on the network.
1760
1671
  state.engaged = !wasEngaged;
1761
1672
  renderEngage();
@@ -1769,12 +1680,12 @@
1769
1680
  // does not serialise 2 round-trips before the AP actually engages.
1770
1681
  const [rEng, rTgt] = await Promise.all([
1771
1682
  _apRetry(apEngage),
1772
- capturedHeading != null ? _apRetry(() => apSetTargetRad(capturedHeading)) : Promise.resolve({ ok: true }),
1683
+ state.heading != null ? _apRetry(() => apSetTargetRad(state.heading)) : Promise.resolve({ ok: true }),
1773
1684
  ]);
1774
1685
  ok = !!(rEng && rEng.ok);
1775
- if (rTgt && rTgt.ok && capturedHeading != null) {
1776
- state.target = capturedHeading;
1777
- state.localTargetRad = capturedHeading;
1686
+ if (rTgt && rTgt.ok && state.heading != null) {
1687
+ state.target = state.heading;
1688
+ state.localTargetRad = state.heading;
1778
1689
  renderControl();
1779
1690
  }
1780
1691
  }
@@ -2576,50 +2487,35 @@
2576
2487
  _activateTab(ids[nextIdx], dir === 1 ? "next" : "prev");
2577
2488
  });
2578
2489
  });
2579
- // Rev48/65: long-press opens the corner-selector popup. Short tap on
2580
- // a corner whose current mapping is "wind" flips the label between
2581
- // AWA/AWS and TWA/TWS, pinning the user's choice so subsequent AP
2582
- // mode changes stop auto-flipping it (see _syncWindCornerFromMode).
2490
+ // Rev48: long-press on any of the 4 corner cells opens the selector
2491
+ // popup so the user assigns whatever data they want per corner.
2583
2492
  _dashLoadCfg();
2584
2493
  _dashPopulateSelectors();
2585
2494
  _dashRenderCorners();
2586
2495
  let _dashLongPressTimer = null;
2587
- let _dashPressStartedAt = 0;
2588
- let _dashPressWasLong = false;
2589
2496
  $$(".dash-rose .dash-cell[data-corner]").forEach((cell) => {
2590
2497
  const openSelector = (e) => {
2591
2498
  e && e.preventDefault();
2592
- _dashPressWasLong = true;
2593
2499
  _dashPopulateSelectors();
2594
2500
  const pop = document.getElementById("dash-help-pop");
2595
2501
  if (pop) pop.classList.add("open");
2596
2502
  };
2597
- const startPress = (e) => {
2598
- _dashPressStartedAt = Date.now();
2599
- _dashPressWasLong = false;
2503
+ cell.addEventListener("touchstart", (e) => {
2600
2504
  if (_dashLongPressTimer) clearTimeout(_dashLongPressTimer);
2601
2505
  _dashLongPressTimer = setTimeout(() => openSelector(e), 500);
2602
- };
2603
- const endPress = () => {
2604
- if (_dashLongPressTimer) { clearTimeout(_dashLongPressTimer); _dashLongPressTimer = null; }
2605
- // Short tap on a wind-corner cell -> toggle AWS/TWS.
2606
- if (_dashPressWasLong) return;
2607
- const elapsed = Date.now() - _dashPressStartedAt;
2608
- if (elapsed >= 500) return;
2609
- const corner = cell.dataset.corner;
2610
- if (_dashCornerCfg[corner] === "wind") _toggleWindCornerShow();
2611
- };
2612
- cell.addEventListener("touchstart", startPress, { passive: true });
2613
- cell.addEventListener("touchend", endPress, { passive: true });
2614
- cell.addEventListener("touchmove", () => { if (_dashLongPressTimer) { clearTimeout(_dashLongPressTimer); _dashLongPressTimer = null; } _dashPressWasLong = true; }, { passive: true });
2615
- cell.addEventListener("touchcancel",() => { if (_dashLongPressTimer) { clearTimeout(_dashLongPressTimer); _dashLongPressTimer = null; } }, { passive: true });
2506
+ }, { passive: true });
2507
+ const cancel = () => { if (_dashLongPressTimer) { clearTimeout(_dashLongPressTimer); _dashLongPressTimer = null; } };
2508
+ cell.addEventListener("touchend", cancel, { passive: true });
2509
+ cell.addEventListener("touchmove", cancel, { passive: true });
2510
+ cell.addEventListener("touchcancel", cancel, { passive: true });
2616
2511
  cell.addEventListener("contextmenu", openSelector);
2512
+ let _mouseTimer = null;
2617
2513
  cell.addEventListener("mousedown", (e) => {
2618
2514
  if (e.button !== 0) return;
2619
- startPress(e);
2515
+ _mouseTimer = setTimeout(() => openSelector(e), 600);
2620
2516
  });
2621
- cell.addEventListener("mouseup", endPress);
2622
- cell.addEventListener("mouseleave", () => { if (_dashLongPressTimer) { clearTimeout(_dashLongPressTimer); _dashLongPressTimer = null; } });
2517
+ cell.addEventListener("mouseup", () => { if (_mouseTimer) { clearTimeout(_mouseTimer); _mouseTimer = null; } });
2518
+ cell.addEventListener("mouseleave", () => { if (_mouseTimer) { clearTimeout(_mouseTimer); _mouseTimer = null; } });
2623
2519
  });
2624
2520
  // Rev43: horizontal swipe between tabs on mobile/tablet.
2625
2521
  // Rules:
package/public/index.html CHANGED
@@ -71,7 +71,7 @@
71
71
  <div class="dash-cell corner-tr" data-corner="tr"></div>
72
72
  <div class="dash-cell corner-bl" data-corner="bl"></div>
73
73
  <div class="dash-cell corner-br" data-corner="br"></div>
74
- <svg id="wind-rose" class="rose-svg" viewBox="-100 -100 200 210" preserveAspectRatio="xMidYMid meet">
74
+ <svg id="wind-rose" class="rose-svg" viewBox="-100 -100 200 200" preserveAspectRatio="xMidYMid meet">
75
75
  <!-- Outer compass ring -->
76
76
  <circle cx="0" cy="0" r="94" fill="none" stroke="#2b3542" stroke-width="2"/>
77
77
  <!-- Rev58: sailing zones as PIE SLICES from origin (r=0) out
@@ -106,13 +106,10 @@
106
106
  <line x1="0" y1="-94" x2="0" y2="-84" transform="rotate(330)"/>
107
107
  </g>
108
108
  <!-- 4 cardinals: N accent yellow, ESW dim white. -->
109
- <text x="0" y="-70" text-anchor="middle" dominant-baseline="middle" fill="#ffd54a" font-size="14" font-weight="900">N</text>
110
- <text x="70" y="0" text-anchor="middle" dominant-baseline="middle" fill="#e5eaf3" font-size="13" font-weight="800">E</text>
111
- <!-- Rev65: S moved inward from y=70 to y=58 so the AWS/TWS
112
- bottom-of-rose label at y=70 does not overlap it when the
113
- card sits at heading 0. -->
114
- <text x="0" y="58" text-anchor="middle" dominant-baseline="middle" fill="#e5eaf3" font-size="13" font-weight="800">S</text>
115
- <text x="-70" y="0" text-anchor="middle" dominant-baseline="middle" fill="#e5eaf3" font-size="13" font-weight="800">W</text>
109
+ <text x="0" y="-70" text-anchor="middle" dominant-baseline="middle" fill="#ffd54a" font-size="14" font-weight="900">N</text>
110
+ <text x="70" y="0" text-anchor="middle" dominant-baseline="middle" fill="#e5eaf3" font-size="13" font-weight="800">E</text>
111
+ <text x="0" y="70" text-anchor="middle" dominant-baseline="middle" fill="#e5eaf3" font-size="13" font-weight="800">S</text>
112
+ <text x="-70" y="0" text-anchor="middle" dominant-baseline="middle" fill="#e5eaf3" font-size="13" font-weight="800">W</text>
116
113
  <!-- 8 numeric labels at 30 deg increments (skip cardinals). -->
117
114
  <g fill="#8b98a5" font-size="7" font-weight="700" font-family="ui-monospace, monospace" text-anchor="middle" dominant-baseline="middle">
118
115
  <text transform="rotate(30) translate(0,-72)">030</text>
@@ -125,46 +122,30 @@
125
122
  <text transform="rotate(330) translate(0,-72)">330</text>
126
123
  </g>
127
124
  </g>
128
- <!-- Rev65 / 2.0.2: HDG in a small chip that straddles the top
129
- rim. The chip's top edge sits above the ring by ~4 units
130
- (visually "asomando" per Carlos), the bottom edge sits
131
- just below the ring so the chip clearly reads as an
132
- overlay on top of the compass, not as a floating badge.
133
- The chip does NOT push the SVG viewBox up - only the
134
- painted rect crosses the ring line. Number CENTERED on
135
- the digits only; degree glyph placed by JS after the
136
- digits so "9" and "298" both anchor at x=0. -->
137
- <g id="rose-hdg-chip">
138
- <rect x="-24" y="-100" width="48" height="18" rx="4" ry="4"
139
- fill="rgba(0,0,0,0.72)" stroke="#4a5867" stroke-width="1"/>
140
- <text id="rose-hdg-value" x="0" y="-90" text-anchor="middle" dominant-baseline="middle" fill="#ffffff" font-size="14" font-weight="900" font-family="ui-monospace, monospace">---</text>
141
- <text id="rose-hdg-deg" x="0" y="-90" text-anchor="start" dominant-baseline="middle" fill="#ffffff" font-size="10" font-weight="700">°</text>
142
- </g>
125
+ <!-- Rev58: HDG pushed to y=-82 (right against the top rim);
126
+ wind speed dropped to y=+82 (bottom rim) to keep the
127
+ whole center free for the boat + arrows. Number CENTERED
128
+ on the digits only; the degree symbol is positioned by
129
+ JS just after them so "9" and "298" both anchor at x=0. -->
130
+ <text id="rose-hdg-value" x="0" y="-82" text-anchor="middle" dominant-baseline="middle" fill="#ffffff" font-size="18" font-weight="900" font-family="ui-monospace, monospace">---</text>
131
+ <text id="rose-hdg-deg" x="0" y="-82" text-anchor="start" dominant-baseline="middle" fill="#ffffff" font-size="13" font-weight="700">°</text>
143
132
  <!-- Rev50: boat overhead SVG (mareas-ihm), scaled 0.22 so
144
133
  the hull spans ~100 units = 50 % of the 200-unit rose
145
134
  diameter. Rotated -90 so bow points up. -->
146
135
  <g id="rose-boat" transform="rotate(-90) scale(0.22)">
147
136
  <image href="boat-cenital.svg" x="-228" y="-76" width="456" height="152"/>
148
137
  </g>
149
- <!-- Rev65 / 2.0.2: two independent wind arrows, both solid
150
- (no more dashed shaft), each labelled AWA / TWA in text
151
- that rotates with the arrow so it always reads from the
152
- head. AWA = amber (the wind the crew feels), big head.
153
- TWA = sea-green (informational), smaller head. Both
154
- toggled independently from the corner popup. -->
138
+ <!-- Rev58: two independent wind arrows so the crew can watch
139
+ apparent AND true at the same time. AWA in amber (crew
140
+ default), TWA in magenta with a dashed shaft. Each is
141
+ toggled independently from the corner popup (localStorage). -->
155
142
  <g id="rose-wind-arrow" style="display:none">
156
- <line x1="0" y1="-86" x2="0" y2="-32" stroke="#ffd54a" stroke-width="3" stroke-linecap="round"/>
157
- <polygon points="0,-24 -9,-40 9,-40" fill="#ffd54a" stroke="#8a6a00" stroke-width="0.6"/>
158
- <text x="0" y="-58" text-anchor="middle" dominant-baseline="middle"
159
- fill="#ffd54a" stroke="#3a2600" stroke-width="0.4" paint-order="stroke fill"
160
- font-size="8" font-weight="900" letter-spacing="1">AWA</text>
143
+ <line x1="0" y1="-86" x2="0" y2="-36" stroke="#ffd54a" stroke-width="3" stroke-linecap="round"/>
144
+ <polygon points="0,-30 -7,-42 7,-42" fill="#ffd54a" stroke="#8a6a00" stroke-width="0.6"/>
161
145
  </g>
162
146
  <g id="rose-wind-arrow-twa" style="display:none">
163
- <line x1="0" y1="-86" x2="0" y2="-38" stroke="#5ce8c0" stroke-width="2.5" stroke-linecap="round"/>
164
- <polygon points="0,-32 -6,-42 6,-42" fill="#5ce8c0" stroke="#1a4438" stroke-width="0.6"/>
165
- <text x="0" y="-62" text-anchor="middle" dominant-baseline="middle"
166
- fill="#5ce8c0" stroke="#0f2620" stroke-width="0.4" paint-order="stroke fill"
167
- font-size="7" font-weight="900" letter-spacing="1">TWA</text>
147
+ <line x1="0" y1="-86" x2="0" y2="-36" stroke="#c780f5" stroke-width="2.5" stroke-linecap="round" stroke-dasharray="4 3"/>
148
+ <polygon points="0,-30 -6,-40 6,-40" fill="#c780f5" stroke="#3f1d5c" stroke-width="0.6"/>
168
149
  </g>
169
150
  <!-- Rev57: target indicator - CYAN diamond just outside the
170
151
  compass ring, only shown when AP is engaged. Rotated to
@@ -173,17 +154,10 @@
173
154
  <g id="rose-target-arrow" style="display:none">
174
155
  <polygon points="0,-104 -7,-96 0,-88 7,-96" fill="#4dd4ff" stroke="#003b52" stroke-width="1.2"/>
175
156
  </g>
176
- <!-- Rev65 / 2.0.2: wind speed split into two stacked lines
177
- - a small dim label (AWS/TWS/both) and a large white
178
- number - separated by empty space so the S cardinal and
179
- the surrounding rose stay visible in between. The whole
180
- stack sits below the compass ring: label just outside at
181
- y=+100, number below at y=+108, both fitting inside the
182
- expanded viewBox (extra 10 units at the bottom). -->
183
- <text id="rose-speed-label" x="0" y="98" text-anchor="middle" dominant-baseline="middle"
184
- fill="#8b98a5" font-size="8" font-weight="700" letter-spacing="1.5"></text>
185
- <text id="rose-speed" x="0" y="108" text-anchor="middle" dominant-baseline="middle"
186
- fill="#ffffff" font-size="15" font-weight="900" font-family="ui-monospace, monospace">--- kn</text>
157
+ <!-- Rev58: wind speed dropped to y=+82 (bottom rim) to keep
158
+ the boat area clean. Sub-label picks the source with a
159
+ hint (AWS / TWS / both). -->
160
+ <text id="rose-speed" x="0" y="82" text-anchor="middle" fill="#ffffff" font-size="16" font-weight="800">--- kn</text>
187
161
  </svg>
188
162
  </div>
189
163
  </div>
@@ -277,18 +251,12 @@
277
251
  <div id="rudder-value" class="value">--</div>
278
252
  </div>
279
253
 
280
- <!-- Rev53/65: swipe affordance strip with central triangle toggle
281
- AND (Rev65 / 2.0.2) a rudder-angle indicator painted on the
282
- same strip. Track: horizontal gradient from red (port) at the
283
- left to green (stbd) at the right, on a black band. Marker:
284
- a thin vertical bar that sits at the exact centre when the
285
- rudder is amidships and slides left/right proportional to
286
- `state.rudderAngle / state.rudderRange`. The central yellow
287
- triangle keeps its nav-toggle role AND visually reads as the
288
- "zero" reference of the rudder bar. -->
254
+ <!-- Rev53: swipe affordance strip with central triangle toggle.
255
+ Left/right arrows navigate to prev/next tab (like a mini
256
+ breadcrumb + swipe hint). Central yellow glow triangle
257
+ hides/shows the top nav bar. This lets the user reclaim
258
+ the full viewport for the rose. Only visible inside Control. -->
289
259
  <div class="swipe-hint" id="swipe-hint" aria-hidden="true">
290
- <div class="rudder-track"></div>
291
- <div class="rudder-marker" id="rudder-marker" style="left:50%"></div>
292
260
  <span class="swipe-hint-arrow" data-swipe-dir="prev">‹</span>
293
261
  <span class="swipe-hint-arrow" data-swipe-dir="prev">‹</span>
294
262
  <button class="nav-toggle" id="nav-toggle" aria-label="Mostrar / ocultar barra de pestañas" title="Mostrar / ocultar barra de pestañas">
@@ -918,6 +886,6 @@
918
886
  </div>
919
887
 
920
888
  <!-- Cache-busting: bump the ?v= on every Rev to force Firefox to fetch fresh app.js -->
921
- <script src="app.js?v=Rev65"></script>
889
+ <script src="app.js?v=Rev66"></script>
922
890
  </body>
923
891
  </html>
package/public/style.css CHANGED
@@ -1543,15 +1543,13 @@ main { touch-action: pan-y; overflow-x: hidden; }
1543
1543
  strip with subtle motion cueing that swipe is available. Also acts
1544
1544
  as tappable arrows for tab navigation. */
1545
1545
  .swipe-hint {
1546
- position: relative; /* Rev65: anchor for the rudder track + marker */
1547
1546
  display: flex;
1548
1547
  align-items: center;
1549
1548
  justify-content: space-between;
1550
1549
  gap: 10px;
1551
1550
  margin-top: 10px;
1552
1551
  padding: 8px 12px;
1553
- background: #08111c; /* Rev65: solid dark base; the rudder track
1554
- below paints its own gradient on top. */
1552
+ background: linear-gradient(90deg, rgba(55,182,255,0.14) 0%, rgba(55,182,255,0.05) 50%, rgba(55,182,255,0.14) 100%);
1555
1553
  border: 1px dashed rgba(55,182,255,0.35);
1556
1554
  border-radius: 10px;
1557
1555
  color: rgba(255, 255, 255, 0.65);
@@ -1561,46 +1559,6 @@ main { touch-action: pan-y; overflow-x: hidden; }
1561
1559
  user-select: none;
1562
1560
  overflow: hidden;
1563
1561
  }
1564
-
1565
- /* Rev65 / 2.0.2 - Rudder angle bar painted inside the swipe-hint strip.
1566
- .rudder-track is a full-width horizontal gradient (red = port left,
1567
- green = stbd right) with a dim mid-tone so amidships reads as
1568
- "neutral". The three foreground layers (arrows, nav-toggle triangle,
1569
- marker) sit above it via z-index. The nav-toggle's own yellow
1570
- triangle visually stands in for the 0 deg reference. */
1571
- .swipe-hint .rudder-track {
1572
- position: absolute;
1573
- inset: 4px 8px; /* small inset from the strip edge */
1574
- border-radius: 8px;
1575
- background: linear-gradient(
1576
- 90deg,
1577
- rgba(231, 76, 60, 0.65) 0%, /* PORT: red */
1578
- rgba(231, 76, 60, 0.20) 40%,
1579
- rgba(46, 46, 46, 0.30) 50%, /* dim mid = amidships */
1580
- rgba(46, 204, 113, 0.20) 60%,
1581
- rgba(46, 204, 113, 0.65) 100% /* STBD: green */
1582
- );
1583
- z-index: 0;
1584
- pointer-events: none;
1585
- }
1586
- .swipe-hint .rudder-marker {
1587
- position: absolute;
1588
- top: 2px;
1589
- bottom: 2px;
1590
- width: 4px;
1591
- margin-left: -2px; /* center on `left` */
1592
- background: #ffffff;
1593
- border-radius: 2px;
1594
- box-shadow: 0 0 6px rgba(255, 255, 255, 0.55);
1595
- z-index: 1;
1596
- transition: left 180ms ease-out;
1597
- pointer-events: none;
1598
- }
1599
- .swipe-hint-arrow,
1600
- .swipe-hint .nav-toggle {
1601
- position: relative; /* stack above the rudder track */
1602
- z-index: 2;
1603
- }
1604
1562
  .swipe-hint-arrow {
1605
1563
  font-size: 28px;
1606
1564
  line-height: 1;