From 69cdea576387d90e814e4e8121c371ab0993cbb5 Mon Sep 17 00:00:00 2001 From: Marco Date: Thu, 18 Jun 2026 15:21:27 +0000 Subject: [PATCH] feat(map): realtime GPS own-position + track recording + alt/speed readout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two top-right map controls: - ๐Ÿ“ Show my location โ€” watchPosition draws a blue position dot + accuracy circle (Leaflet layers), pans to the first fix, and shows a readout of GPS altitude (ft) and ground speed (kt) when available. - โบ Record track โ€” appends each fix to a breadcrumb polyline (auto-starts tracking). Toggling off / location off clears the layers + readout. Geolocation errors / unsupported surface a toast. Strings (en + he) added. Fixes #676 Co-Authored-By: Claude Opus 4.8 --- docs/app/core.js | 4 ++ docs/app/style.css | 32 ++++++++++ docs/app/ui.js | 122 ++++++++++++++++++++++++++++++++++++ docs/i18n/he/strings.js | 4 ++ tests/live-location.spec.js | 58 +++++++++++++++++ 5 files changed, 220 insertions(+) create mode 100644 tests/live-location.spec.js diff --git a/docs/app/core.js b/docs/app/core.js index 19d5459d..bf00f96b 100644 --- a/docs/app/core.js +++ b/docs/app/core.js @@ -399,6 +399,10 @@ window.S = Object.assign({ errInvalidRoute: function(msg) { return 'Invalid route file: ' + msg; }, errInvalidNavWaypoints: function(msg) { return 'Invalid nav-waypoints data: ' + msg; }, errInvalidVors: function(msg) { return 'Invalid VOR data: ' + msg; }, + tbMyLocation: 'Show my location (GPS)', + tbRecordTrack: 'Record track', + geoUnsupported: 'Geolocation is not supported on this device.', + geoError: 'Location unavailable', tbShowVor: 'Show VOR stations', tbShowVorTitle: 'Overlay Israeli VOR/DME stations and pick a reference for radial/DME', vorRefLabel: 'VOR ref', diff --git a/docs/app/style.css b/docs/app/style.css index ee6c5e98..f1084c80 100644 --- a/docs/app/style.css +++ b/docs/app/style.css @@ -370,6 +370,38 @@ html, body { pointer-events: none; display: none; } +.leaflet-control.navaid-loc-btn, +.leaflet-control.navaid-rec-btn { + display: block; + width: 34px; + height: 34px; + margin: 8px 12px 0 0; + padding: 0; + font-size: 17px; + line-height: 32px; + text-align: center; + cursor: pointer; + color: #fff; + background: rgba(20, 18, 18, 0.88); + border: 1px solid #3a3636; + border-radius: 5px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.45); +} +.leaflet-control.navaid-loc-btn.active { background: #1d6fe0; border-color: #1d6fe0; } +.leaflet-control.navaid-rec-btn.active { background: #e23b3b; border-color: #e23b3b; } +.leaflet-control.navaid-loc-readout { + margin: 8px 12px 0 0; + padding: 4px 8px; + font: 700 13px/1 monospace; + color: #fff; + background: rgba(20, 18, 18, 0.88); + border: 1px solid #3a3636; + border-radius: 5px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.45); + white-space: nowrap; + direction: ltr; + unicode-bidi: isolate; +} .leaflet-control.zulu-clock { display: block; min-width: var(--navaid-zulu-clock-min-width, 82px); diff --git a/docs/app/ui.js b/docs/app/ui.js index 3341f571..e595d1bd 100644 --- a/docs/app/ui.js +++ b/docs/app/ui.js @@ -187,6 +187,128 @@ function refreshZuluClock() { } refreshZuluClock(); setInterval(refreshZuluClock, 1000); + +// --- realtime own-position (GPS) ------------------------------------ +// A topright toggle that tracks the device location via the Geolocation API +// and shows a blue dot + accuracy circle as Leaflet layers (independent of the +// route overlay). First fix pans to the position; it does not keep following. +let _locWatch = null, _locMarker = null, _locAccuracy = null, _locFollowed = false; +let _locTrack = null, _locRecording = false; +function liveLocationBtn() { return document.getElementById('navaid-loc-btn'); } +function recordTrackBtn() { return document.getElementById('navaid-rec-btn'); } +function stopLiveLocation() { + if (_locWatch != null && navigator.geolocation) navigator.geolocation.clearWatch(_locWatch); + _locWatch = null; + if (_locMarker) { map.removeLayer(_locMarker); _locMarker = null; } + if (_locAccuracy) { map.removeLayer(_locAccuracy); _locAccuracy = null; } + if (_locTrack) { map.removeLayer(_locTrack); _locTrack = null; } + const ro = document.getElementById('navaid-loc-readout'); + if (ro) { ro.textContent = ''; ro.style.display = 'none'; } + _locRecording = false; + const b = liveLocationBtn(); + if (b) { b.classList.remove('active'); b.setAttribute('aria-pressed', 'false'); } + const r = recordTrackBtn(); + if (r) { r.classList.remove('active'); r.setAttribute('aria-pressed', 'false'); } +} +function onLiveLocationFix(pos) { + const lat = pos.coords.latitude, lng = pos.coords.longitude; + const acc = Number.isFinite(pos.coords.accuracy) ? pos.coords.accuracy : 0; + const ll = [lat, lng]; + if (!_locMarker) { + _locAccuracy = L.circle(ll, { radius: acc, color: '#1d6fe0', weight: 1, + fillColor: '#1d6fe0', fillOpacity: 0.12, interactive: false }).addTo(map); + _locMarker = L.circleMarker(ll, { radius: 6, color: '#fff', weight: 2, + fillColor: '#1d6fe0', fillOpacity: 1, interactive: false }).addTo(map); + } else { + _locMarker.setLatLng(ll); + _locAccuracy.setLatLng(ll).setRadius(acc); + } + // GPS altitude (m โ†’ ft) + ground speed (m/s โ†’ kt) readout, when available. + const ro = document.getElementById('navaid-loc-readout'); + if (ro) { + const parts = []; + if (Number.isFinite(pos.coords.altitude)) parts.push(Math.round(pos.coords.altitude * 3.28084) + ' ft'); + if (Number.isFinite(pos.coords.speed) && pos.coords.speed >= 0) parts.push(Math.round(pos.coords.speed * 1.94384) + ' kt'); + ro.textContent = parts.join(' ยท '); + ro.style.display = parts.length ? 'block' : 'none'; + } + if (_locRecording) { + if (!_locTrack) { + _locTrack = L.polyline([ll], { color: '#e23b3b', weight: 3, opacity: 0.85, interactive: false }).addTo(map); + } else { + _locTrack.addLatLng(ll); + } + } + if (!_locFollowed) { _locFollowed = true; map.panTo(ll); } +} +function onLiveLocationError(e) { + if (typeof showToast === 'function') { + showToast((S.geoError || 'Location unavailable') + (e && e.message ? ': ' + e.message : '')); + } + stopLiveLocation(); +} +function toggleLiveLocation() { + if (_locWatch != null) { stopLiveLocation(); return; } + if (!navigator.geolocation) { + if (typeof showToast === 'function') showToast(S.geoUnsupported || 'Geolocation not supported'); + return; + } + _locFollowed = false; + const b = liveLocationBtn(); + if (b) { b.classList.add('active'); b.setAttribute('aria-pressed', 'true'); } + _locWatch = navigator.geolocation.watchPosition(onLiveLocationFix, onLiveLocationError, + { enableHighAccuracy: true, maximumAge: 2000, timeout: 15000 }); +} +// Record toggle โ€” appends each GPS fix to a breadcrumb polyline. Turning it on +// also starts location tracking if it isn't already running. +function toggleRecordTrack() { + _locRecording = !_locRecording; + const r = recordTrackBtn(); + if (r) { r.classList.toggle('active', _locRecording); r.setAttribute('aria-pressed', _locRecording ? 'true' : 'false'); } + if (_locRecording && _locWatch == null) toggleLiveLocation(); + if (!_locRecording && _locTrack) { map.removeLayer(_locTrack); _locTrack = null; } +} +window.toggleLiveLocation = toggleLiveLocation; +window.toggleRecordTrack = toggleRecordTrack; +const liveLocationCtrl = L.control({ position: 'topright' }); +liveLocationCtrl.onAdd = function () { + const b = L.DomUtil.create('button', 'leaflet-control navaid-loc-btn'); + b.id = 'navaid-loc-btn'; + b.type = 'button'; + b.textContent = '๐Ÿ“'; + b.title = S.tbMyLocation || 'Show my location'; + b.setAttribute('aria-label', S.tbMyLocation || 'Show my location'); + b.setAttribute('aria-pressed', 'false'); + L.DomEvent.disableClickPropagation(b); + L.DomEvent.on(b, 'click', e => { L.DomEvent.preventDefault(e); toggleLiveLocation(); }); + return b; +}; +liveLocationCtrl.addTo(map); +const recordTrackCtrl = L.control({ position: 'topright' }); +recordTrackCtrl.onAdd = function () { + const b = L.DomUtil.create('button', 'leaflet-control navaid-rec-btn'); + b.id = 'navaid-rec-btn'; + b.type = 'button'; + b.textContent = 'โบ'; + b.title = S.tbRecordTrack || 'Record track'; + b.setAttribute('aria-label', S.tbRecordTrack || 'Record track'); + b.setAttribute('aria-pressed', 'false'); + L.DomEvent.disableClickPropagation(b); + L.DomEvent.on(b, 'click', e => { L.DomEvent.preventDefault(e); toggleRecordTrack(); }); + return b; +}; +recordTrackCtrl.addTo(map); +const locReadoutCtrl = L.control({ position: 'topright' }); +locReadoutCtrl.onAdd = function () { + const box = L.DomUtil.create('div', 'leaflet-control navaid-loc-readout'); + box.id = 'navaid-loc-readout'; + box.dir = 'ltr'; + box.style.display = 'none'; + box.setAttribute('aria-live', 'off'); + return box; +}; +locReadoutCtrl.addTo(map); + // --- map legend (bottom-left) --------------------------------------- // The legend markup lives in index.html so applyI18n() fills its text at // boot; here we lift that element into a Leaflet control so it floats over diff --git a/docs/i18n/he/strings.js b/docs/i18n/he/strings.js index 65c49354..d8f06e82 100644 --- a/docs/i18n/he/strings.js +++ b/docs/i18n/he/strings.js @@ -98,6 +98,10 @@ window.S = { errInvalidRoute: function(msg) { return 'ืงื•ื‘ืฅ ืžืกืœื•ืœ ืœื ืชืงื™ืŸ: ' + msg; }, errInvalidNavWaypoints: function(msg) { return 'ื ืชื•ื ื™ ืฆื™ื•ื ื™ ื ื™ื•ื•ื˜ ืœื ืชืงื™ื ื™ื: ' + msg; }, errInvalidVors: function(msg) { return 'ื ืชื•ื ื™ VOR ืœื ืชืงื™ื ื™ื: ' + msg; }, + tbMyLocation: 'ื”ืฆื’ ืžื™ืงื•ื ื ื•ื›ื—ื™ (GPS)', + tbRecordTrack: 'ื”ืงืœื˜ ืžืกืœื•ืœ', + geoUnsupported: 'ืื™ืชื•ืจ ืžื™ืงื•ื ืื™ื ื• ื ืชืžืš ื‘ืžื›ืฉื™ืจ ื–ื”.', + geoError: 'ืžื™ืงื•ื ืœื ื–ืžื™ืŸ', tbShowVor: 'ื”ืฆื’ ืชื—ื ื•ืช VOR', tbShowVorTitle: 'ื”ืฆื’ ืชื—ื ื•ืช VOR/DME ื•ื‘ื—ืจ ืชื—ื ืช ื™ื™ื—ื•ืก ืœืจื“ื™ืืœ/DME', vorRefLabel: 'ืชื—ื ืช ื™ื™ื—ื•ืก', diff --git a/tests/live-location.spec.js b/tests/live-location.spec.js new file mode 100644 index 00000000..a3b97afb --- /dev/null +++ b/tests/live-location.spec.js @@ -0,0 +1,58 @@ +// @ts-check +// Realtime own-position (GPS) + track recording (#676). Geolocation is mocked +// so the watch can be fired deterministically. +const { test, expect } = require('@playwright/test'); + +async function bootWithGeo(page) { + await page.addInitScript(() => { + navigator.geolocation.watchPosition = (ok) => { + window.__fireFix = c => ok({ coords: c }); + return 42; + }; + navigator.geolocation.clearWatch = () => {}; + }); + await page.goto('?lang=en'); + await page.waitForFunction(() => typeof toggleLiveLocation === 'function'); +} + +const FIX = { latitude: 32.1, longitude: 34.8, accuracy: 30, altitude: 762, speed: 51.4 }; + +test('location toggle shows a position marker + alt/speed readout', async ({ page }) => { + await bootWithGeo(page); + const btn = page.locator('#navaid-loc-btn'); + await expect(btn).toBeVisible(); + await btn.click(); + await expect(btn).toHaveClass(/active/); + await page.evaluate(f => window.__fireFix(f), FIX); + // 762 m โ†’ 2500 ft, 51.4 m/s โ†’ 100 kt. + const readout = page.locator('#navaid-loc-readout'); + await expect(readout).toBeVisible(); + await expect(readout).toHaveText('2500 ft ยท 100 kt'); + // A position marker exists on the overlay pane. + expect(await page.locator('.leaflet-overlay-pane path').count()).toBeGreaterThan(0); + // Toggling off clears the marker + readout. + await btn.click(); + await expect(btn).not.toHaveClass(/active/); + await expect(readout).toBeHidden(); +}); + +test('record toggle starts tracking and draws a breadcrumb polyline', async ({ page }) => { + await bootWithGeo(page); + const rec = page.locator('#navaid-rec-btn'); + await rec.click(); + await expect(rec).toHaveClass(/active/); + // Recording auto-starts location tracking. + await expect(page.locator('#navaid-loc-btn')).toHaveClass(/active/); + await page.evaluate(f => window.__fireFix(f), FIX); + await page.evaluate(f => window.__fireFix({ ...f, latitude: 32.2, longitude: 34.85 }), FIX); + // A track polyline (โ‰ฅ2 fixes) is on the map. + const tracked = await page.evaluate(() => { + let n = 0; + document.querySelectorAll('.leaflet-overlay-pane path').forEach(p => { + const d = p.getAttribute('d') || ''; + if ((d.match(/L/g) || []).length >= 1) n++; + }); + return n; + }); + expect(tracked).toBeGreaterThan(0); +});