signalk-pypilot-newui 2.0.0 → 2.0.2

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,5 +1,52 @@
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).
42
+
43
+ ## 2.0.1 — 2026-08-20 — Rev64
44
+
45
+ Patch release to validate the GitHub Actions publish pipeline with
46
+ [npm Trusted Publishing](https://docs.npmjs.com/trusted-publishers)
47
+ (OIDC-based, no NPM_TOKEN secret in the repo). No functional changes
48
+ versus 2.0.0. Look for the "verified provenance" badge on the npm page.
49
+
3
50
  ## 2.0.0 — 2026-08-20 — Rev63
4
51
 
5
52
  **Breaking release addressing feedback from Sean D'Epagnier (pypilot author,
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 = "Rev63";
9
+ const PLUGIN_REVISION = "Rev65";
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.0",
3
+ "version": "2.0.2",
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,6 +534,20 @@
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
+ })(),
537
551
  };
538
552
 
539
553
  const RAD2DEG = 180 / Math.PI;
@@ -672,7 +686,7 @@
672
686
  function applyValue(path, value) {
673
687
  switch (path) {
674
688
  case "steering.autopilot.state": state.apState = value; break;
675
- case "steering.autopilot.mode": state.mode = value; setSelect("#mode-select", value); break;
689
+ case "steering.autopilot.mode": state.mode = value; setSelect("#mode-select", value); _syncWindCornerFromMode(value); break;
676
690
  case "steering.autopilot.target": state.target = numericOrNull(value); renderTargetArrow(); break;
677
691
  case "steering.autopilot.engaged": state.engaged = !!value; renderEngage(); renderTargetArrow(); break;
678
692
  case "steering.autopilot.availableActions": state.availableActions = value || []; break;
@@ -780,9 +794,12 @@
780
794
  // getComputedTextLength() only works on rendered SVG text nodes.
781
795
  // Fall back to a heuristic width if the SVG has not laid out yet
782
796
  // (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.
783
800
  let w;
784
- try { w = hdgTxt.getComputedTextLength(); } catch { w = s.length * 12; }
785
- if (!isFinite(w) || w <= 0) w = s.length * 12;
801
+ try { w = hdgTxt.getComputedTextLength(); } catch { w = s.length * 9; }
802
+ if (!isFinite(w) || w <= 0) w = s.length * 9;
786
803
  hdgDeg.setAttribute("x", (w / 2 + 1).toFixed(1));
787
804
  }
788
805
  }
@@ -804,9 +821,15 @@
804
821
  twa: { label: "TWA", read: () => state.windAngleTrue == null ? "---" : _sgn(state.windAngleTrue * RAD2DEG) + "°" },
805
822
  tws: { label: "TWS", read: () => state.windSpeedTrue == null ? "---" : (state.windSpeedTrue * 1.94384).toFixed(1) + " kn" },
806
823
  twd: { label: "TWD", read: () => state.windDirTrue == null ? "---" : Math.round(state.windDirTrue * RAD2DEG) + "°" },
807
- wind: { label: () => (String(state.mode || "").includes("true") ? "TWA/TWS" : "AWA/AWS"),
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"),
808
831
  read: () => {
809
- const useTrue = String(state.mode || "").includes("true");
832
+ const useTrue = state.windCornerShow === "twa";
810
833
  const ang = useTrue ? state.windAngleTrue : state.windAngle;
811
834
  const spd = useTrue ? state.windSpeedTrue : state.windSpeed;
812
835
  const a = ang == null ? "---" : _sgn(ang * RAD2DEG) + "°";
@@ -885,6 +908,32 @@
885
908
  });
886
909
  }
887
910
  _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
+ }
888
937
  function _sgn(n) { return (n > 0 ? "+" : "") + Math.round(n); }
889
938
  function _dashLoadCfg() {
890
939
  try {
@@ -1062,9 +1111,10 @@
1062
1111
  }
1063
1112
 
1064
1113
  // SVG compass rose. The card (cardinals + ticks) rotates so N points to
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.
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.
1068
1118
  function renderWindRose() {
1069
1119
  const card = document.getElementById("rose-card");
1070
1120
  if (card && state.heading != null) {
@@ -1090,22 +1140,29 @@
1090
1140
  }
1091
1141
  }
1092
1142
 
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");
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;
1105
1162
  if (speedText) {
1106
1163
  speedText.textContent = windSpeed == null
1107
1164
  ? "--- kn"
1108
- : (windSpeed * 1.94384).toFixed(1) + " kn" + (tag ? " " + tag : "");
1165
+ : (windSpeed * 1.94384).toFixed(1) + " kn";
1109
1166
  }
1110
1167
  }
1111
1168
 
@@ -1200,14 +1257,36 @@
1200
1257
  }
1201
1258
 
1202
1259
  function renderRudder(rad) {
1203
- if (rad == null) { $("#rudder-widget").setAttribute("hidden", ""); return; }
1204
- $("#rudder-widget").removeAttribute("hidden");
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");
1205
1271
  const deg = rad * RAD2DEG;
1206
- const pct = Math.max(-1, Math.min(1, deg / 45));
1272
+ const pct45 = Math.max(-1, Math.min(1, deg / 45));
1207
1273
  const fill = $("#rudder-fill");
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";
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
+ }
1211
1290
  }
1212
1291
 
1213
1292
  // Rev36 helper: build a slider row with nudge buttons -10/-1/+1/+10.
@@ -1667,6 +1746,16 @@
1667
1746
  _apPending = true;
1668
1747
  eng.classList.add("pending");
1669
1748
  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;
1670
1759
  // Optimistic: flip the visual state before waiting on the network.
1671
1760
  state.engaged = !wasEngaged;
1672
1761
  renderEngage();
@@ -1680,12 +1769,12 @@
1680
1769
  // does not serialise 2 round-trips before the AP actually engages.
1681
1770
  const [rEng, rTgt] = await Promise.all([
1682
1771
  _apRetry(apEngage),
1683
- state.heading != null ? _apRetry(() => apSetTargetRad(state.heading)) : Promise.resolve({ ok: true }),
1772
+ capturedHeading != null ? _apRetry(() => apSetTargetRad(capturedHeading)) : Promise.resolve({ ok: true }),
1684
1773
  ]);
1685
1774
  ok = !!(rEng && rEng.ok);
1686
- if (rTgt && rTgt.ok && state.heading != null) {
1687
- state.target = state.heading;
1688
- state.localTargetRad = state.heading;
1775
+ if (rTgt && rTgt.ok && capturedHeading != null) {
1776
+ state.target = capturedHeading;
1777
+ state.localTargetRad = capturedHeading;
1689
1778
  renderControl();
1690
1779
  }
1691
1780
  }
@@ -2487,35 +2576,50 @@
2487
2576
  _activateTab(ids[nextIdx], dir === 1 ? "next" : "prev");
2488
2577
  });
2489
2578
  });
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.
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).
2492
2583
  _dashLoadCfg();
2493
2584
  _dashPopulateSelectors();
2494
2585
  _dashRenderCorners();
2495
2586
  let _dashLongPressTimer = null;
2587
+ let _dashPressStartedAt = 0;
2588
+ let _dashPressWasLong = false;
2496
2589
  $$(".dash-rose .dash-cell[data-corner]").forEach((cell) => {
2497
2590
  const openSelector = (e) => {
2498
2591
  e && e.preventDefault();
2592
+ _dashPressWasLong = true;
2499
2593
  _dashPopulateSelectors();
2500
2594
  const pop = document.getElementById("dash-help-pop");
2501
2595
  if (pop) pop.classList.add("open");
2502
2596
  };
2503
- cell.addEventListener("touchstart", (e) => {
2597
+ const startPress = (e) => {
2598
+ _dashPressStartedAt = Date.now();
2599
+ _dashPressWasLong = false;
2504
2600
  if (_dashLongPressTimer) clearTimeout(_dashLongPressTimer);
2505
2601
  _dashLongPressTimer = setTimeout(() => openSelector(e), 500);
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 });
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 });
2511
2616
  cell.addEventListener("contextmenu", openSelector);
2512
- let _mouseTimer = null;
2513
2617
  cell.addEventListener("mousedown", (e) => {
2514
2618
  if (e.button !== 0) return;
2515
- _mouseTimer = setTimeout(() => openSelector(e), 600);
2619
+ startPress(e);
2516
2620
  });
2517
- cell.addEventListener("mouseup", () => { if (_mouseTimer) { clearTimeout(_mouseTimer); _mouseTimer = null; } });
2518
- cell.addEventListener("mouseleave", () => { if (_mouseTimer) { clearTimeout(_mouseTimer); _mouseTimer = null; } });
2621
+ cell.addEventListener("mouseup", endPress);
2622
+ cell.addEventListener("mouseleave", () => { if (_dashLongPressTimer) { clearTimeout(_dashLongPressTimer); _dashLongPressTimer = null; } });
2519
2623
  });
2520
2624
  // Rev43: horizontal swipe between tabs on mobile/tablet.
2521
2625
  // 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 200" preserveAspectRatio="xMidYMid meet">
74
+ <svg id="wind-rose" class="rose-svg" viewBox="-100 -100 200 210" 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,10 +106,13 @@
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
- <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>
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>
113
116
  <!-- 8 numeric labels at 30 deg increments (skip cardinals). -->
114
117
  <g fill="#8b98a5" font-size="7" font-weight="700" font-family="ui-monospace, monospace" text-anchor="middle" dominant-baseline="middle">
115
118
  <text transform="rotate(30) translate(0,-72)">030</text>
@@ -122,30 +125,46 @@
122
125
  <text transform="rotate(330) translate(0,-72)">330</text>
123
126
  </g>
124
127
  </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>
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>
132
143
  <!-- Rev50: boat overhead SVG (mareas-ihm), scaled 0.22 so
133
144
  the hull spans ~100 units = 50 % of the 200-unit rose
134
145
  diameter. Rotated -90 so bow points up. -->
135
146
  <g id="rose-boat" transform="rotate(-90) scale(0.22)">
136
147
  <image href="boat-cenital.svg" x="-228" y="-76" width="456" height="152"/>
137
148
  </g>
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). -->
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. -->
142
155
  <g id="rose-wind-arrow" style="display:none">
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"/>
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>
145
161
  </g>
146
162
  <g id="rose-wind-arrow-twa" style="display:none">
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"/>
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>
149
168
  </g>
150
169
  <!-- Rev57: target indicator - CYAN diamond just outside the
151
170
  compass ring, only shown when AP is engaged. Rotated to
@@ -154,10 +173,17 @@
154
173
  <g id="rose-target-arrow" style="display:none">
155
174
  <polygon points="0,-104 -7,-96 0,-88 7,-96" fill="#4dd4ff" stroke="#003b52" stroke-width="1.2"/>
156
175
  </g>
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>
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>
161
187
  </svg>
162
188
  </div>
163
189
  </div>
@@ -251,12 +277,18 @@
251
277
  <div id="rudder-value" class="value">--</div>
252
278
  </div>
253
279
 
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. -->
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. -->
259
289
  <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>
260
292
  <span class="swipe-hint-arrow" data-swipe-dir="prev">‹</span>
261
293
  <span class="swipe-hint-arrow" data-swipe-dir="prev">‹</span>
262
294
  <button class="nav-toggle" id="nav-toggle" aria-label="Mostrar / ocultar barra de pestañas" title="Mostrar / ocultar barra de pestañas">
@@ -886,6 +918,6 @@
886
918
  </div>
887
919
 
888
920
  <!-- Cache-busting: bump the ?v= on every Rev to force Firefox to fetch fresh app.js -->
889
- <script src="app.js?v=Rev63"></script>
921
+ <script src="app.js?v=Rev65"></script>
890
922
  </body>
891
923
  </html>
package/public/style.css CHANGED
@@ -1543,13 +1543,15 @@ 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 */
1546
1547
  display: flex;
1547
1548
  align-items: center;
1548
1549
  justify-content: space-between;
1549
1550
  gap: 10px;
1550
1551
  margin-top: 10px;
1551
1552
  padding: 8px 12px;
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%);
1553
+ background: #08111c; /* Rev65: solid dark base; the rudder track
1554
+ below paints its own gradient on top. */
1553
1555
  border: 1px dashed rgba(55,182,255,0.35);
1554
1556
  border-radius: 10px;
1555
1557
  color: rgba(255, 255, 255, 0.65);
@@ -1559,6 +1561,46 @@ main { touch-action: pan-y; overflow-x: hidden; }
1559
1561
  user-select: none;
1560
1562
  overflow: hidden;
1561
1563
  }
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
+ }
1562
1604
  .swipe-hint-arrow {
1563
1605
  font-size: 28px;
1564
1606
  line-height: 1;