File size: 6,326 Bytes
766d85d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
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
}