diff --git a/.ai/navaid-dev.md b/.ai/navaid-dev.md
index 5cbaeaa1..14b7e13a 100644
--- a/.ai/navaid-dev.md
+++ b/.ai/navaid-dev.md
@@ -464,6 +464,12 @@ commit on `main`, `dev`, or an unrelated feature branch by mistake.
An "Include cumulative time" checkbox in the export modal (default on)
includes the cumulative-time kite layer in the exported PNG; disabling
it still renders leg markers but hides cumulative kites.
+- **GPS track recorder:** the `๐ Record GPS track` toggle in the View/Set
+ toolbar section records the flown path from the device GPS (live own-ship
+ dot + breadcrumb trail on the map). On Stop it auto-saves a timestamped
+ `kind:'gps'` saved-route entry containing simplified waypoints plus the raw
+ `track[]` breadcrumb, carried by the existing Drive sync. Requires HTTPS;
+ backgrounded phones may leave gaps (no wake-lock).
## Persistence (`localStorage` + `sessionStorage`, all keyed `navaid.*`)
@@ -536,7 +542,10 @@ commit on `main`, `dev`, or an unrelated feature branch by mistake.
- `navaid.aircraft` โ last-used aircraft profile JSON (fuel planner).
- `navaid.profileVS` โ vertical-profile climb/descent rate input for timing and
TOC/TOD ramp distance.
-- `navaid.routes` โ saved-route library entries and tombstones.
+- `navaid.routes` โ saved-route library entries and tombstones. An entry
+ may carry `kind: 'gps'` plus a raw `track[]` (the recorded GPS
+ breadcrumb: `{lat,lng,t,alt?,acc?}`); loading applies the simplified
+ waypoint route, the raw track is retained for fidelity.
- `navaid.pageSize` โ selected page frame size (`A3` / `A4`) or cleared.
- `navaid.pageOrient` โ `'portrait'` / `'landscape'` for page export.
- `navaid.fpPos` โ `{x, y}` of the dragged Flight Plan modal.
diff --git a/docs/app/core.js b/docs/app/core.js
index e8aad50f..9e41077f 100644
--- a/docs/app/core.js
+++ b/docs/app/core.js
@@ -217,6 +217,8 @@ NavAid.tuningDefaults = {
driftLineColor: { value: '#141414', type: 'color', label: 'Drift line color' },
driftLineAlpha: { value: 0.6, min: 0, max: 1, step: 0.05, label: 'Drift line alpha' },
+ gpsBreadcrumbColor: { value: '#1e88e5', type: 'color', label: 'GPS breadcrumb color' },
+ gpsBreadcrumbWidthPx: { value: 3, min: 1, max: 10, step: 0.5, label: 'GPS breadcrumb width' },
overlayLabelHaloColor: { value: '#ffffff', type: 'color', label: 'Overlay label halo color' },
overlayLabelHaloAlpha: { value: 0.85, min: 0, max: 1, step: 0.05, label: 'Overlay label halo alpha' },
@@ -368,6 +370,7 @@ NavAid.tuningGroups = [
{ name: 'Behaviour', keys: ['undoLimit', 'rotDragPx', 'shareMaxWaypoints', 'commChangeSnapPx', 'originResnapArmPx'] },
{ name: 'Route line', keys: ['routeLineWidthPx', 'routeSelectedLineWidthPx'] },
{ name: 'Drift lines', keys: ['driftAngleDeg', 'driftLengthFactor', 'driftDashOnPx', 'driftDashOffPx', 'driftStrokeWidthPx', 'driftLineColor', 'driftLineAlpha'] },
+ { name: 'GPS track', keys: ['gpsBreadcrumbColor', 'gpsBreadcrumbWidthPx'] },
{ name: 'Wind arrows', keys: ['windArrowColor', 'windArrowHaloColor', 'windTextHaloColor'] },
{ name: 'Default marker locations', keys: ['defaultLabelMarginPx', 'defaultKiteHalfWidthPx'] },
{ name: 'Leg kites', keys: ['legKiteFillColor', 'returnKiteFillColor', 'legKiteHeightPx', 'legKiteCellWidthPx', 'legKiteTriangleLenPx', 'legKiteBorderPx', 'legKiteDividerPx', 'legKiteHaloPx', 'legKiteTextPx', 'legKiteHeadingTextPx', 'legKiteHeadingAnchor'] },
@@ -951,6 +954,15 @@ window.S = Object.assign({
tbPrintTitle: 'Save the framed map + route as a PNG',
tbMagnifier: '๐ Magnifying glass (M)',
tbMagnifierTitle: 'Magnifying glass (M) โ zoomed view at cursor; +/โ adjust loupe zoom while open',
+ tbGpsRecord: '๐ Record GPS track',
+ tbGpsRecordTitle: 'Record your flown track from the device GPS and save it',
+ tbGpsStop: 'โ Stop & save',
+ tbGpsLive: '๐ Show my location',
+ tbGpsLiveTitle: 'Show your live position on the map (device GPS, no recording)',
+ tbGpsLiveStop: 'โ Hide my location',
+ gpsUnsupported: 'GPS is not available in this browser.',
+ gpsNoTrack: 'No track recorded.',
+ gpsError: 'GPS error: ',
magSettingsTitle: 'Magnifier',
magZoomLabel: 'Zoom',
magZoomTitle: 'Magnifier zoom factor',
diff --git a/docs/app/draw.js b/docs/app/draw.js
index 1d796cb3..a179e40f 100644
--- a/docs/app/draw.js
+++ b/docs/app/draw.js
@@ -33,14 +33,15 @@ function legKiteAlongHalfPx(sc) {
}
// --- drawing ---------------------------------------------------------
-// Draw the live simulator aircraft at its current position with heading.
-// Top-down airplane silhouette: nose points up in local frame, rotated to
-// (aircraft heading โ map bearing) so it tracks correctly on a rotated map.
-function drawSimAircraft() {
- if (!simOn || !simAircraft) return;
- const s = proj(simAircraft);
+// Draws the own-ship symbol at pos {lat,lng} with true heading `hdg` (used for
+// both the simulator aircraft and the live GPS position). Top-down airplane
+// silhouette: nose up in local frame, rotated to (heading โ map bearing) so it
+// tracks correctly on a rotated map.
+function drawOwnShip(pos, hdg) {
+ if (!pos) return;
+ const s = proj(pos);
const mapBearing = (typeof map !== 'undefined' && map.getBearing) ? map.getBearing() : 0;
- const screenAngle = ((simAircraft.hdg || 0) - mapBearing) * Math.PI / 180;
+ const screenAngle = ((hdg || 0) - mapBearing) * Math.PI / 180;
const r = tune('liveAircraftRadiusPx');
octx.save();
octx.translate(s.x, s.y);
@@ -309,7 +310,8 @@ function draw() {
drawWaypoints();
drawNotes();
if (window.showProfile) drawProfileMarkers(); // TOC/TOD markers (#672)
- drawSimAircraft();
+ if (typeof drawGpsTrack === 'function') drawGpsTrack(); // GPS breadcrumb + own-ship (recording or live location)
+ if (!gpsRecording && !gpsLiveOn && simOn && simAircraft) drawOwnShip(simAircraft, simAircraft.hdg); // sim own-ship
drawInfo();
drawPageFrame();
drawPlanCard(); // flight-plan card placed for PNG export (#378)
diff --git a/docs/app/gps.js b/docs/app/gps.js
new file mode 100644
index 00000000..49ffa7b5
--- /dev/null
+++ b/docs/app/gps.js
@@ -0,0 +1,230 @@
+// gps.js โ device-GPS track recorder. Loaded after gdrive.js, before ui.js.
+// Records the flown path, shows a live own-ship + breadcrumb, and on stop
+// auto-saves a timestamped saved-route entry (simplified route + raw track).
+
+var gpsRecording = false;
+var gpsTrack = []; // [{lat,lng,t,alt,acc}]
+var gpsWatchId = null;
+var gpsOwn = null; // {lat,lng,hdg} last fix for own-ship rendering
+
+const GPS_SIMPLIFY_EPS_DEG = 0.0003; // ~30 m
+const GPS_MIN_MOVE_M = 10; // de-jitter: drop sub-10 m steps
+const GPS_MAX_ACC_M = 100; // drop low-accuracy fixes
+const GPS_MAX_POINTS = 50000;
+
+// DouglasโPeucker simplification. eps in degrees. Endpoints always kept.
+// Iterative (explicit-stack) implementation โ overflow-safe for up to GPS_MAX_POINTS.
+function simplifyTrack(points, eps) {
+ if (!Array.isArray(points) || points.length < 3) return (points || []).slice();
+ const n = points.length;
+ const keep = new Uint8Array(n);
+ keep[0] = keep[n - 1] = 1;
+ const eps2 = eps * eps;
+ // Flat lo/hi stack (no per-split tuple allocation). Squared perpendicular
+ // distances in the inner loop โ no sqrt/hypot per point (it ran O(nยฒ) times
+ // and dominated the worst-case zigzag). d > eps โ dยฒ > epsยฒ, so the kept set
+ // is identical to the hypot version.
+ const stack = [0, n - 1];
+ while (stack.length) {
+ const hi = stack.pop(), lo = stack.pop();
+ if (hi - lo < 2) continue;
+ const x1 = points[lo].lng, y1 = points[lo].lat;
+ const dx = points[hi].lng - x1, dy = points[hi].lat - y1;
+ const L2 = dx * dx + dy * dy;
+ let maxD2 = -1, idx = -1;
+ for (let i = lo + 1; i < hi; i++) {
+ const px = points[i].lng - x1, py = points[i].lat - y1;
+ let d2;
+ if (L2 === 0) {
+ d2 = px * px + py * py;
+ } else {
+ let t = (px * dx + py * dy) / L2;
+ t = t < 0 ? 0 : (t > 1 ? 1 : t);
+ const ex = px - t * dx, ey = py - t * dy;
+ d2 = ex * ex + ey * ey;
+ }
+ if (d2 > maxD2) { maxD2 = d2; idx = i; }
+ }
+ if (maxD2 > eps2) { keep[idx] = 1; stack.push(lo, idx, idx, hi); }
+ }
+ const out = [];
+ for (let i = 0; i < n; i++) if (keep[i]) out.push(points[i]);
+ return out;
+}
+
+// Great-circle distance in metres between two {lat,lng}.
+function _gpsMetres(a, b) {
+ const R = 6371000, rad = x => x * Math.PI / 180;
+ const dLa = rad(b.lat - a.lat), dLo = rad(b.lng - a.lng);
+ const h = Math.sin(dLa / 2) ** 2 + Math.cos(rad(a.lat)) * Math.cos(rad(b.lat)) * Math.sin(dLo / 2) ** 2;
+ return 2 * R * Math.asin(Math.sqrt(Math.min(1, h)));
+}
+
+var gpsLiveOn = false;
+var gpsLiveWatchId = null;
+var _gpsLivePrev = null;
+
+function onLivePosition(pos) {
+ if (!gpsLiveOn || !pos || !pos.coords) return;
+ const c = pos.coords;
+ if (c.accuracy != null && c.accuracy > GPS_MAX_ACC_M) return;
+ const p = { lat: r5(c.latitude), lng: r5(c.longitude) };
+ const hdg = (c.heading != null && !isNaN(c.heading)) ? c.heading
+ : (_gpsLivePrev ? geo(_gpsLivePrev, p).brg : 0);
+ _gpsLivePrev = p;
+ gpsOwn = { lat: p.lat, lng: p.lng, hdg };
+ scheduleDraw();
+ if (gpsFollow && typeof map !== 'undefined') map.setView([p.lat, p.lng], map.getZoom());
+}
+
+function startLiveLocation() {
+ if (gpsLiveOn) return;
+ if (!navigator.geolocation) { alert(S.gpsUnsupported || 'GPS is not available in this browser.'); return; }
+ gpsLiveOn = true; _gpsLivePrev = null;
+ gpsLiveWatchId = navigator.geolocation.watchPosition(onLivePosition, onGpsError, { enableHighAccuracy: true });
+ scheduleDraw();
+}
+
+function stopLiveLocation() {
+ if (gpsLiveWatchId != null && navigator.geolocation) navigator.geolocation.clearWatch(gpsLiveWatchId);
+ gpsLiveWatchId = null;
+ gpsLiveOn = false;
+ _gpsLivePrev = null;
+ if (!gpsRecording) gpsOwn = null; // keep own-ship if a recording is still running
+ scheduleDraw();
+}
+
+var gpsFollow = true; // recenter on own-ship while recording
+var gpsStartT = 0;
+
+// Live readout next to the toolbar button (points ยท elapsed). No-op if absent.
+function gpsUpdateReadout() {
+ const el = document.getElementById('gps-readout');
+ if (!el) return;
+ if (!gpsRecording) { el.textContent = ''; return; }
+ const secs = gpsStartT ? Math.round((Date.now() - gpsStartT) / 1000) : 0;
+ const mm = String(Math.floor(secs / 60)).padStart(2, '0');
+ const ss = String(secs % 60).padStart(2, '0');
+ el.textContent = gpsTrack.length + ' pts ยท ' + mm + ':' + ss;
+}
+
+function onGpsPosition(pos) {
+ if (!gpsRecording || !pos || !pos.coords) return;
+ const c = pos.coords;
+ if (c.accuracy != null && c.accuracy > GPS_MAX_ACC_M) return; // too imprecise
+ const pt = { lat: r5(c.latitude), lng: r5(c.longitude), t: pos.timestamp || Date.now(),
+ alt: c.altitude != null ? c.altitude : null,
+ acc: c.accuracy != null ? c.accuracy : null };
+ const prev = gpsTrack[gpsTrack.length - 1];
+ if (prev && _gpsMetres(prev, pt) < GPS_MIN_MOVE_M) return; // de-jitter
+ if (gpsTrack.length >= GPS_MAX_POINTS) return;
+ gpsTrack.push(pt);
+ // heading: device value when moving, else bearing from the previous point.
+ let hdg = (c.heading != null && !isNaN(c.heading)) ? c.heading
+ : (prev ? geo(prev, pt).brg : 0);
+ gpsOwn = { lat: pt.lat, lng: pt.lng, hdg };
+ gpsUpdateReadout();
+ scheduleDraw();
+ if (gpsFollow && typeof map !== 'undefined') map.setView([pt.lat, pt.lng], map.getZoom());
+}
+
+function onGpsError(err) {
+ stopGpsRecording();
+ stopLiveLocation();
+ const rb = document.getElementById('gps-record'); if (rb) rb.textContent = S.tbGpsRecord;
+ const lb = document.getElementById('gps-live'); if (lb) { lb.textContent = S.tbGpsLive; lb.setAttribute('aria-pressed', 'false'); }
+ alert((S.gpsError || 'GPS error: ') + (err && err.message ? err.message : ''));
+}
+
+function startGpsRecording() {
+ if (gpsRecording) return;
+ if (!navigator.geolocation) { alert(S.gpsUnsupported || 'GPS is not available in this browser.'); return; }
+ gpsRecording = true;
+ gpsTrack = [];
+ if (!gpsLiveOn) gpsOwn = null;
+ gpsStartT = Date.now();
+ gpsWatchId = navigator.geolocation.watchPosition(onGpsPosition, onGpsError, { enableHighAccuracy: true });
+ gpsUpdateReadout();
+ scheduleDraw();
+}
+
+function gpsTrackName() {
+ const d = new Date();
+ const p = n => String(n).padStart(2, '0');
+ return 'Track ' + d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate())
+ + ' ' + p(d.getHours()) + ':' + p(d.getMinutes());
+}
+
+// Build a validateRoute-passing route `data` from simplified points by reusing
+// the canonical serializer with a guarded temporary state swap.
+// Also neutralizes state.commChangeSuppressions and state.wind so the saved GPS
+// entry is not polluted with the user's current comm-change suppressions or wind.
+function gpsRouteDataFromPoints(points) {
+ const saved = {
+ waypoints: state.waypoints, legs: state.legs, notes: state.notes,
+ commChangeSuppressions: state.commChangeSuppressions,
+ wind: state.wind,
+ };
+ try {
+ state.waypoints = points.map(p => ({ lat: r5(p.lat), lng: r5(p.lng), name: '' }));
+ state.legs = [];
+ state.notes = [];
+ state.commChangeSuppressions = [];
+ state.wind = { dir: 270, speed: 0 }; // calm โ encodeWind omits speed:0
+ syncLegs();
+ return serializeRoute();
+ } finally {
+ state.waypoints = saved.waypoints; state.legs = saved.legs; state.notes = saved.notes;
+ state.commChangeSuppressions = saved.commChangeSuppressions;
+ state.wind = saved.wind;
+ // Do NOT call syncLegs() here โ saved.legs already has the correct length
+ // for saved.waypoints, and syncLegs() would call applyLegAltitudesToRoute()
+ // which overwrites any _legAltitudeAuto leg values (e.g. custom altitudes
+ // the user set) with NaN when the waypoint names don't match the dataset.
+ }
+}
+
+// Stop recording AND save. Returns the new library entry, or null.
+function stopGpsRecordingAndSave() {
+ const raw = gpsTrack.slice();
+ stopGpsRecording();
+ if (raw.length < 2) { alert(S.gpsNoTrack || 'No track recorded.'); return null; }
+ const simp = simplifyTrack(raw.map(p => ({ lat: p.lat, lng: p.lng })), GPS_SIMPLIFY_EPS_DEG);
+ const data = gpsRouteDataFromPoints(simp);
+ const entry = {
+ id: routeLibraryId(),
+ name: gpsTrackName(),
+ savedAt: new Date().toISOString(),
+ kind: 'gps',
+ data,
+ track: raw.map(p => ({ lat: r5(p.lat), lng: r5(p.lng), t: p.t,
+ ...(p.alt != null ? { alt: Math.round(p.alt) } : {}),
+ ...(p.acc != null ? { acc: Math.round(p.acc) } : {}) })),
+ };
+ const list = loadRouteLibrary();
+ list.unshift(entry);
+ return persistRouteLibrary(list) ? entry : null;
+}
+
+// Breadcrumb of the in-progress recording, drawn on the overlay.
+function drawGpsTrack() {
+ if (!gpsRecording && !gpsLiveOn) return;
+ if (gpsRecording && gpsTrack.length > 1) {
+ octx.save(); octx.beginPath();
+ for (let i = 0; i < gpsTrack.length; i++) { const s = proj(gpsTrack[i]); if (i === 0) octx.moveTo(s.x, s.y); else octx.lineTo(s.x, s.y); }
+ octx.lineWidth = tune('gpsBreadcrumbWidthPx'); octx.strokeStyle = tune('gpsBreadcrumbColor');
+ octx.lineCap = 'round'; octx.lineJoin = 'round'; octx.stroke(); octx.restore();
+ if (typeof window !== 'undefined') window.__gpsBreadcrumbDrawn = (window.__gpsBreadcrumbDrawn || 0) + 1;
+ }
+ if (gpsOwn && (gpsRecording || gpsLiveOn)) drawOwnShip(gpsOwn, gpsOwn.hdg);
+}
+
+// Stop watching without saving. (Save handled in a later task.)
+function stopGpsRecording() {
+ if (gpsWatchId != null && navigator.geolocation) navigator.geolocation.clearWatch(gpsWatchId);
+ gpsWatchId = null;
+ gpsRecording = false;
+ if (!gpsLiveOn) gpsOwn = null;
+ gpsUpdateReadout();
+ scheduleDraw();
+}
diff --git a/docs/app/style.css b/docs/app/style.css
index edff82dd..2b3a18c0 100644
--- a/docs/app/style.css
+++ b/docs/app/style.css
@@ -755,6 +755,7 @@ html[dir="rtl"] .leaflet-control.zulu-clock {
transform: translateY(46px);
}
.coord-readout.show { display: block; }
+.gps-readout { display: block; font-size: 11px; opacity: 0.75; margin-top: 2px; }
.zoom-readout {
display: block;
padding: 2px 4px;
diff --git a/docs/app/ui.js b/docs/app/ui.js
index d53a7a67..4ea99f01 100644
--- a/docs/app/ui.js
+++ b/docs/app/ui.js
@@ -1655,6 +1655,33 @@ document.getElementById('plan').onclick = showFlightPlan;
document.getElementById('freq-table').onclick = showFreqTableModal;
document.getElementById('alt-pairs').onclick = showAltitudePairsModal;
document.getElementById('charts').onclick = showChartsModal;
+const gpsBtn = document.getElementById('gps-record');
+if (gpsBtn) {
+ if (!navigator.geolocation) { gpsBtn.disabled = true; }
+ gpsBtn.addEventListener('click', () => {
+ if (gpsRecording) {
+ stopGpsRecordingAndSave();
+ gpsBtn.textContent = S.tbGpsRecord;
+ if (typeof window.refreshRouteLibrary === 'function') window.refreshRouteLibrary();
+ } else {
+ startGpsRecording();
+ if (gpsRecording) gpsBtn.textContent = S.tbGpsStop;
+ }
+ });
+}
+const liveBtn = document.getElementById('gps-live');
+if (liveBtn) {
+ if (!navigator.geolocation) { liveBtn.disabled = true; }
+ liveBtn.addEventListener('click', () => {
+ if (gpsLiveOn) {
+ stopLiveLocation();
+ liveBtn.textContent = S.tbGpsLive; liveBtn.setAttribute('aria-pressed', 'false');
+ } else {
+ startLiveLocation();
+ if (gpsLiveOn) { liveBtn.textContent = S.tbGpsLiveStop; liveBtn.setAttribute('aria-pressed', 'true'); }
+ }
+ });
+}
const RETURN_KEY = 'navaid.showReturn';
const MIDLEG_KEY = 'navaid.showMidLeg';
const CUMTIME_KEY = 'navaid.showCumTime';
diff --git a/docs/i18n/he/strings.js b/docs/i18n/he/strings.js
index 1ea1d702..8a789b37 100644
--- a/docs/i18n/he/strings.js
+++ b/docs/i18n/he/strings.js
@@ -537,4 +537,13 @@ window.S = {
magZoomLabel: 'ืชืงืจืื',
magZoomTitle: 'ืืืจื ืืชืงืจืื ืฉื ืืืืืืืช ืืืืืืช',
magLoading: 'ืืฉืืืโฆ',
+ tbGpsRecord: '๐ ืืงืืืช ืืกืืื GPS',
+ tbGpsRecordTitle: 'ืืงืืืช ืืืกืืื ืืคืืขื ื-GPS ืืืืฉืืจ ืืฉืืืจืชื',
+ tbGpsStop: 'โ ืขืฆืืจ ืืฉืืืจ',
+ tbGpsLive: '๐ ืืฆื ืืืงืื',
+ tbGpsLiveTitle: 'ืืฆืืช ืืืืงืื ืืื ืฉืื ืขื ืืืคื (GPS, ืืื ืืงืืื)',
+ tbGpsLiveStop: 'โ ืืกืชืจ ืืืงืื',
+ gpsUnsupported: 'GPS ืืื ื ืืืื ืืืคืืคื ืื.',
+ gpsNoTrack: 'ืื ืืืงืื ืืกืืื.',
+ gpsError: 'ืฉืืืืช GPS: ',
};
diff --git a/docs/index.html b/docs/index.html
index 21da9443..beab2a91 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -259,6 +259,9 @@
Israeli airfields and waypoints
+
+
+
@@ -635,6 +638,7 @@ Simulator
'app/io.js' + v,
'app/alt-pair-directions.js' + v,
'app/gdrive.js' + v,
+ 'app/gps.js' + v,
'app/ui.js' + v
];
for (var i = 0; i < srcs.length; i++) {
diff --git a/docs/superpowers/plans/2026-06-18-gps-track-recorder.md b/docs/superpowers/plans/2026-06-18-gps-track-recorder.md
new file mode 100644
index 00000000..acb91f3c
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-18-gps-track-recorder.md
@@ -0,0 +1,630 @@
+# GPS Track Recorder Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Record the flown track from the device GPS, show a live own-ship + breadcrumb, and on stop auto-save it as a timestamped saved-route entry (simplified waypoints + raw track) that the existing Drive sync carries.
+
+**Architecture:** A new `docs/app/gps.js` module owns geolocation + recording state and the DouglasโPeucker simplifier. The live own-ship reuses the simulator's marker renderer via a small refactor (`drawSimAircraft` โ `drawOwnShip(pos, hdg)`); a new `drawGpsTrack()` paints the breadcrumb. On stop, the simplified points are turned into a valid route via the canonical `serializeRoute()` (guaranteed to pass `validateRoute`) and stored in `navaid.routes`.
+
+**Tech Stack:** Plain global-scope ES (no build), Leaflet overlay canvas, Playwright tests. Spec: `docs/superpowers/specs/2026-06-18-gps-track-recorder-design.md`.
+
+**Test harness:** start `python3 -m http.server -d docs 8000 --bind 127.0.0.1`; run `BASE_URL=http://127.0.0.1:8000 npx playwright test `. Stop the server when done.
+
+---
+
+## File structure
+
+- **Create** `docs/app/gps.js` โ geolocation watch, recording state, `simplifyTrack`, start/stop, `routeLibrarySaveTrack`, breadcrumb draw, own-ship source.
+- **Modify** `docs/index.html` โ add `app/gps.js` to the script array (before `app/ui.js`); add the View/Set toolbar control.
+- **Modify** `docs/app/draw.js` โ refactor `drawSimAircraft()` โ `drawOwnShip(pos, hdg)`; call `drawGpsTrack()` + own-ship in `draw()`.
+- **Modify** `docs/app/core.js` โ English `S.*` strings.
+- **Modify** `docs/app/i18n/he/strings.js` โ Hebrew overrides. (path: `docs/i18n/he/strings.js`)
+- **Modify** `docs/app/ui.js` โ wire the toolbar button to `startGpsRecording` / `stopGpsRecordingAndSave`; live readout.
+- **Create** `tests/gps-track-recorder.spec.js` โ all behaviour tests.
+- **Modify** `.ai/navaid-dev.md` โ persistence keys + feature note.
+
+---
+
+## Task 1: Track simplifier + gps.js skeleton
+
+**Files:**
+- Create: `docs/app/gps.js`
+- Create: `tests/gps-track-recorder.spec.js`
+
+- [ ] **Step 1: Write the failing test**
+
+```js
+// tests/gps-track-recorder.spec.js
+const { test, expect } = require('./_setup');
+
+async function boot(page) {
+ await page.goto('?lang=en');
+ await page.waitForFunction(() => typeof map !== 'undefined' && typeof simplifyTrack === 'function');
+}
+
+test('simplifyTrack reduces collinear points and keeps the endpoints', async ({ page }) => {
+ await boot(page);
+ const out = await page.evaluate(() => {
+ const pts = [
+ { lat: 32.00, lng: 34.00 }, { lat: 32.01, lng: 34.00 },
+ { lat: 32.02, lng: 34.00 }, { lat: 32.03, lng: 34.00 }, // collinear N
+ { lat: 32.03, lng: 34.05 }, // sharp turn E
+ ];
+ const s = simplifyTrack(pts, 0.0003);
+ return { n: s.length, first: s[0], last: s[s.length - 1] };
+ });
+ expect(out.n).toBeLessThan(5); // collinear middle points dropped
+ expect(out.n).toBeGreaterThanOrEqual(3); // turn point kept
+ expect(out.first).toMatchObject({ lat: 32.00, lng: 34.00 });
+ expect(out.last).toMatchObject({ lat: 32.03, lng: 34.05 });
+});
+```
+
+- [ ] **Step 2: Run it โ expect FAIL**
+
+Run: `(python3 -m http.server -d docs 8000 --bind 127.0.0.1 >/tmp/srv.log 2>&1 &); sleep 1; BASE_URL=http://127.0.0.1:8000 npx playwright test tests/gps-track-recorder.spec.js -g simplifyTrack`
+Expected: FAIL โ `simplifyTrack` is not defined (waitForFunction times out).
+
+- [ ] **Step 3: Create `docs/app/gps.js` with the simplifier**
+
+```js
+// gps.js โ device-GPS track recorder. Loaded after gdrive.js, before ui.js.
+// Records the flown path, shows a live own-ship + breadcrumb, and on stop
+// auto-saves a timestamped saved-route entry (simplified route + raw track).
+
+var gpsRecording = false;
+var gpsTrack = []; // [{lat,lng,t,alt,acc}]
+var gpsWatchId = null;
+var gpsOwn = null; // {lat,lng,hdg} last fix for own-ship rendering
+
+const GPS_SIMPLIFY_EPS_DEG = 0.0003; // ~30 m
+const GPS_MIN_MOVE_M = 10; // de-jitter: drop sub-10 m steps
+const GPS_MAX_ACC_M = 100; // drop low-accuracy fixes
+const GPS_MAX_POINTS = 50000;
+
+// Perpendicular distance (in degrees) of point p from segment a->b.
+function _perpDeg(p, a, b) {
+ const x = p.lng, y = p.lat, x1 = a.lng, y1 = a.lat, x2 = b.lng, y2 = b.lat;
+ const dx = x2 - x1, dy = y2 - y1;
+ const L2 = dx * dx + dy * dy;
+ if (L2 === 0) return Math.hypot(x - x1, y - y1);
+ let t = ((x - x1) * dx + (y - y1) * dy) / L2;
+ t = Math.max(0, Math.min(1, t));
+ return Math.hypot(x - (x1 + t * dx), y - (y1 + t * dy));
+}
+
+// DouglasโPeucker simplification. eps in degrees. Endpoints always kept.
+function simplifyTrack(points, eps) {
+ if (!Array.isArray(points) || points.length < 3) return (points || []).slice();
+ let maxD = -1, idx = -1;
+ const a = points[0], b = points[points.length - 1];
+ for (let i = 1; i < points.length - 1; i++) {
+ const d = _perpDeg(points[i], a, b);
+ if (d > maxD) { maxD = d; idx = i; }
+ }
+ if (maxD > eps) {
+ const left = simplifyTrack(points.slice(0, idx + 1), eps);
+ const right = simplifyTrack(points.slice(idx), eps);
+ return left.slice(0, -1).concat(right);
+ }
+ return [a, b];
+}
+```
+
+- [ ] **Step 4: Add `app/gps.js` to the script array in `docs/index.html`**
+
+Find the script list (around line 519-526) and insert `gps.js` before `ui.js`:
+
+```js
+ 'app/gdrive.js' + v,
+ 'app/gps.js' + v,
+ 'app/ui.js' + v
+```
+
+- [ ] **Step 5: Run the test โ expect PASS**
+
+Run: `BASE_URL=http://127.0.0.1:8000 npx playwright test tests/gps-track-recorder.spec.js -g simplifyTrack`
+Expected: PASS.
+
+- [ ] **Step 6: node --check + commit**
+
+```bash
+node --check docs/app/gps.js
+git add docs/app/gps.js docs/index.html tests/gps-track-recorder.spec.js
+git commit -m "feat(gps): track simplifier + gps.js module skeleton"
+```
+
+---
+
+## Task 2: Recording start/stop + position filter
+
+**Files:**
+- Modify: `docs/app/gps.js`
+- Test: `tests/gps-track-recorder.spec.js`
+
+- [ ] **Step 1: Write the failing test** (stub geolocation, feed fixes)
+
+```js
+test('recording collects filtered fixes and stops cleanly', async ({ page }) => {
+ await page.addInitScript(() => {
+ // Deterministic geolocation stub: capture the watch callback so the test drives it.
+ window.__geoCb = null;
+ navigator.geolocation.watchPosition = (cb) => { window.__geoCb = cb; return 42; };
+ navigator.geolocation.clearWatch = () => { window.__geoCb = null; };
+ });
+ await boot(page);
+ await page.evaluate(() => startGpsRecording());
+ const fed = await page.evaluate(() => {
+ const fix = (lat, lng, acc) => window.__geoCb({ coords: { latitude: lat, longitude: lng, accuracy: acc, heading: null, altitude: null }, timestamp: Date.now() });
+ fix(32.0000, 34.0000, 8); // kept
+ fix(32.00001, 34.00001, 8); // < 10 m from prev -> dropped
+ fix(32.0100, 34.0000, 8); // kept (moved)
+ fix(32.0200, 34.0000, 250); // accuracy > 100 -> dropped
+ return { recording: gpsRecording, n: gpsTrack.length };
+ });
+ expect(fed.recording).toBe(true);
+ expect(fed.n).toBe(2);
+ const stopped = await page.evaluate(() => { gpsRecording = false; return typeof clearWatch === 'undefined'; });
+ expect(stopped).toBe(true);
+});
+```
+
+- [ ] **Step 2: Run it โ expect FAIL** (`startGpsRecording` undefined)
+
+Run: `BASE_URL=http://127.0.0.1:8000 npx playwright test tests/gps-track-recorder.spec.js -g "collects filtered"`
+Expected: FAIL.
+
+- [ ] **Step 3: Add recording functions to `gps.js`**
+
+```js
+// Great-circle distance in metres between two {lat,lng}.
+function _gpsMetres(a, b) {
+ const R = 6371000, rad = x => x * Math.PI / 180;
+ const dLa = rad(b.lat - a.lat), dLo = rad(b.lng - a.lng);
+ const h = Math.sin(dLa / 2) ** 2 + Math.cos(rad(a.lat)) * Math.cos(rad(b.lat)) * Math.sin(dLo / 2) ** 2;
+ return 2 * R * Math.asin(Math.sqrt(h));
+}
+
+function onGpsPosition(pos) {
+ if (!gpsRecording || !pos || !pos.coords) return;
+ const c = pos.coords;
+ if (c.accuracy != null && c.accuracy > GPS_MAX_ACC_M) return; // too imprecise
+ const pt = { lat: r5(c.latitude), lng: r5(c.longitude), t: pos.timestamp || Date.now(),
+ alt: c.altitude != null ? c.altitude : null,
+ acc: c.accuracy != null ? c.accuracy : null };
+ const prev = gpsTrack[gpsTrack.length - 1];
+ if (prev && _gpsMetres(prev, pt) < GPS_MIN_MOVE_M) return; // de-jitter
+ if (gpsTrack.length >= GPS_MAX_POINTS) return;
+ gpsTrack.push(pt);
+ // heading: device value when moving, else bearing from the previous point.
+ let hdg = (c.heading != null && !isNaN(c.heading)) ? c.heading
+ : (prev ? geo(prev, pt).brg : 0);
+ gpsOwn = { lat: pt.lat, lng: pt.lng, hdg };
+ try { sessionStorage.setItem('navaid.gpsTrack', JSON.stringify(gpsTrack)); } catch (e) { /* */ }
+ gpsUpdateReadout();
+ scheduleDraw();
+ if (gpsFollow && typeof map !== 'undefined') map.setView([pt.lat, pt.lng], map.getZoom());
+}
+
+function onGpsError(err) {
+ stopGpsRecording();
+ alert((S.gpsError || 'GPS error: ') + (err && err.message ? err.message : ''));
+}
+
+var gpsFollow = true; // recenter on own-ship while recording
+var gpsStartT = 0;
+
+// Live readout next to the toolbar button (points ยท elapsed). No-op if absent.
+function gpsUpdateReadout() {
+ const el = document.getElementById('gps-readout');
+ if (!el) return;
+ if (!gpsRecording) { el.textContent = ''; return; }
+ const secs = gpsStartT ? Math.round((Date.now() - gpsStartT) / 1000) : 0;
+ const mm = String(Math.floor(secs / 60)).padStart(2, '0');
+ const ss = String(secs % 60).padStart(2, '0');
+ el.textContent = gpsTrack.length + ' pts ยท ' + mm + ':' + ss;
+}
+
+function startGpsRecording() {
+ if (gpsRecording) return;
+ if (!navigator.geolocation) { alert(S.gpsUnsupported || 'GPS is not available in this browser.'); return; }
+ gpsRecording = true;
+ gpsTrack = [];
+ gpsOwn = null;
+ gpsStartT = Date.now();
+ gpsWatchId = navigator.geolocation.watchPosition(onGpsPosition, onGpsError, { enableHighAccuracy: true });
+ gpsUpdateReadout();
+ scheduleDraw();
+}
+
+// Stop watching without saving. (Save handled separately.)
+function stopGpsRecording() {
+ if (gpsWatchId != null && navigator.geolocation) navigator.geolocation.clearWatch(gpsWatchId);
+ gpsWatchId = null;
+ gpsRecording = false;
+ gpsOwn = null;
+ try { sessionStorage.removeItem('navaid.gpsTrack'); } catch (e) { /* */ }
+ gpsUpdateReadout();
+ scheduleDraw();
+}
+```
+
+Note: `geo(a,b)` returns `{ brgTrue, ... }` (existing core.js helper); `r5`, `scheduleDraw`, `S` are existing globals.
+
+- [ ] **Step 4: Run the test โ expect PASS**
+
+Run: `BASE_URL=http://127.0.0.1:8000 npx playwright test tests/gps-track-recorder.spec.js -g "collects filtered"`
+Expected: PASS.
+
+- [ ] **Step 5: node --check + commit**
+
+```bash
+node --check docs/app/gps.js
+git add docs/app/gps.js tests/gps-track-recorder.spec.js
+git commit -m "feat(gps): start/stop recording with accuracy + min-move filter"
+```
+
+---
+
+## Task 3: Save on stop โ saved-route library entry
+
+**Files:**
+- Modify: `docs/app/gps.js`
+- Test: `tests/gps-track-recorder.spec.js`
+
+- [ ] **Step 1: Write the failing test**
+
+```js
+test('stop saves a kind:gps library entry with simplified route + raw track', async ({ page }) => {
+ await page.addInitScript(() => {
+ window.__geoCb = null;
+ navigator.geolocation.watchPosition = (cb) => { window.__geoCb = cb; return 7; };
+ navigator.geolocation.clearWatch = () => {};
+ try { localStorage.removeItem('navaid.routes'); } catch (e) {}
+ });
+ await boot(page);
+ await page.evaluate(() => {
+ startGpsRecording();
+ const fix = (lat, lng) => window.__geoCb({ coords: { latitude: lat, longitude: lng, accuracy: 8, heading: null, altitude: 100 }, timestamp: Date.now() });
+ fix(32.00, 34.00); fix(32.05, 34.00); fix(32.10, 34.02); fix(32.15, 34.10);
+ });
+ const entry = await page.evaluate(() => stopGpsRecordingAndSave());
+ expect(entry).toBeTruthy();
+ expect(entry.kind).toBe('gps');
+ expect(entry.name).toMatch(/^Track /);
+ expect(Array.isArray(entry.track)).toBe(true);
+ expect(entry.track.length).toBeGreaterThanOrEqual(4);
+ expect(entry.data.waypoints.length).toBeGreaterThanOrEqual(2);
+ expect(entry.data.legs.length).toBe(entry.data.waypoints.length - 1);
+ // persisted + validates as a real route
+ const persisted = await page.evaluate(() => JSON.parse(localStorage.getItem('navaid.routes'))[0]);
+ expect(persisted.id).toBe(entry.id);
+ expect(await page.evaluate((d) => (typeof validateRoute === 'function' ? validateRoute(d) : null), entry.data)).toBeNull();
+});
+```
+
+- [ ] **Step 2: Run it โ expect FAIL** (`stopGpsRecordingAndSave` undefined)
+
+Run: `BASE_URL=http://127.0.0.1:8000 npx playwright test tests/gps-track-recorder.spec.js -g "kind:gps"`
+Expected: FAIL.
+
+- [ ] **Step 3: Add save logic to `gps.js`**
+
+```js
+function gpsTrackName() {
+ const d = new Date();
+ const p = n => String(n).padStart(2, '0');
+ return 'Track ' + d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate())
+ + ' ' + p(d.getHours()) + ':' + p(d.getMinutes());
+}
+
+// Build a validateRoute-passing route `data` from simplified points by reusing
+// the canonical serializer with a guarded temporary state swap.
+function gpsRouteDataFromPoints(points) {
+ const saved = { waypoints: state.waypoints, legs: state.legs, notes: state.notes };
+ try {
+ state.waypoints = points.map(p => ({ lat: r5(p.lat), lng: r5(p.lng), name: '' }));
+ state.notes = [];
+ syncLegs();
+ return serializeRoute();
+ } finally {
+ state.waypoints = saved.waypoints; state.legs = saved.legs; state.notes = saved.notes;
+ syncLegs();
+ }
+}
+
+// Stop recording AND save. Returns the new library entry, or null.
+function stopGpsRecordingAndSave() {
+ const raw = gpsTrack.slice();
+ stopGpsRecording();
+ if (raw.length < 2) { alert(S.gpsNoTrack || 'No track recorded.'); return null; }
+ const simp = simplifyTrack(raw.map(p => ({ lat: p.lat, lng: p.lng })), GPS_SIMPLIFY_EPS_DEG);
+ const data = gpsRouteDataFromPoints(simp);
+ const entry = {
+ id: routeLibraryId(),
+ name: gpsTrackName(),
+ savedAt: new Date().toISOString(),
+ kind: 'gps',
+ data,
+ track: raw.map(p => ({ lat: r5(p.lat), lng: r5(p.lng), t: p.t,
+ ...(p.alt != null ? { alt: Math.round(p.alt) } : {}),
+ ...(p.acc != null ? { acc: Math.round(p.acc) } : {}) })),
+ };
+ const list = loadRouteLibrary();
+ list.unshift(entry);
+ return persistRouteLibrary(list) ? entry : null;
+}
+```
+
+Note: `state`, `syncLegs`, `serializeRoute`, `routeLibraryId`, `loadRouteLibrary`, `persistRouteLibrary` are existing globals from core.js/io.js (loaded before gps.js).
+
+- [ ] **Step 4: Run the test โ expect PASS**
+
+Run: `BASE_URL=http://127.0.0.1:8000 npx playwright test tests/gps-track-recorder.spec.js -g "kind:gps"`
+Expected: PASS.
+
+- [ ] **Step 5: node --check + commit**
+
+```bash
+node --check docs/app/gps.js
+git add docs/app/gps.js tests/gps-track-recorder.spec.js
+git commit -m "feat(gps): auto-save flown track as a kind:gps library entry"
+```
+
+---
+
+## Task 4: Live render โ own-ship refactor + breadcrumb
+
+**Files:**
+- Modify: `docs/app/draw.js:39` (`drawSimAircraft`), `docs/app/draw.js:299-311` (`draw()`)
+- Modify: `docs/app/gps.js`
+- Test: `tests/gps-track-recorder.spec.js`
+
+- [ ] **Step 1: Write the failing test**
+
+```js
+test('breadcrumb + own-ship are drawn while recording', async ({ page }) => {
+ await page.addInitScript(() => {
+ window.__geoCb = null;
+ navigator.geolocation.watchPosition = (cb) => { window.__geoCb = cb; return 1; };
+ navigator.geolocation.clearWatch = () => {};
+ });
+ await boot(page);
+ const drawn = await page.evaluate(() => {
+ window.__gpsBreadcrumbDrawn = 0;
+ startGpsRecording();
+ const fix = (lat, lng) => window.__geoCb({ coords: { latitude: lat, longitude: lng, accuracy: 8, heading: 90, altitude: null }, timestamp: Date.now() });
+ fix(32.05, 34.80); fix(32.06, 34.81); fix(32.07, 34.82);
+ draw();
+ return { points: gpsTrack.length, ownHdg: gpsOwn && gpsOwn.hdg, breadcrumb: window.__gpsBreadcrumbDrawn };
+ });
+ expect(drawn.points).toBe(3);
+ expect(drawn.ownHdg).toBe(90);
+ expect(drawn.breadcrumb).toBeGreaterThan(0); // drawGpsTrack ran with >1 point
+});
+```
+
+- [ ] **Step 2: Run it โ expect FAIL** (`__gpsBreadcrumbDrawn` stays 0)
+
+Run: `BASE_URL=http://127.0.0.1:8000 npx playwright test tests/gps-track-recorder.spec.js -g breadcrumb`
+Expected: FAIL.
+
+- [ ] **Step 3: Refactor own-ship in `draw.js`**
+
+In `drawSimAircraft()` (line 39), change the guard + position source so the body draws an arbitrary own-ship. Replace the function header and the first two lines:
+
+Old:
+```js
+function drawSimAircraft() {
+ if (!simOn || !simAircraft) return;
+ const s = proj(simAircraft);
+ const mapBearing = (typeof map !== 'undefined' && map.getBearing) ? map.getBearing() : 0;
+ const screenAngle = ((simAircraft.hdg || 0) - mapBearing) * Math.PI / 180;
+```
+New:
+```js
+// Draws an own-ship arrow at pos {lat,lng} with true heading `hdg`.
+function drawOwnShip(pos, hdg) {
+ if (!pos) return;
+ const s = proj(pos);
+ const mapBearing = (typeof map !== 'undefined' && map.getBearing) ? map.getBearing() : 0;
+ const screenAngle = ((hdg || 0) - mapBearing) * Math.PI / 180;
+```
+(The rest of the aircraft-drawing body is unchanged โ it already uses `s`, `screenAngle`, `octx`.)
+
+- [ ] **Step 4: Add breadcrumb + own-ship dispatch in `gps.js`**
+
+```js
+// Breadcrumb of the in-progress recording, drawn on the overlay.
+function drawGpsTrack() {
+ if (!gpsRecording || gpsTrack.length < 1) return;
+ if (gpsTrack.length > 1) {
+ octx.save();
+ octx.beginPath();
+ for (let i = 0; i < gpsTrack.length; i++) {
+ const s = proj(gpsTrack[i]);
+ if (i === 0) octx.moveTo(s.x, s.y); else octx.lineTo(s.x, s.y);
+ }
+ octx.lineWidth = 3;
+ octx.strokeStyle = '#1e88e5';
+ octx.stroke();
+ octx.restore();
+ if (typeof window !== 'undefined') window.__gpsBreadcrumbDrawn = (window.__gpsBreadcrumbDrawn || 0) + 1;
+ }
+ if (gpsOwn) drawOwnShip(gpsOwn, gpsOwn.hdg);
+}
+```
+
+- [ ] **Step 5: Wire into `draw()` and keep sim working** โ edit `draw.js` line ~311
+
+Old:
+```js
+ drawWaypoints();
+ drawNotes();
+ ...
+ drawSimAircraft();
+```
+New:
+```js
+ drawWaypoints();
+ drawNotes();
+ ...
+ if (typeof drawGpsTrack === 'function') drawGpsTrack(); // GPS breadcrumb + own-ship while recording
+ if (!gpsRecording && simOn && simAircraft) drawOwnShip(simAircraft, simAircraft.hdg); // sim own-ship
+```
+(Remove the old `drawSimAircraft();` call โ `drawOwnShip` replaces it.)
+
+- [ ] **Step 6: Run the test โ expect PASS**
+
+Run: `BASE_URL=http://127.0.0.1:8000 npx playwright test tests/gps-track-recorder.spec.js -g breadcrumb`
+Expected: PASS.
+
+- [ ] **Step 7: Sim regression + node --check + commit**
+
+```bash
+node --check docs/app/draw.js && node --check docs/app/gps.js
+BASE_URL=http://127.0.0.1:8000 npx playwright test tests/ # ensure sim/draw suites stay green
+git add docs/app/draw.js docs/app/gps.js tests/gps-track-recorder.spec.js
+git commit -m "feat(gps): live breadcrumb + shared own-ship renderer (sim + GPS)"
+```
+
+---
+
+## Task 5: Toolbar control + i18n + ui.js wiring
+
+**Files:**
+- Modify: `docs/index.html` (View/Set section), `docs/app/core.js` (strings), `docs/i18n/he/strings.js`, `docs/app/ui.js` (wiring)
+- Test: `tests/gps-track-recorder.spec.js`
+
+- [ ] **Step 1: Write the failing test**
+
+```js
+test('toolbar GPS button toggles recording and updates its label', async ({ page }) => {
+ await page.addInitScript(() => {
+ window.__geoCb = null;
+ navigator.geolocation.watchPosition = (cb) => { window.__geoCb = cb; return 5; };
+ navigator.geolocation.clearWatch = () => {};
+ });
+ await boot(page);
+ const btn = page.locator('#gps-record');
+ await expect(btn).toHaveCount(1);
+ await btn.click();
+ expect(await page.evaluate(() => gpsRecording)).toBe(true);
+ await expect(btn).toContainText('Stop');
+ await page.evaluate(() => { const f=(a,b)=>window.__geoCb({coords:{latitude:a,longitude:b,accuracy:8,heading:null,altitude:null},timestamp:Date.now()}); f(32.0,34.0); f(32.1,34.0); });
+ await btn.click();
+ expect(await page.evaluate(() => gpsRecording)).toBe(false);
+});
+```
+
+- [ ] **Step 2: Run it โ expect FAIL** (`#gps-record` not found)
+
+Run: `BASE_URL=http://127.0.0.1:8000 npx playwright test tests/gps-track-recorder.spec.js -g "toolbar GPS"`
+Expected: FAIL.
+
+- [ ] **Step 3: Add the button to the View/Set section in `docs/index.html`**
+
+Locate the View/Set section (the one containing the existing overlay toggles such as `#commchange-cb`). Add, after the last control in that section:
+
+```html
+
+
+```
+
+(The `gpsUpdateReadout()` defined in Task 2 fills `#gps-readout` with `N pts ยท MM:SS` while recording and clears it on stop โ no extra wiring needed beyond the span existing.)
+
+- [ ] **Step 4: Add English strings in `docs/app/core.js`** (inside the `window.S = { โฆ }` defaults)
+
+```js
+ tbGpsRecord: '๐ Record GPS track',
+ tbGpsRecordTitle: 'Record your flown track from the device GPS and save it',
+ tbGpsStop: 'โ Stop & save',
+ gpsUnsupported: 'GPS is not available in this browser.',
+ gpsNoTrack: 'No track recorded.',
+ gpsError: 'GPS error: ',
+```
+
+- [ ] **Step 5: Add Hebrew overrides in `docs/i18n/he/strings.js`**
+
+```js
+ tbGpsRecord: '๐ ืืงืืืช ืืกืืื GPS',
+ tbGpsRecordTitle: 'ืืงืืืช ืืืกืืื ืืคืืขื ื-GPS ืืืืฉืืจ ืืฉืืืจืชื',
+ tbGpsStop: 'โ ืขืฆืืจ ืืฉืืืจ',
+ gpsUnsupported: 'GPS ืืื ื ืืืื ืืืคืืคื ืื.',
+ gpsNoTrack: 'ืื ืืืงืื ืืกืืื.',
+ gpsError: 'ืฉืืืืช GPS: ',
+```
+
+- [ ] **Step 6: Wire the button in `docs/app/ui.js`** (near other toolbar button wiring, e.g. where `#plan` / `#charts` are bound)
+
+```js
+ const gpsBtn = document.getElementById('gps-record');
+ if (gpsBtn) {
+ if (!navigator.geolocation) { gpsBtn.disabled = true; }
+ gpsBtn.addEventListener('click', () => {
+ if (gpsRecording) {
+ stopGpsRecordingAndSave();
+ gpsBtn.textContent = S.tbGpsRecord;
+ if (typeof window.refreshRouteLibrary === 'function') window.refreshRouteLibrary();
+ } else {
+ startGpsRecording();
+ if (gpsRecording) gpsBtn.textContent = S.tbGpsStop;
+ }
+ });
+ }
+```
+
+- [ ] **Step 7: Run the test + spell lint โ expect PASS**
+
+Run: `BASE_URL=http://127.0.0.1:8000 npx playwright test tests/gps-track-recorder.spec.js -g "toolbar GPS"`
+Then: `npm run lint:spell`
+Expected: test PASS; spell lint clean (add proper-noun exceptions only if needed).
+
+- [ ] **Step 8: node --check + commit**
+
+```bash
+node --check docs/app/ui.js docs/app/core.js docs/i18n/he/strings.js
+git add docs/index.html docs/app/core.js docs/i18n/he/strings.js docs/app/ui.js tests/gps-track-recorder.spec.js
+git commit -m "feat(gps): View/Set record toggle, i18n strings, ui wiring"
+```
+
+---
+
+## Task 6: Docs + full regression
+
+**Files:**
+- Modify: `.ai/navaid-dev.md`
+- Test: full suite
+
+- [ ] **Step 1: Document the new keys/feature in `.ai/navaid-dev.md`**
+
+Add to the Persistence section:
+```
+- `navaid.gpsTrack` (sessionStorage) โ best-effort in-progress GPS recording
+ checkpoint; cleared on save/discard.
+```
+Add to the route-library note: saved-route entries may carry `kind: 'gps'` + a
+raw `track[]` (recorded GPS breadcrumb); loading applies the simplified route.
+Add a Features bullet describing the GPS track recorder (๐ Record GPS track in
+the View/Set section; auto-saves a timestamped library entry; Drive-synced).
+
+- [ ] **Step 2: Run the full suite**
+
+Run: `BASE_URL=http://127.0.0.1:8000 npx playwright test tests/`
+Expected: all green (gps-track-recorder + no regressions in sim/draw/route-library/bidi).
+
+- [ ] **Step 3: Stop the server + commit**
+
+```bash
+pkill -f "http.server -d docs 8000" 2>/dev/null
+git add .ai/navaid-dev.md
+git commit -m "docs(gps): document GPS track recorder + navaid.gpsTrack key"
+```
+
+---
+
+## Notes for the implementer
+- No build step โ scripts share one global scope; load order matters (`gps.js` after `gdrive.js`, before `ui.js`).
+- Keep `?v=` placeholders in `index.html` consistent; deploy rewrites them.
+- HTTPS is required for `geolocation` in real browsers (Pages โ, `localhost` โ). Tests stub `watchPosition`, so no real permission prompt.
+- Background suspension on phones is a known limitation (no wake-lock in v1) โ do not add one.
diff --git a/docs/superpowers/specs/2026-06-18-gps-track-recorder-design.md b/docs/superpowers/specs/2026-06-18-gps-track-recorder-design.md
new file mode 100644
index 00000000..3f9b5b42
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-18-gps-track-recorder-design.md
@@ -0,0 +1,129 @@
+# GPS Track Recorder โ Design
+
+**Date:** 2026-06-18
+**Status:** Approved (brainstorm)
+**Related:** issue #676 (Moving-map live tracking / GPS own-ship)
+
+## Goal
+
+Record where the pilot actually flies using the device GPS, show it live on the
+map, and on stop auto-save the flown track as a saved-route library entry
+(which the existing Google Drive sync then carries).
+
+## Decisions (locked)
+
+- **Source:** browser `navigator.geolocation` (device GPS). HTTPS only (GitHub
+ Pages โ, `localhost` โ for dev).
+- **Live display:** a live own-ship marker plus a growing breadcrumb trail.
+- **On Stop:** auto-save a timestamped entry to the saved-route library
+ (`navaid.routes`); existing Drive sync picks it up. No separate export step.
+- **Stored form:** a **simplified waypoint route** (editable like any NavAid
+ route) **plus** the **raw breadcrumb** for fidelity.
+- **Approach A:** a dedicated `gps.js` module owns geolocation + recording
+ state; the live own-ship reuses the simulator's marker renderer via a small
+ refactor. (Rejected B: feeding device GPS into the simulator pipeline โ
+ couples real GPS to "simulator" semantics.)
+
+### Confirmed defaults
+- Record toggle lives in the toolbar **View/Set** section.
+- **Follow on** while recording (map recenters on own-ship).
+- Track simplification epsilon **โ 30 m** (โ 0.0003ยฐ).
+
+## Architecture
+
+### 1. Module & load
+New `docs/app/gps.js`, inserted in the `index.html` script array **before**
+`app/ui.js` (so `ui.js` can wire the toolbar control to it). It uses globals
+from earlier modules (`state`, `draw`/`scheduleDraw`, `proj`, route-library
+helpers in `io.js`).
+
+Globals it owns:
+- `gpsRecording` (bool)
+- `gpsTrack` โ array of `{lat, lng, t, alt, acc}` (coords 5 dp, `t` = ms epoch)
+- `gpsWatchId` โ `watchPosition` handle
+- `gpsOwn` โ last fix `{lat, lng, hdg?}` for own-ship rendering
+
+Public functions: `startGpsRecording()`, `stopGpsRecordingAndSave()`,
+`onGpsPosition(pos)` (internal), `gpsOwnShip()` (getter for the renderer).
+
+### 2. Recording mechanics
+`navigator.geolocation.watchPosition(onGpsPosition, onGpsError,
+{ enableHighAccuracy: true })`. Each fix:
+- **Filter:** drop if `accuracy` > ~100 m, or if moved < ~10 m from the last
+ kept point (de-jitter / de-dupe).
+- **Append** `{lat, lng, t, alt, acc}` (lat/lng rounded 5 dp).
+- Safety cap ~50 000 points.
+- Update `gpsOwn`, then `scheduleDraw()`; if follow is on, recenter the map.
+- **Errors:** permission denied / position error โ stop recording, surface a
+ notice, save nothing.
+
+### 3. Live display (own-ship + breadcrumb)
+- Refactor `drawSimAircraft()` (draw.js) into a shared `drawOwnShip(pos, hdg)`.
+ The active own-ship = the GPS fix while recording, else the simulator
+ aircraft. Sim behaviour is unchanged when not recording.
+- **Heading** uses `position.coords.heading` when present (only reported while
+ moving on most devices); otherwise it falls back to the great-circle bearing
+ from the previous kept point, and to 0 (north) for the very first fix.
+- New `drawGpsTrack()` draws the breadcrumb polyline from `gpsTrack` on the
+ overlay canvas, gated on `gpsRecording`.
+- Both are invoked from the existing `draw()` pipeline; each fix triggers
+ `scheduleDraw()`.
+
+### 4. Save on Stop
+- **Simplify** `gpsTrack` lat/lng with DouglasโPeucker (ฮต โ 30 m) โ
+ `waypoints` (blank/sequential names; leg altitudes left at defaults โ
+ GPS altitude is kept only in the raw track, not as CVFR leg altitudes).
+- Build a library entry via a new `routeLibrarySaveTrack()` mirroring
+ `routeLibrarySaveCurrent()` (io.js):
+ ```
+ { id, name: "Track 2026-06-18 14:30", savedAt, kind: 'gps',
+ data: , track: [ {lat,lng,t,alt,acc} โฆ ] }
+ ```
+ `unshift` into `navaid.routes`; existing persistence + Drive sync handle the
+ rest.
+- **No points captured** โ discard, inform the user (nothing saved).
+- **Loading** the entry applies the simplified route (existing
+ `routeLibraryApply`); the raw `track` is retained for fidelity / future
+ use. Existing route entries (no `kind`/`track`) are unaffected.
+
+### 5. UI
+- A toolbar toggle in **View/Set**: `๐ Record GPS track`. While recording it
+ flips to `โ Stop & save` with a small live readout (duration ยท points ยท
+ distance).
+- i18n: English defaults in `core.js`, Hebrew overrides in
+ `i18n/he/strings.js`.
+- If `navigator.geolocation` is unavailable, the control is hidden/disabled.
+- No new global keyboard shortcut in v1.
+
+### 6. Error handling / limits
+- HTTPS required (Pages โ).
+- **Background suspension:** phones suspend page JS when the screen sleeps or
+ the tab is backgrounded, so the breadcrumb will have gaps. Documented
+ limitation; no wake-lock in v1.
+- **Drive payload:** the raw track grows `navaid-routes.json`. Mitigate with
+ 5 dp coords and minimal per-point fields; accepted for v1.
+
+### 7. Persistence keys
+- `navaid.routes` โ existing route library; entries gain optional `kind` +
+ `track` fields (back-compatible).
+- `navaid.gpsTrack` (`sessionStorage`) โ deferred/not in v1; resume-on-reload
+ was not implemented and the checkpoint key is unused.
+
+## Testing
+- Playwright stubs `navigator.geolocation.watchPosition` (via `addInitScript`)
+ to emit a scripted sequence of fixes:
+ - start โ feed fixes โ assert own-ship + breadcrumb are drawn (expose
+ `window.__gpsTrack` for inspection);
+ - stop โ assert a `navaid.routes` entry was added with `kind: 'gps'`,
+ simplified `data.waypoints`, and a raw `track[]`.
+- Unit-test the DouglasโPeucker simplify helper (point reduction + endpoint
+ preservation).
+- Filter test: low-accuracy / sub-10 m points are dropped.
+- Run on both mobile and desktop viewports.
+
+## Non-goals (v1, YAGNI)
+- Background/locked-screen recording, wake-lock.
+- GPX-of-track export (a saved route already exports via the existing route
+ GPX path).
+- Full moving-map polish from #676 (turn indicators, heading-up, etc.).
+- Editing the raw track; it is read-only fidelity data.
diff --git a/tests/gps-track-recorder.spec.js b/tests/gps-track-recorder.spec.js
new file mode 100644
index 00000000..bdd55aee
--- /dev/null
+++ b/tests/gps-track-recorder.spec.js
@@ -0,0 +1,253 @@
+const { test, expect } = require('./_setup');
+
+async function boot(page) {
+ await page.goto('?lang=en');
+ await page.waitForFunction(() => typeof map !== 'undefined' && typeof simplifyTrack === 'function');
+}
+
+test('simplifyTrack reduces collinear points and keeps the endpoints', async ({ page }) => {
+ await boot(page);
+ const out = await page.evaluate(() => {
+ const pts = [
+ { lat: 32.00, lng: 34.00 }, { lat: 32.01, lng: 34.00 },
+ { lat: 32.02, lng: 34.00 }, { lat: 32.03, lng: 34.00 }, // collinear N
+ { lat: 32.03, lng: 34.05 }, // sharp turn E
+ ];
+ const s = simplifyTrack(pts, 0.0003);
+ return { n: s.length, first: s[0], last: s[s.length - 1] };
+ });
+ expect(out.n).toBeLessThan(5);
+ expect(out.n).toBeGreaterThanOrEqual(3);
+ expect(out.first).toMatchObject({ lat: 32.00, lng: 34.00 });
+ expect(out.last).toMatchObject({ lat: 32.03, lng: 34.05 });
+});
+
+test('simplifyTrack handles a very large input without overflowing', async ({ page }) => {
+ test.setTimeout(10000); // iterative D-P on 20k-point worst-case zigzag runs in ~2-3 s
+ await page.goto('?lang=en');
+ await page.waitForFunction(() => typeof simplifyTrack === 'function');
+ const out = await page.evaluate(() => {
+ // 20k-point zigzag: exceeds ~15k recursion-overflow threshold, guards the iterative path.
+ const pts = [];
+ for (let i = 0; i < 20000; i++) pts.push({ lat: 32 + i * 1e-5, lng: 34 + (i % 2) * 1e-3 });
+ const s = simplifyTrack(pts, 0.0003);
+ return { n: s.length, first: s[0], last: s[s.length - 1] };
+ });
+ expect(out.n).toBeGreaterThan(2);
+ expect(out.first).toMatchObject({ lat: 32, lng: 34 });
+});
+
+test('recording collects filtered fixes and stops cleanly', async ({ page }) => {
+ await page.addInitScript(() => {
+ window.__geoCb = null; window.__cleared = 0;
+ navigator.geolocation.watchPosition = (cb) => { window.__geoCb = cb; return 42; };
+ navigator.geolocation.clearWatch = () => { window.__cleared++; };
+ });
+ await page.goto('?lang=en');
+ await page.waitForFunction(() => typeof startGpsRecording === 'function');
+ const fed = await page.evaluate(() => {
+ startGpsRecording();
+ const fix = (lat, lng, acc) => window.__geoCb({ coords: { latitude: lat, longitude: lng, accuracy: acc, heading: null, altitude: null }, timestamp: Date.now() });
+ fix(32.0000, 34.0000, 8); // kept
+ fix(32.00001, 34.00001, 8); // < 10 m from prev -> dropped
+ fix(32.0100, 34.0000, 8); // kept (moved ~1.1 km)
+ fix(32.0200, 34.0000, 250); // accuracy > 100 -> dropped
+ return { recording: gpsRecording, n: gpsTrack.length };
+ });
+ expect(fed.recording).toBe(true);
+ expect(fed.n).toBe(2);
+ const after = await page.evaluate(() => { stopGpsRecording(); return { recording: gpsRecording, cleared: window.__cleared, watch: gpsWatchId }; });
+ expect(after.recording).toBe(false);
+ expect(after.cleared).toBe(1);
+ expect(after.watch).toBeNull();
+});
+
+test('stop saves a kind:gps library entry with simplified route + raw track', async ({ page }) => {
+ await page.addInitScript(() => {
+ window.__geoCb = null;
+ navigator.geolocation.watchPosition = (cb) => { window.__geoCb = cb; return 7; };
+ navigator.geolocation.clearWatch = () => {};
+ try { localStorage.removeItem('navaid.routes'); } catch (e) {}
+ });
+ await page.goto('?lang=en');
+ await page.waitForFunction(() => typeof stopGpsRecordingAndSave === 'function');
+ await page.evaluate(() => {
+ startGpsRecording();
+ const fix = (lat, lng) => window.__geoCb({ coords: { latitude: lat, longitude: lng, accuracy: 8, heading: null, altitude: 100 }, timestamp: Date.now() });
+ fix(32.00, 34.00); fix(32.05, 34.00); fix(32.10, 34.02); fix(32.15, 34.10);
+ });
+ const entry = await page.evaluate(() => stopGpsRecordingAndSave());
+ expect(entry).toBeTruthy();
+ expect(entry.kind).toBe('gps');
+ expect(entry.name).toMatch(/^Track /);
+ expect(Array.isArray(entry.track)).toBe(true);
+ expect(entry.track.length).toBeGreaterThanOrEqual(4);
+ expect(entry.data.waypoints.length).toBeGreaterThanOrEqual(2);
+ expect(entry.data.legs.length).toBe(entry.data.waypoints.length - 1);
+ // Fix: saved GPS entry must not be polluted with user's current wind or suppressions.
+ expect(entry.data.commChangeSuppressions || []).toEqual([]);
+ expect(entry.data.wind == null || (entry.data.wind.speed === 0)).toBe(true);
+ const persisted = await page.evaluate(() => JSON.parse(localStorage.getItem('navaid.routes'))[0]);
+ expect(persisted.id).toBe(entry.id);
+ expect(await page.evaluate((d) => (typeof validateRoute === 'function' ? validateRoute(d) : null), entry.data)).toBeNull();
+});
+
+test('breadcrumb + own-ship are drawn while recording', async ({ page }) => {
+ await page.addInitScript(() => {
+ window.__geoCb = null;
+ navigator.geolocation.watchPosition = (cb) => { window.__geoCb = cb; return 1; };
+ navigator.geolocation.clearWatch = () => {};
+ });
+ await page.goto('?lang=en');
+ await page.waitForFunction(() => typeof startGpsRecording === 'function' && typeof drawGpsTrack === 'function');
+ const drawn = await page.evaluate(() => {
+ window.__gpsBreadcrumbDrawn = 0;
+ startGpsRecording();
+ const fix = (lat, lng) => window.__geoCb({ coords: { latitude: lat, longitude: lng, accuracy: 8, heading: 90, altitude: null }, timestamp: Date.now() });
+ fix(32.05, 34.80); fix(32.06, 34.81); fix(32.07, 34.82);
+ draw();
+ return { points: gpsTrack.length, ownHdg: gpsOwn && gpsOwn.hdg, breadcrumb: window.__gpsBreadcrumbDrawn };
+ });
+ expect(drawn.points).toBe(3);
+ expect(drawn.ownHdg).toBe(90);
+ expect(drawn.breadcrumb).toBeGreaterThan(0);
+});
+
+test('toolbar GPS button toggles recording and updates its label', async ({ page }) => {
+ await page.addInitScript(() => {
+ window.__geoCb = null;
+ navigator.geolocation.watchPosition = (cb) => { window.__geoCb = cb; return 5; };
+ navigator.geolocation.clearWatch = () => {};
+ try { localStorage.removeItem('navaid.routes'); } catch (e) {}
+ try { localStorage.setItem('navaid.sec.view', '1'); } catch (e) {}
+ });
+ await page.goto('?lang=en');
+ await page.waitForFunction(() => typeof startGpsRecording === 'function');
+ const btn = page.locator('#gps-record');
+ await expect(btn).toHaveCount(1);
+ await expect(btn).toBeVisible();
+ await btn.click();
+ expect(await page.evaluate(() => gpsRecording)).toBe(true);
+ await expect(btn).toContainText('Stop');
+ await page.evaluate(() => { const f=(a,b)=>window.__geoCb({coords:{latitude:a,longitude:b,accuracy:8,heading:null,altitude:null},timestamp:Date.now()}); f(32.0,34.0); f(32.1,34.0); });
+ await btn.click();
+ expect(await page.evaluate(() => gpsRecording)).toBe(false);
+ await expect(btn).toContainText('Record');
+});
+
+test('saving a GPS track does not mutate the currently-loaded routes legs', async ({ page }) => {
+ await page.addInitScript(() => {
+ window.__geoCb = null;
+ navigator.geolocation.watchPosition = (cb) => { window.__geoCb = cb; return 3; };
+ navigator.geolocation.clearWatch = () => {};
+ try { localStorage.removeItem('navaid.routes'); } catch (e) {}
+ });
+ await page.goto('?lang=en');
+ await page.waitForFunction(() => typeof stopGpsRecordingAndSave === 'function');
+ const before = await page.evaluate(() => {
+ state.waypoints = [
+ { lat: 32.0, lng: 34.8, name: 'A' }, { lat: 32.2, lng: 34.9, name: 'B' }, { lat: 32.4, lng: 35.0, name: 'C' },
+ ];
+ syncLegs();
+ state.legs[0].flightSpeed = 137; // custom edit on a leg
+ state.legs[1].inboundAltitude = 4500;
+ draw();
+ startGpsRecording();
+ const f = (a, b) => window.__geoCb({ coords: { latitude: a, longitude: b, accuracy: 8, heading: null, altitude: null }, timestamp: Date.now() });
+ f(31.0, 34.0); f(31.5, 34.2); f(31.9, 34.6);
+ return { speed: state.legs[0].flightSpeed, alt: state.legs[1].inboundAltitude, nLegs: state.legs.length };
+ });
+ await page.evaluate(() => stopGpsRecordingAndSave());
+ const after = await page.evaluate(() => ({ speed: state.legs[0].flightSpeed, alt: state.legs[1].inboundAltitude, nLegs: state.legs.length, wps: state.waypoints.map(w => w.name) }));
+ expect(after.nLegs).toBe(before.nLegs); // 2 legs, not regrown/truncated
+ expect(after.speed).toBe(137); // custom speed preserved
+ expect(after.alt).toBe(4500); // custom altitude preserved
+ expect(after.wps).toEqual(['A', 'B', 'C']); // live waypoints untouched
+});
+
+test('GPS error resets recording state and button label', async ({ page }) => {
+ await page.addInitScript(() => {
+ window.__errCb = null;
+ navigator.geolocation.watchPosition = (cb, err) => { window.__errCb = err; return 9; };
+ navigator.geolocation.clearWatch = () => {};
+ try { localStorage.setItem('navaid.sec.view', '1'); } catch (e) {}
+ });
+ await page.goto('?lang=en');
+ await page.waitForFunction(() => typeof startGpsRecording === 'function');
+ page.on('dialog', d => d.dismiss().catch(() => {})); // swallow the alert
+ const btn = page.locator('#gps-record');
+ await btn.click();
+ expect(await page.evaluate(() => gpsRecording)).toBe(true);
+ await page.evaluate(() => window.__errCb && window.__errCb({ code: 1, message: 'denied' }));
+ expect(await page.evaluate(() => gpsRecording)).toBe(false);
+ await expect(btn).toContainText('Record');
+});
+
+test('Show my location shows own-ship without recording or saving a track', async ({ page }) => {
+ await page.addInitScript(() => {
+ window.__liveCb = null;
+ navigator.geolocation.watchPosition = (cb) => { window.__liveCb = cb; return 11; };
+ navigator.geolocation.clearWatch = () => {};
+ try { localStorage.removeItem('navaid.routes'); localStorage.setItem('navaid.sec.view', '1'); } catch (e) {}
+ });
+ await page.goto('?lang=en');
+ await page.waitForFunction(() => typeof startLiveLocation === 'function');
+ const btn = page.locator('#gps-live');
+ await expect(btn).toBeVisible();
+ await btn.click();
+ const st = await page.evaluate(() => {
+ const f = (a, b) => window.__liveCb({ coords: { latitude: a, longitude: b, accuracy: 8, heading: 45, altitude: null }, timestamp: Date.now() });
+ f(32.0, 34.8); f(32.1, 34.9);
+ draw();
+ return { live: gpsLiveOn, recording: gpsRecording, track: gpsTrack.length, own: gpsOwn && gpsOwn.hdg };
+ });
+ expect(st.live).toBe(true);
+ expect(st.recording).toBe(false); // NOT recording
+ expect(st.track).toBe(0); // NOT collecting a track
+ expect(st.own).toBe(45); // own-ship updated
+ expect(await page.evaluate(() => localStorage.getItem('navaid.routes'))).toBeNull(); // nothing saved
+ await btn.click();
+ expect(await page.evaluate(() => gpsLiveOn)).toBe(false);
+ expect(await page.evaluate(() => gpsOwn)).toBeNull(); // own-ship cleared (no recording active)
+});
+
+test('stopping a recording keeps own-ship when live location is still on', async ({ page }) => {
+ await page.addInitScript(() => {
+ window.__recCb = null; window.__liveCb = null; let n = 0;
+ navigator.geolocation.watchPosition = (cb) => { n++; if (n === 1) window.__recCb = cb; else window.__liveCb = cb; return n; };
+ navigator.geolocation.clearWatch = () => {};
+ try { localStorage.removeItem('navaid.routes'); localStorage.setItem('navaid.sec.view', '1'); } catch (e) {}
+ });
+ await page.goto('?lang=en');
+ await page.waitForFunction(() => typeof startGpsRecording === 'function' && typeof startLiveLocation === 'function');
+ const out = await page.evaluate(() => {
+ startGpsRecording(); // watch #1 -> __recCb
+ startLiveLocation(); // watch #2 -> __liveCb
+ window.__recCb({ coords: { latitude: 32.0, longitude: 34.8, accuracy: 8, heading: 10, altitude: null }, timestamp: Date.now() });
+ window.__liveCb({ coords: { latitude: 32.1, longitude: 34.9, accuracy: 8, heading: 20, altitude: null }, timestamp: Date.now() });
+ stopGpsRecordingAndSave(); // stop recording; live still on
+ return { live: gpsLiveOn, recording: gpsRecording, ownAfter: gpsOwn };
+ });
+ expect(out.recording).toBe(false);
+ expect(out.live).toBe(true);
+ expect(out.ownAfter).not.toBeNull(); // own-ship preserved because live is on
+});
+
+test('GPS error resets the live-location button too', async ({ page }) => {
+ await page.addInitScript(() => {
+ window.__errCb = null;
+ navigator.geolocation.watchPosition = (cb, err) => { window.__errCb = err; return 4; };
+ navigator.geolocation.clearWatch = () => {};
+ try { localStorage.setItem('navaid.sec.view', '1'); } catch (e) {}
+ });
+ await page.goto('?lang=en');
+ await page.waitForFunction(() => typeof startLiveLocation === 'function');
+ page.on('dialog', d => d.dismiss().catch(() => {}));
+ const btn = page.locator('#gps-live');
+ await btn.click();
+ expect(await page.evaluate(() => gpsLiveOn)).toBe(true);
+ await page.evaluate(() => window.__errCb && window.__errCb({ code: 1, message: 'denied' }));
+ expect(await page.evaluate(() => gpsLiveOn)).toBe(false);
+ await expect(btn).toHaveAttribute('aria-pressed', 'false');
+ await expect(btn).toContainText('Show');
+});
diff --git a/tests/keyboard-shortcuts-help.spec.js b/tests/keyboard-shortcuts-help.spec.js
index 8fe0d960..9b5493be 100644
--- a/tests/keyboard-shortcuts-help.spec.js
+++ b/tests/keyboard-shortcuts-help.spec.js
@@ -5,6 +5,7 @@
// inside inputs, and existing global shortcuts (F, Ctrl-F, Esc, Backspace)
// keep working after the change.
const { test, expect } = require('./_setup');
+const { hideToolbarMenus } = require('./_toolbar');
async function boot(page, lang = 'en') {
await page.addInitScript(() => {
@@ -122,6 +123,9 @@ test.describe('Keyboard-shortcuts cheat-sheet (#420)', () => {
showInspector();
draw();
});
+ // Desktop menubar dropdowns overlap the inspector; close them so the
+ // input is clickable.
+ await hideToolbarMenus(page);
const nameInput = page.locator('#insp-body .row input[type="text"]').first();
await nameInput.click();
await nameInput.focus();
diff --git a/tests/routes.spec.js b/tests/routes.spec.js
index a9e968a5..c1baf400 100644
--- a/tests/routes.spec.js
+++ b/tests/routes.spec.js
@@ -4,6 +4,7 @@
// LLHZ โ LLHA fixture as tests/flight-plan.spec.js and tests/share-route.spec.js.
const { test, expect } = require('./_setup');
const { LLHZ, LLHA } = require('./_airfieldArp');
+const { hideToolbarMenus } = require('./_toolbar');
const ROUTE = {
waypoints: [
@@ -661,6 +662,7 @@ test.describe('Inspector close behaviour', () => {
state.selected = { type: 'wp', index: 3 };
showInspector();
});
+ await hideToolbarMenus(page);
await page.locator('#insp-close').click();
expect(await page.evaluate(() => state.selected)).toBeNull();
});
diff --git a/tests/tuning-panel.spec.js b/tests/tuning-panel.spec.js
index d4d957a4..31440bd5 100644
--- a/tests/tuning-panel.spec.js
+++ b/tests/tuning-panel.spec.js
@@ -299,7 +299,7 @@ test.describe('Hidden tuning panel', () => {
simOn = true;
simAircraft = { lat: 32.12, lng: 34.82, hdg: 90 };
- drawSimAircraft();
+ drawOwnShip(simAircraft, simAircraft.hdg);
navWP = [{ lat: 32.12, lng: 34.82, name: 'TEST' }];
commChangeMap = { TEST: { commChange: true } };
diff --git a/tests/vor-overlay.spec.js b/tests/vor-overlay.spec.js
index c9608ebc..f3c9a7b2 100644
--- a/tests/vor-overlay.spec.js
+++ b/tests/vor-overlay.spec.js
@@ -3,6 +3,7 @@
// a selectable reference VOR, and magnetic radial + DME of any point shown in
// its own bottom readout and the waypoint inspector.
const { test, expect } = require('./_setup');
+const { hideToolbarMenus } = require('./_toolbar');
async function boot(page, lang = 'en') {
await page.addInitScript(() => {
@@ -99,6 +100,7 @@ test.describe('VOR overlay + radial/DME (#404)', () => {
syncLegs();
state.selected = { type: 'wp', index: 0 }; showInspector();
});
+ await hideToolbarMenus(page);
const row = page.locator('#insp-body .vor-radial-row');
await expect(row).toHaveCount(1);
const sel = row.locator('select.insp-vor-ref');