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');