import mapboxgl from 'mapbox-gl' import type { GeoPosition } from '../../hooks/useGeolocation' // Build the DOM element that backs the mapbox Marker. We animate the // heading cone via a CSS rotation so the DOM stays stable across updates // and mapbox doesn't get confused about which element to position. function buildLocationEl(): { root: HTMLDivElement; cone: HTMLDivElement } { const root = document.createElement('div') root.style.cssText = 'width:28px;height:28px;position:relative;pointer-events:none;' // Accuracy pulse behind the dot const pulse = document.createElement('div') pulse.style.cssText = ` position:absolute;inset:-14px;border-radius:50%; background:#3b82f6;opacity:0.25; animation:trek-location-pulse 2s ease-out infinite; ` // Heading cone (conic gradient fan) const cone = document.createElement('div') cone.style.cssText = ` position:absolute;left:50%;top:50%;width:60px;height:60px; transform:translate(-50%,-50%) rotate(0deg); background:conic-gradient(from -30deg, rgba(59,130,246,0) 0deg, rgba(59,130,246,0.35) 15deg, rgba(59,130,246,0) 60deg, rgba(59,130,246,0) 360deg); border-radius:50%; mask:radial-gradient(circle, transparent 12px, black 13px); -webkit-mask:radial-gradient(circle, transparent 12px, black 13px); transition:transform 0.12s ease-out; display:none; ` // Blue dot const dot = document.createElement('div') dot.style.cssText = ` position:absolute;left:50%;top:50%; transform:translate(-50%,-50%); width:18px;height:18px;border-radius:50%; background:#3b82f6;border:3px solid white; box-shadow:0 0 0 1px rgba(0,0,0,0.15), 0 2px 6px rgba(0,0,0,0.3); ` root.appendChild(pulse) root.appendChild(cone) root.appendChild(dot) return { root, cone } } // Inject the pulse keyframes once per document so the animation is // available for every map instance. function ensurePulseStyle() { if (document.getElementById('trek-location-style')) return const s = document.createElement('style') s.id = 'trek-location-style' s.textContent = ` @keyframes trek-location-pulse { 0% { transform: scale(0.6); opacity: 0.35; } 70% { transform: scale(1.6); opacity: 0; } 100% { transform: scale(1.6); opacity: 0; } } ` document.head.appendChild(s) } export interface LocationMarkerHandle { update: (p: GeoPosition | null) => void destroy: () => void } // Creates (or reuses) a location marker + accuracy circle on the given // mapbox map. Returns a handle the caller uses to push position updates // and clean up. Keeps its own DOM element and GeoJSON source so it can // coexist with the regular trip markers. export function attachLocationMarker(map: mapboxgl.Map): LocationMarkerHandle { ensurePulseStyle() const { root, cone } = buildLocationEl() const marker = new mapboxgl.Marker({ element: root, anchor: 'center' }) const ensureAccuracyLayer = () => { if (map.getSource('trek-location-accuracy')) return try { map.addSource('trek-location-accuracy', { type: 'geojson', data: { type: 'FeatureCollection', features: [] }, }) // Draw the accuracy ring as a geographic polygon: it's a real geodesic // circle defined in meters, so mapbox automatically scales it with // zoom the way Apple/Google Maps does — always the same real-world // size regardless of viewport. map.addLayer({ id: 'trek-location-accuracy', type: 'fill', source: 'trek-location-accuracy', paint: { 'fill-color': '#3b82f6', 'fill-opacity': 0.14, 'fill-outline-color': '#3b82f6', }, }) } catch { /* noop */ } } // Build a polygon approximating a geodesic circle around (lng, lat) // with the given radius in meters. 48 segments is plenty for a smooth // edge without paying much CPU per fix. const geodesicCircle = (lng: number, lat: number, radiusMeters: number): number[][] => { const earth = 6378137 const d = radiusMeters / earth const lat1 = lat * Math.PI / 180 const lng1 = lng * Math.PI / 180 const coords: number[][] = [] const segments = 48 for (let i = 0; i <= segments; i++) { const bearing = (i / segments) * 2 * Math.PI const lat2 = Math.asin(Math.sin(lat1) * Math.cos(d) + Math.cos(lat1) * Math.sin(d) * Math.cos(bearing)) const lng2 = lng1 + Math.atan2( Math.sin(bearing) * Math.sin(d) * Math.cos(lat1), Math.cos(d) - Math.sin(lat1) * Math.sin(lat2), ) coords.push([lng2 * 180 / Math.PI, lat2 * 180 / Math.PI]) } return coords } const setAccuracy = (p: GeoPosition) => { const src = map.getSource('trek-location-accuracy') as mapboxgl.GeoJSONSource | undefined if (!src) return if (!p.accuracy || p.accuracy < 1) { src.setData({ type: 'FeatureCollection', features: [] }) return } const ring = geodesicCircle(p.lng, p.lat, p.accuracy) src.setData({ type: 'FeatureCollection', features: [{ type: 'Feature', properties: {}, geometry: { type: 'Polygon', coordinates: [ring] }, }], }) } let lastPosRef: GeoPosition | null = null if (map.loaded()) ensureAccuracyLayer() else map.once('load', ensureAccuracyLayer) const handle: LocationMarkerHandle = { update: (p) => { lastPosRef = p if (!p) { marker.remove() const src = map.getSource('trek-location-accuracy') as mapboxgl.GeoJSONSource | undefined src?.setData({ type: 'FeatureCollection', features: [] }) return } marker.setLngLat([p.lng, p.lat]) if (!marker.getElement().parentElement) marker.addTo(map) if (p.heading !== null && !Number.isNaN(p.heading)) { cone.style.display = 'block' cone.style.transform = `translate(-50%,-50%) rotate(${p.heading}deg)` } else { cone.style.display = 'none' } setAccuracy(p) }, destroy: () => { try { marker.remove() } catch { /* noop */ } try { if (map.getLayer('trek-location-accuracy')) map.removeLayer('trek-location-accuracy') if (map.getSource('trek-location-accuracy')) map.removeSource('trek-location-accuracy') } catch { /* noop */ } }, } return handle }