Instantly share code, notes, and snippets.
Created
July 15, 2026 14:17
-
Star
0
(0)
You must be signed in to star a gist -
Fork
0
(0)
You must be signed in to fork a gist
-
-
Save tomyo/2df3c2295794932d4407e7460b52a6a1 to your computer and use it in GitHub Desktop.
Generic Garmin MapShare race tracker overlay
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| (function () { | |
| 'use strict'; | |
| const VERSION = '0.1.0'; | |
| const GLOBAL = 'GarminRaceTracker'; | |
| const DEFAULTS = { | |
| mapName: '', | |
| refreshMs: 60000, | |
| staleAfterMin: 15, | |
| feedUrl: '', | |
| waypointsUrl: '', | |
| routesUrl: '', | |
| collectionsUrl: '', | |
| showMapFeatures: true, | |
| mockMe: false, | |
| autoFit: true, | |
| leafletCss: 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css', | |
| leafletJs: 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js', | |
| }; | |
| const cfg = Object.assign({}, DEFAULTS, window.GARMIN_TRACKER_CONFIG || {}); | |
| cfg.mapName = cfg.mapName || inferMapName(); | |
| if (!cfg.mapName) { | |
| console.warn('Garmin Race Tracker: open a public MapShare page like https://share.garmin.com/<name> first.'); | |
| return; | |
| } | |
| cfg.feedUrl = cfg.feedUrl || `/feed/share/${encodeURIComponent(cfg.mapName)}`; | |
| cfg.waypointsUrl = cfg.waypointsUrl || `/${encodeURIComponent(cfg.mapName)}/Waypoints`; | |
| cfg.routesUrl = cfg.routesUrl || `/${encodeURIComponent(cfg.mapName)}/routes/`; | |
| cfg.collectionsUrl = cfg.collectionsUrl || `/${encodeURIComponent(cfg.mapName)}/Collections`; | |
| if (window[GLOBAL] && typeof window[GLOBAL].destroy === 'function') { | |
| window[GLOBAL].destroy(); | |
| } | |
| const state = { | |
| overlay: null, | |
| map: null, | |
| layers: null, | |
| featureLayers: null, | |
| waypointLayer: null, | |
| routeLayer: null, | |
| trackLayer: null, | |
| racerMarker: null, | |
| meMarker: null, | |
| accuracyCircle: null, | |
| connector: null, | |
| racerTrail: null, | |
| refreshTimer: null, | |
| geoWatch: null, | |
| mockTimer: null, | |
| racer: null, | |
| me: null, | |
| mapFeatures: { waypoints: [], routes: [], collections: null }, | |
| featuresVisible: true, | |
| firstFitDone: false, | |
| closed: false, | |
| }; | |
| const api = { | |
| version: VERSION, | |
| refresh, | |
| fitBoth, | |
| centerRacer, | |
| centerMe, | |
| loadMapFeatures, | |
| toggleMapFeatures, | |
| destroy, | |
| getState: () => ({ racer: state.racer, me: state.me, mapName: cfg.mapName, mapFeatures: state.mapFeatures }), | |
| }; | |
| window[GLOBAL] = api; | |
| boot().catch((err) => { | |
| console.error(err); | |
| alert(`Garmin Race Tracker error: ${err.message || err}`); | |
| }); | |
| async function boot() { | |
| injectCss(); | |
| createOverlay(); | |
| await loadLeaflet(); | |
| initMap(); | |
| startOwnLocation(); | |
| await Promise.all([refresh(), loadMapFeatures()]); | |
| state.refreshTimer = setInterval(() => { | |
| refresh(); | |
| loadMapFeatures(); | |
| }, cfg.refreshMs); | |
| } | |
| function inferMapName() { | |
| const parts = location.pathname.split('/').filter(Boolean); | |
| if (parts[0] === 'feed' && parts[1] === 'share' && parts[2]) return parts[2]; | |
| if (parts[0] && !['feed', 'css', 'js', 'Content', 'Account', 'Home', 'bundles', 'scripts'].includes(parts[0])) return parts[0]; | |
| return ''; | |
| } | |
| function createOverlay() { | |
| const overlay = document.createElement('div'); | |
| overlay.id = 'grt-overlay'; | |
| overlay.innerHTML = ` | |
| <div class="grt-top"> | |
| <div> | |
| <div class="grt-title">Race Tracker</div> | |
| <div class="grt-subtitle">Garmin MapShare: <b>${escapeHtml(cfg.mapName)}</b></div> | |
| </div> | |
| <button class="grt-close" type="button" title="Close">×</button> | |
| </div> | |
| <div class="grt-stats"> | |
| <div><span>Racer</span><b data-grt="racer-status">loading…</b></div> | |
| <div><span>Speed</span><b data-grt="speed">—</b></div> | |
| <div><span>Course</span><b data-grt="course">—</b></div> | |
| <div><span>Me → Racer</span><b data-grt="range">GPS…</b></div> | |
| </div> | |
| <div class="grt-actions"> | |
| <button type="button" data-action="fit">Fit both</button> | |
| <button type="button" data-action="racer">Racer</button> | |
| <button type="button" data-action="me">Me</button> | |
| <button type="button" data-action="refresh">Refresh</button> | |
| <button type="button" data-action="features">Features</button> | |
| <a data-grt="google" target="_blank" rel="noopener">Google Maps</a> | |
| <a data-grt="osm" target="_blank" rel="noopener">OSM</a> | |
| </div> | |
| <div class="grt-info" data-grt="info">Loading Garmin feed…</div> | |
| <div id="grt-map"></div> | |
| `; | |
| document.body.appendChild(overlay); | |
| state.overlay = overlay; | |
| overlay.querySelector('.grt-close').addEventListener('click', destroy); | |
| overlay.querySelector('[data-action="fit"]').addEventListener('click', fitBoth); | |
| overlay.querySelector('[data-action="racer"]').addEventListener('click', centerRacer); | |
| overlay.querySelector('[data-action="me"]').addEventListener('click', centerMe); | |
| overlay.querySelector('[data-action="refresh"]').addEventListener('click', () => { refresh(); loadMapFeatures(); }); | |
| overlay.querySelector('[data-action="features"]').addEventListener('click', toggleMapFeatures); | |
| } | |
| async function loadLeaflet() { | |
| if (window.L && window.L.map) return; | |
| if (!document.querySelector('link[data-grt-leaflet]')) { | |
| const link = document.createElement('link'); | |
| link.rel = 'stylesheet'; | |
| link.href = cfg.leafletCss; | |
| link.dataset.grtLeaflet = '1'; | |
| document.head.appendChild(link); | |
| } | |
| await new Promise((resolve, reject) => { | |
| const existing = document.querySelector('script[data-grt-leaflet]'); | |
| if (existing) { | |
| existing.addEventListener('load', resolve, { once: true }); | |
| existing.addEventListener('error', reject, { once: true }); | |
| return; | |
| } | |
| const script = document.createElement('script'); | |
| script.src = cfg.leafletJs; | |
| script.dataset.grtLeaflet = '1'; | |
| script.onload = resolve; | |
| script.onerror = () => reject(new Error('Could not load Leaflet.')); | |
| document.head.appendChild(script); | |
| }); | |
| } | |
| function initMap() { | |
| const L = window.L; | |
| state.map = L.map('grt-map', { zoomControl: true }).setView([0, 0], 2); | |
| L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { | |
| maxZoom: 19, | |
| attribution: '© OpenStreetMap contributors', | |
| }).addTo(state.map); | |
| state.layers = L.layerGroup().addTo(state.map); | |
| state.featureLayers = L.layerGroup().addTo(state.map); | |
| state.routeLayer = L.layerGroup().addTo(state.featureLayers); | |
| state.trackLayer = L.layerGroup().addTo(state.featureLayers); | |
| state.waypointLayer = L.layerGroup().addTo(state.featureLayers); | |
| state.racerTrail = L.polyline([], { color: '#0b73ff', weight: 4, opacity: 0.9 }).addTo(state.trackLayer); | |
| state.connector = L.polyline([], { color: '#ffd43b', weight: 3, opacity: 0.9, dashArray: '8 8' }).addTo(state.layers); | |
| } | |
| async function refresh() { | |
| setText('racer-status', 'refreshing…'); | |
| try { | |
| const res = await fetch(addCacheBust(cfg.feedUrl), { cache: 'no-store', credentials: 'same-origin' }); | |
| if (!res.ok) throw new Error(`Garmin feed HTTP ${res.status}`); | |
| const xmlText = await res.text(); | |
| const racer = parseKml(xmlText); | |
| if (!racer) throw new Error('No usable location found in Garmin KML.'); | |
| state.racer = racer; | |
| updateRacerLayer(); | |
| updatePanel(); | |
| updateLinks(); | |
| maybeInitialFit(); | |
| } catch (err) { | |
| console.error(err); | |
| setText('racer-status', 'feed error'); | |
| setText('info', err.message || String(err)); | |
| } | |
| } | |
| async function loadMapFeatures() { | |
| if (!cfg.showMapFeatures || !state.map || !state.featureLayers) return; | |
| try { | |
| const [waypointsRes, routesRes, collectionsRes] = await Promise.allSettled([ | |
| fetchJson(cfg.waypointsUrl), | |
| fetchJson(cfg.routesUrl), | |
| fetchJson(cfg.collectionsUrl), | |
| ]); | |
| const waypoints = waypointsRes.status === 'fulfilled' ? normalizeWaypoints(waypointsRes.value) : []; | |
| const routes = routesRes.status === 'fulfilled' ? normalizeRoutes(routesRes.value) : []; | |
| const collections = collectionsRes.status === 'fulfilled' ? collectionsRes.value : null; | |
| state.mapFeatures = { waypoints, routes, collections }; | |
| renderMapFeatures(); | |
| updateFeatureButton(); | |
| } catch (err) { | |
| console.warn('Map feature load failed', err); | |
| } | |
| } | |
| async function fetchJson(url) { | |
| const res = await fetch(addCacheBust(url), { cache: 'no-store', credentials: 'same-origin', headers: { Accept: 'application/json' } }); | |
| if (!res.ok) throw new Error(`${url} HTTP ${res.status}`); | |
| return res.json(); | |
| } | |
| function normalizeWaypoints(data) { | |
| const arr = Array.isArray(data) ? data : (data && data.Waypoints) || []; | |
| return arr.map((w) => ({ | |
| id: w.D ?? w.ID ?? w.Id, | |
| name: w.X || w.Name || w.Label || 'WP', | |
| lat: Number(w.L ?? w.Latitude), | |
| lon: Number(w.N ?? w.Longitude), | |
| icon: w.I ?? w.IconIndex, | |
| })).filter((w) => Number.isFinite(w.lat) && Number.isFinite(w.lon)); | |
| } | |
| function normalizeRoutes(data) { | |
| const arr = Array.isArray(data) ? data : (data && data.Routes) || []; | |
| return arr.map((r) => ({ | |
| id: r.RouteID ?? r.ID ?? r.Id, | |
| name: r.Label || r.Name || 'Route', | |
| color: r.ColorHex || '#ff0000', | |
| points: (r.Points || []).map((p) => [Number(p.L ?? p.Latitude), Number(p.N ?? p.Longitude)]).filter((p) => Number.isFinite(p[0]) && Number.isFinite(p[1])), | |
| })).filter((r) => r.points.length >= 2); | |
| } | |
| function renderMapFeatures() { | |
| const L = window.L; | |
| if (!L || !state.routeLayer || !state.waypointLayer) return; | |
| state.routeLayer.clearLayers(); | |
| state.waypointLayer.clearLayers(); | |
| for (const route of state.mapFeatures.routes) { | |
| const line = L.polyline(route.points, { color: route.color || '#ff0000', weight: 5, opacity: 0.9 }).addTo(state.routeLayer); | |
| line.bindPopup(`<b>Route</b><br>${escapeHtml(route.name)}<br>${route.points.length} points`); | |
| const first = route.points[0]; | |
| const last = route.points[route.points.length - 1]; | |
| L.circleMarker(first, { radius: 5, color: '#003b85', fillColor: '#0b73ff', fillOpacity: 1, weight: 2 }).addTo(state.routeLayer); | |
| L.circleMarker(last, { radius: 5, color: '#003b85', fillColor: '#0b73ff', fillOpacity: 1, weight: 2 }).addTo(state.routeLayer); | |
| } | |
| for (const wp of state.mapFeatures.waypoints) { | |
| const marker = L.marker([wp.lat, wp.lon], { | |
| icon: L.divIcon({ | |
| className: 'grt-waypoint-wrap', | |
| iconSize: [30, 30], | |
| iconAnchor: [11, 28], | |
| popupAnchor: [0, -28], | |
| html: `<div class="grt-waypoint-flag">⚑</div><div class="grt-waypoint-label">${escapeHtml(wp.name)}</div>`, | |
| }), | |
| title: wp.name, | |
| }).addTo(state.waypointLayer); | |
| marker.bindPopup(`<b>Waypoint</b><br>${escapeHtml(wp.name)}<br>${wp.lat.toFixed(6)}, ${wp.lon.toFixed(6)}`); | |
| } | |
| } | |
| function toggleMapFeatures() { | |
| if (!state.map || !state.featureLayers) return; | |
| state.featuresVisible = !state.featuresVisible; | |
| if (state.featuresVisible) state.featureLayers.addTo(state.map); | |
| else state.map.removeLayer(state.featureLayers); | |
| updateFeatureButton(); | |
| } | |
| function updateFeatureButton() { | |
| const btn = state.overlay && state.overlay.querySelector('[data-action="features"]'); | |
| if (!btn) return; | |
| const w = state.mapFeatures.waypoints.length; | |
| const r = state.mapFeatures.routes.length; | |
| btn.textContent = state.featuresVisible ? `Features ${w}/${r}` : 'Features off'; | |
| btn.title = `${w} waypoints, ${r} routes. Garmin track geometry is not exposed by the simple public JSON endpoints; the blue line uses any history present in the KML feed.`; | |
| } | |
| function parseKml(xmlText) { | |
| const doc = new DOMParser().parseFromString(xmlText, 'application/xml'); | |
| if (doc.querySelector('parsererror')) throw new Error('Garmin feed XML parse error.'); | |
| const placemarks = Array.from(doc.getElementsByTagNameNS('*', 'Placemark')); | |
| const points = placemarks.map(parsePlacemark).filter(Boolean); | |
| points.sort((a, b) => { | |
| const at = a.utcMs || 0; | |
| const bt = b.utcMs || 0; | |
| if (at !== bt) return bt - at; | |
| return (Number(b.id) || 0) - (Number(a.id) || 0); | |
| }); | |
| const latest = points[0] || null; | |
| if (latest) latest.history = dedupeLatLon(points.slice().reverse()); | |
| return latest; | |
| } | |
| function parsePlacemark(pm) { | |
| const data = {}; | |
| for (const d of Array.from(pm.getElementsByTagNameNS('*', 'Data'))) { | |
| const name = d.getAttribute('name'); | |
| const valueEl = Array.from(d.childNodes).find((n) => n.localName === 'value'); | |
| if (name && valueEl) data[name] = valueEl.textContent.trim(); | |
| } | |
| const coordEl = Array.from(pm.getElementsByTagNameNS('*', 'coordinates'))[0]; | |
| const coordText = coordEl ? coordEl.textContent.trim().split(/\s+/)[0] : ''; | |
| const coord = coordText ? coordText.split(',').map(Number) : []; | |
| const lon = Number(data.Longitude ?? coord[0]); | |
| const lat = Number(data.Latitude ?? coord[1]); | |
| const ele = Number.parseFloat(data.Elevation || coord[2] || ''); | |
| if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null; | |
| return { | |
| id: data.Id || '', | |
| name: data['Map Display Name'] || data.Name || textOf(pm, 'name') || cfg.mapName, | |
| lat, | |
| lon, | |
| ele: Number.isFinite(ele) ? ele : null, | |
| speedText: data.Velocity || '—', | |
| speedKmh: Number.parseFloat(data.Velocity || ''), | |
| courseText: data.Course || '—', | |
| courseDeg: Number.parseFloat(data.Course || ''), | |
| time: data.Time || '', | |
| timeUtc: data['Time UTC'] || '', | |
| utcMs: parseGarminUtc(data['Time UTC']), | |
| gpsFix: data['Valid GPS Fix'] || '', | |
| emergency: data['In Emergency'] || '', | |
| text: data.Text || data.Note || '', | |
| event: data.Event || '', | |
| }; | |
| } | |
| function textOf(el, localName) { | |
| const found = Array.from(el.childNodes).find((n) => n.localName === localName); | |
| return found ? found.textContent.trim() : ''; | |
| } | |
| function parseGarminUtc(s) { | |
| if (!s) return 0; | |
| const m = s.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})\s+(\d{1,2}):(\d{2}):(\d{2})\s*([AP]M)$/i); | |
| if (!m) return Date.parse(`${s} UTC`) || Date.parse(s) || 0; | |
| let [, mo, da, yr, hr, mi, se, ap] = m; | |
| let h = Number(hr) % 12; | |
| if (ap.toUpperCase() === 'PM') h += 12; | |
| return Date.UTC(Number(yr), Number(mo) - 1, Number(da), h, Number(mi), Number(se)); | |
| } | |
| function updateRacerLayer() { | |
| const r = state.racer; | |
| const L = window.L; | |
| const ll = [r.lat, r.lon]; | |
| const icon = L.divIcon({ | |
| className: 'grt-racer-icon-wrap', | |
| iconSize: [38, 38], | |
| iconAnchor: [19, 19], | |
| popupAnchor: [0, -20], | |
| html: `<div class="grt-racer-icon"><div class="grt-racer-arrow" style="transform:rotate(${Number.isFinite(r.courseDeg) ? r.courseDeg : 0}deg);opacity:${Number.isFinite(r.courseDeg) ? 1 : 0.25}"></div></div>`, | |
| }); | |
| if (!state.racerMarker) { | |
| state.racerMarker = L.marker(ll, { icon, zIndexOffset: 4000, title: r.name }).addTo(state.layers); | |
| } else { | |
| state.racerMarker.setLatLng(ll); | |
| state.racerMarker.setIcon(icon); | |
| } | |
| state.racerMarker.bindPopup(racerPopup(r)); | |
| updateRacerTrail(); | |
| updateConnector(); | |
| } | |
| function updateRacerTrail() { | |
| if (!state.racerTrail || !state.racer || !state.racer.history) return; | |
| state.racerTrail.setLatLngs(state.racer.history.map((p) => [p.lat, p.lon])); | |
| } | |
| function dedupeLatLon(points) { | |
| const out = []; | |
| let prev = null; | |
| for (const p of points) { | |
| if (!prev || Math.abs(prev.lat - p.lat) > 1e-7 || Math.abs(prev.lon - p.lon) > 1e-7) out.push(p); | |
| prev = p; | |
| } | |
| return out; | |
| } | |
| function startOwnLocation() { | |
| if (cfg.mockMe) return startMockMe(); | |
| if (!navigator.geolocation) { | |
| setText('range', 'no GPS'); | |
| return; | |
| } | |
| state.geoWatch = navigator.geolocation.watchPosition(onMePosition, onMeError, { | |
| enableHighAccuracy: true, | |
| maximumAge: 1000, | |
| timeout: 15000, | |
| }); | |
| } | |
| function startMockMe() { | |
| const base = { lat: -18.181, lon: -40.735 }; | |
| let t = 0; | |
| state.mockTimer = setInterval(() => { | |
| t += 1; | |
| onMe({ | |
| lat: base.lat + Math.sin(t / 20) * 0.004, | |
| lon: base.lon + Math.cos(t / 20) * 0.004, | |
| accuracy: 12, | |
| heading: (t * 8) % 360, | |
| speed: 6, | |
| timestamp: Date.now(), | |
| }); | |
| }, 1000); | |
| } | |
| function onMePosition(pos) { | |
| const c = pos.coords; | |
| const next = { | |
| lat: c.latitude, | |
| lon: c.longitude, | |
| accuracy: Number.isFinite(c.accuracy) ? c.accuracy : null, | |
| heading: Number.isFinite(c.heading) ? c.heading : null, | |
| speed: Number.isFinite(c.speed) ? c.speed : null, | |
| timestamp: pos.timestamp || Date.now(), | |
| }; | |
| if (next.heading == null && state.me) { | |
| const moved = distanceM(state.me.lat, state.me.lon, next.lat, next.lon); | |
| if (moved > 3) next.heading = bearingDeg(state.me.lat, state.me.lon, next.lat, next.lon); | |
| } | |
| onMe(next); | |
| } | |
| function onMeError(err) { | |
| setText('range', 'GPS denied'); | |
| const msg = err && err.message ? err.message : String(err || 'GPS error'); | |
| const info = state.racer ? infoText(state.racer) : ''; | |
| setText('info', `${info}${info ? ' · ' : ''}Phone GPS: ${msg}`); | |
| } | |
| function onMe(next) { | |
| state.me = next; | |
| const L = window.L; | |
| const ll = [next.lat, next.lon]; | |
| const icon = L.divIcon({ | |
| className: 'grt-me-icon-wrap', | |
| iconSize: [34, 34], | |
| iconAnchor: [17, 17], | |
| popupAnchor: [0, -18], | |
| html: `<div class="grt-me-icon"><div class="grt-me-arrow" style="transform:rotate(${Number.isFinite(next.heading) ? next.heading : 0}deg);opacity:${Number.isFinite(next.heading) ? 1 : 0.25}"></div></div>`, | |
| }); | |
| if (!state.meMarker) { | |
| state.meMarker = L.marker(ll, { icon, zIndexOffset: 5000, title: 'Me' }).addTo(state.layers); | |
| } else { | |
| state.meMarker.setLatLng(ll); | |
| state.meMarker.setIcon(icon); | |
| } | |
| state.meMarker.bindPopup(mePopup(next)); | |
| if (!state.accuracyCircle) { | |
| state.accuracyCircle = L.circle(ll, accuracyStyle(next.accuracy)).addTo(state.layers); | |
| } else { | |
| state.accuracyCircle.setLatLng(ll); | |
| state.accuracyCircle.setRadius(next.accuracy || 0); | |
| } | |
| updateConnector(); | |
| updatePanel(); | |
| maybeInitialFit(); | |
| } | |
| function accuracyStyle(accuracy) { | |
| return { radius: accuracy || 0, color: '#1a73e8', weight: 2, opacity: 0.65, fillColor: '#1a73e8', fillOpacity: 0.12, interactive: false }; | |
| } | |
| function updateConnector() { | |
| if (!state.connector) return; | |
| if (state.racer && state.me) state.connector.setLatLngs([[state.me.lat, state.me.lon], [state.racer.lat, state.racer.lon]]); | |
| else state.connector.setLatLngs([]); | |
| } | |
| function updatePanel() { | |
| const r = state.racer; | |
| if (!r) return; | |
| const stale = staleMinutes(r); | |
| const staleText = stale != null && stale > cfg.staleAfterMin ? `stale ${Math.round(stale)} min` : 'live'; | |
| setText('racer-status', r.gpsFix === 'False' ? 'no GPS fix' : staleText); | |
| setText('speed', r.speedText || '—'); | |
| setText('course', r.courseText || '—'); | |
| if (state.me) { | |
| const d = distanceM(state.me.lat, state.me.lon, r.lat, r.lon); | |
| const b = bearingDeg(state.me.lat, state.me.lon, r.lat, r.lon); | |
| setText('range', `${formatDistance(d)} · ${Math.round(b)}°`); | |
| } else { | |
| setText('range', 'waiting GPS'); | |
| } | |
| setText('info', infoText(r)); | |
| } | |
| function infoText(r) { | |
| const parts = []; | |
| parts.push(`Updated: ${r.time || r.timeUtc || '—'}`); | |
| if (r.ele != null) parts.push(`Elev: ${Math.round(r.ele)} m`); | |
| if (r.gpsFix) parts.push(`GPS fix: ${r.gpsFix}`); | |
| if (r.emergency === 'True') parts.push('EMERGENCY'); | |
| if (r.text) parts.push(`Text: ${r.text}`); | |
| return parts.join(' · '); | |
| } | |
| function updateLinks() { | |
| if (!state.racer) return; | |
| const { lat, lon } = state.racer; | |
| const google = state.overlay.querySelector('[data-grt="google"]'); | |
| const osm = state.overlay.querySelector('[data-grt="osm"]'); | |
| google.href = `https://www.google.com/maps/dir/?api=1&destination=${lat},${lon}`; | |
| osm.href = `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lon}#map=16/${lat}/${lon}`; | |
| } | |
| function racerPopup(r) { | |
| return `<b>${escapeHtml(r.name)}</b><br>${r.lat.toFixed(6)}, ${r.lon.toFixed(6)}<br>Speed: ${escapeHtml(r.speedText)}<br>Course: ${escapeHtml(r.courseText)}<br>Updated: ${escapeHtml(r.time || r.timeUtc || '—')}`; | |
| } | |
| function mePopup(m) { | |
| const speed = m.speed == null ? '—' : `${(m.speed * 3.6).toFixed(1)} km/h`; | |
| const acc = m.accuracy == null ? '—' : `±${Math.round(m.accuracy)} m`; | |
| return `<b>Me</b><br>${m.lat.toFixed(6)}, ${m.lon.toFixed(6)}<br>Accuracy: ${acc}<br>Speed: ${speed}`; | |
| } | |
| function maybeInitialFit() { | |
| if (!cfg.autoFit || state.firstFitDone || !state.racer) return; | |
| if (state.me) { | |
| fitBoth(); | |
| state.firstFitDone = true; | |
| } else { | |
| centerRacer(); | |
| } | |
| } | |
| function fitBoth() { | |
| if (!state.map || !state.racer) return; | |
| if (state.me) { | |
| state.map.fitBounds([[state.me.lat, state.me.lon], [state.racer.lat, state.racer.lon]], { padding: [40, 40], maxZoom: 16 }); | |
| } else { | |
| centerRacer(); | |
| } | |
| } | |
| function centerRacer() { | |
| if (state.map && state.racer) state.map.setView([state.racer.lat, state.racer.lon], Math.max(state.map.getZoom(), 15)); | |
| } | |
| function centerMe() { | |
| if (state.map && state.me) state.map.setView([state.me.lat, state.me.lon], Math.max(state.map.getZoom(), 15)); | |
| } | |
| function destroy() { | |
| state.closed = true; | |
| if (state.refreshTimer) clearInterval(state.refreshTimer); | |
| if (state.geoWatch != null && navigator.geolocation) navigator.geolocation.clearWatch(state.geoWatch); | |
| if (state.mockTimer) clearInterval(state.mockTimer); | |
| if (state.map) state.map.remove(); | |
| if (state.overlay) state.overlay.remove(); | |
| document.getElementById('grt-style')?.remove(); | |
| delete window[GLOBAL]; | |
| } | |
| function setText(key, value) { | |
| const el = state.overlay && state.overlay.querySelector(`[data-grt="${key}"]`); | |
| if (el) el.textContent = value; | |
| } | |
| function addCacheBust(url) { | |
| const join = url.includes('?') ? '&' : '?'; | |
| return `${url}${join}_=${Date.now()}`; | |
| } | |
| function staleMinutes(r) { | |
| if (!r.utcMs) return null; | |
| return (Date.now() - r.utcMs) / 60000; | |
| } | |
| function formatDistance(m) { | |
| return m < 1000 ? `${Math.round(m)} m` : `${(m / 1000).toFixed(m < 10000 ? 1 : 0)} km`; | |
| } | |
| function distanceM(lat1, lon1, lat2, lon2) { | |
| const R = 6371000; | |
| const p1 = lat1 * Math.PI / 180; | |
| const p2 = lat2 * Math.PI / 180; | |
| const dp = (lat2 - lat1) * Math.PI / 180; | |
| const dl = (lon2 - lon1) * Math.PI / 180; | |
| const a = Math.sin(dp / 2) ** 2 + Math.cos(p1) * Math.cos(p2) * Math.sin(dl / 2) ** 2; | |
| return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); | |
| } | |
| function bearingDeg(lat1, lon1, lat2, lon2) { | |
| const p1 = lat1 * Math.PI / 180; | |
| const p2 = lat2 * Math.PI / 180; | |
| const dl = (lon2 - lon1) * Math.PI / 180; | |
| const y = Math.sin(dl) * Math.cos(p2); | |
| const x = Math.cos(p1) * Math.sin(p2) - Math.sin(p1) * Math.cos(p2) * Math.cos(dl); | |
| return (Math.atan2(y, x) * 180 / Math.PI + 360) % 360; | |
| } | |
| function escapeHtml(value) { | |
| return String(value ?? '').replace(/[&<>"']/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch])); | |
| } | |
| function injectCss() { | |
| if (document.getElementById('grt-style')) return; | |
| const style = document.createElement('style'); | |
| style.id = 'grt-style'; | |
| style.textContent = ` | |
| #grt-overlay{position:fixed;inset:0;z-index:2147483647;background:#0b1020;color:#fff;font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;display:flex;flex-direction:column} | |
| #grt-overlay *{box-sizing:border-box}.grt-top{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 14px;background:#111827;border-bottom:1px solid #2b3447}.grt-title{font-size:20px;font-weight:800}.grt-subtitle{font-size:12px;color:#cbd5e1}.grt-close{border:0;background:transparent;color:#fff;font-size:34px;line-height:1;padding:0 6px;cursor:pointer} | |
| .grt-stats{display:grid;grid-template-columns:repeat(4,1fr);background:#172554;border-bottom:1px solid #2b3447;text-align:center}.grt-stats div{padding:9px 6px;border-right:1px solid #2b3447}.grt-stats div:last-child{border-right:0}.grt-stats span{display:block;color:#93a4bd;font-size:11px;text-transform:uppercase;letter-spacing:.04em}.grt-stats b{display:block;font-size:15px;margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} | |
| .grt-actions{display:flex;gap:6px;padding:8px;background:#0f172a;border-bottom:1px solid #2b3447;overflow-x:auto}.grt-actions button,.grt-actions a{border:1px solid #475569;background:#1e293b;color:#fff;border-radius:7px;padding:8px 10px;text-decoration:none;font:700 13px system-ui;white-space:nowrap}.grt-actions a{background:#2563eb}.grt-info{padding:7px 10px;background:#111827;color:#d1d5db;font-size:12px;border-bottom:1px solid #2b3447;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}#grt-map{flex:1;min-height:260px;background:#1f2937;color:#111} | |
| .grt-racer-icon-wrap,.grt-me-icon-wrap{background:transparent;border:0}.grt-racer-icon,.grt-me-icon{position:relative;border-radius:50%;border:3px solid #fff;box-shadow:0 1px 8px rgba(0,0,0,.65)}.grt-racer-icon{width:32px;height:32px;background:#e03131}.grt-me-icon{width:28px;height:28px;background:#1a73e8}.grt-racer-icon:after,.grt-me-icon:after{content:"";position:absolute;border-radius:50%;background:#fff}.grt-racer-icon:after{left:10px;top:10px;width:6px;height:6px}.grt-me-icon:after{left:8px;top:8px;width:6px;height:6px}.grt-racer-arrow,.grt-me-arrow{position:absolute;width:0;height:0;border-left:5px solid transparent;border-right:5px solid transparent;filter:drop-shadow(0 1px 1px rgba(0,0,0,.5))}.grt-racer-arrow{left:8px;top:-15px;border-bottom:18px solid #e03131;transform-origin:50% 31px}.grt-me-arrow{left:7px;top:-13px;border-bottom:16px solid #1a73e8;transform-origin:50% 27px} | |
| .grt-waypoint-wrap{background:transparent;border:0;overflow:visible!important}.grt-waypoint-flag{font-size:27px;line-height:27px;color:#111;text-shadow:-1px -1px 0 #fff,1px -1px 0 #fff,-1px 1px 0 #fff,1px 1px 0 #fff,0 2px 5px rgba(0,0,0,.5)}.grt-waypoint-label{position:absolute;left:17px;top:8px;background:rgba(255,255,255,.86);color:#111;border-radius:4px;padding:1px 4px;font:700 12px system-ui;white-space:nowrap;box-shadow:0 1px 4px rgba(0,0,0,.25)} | |
| @media(max-width:620px){.grt-stats{grid-template-columns:repeat(2,1fr)}.grt-title{font-size:18px}.grt-stats b{font-size:14px}.grt-waypoint-label{font-size:11px}} | |
| `; | |
| document.head.appendChild(style); | |
| } | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment