gpsLayer.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290
  1. import { calibratedLonLatToWorldTile } from '../../utils/projection'
  2. import { worldToScreen, type CameraState } from '../camera/camera'
  3. import { type MapLayer, type LayerRenderContext } from './mapLayer'
  4. import { type MapScene } from '../renderer/mapRenderer'
  5. import { type ScreenPoint } from './trackLayer'
  6. function getGpsMarkerMetrics(size: MapScene['gpsMarkerStyleConfig']['size']) {
  7. if (size === 'small') {
  8. return {
  9. coreRadius: 7,
  10. ringRadius: 8.35,
  11. pulseRadius: 14,
  12. indicatorOffset: 1.1,
  13. indicatorSize: 7,
  14. ringWidth: 2.5,
  15. }
  16. }
  17. if (size === 'large') {
  18. return {
  19. coreRadius: 11,
  20. ringRadius: 12.95,
  21. pulseRadius: 22,
  22. indicatorOffset: 1.45,
  23. indicatorSize: 10,
  24. ringWidth: 3.5,
  25. }
  26. }
  27. return {
  28. coreRadius: 9,
  29. ringRadius: 10.65,
  30. pulseRadius: 18,
  31. indicatorOffset: 1.25,
  32. indicatorSize: 8.5,
  33. ringWidth: 3,
  34. }
  35. }
  36. function scaleGpsMarkerMetrics(
  37. metrics: ReturnType<typeof getGpsMarkerMetrics>,
  38. effectScale: number,
  39. ): ReturnType<typeof getGpsMarkerMetrics> {
  40. const safeScale = Math.max(0.88, Math.min(1.28, effectScale || 1))
  41. return {
  42. coreRadius: metrics.coreRadius * safeScale,
  43. ringRadius: metrics.ringRadius * safeScale,
  44. pulseRadius: metrics.pulseRadius * safeScale,
  45. indicatorOffset: metrics.indicatorOffset * safeScale,
  46. indicatorSize: metrics.indicatorSize * safeScale,
  47. ringWidth: Math.max(2, metrics.ringWidth * (0.96 + (safeScale - 1) * 0.35)),
  48. }
  49. }
  50. function rotatePoint(x: number, y: number, angleRad: number): { x: number; y: number } {
  51. const cos = Math.cos(angleRad)
  52. const sin = Math.sin(angleRad)
  53. return {
  54. x: x * cos - y * sin,
  55. y: x * sin + y * cos,
  56. }
  57. }
  58. function hexToRgbTuple(hex: string): [number, number, number] {
  59. const safeHex = /^#[0-9a-fA-F]{6}$/.test(hex) ? hex : '#ffffff'
  60. return [
  61. parseInt(safeHex.slice(1, 3), 16),
  62. parseInt(safeHex.slice(3, 5), 16),
  63. parseInt(safeHex.slice(5, 7), 16),
  64. ]
  65. }
  66. function getGpsPulsePhase(
  67. pulseFrame: number,
  68. motionState: MapScene['gpsMarkerStyleConfig']['motionState'],
  69. ): number {
  70. const divisor = motionState === 'idle'
  71. ? 11.5
  72. : motionState === 'moving'
  73. ? 6.2
  74. : motionState === 'fast-moving'
  75. ? 4.3
  76. : 4.8
  77. return 0.5 + 0.5 * Math.sin(pulseFrame / divisor)
  78. }
  79. function getAnimatedPulseRadius(
  80. pulseFrame: number,
  81. metrics: ReturnType<typeof getGpsMarkerMetrics>,
  82. motionState: MapScene['gpsMarkerStyleConfig']['motionState'],
  83. pulseStrength: number,
  84. motionIntensity: number,
  85. ): number {
  86. const phase = getGpsPulsePhase(pulseFrame, motionState)
  87. const baseRadius = motionState === 'idle'
  88. ? metrics.pulseRadius * 0.82
  89. : motionState === 'moving'
  90. ? metrics.pulseRadius * 0.94
  91. : motionState === 'fast-moving'
  92. ? metrics.pulseRadius * 1.04
  93. : metrics.pulseRadius
  94. const amplitude = motionState === 'idle'
  95. ? metrics.pulseRadius * 0.12
  96. : motionState === 'moving'
  97. ? metrics.pulseRadius * 0.18
  98. : motionState === 'fast-moving'
  99. ? metrics.pulseRadius * 0.24
  100. : metrics.pulseRadius * 0.2
  101. return baseRadius + amplitude * phase * (0.8 + pulseStrength * 0.18 + motionIntensity * 0.1)
  102. }
  103. function buildVectorCamera(scene: MapScene): CameraState {
  104. return {
  105. centerWorldX: scene.exactCenterWorldX,
  106. centerWorldY: scene.exactCenterWorldY,
  107. viewportWidth: scene.viewportWidth,
  108. viewportHeight: scene.viewportHeight,
  109. visibleColumns: scene.visibleColumns,
  110. rotationRad: scene.rotationRad,
  111. }
  112. }
  113. export class GpsLayer implements MapLayer {
  114. projectPoint(scene: MapScene): ScreenPoint | null {
  115. if (!scene.gpsPoint) {
  116. return null
  117. }
  118. const camera = buildVectorCamera(scene)
  119. const worldPoint = calibratedLonLatToWorldTile(scene.gpsPoint, scene.zoom, scene.gpsCalibration, scene.gpsCalibrationOrigin)
  120. return worldToScreen(camera, worldPoint, false)
  121. }
  122. getPulseRadius(pulseFrame: number): number {
  123. return 18 + 6 * (0.5 + 0.5 * Math.sin(pulseFrame / 6))
  124. }
  125. draw(context: LayerRenderContext): void {
  126. const { ctx, scene, pulseFrame } = context
  127. if (!scene.gpsMarkerStyleConfig.visible) {
  128. return
  129. }
  130. const gpsScreenPoint = this.projectPoint(scene)
  131. if (!gpsScreenPoint) {
  132. return
  133. }
  134. const metrics = scaleGpsMarkerMetrics(
  135. getGpsMarkerMetrics(scene.gpsMarkerStyleConfig.size),
  136. scene.gpsMarkerStyleConfig.effectScale || 1,
  137. )
  138. const style = scene.gpsMarkerStyleConfig.style
  139. const hasBadgeLogo = style === 'badge' && !!scene.gpsMarkerStyleConfig.logoUrl
  140. const pulseStrength = Math.max(0.45, Math.min(1.85, scene.gpsMarkerStyleConfig.pulseStrength || 1))
  141. const motionState = scene.gpsMarkerStyleConfig.motionState || 'idle'
  142. const motionIntensity = Math.max(0, Math.min(1.2, scene.gpsMarkerStyleConfig.motionIntensity || 0))
  143. const wakeStrength = Math.max(0, Math.min(1, scene.gpsMarkerStyleConfig.wakeStrength || 0))
  144. const warningGlowStrength = Math.max(0, Math.min(1, scene.gpsMarkerStyleConfig.warningGlowStrength || 0))
  145. const indicatorScale = Math.max(0.86, Math.min(1.28, scene.gpsMarkerStyleConfig.indicatorScale || 1))
  146. const pulse = style === 'dot'
  147. ? metrics.pulseRadius * 0.82
  148. : getAnimatedPulseRadius(pulseFrame, metrics, motionState, pulseStrength, motionIntensity)
  149. const [markerR, markerG, markerB] = hexToRgbTuple(scene.gpsMarkerStyleConfig.colorHex)
  150. ctx.save()
  151. if (wakeStrength > 0.05 && scene.gpsHeadingDeg !== null) {
  152. const headingScreenRad = (scene.gpsHeadingDeg * Math.PI / 180) - scene.rotationRad
  153. const wakeHeadingRad = headingScreenRad + Math.PI
  154. const wakeCount = motionState === 'fast-moving' ? 3 : 2
  155. for (let index = 0; index < wakeCount; index += 1) {
  156. const offset = metrics.coreRadius * (0.85 + index * 0.64) * (0.9 + wakeStrength * 0.72)
  157. const center = rotatePoint(0, -offset, wakeHeadingRad)
  158. const radius = metrics.coreRadius * Math.max(0.22, 0.58 - index * 0.12 + wakeStrength * 0.08)
  159. const alpha = Math.max(0.06, (0.14 + wakeStrength * 0.12) * (1 - index * 0.26))
  160. ctx.beginPath()
  161. ctx.fillStyle = `rgba(${markerR}, ${markerG}, ${markerB}, ${alpha})`
  162. ctx.arc(gpsScreenPoint.x + center.x, gpsScreenPoint.y + center.y, radius, 0, Math.PI * 2)
  163. ctx.fill()
  164. }
  165. }
  166. if (warningGlowStrength > 0.04) {
  167. const glowPhase = getGpsPulsePhase(pulseFrame, motionState)
  168. ctx.beginPath()
  169. ctx.strokeStyle = `rgba(${markerR}, ${markerG}, ${markerB}, ${0.18 + warningGlowStrength * 0.18})`
  170. ctx.lineWidth = Math.max(2, metrics.ringWidth * (1.04 + warningGlowStrength * 0.2))
  171. ctx.arc(
  172. gpsScreenPoint.x,
  173. gpsScreenPoint.y,
  174. metrics.ringRadius * (1.18 + warningGlowStrength * 0.12 + glowPhase * 0.04),
  175. 0,
  176. Math.PI * 2,
  177. )
  178. ctx.stroke()
  179. }
  180. if (style === 'beacon' || (style === 'badge' && !hasBadgeLogo)) {
  181. ctx.beginPath()
  182. const pulseAlpha = style === 'badge'
  183. ? Math.min(0.2, 0.08 + pulseStrength * 0.06)
  184. : Math.min(0.26, 0.1 + pulseStrength * 0.08)
  185. ctx.fillStyle = `rgba(255, 255, 255, ${pulseAlpha})`
  186. ctx.arc(gpsScreenPoint.x, gpsScreenPoint.y, pulse, 0, Math.PI * 2)
  187. ctx.fill()
  188. }
  189. if (style === 'dot') {
  190. ctx.beginPath()
  191. ctx.fillStyle = scene.gpsMarkerStyleConfig.colorHex
  192. ctx.arc(gpsScreenPoint.x, gpsScreenPoint.y, metrics.coreRadius * 0.82, 0, Math.PI * 2)
  193. ctx.fill()
  194. ctx.beginPath()
  195. ctx.strokeStyle = scene.gpsMarkerStyleConfig.ringColorHex
  196. ctx.lineWidth = metrics.ringWidth
  197. ctx.arc(gpsScreenPoint.x, gpsScreenPoint.y, metrics.coreRadius + metrics.ringWidth * 0.3, 0, Math.PI * 2)
  198. ctx.stroke()
  199. } else if (style === 'disc') {
  200. ctx.beginPath()
  201. ctx.strokeStyle = scene.gpsMarkerStyleConfig.ringColorHex
  202. ctx.lineWidth = metrics.ringWidth * 1.18
  203. ctx.arc(gpsScreenPoint.x, gpsScreenPoint.y, metrics.ringRadius * 1.05, 0, Math.PI * 2)
  204. ctx.stroke()
  205. ctx.beginPath()
  206. ctx.fillStyle = scene.gpsMarkerStyleConfig.colorHex
  207. ctx.arc(gpsScreenPoint.x, gpsScreenPoint.y, metrics.coreRadius * 1.02, 0, Math.PI * 2)
  208. ctx.fill()
  209. ctx.beginPath()
  210. ctx.fillStyle = 'rgba(255, 255, 255, 0.96)'
  211. ctx.arc(gpsScreenPoint.x, gpsScreenPoint.y, metrics.coreRadius * 0.22, 0, Math.PI * 2)
  212. ctx.fill()
  213. } else if (style === 'badge') {
  214. ctx.beginPath()
  215. ctx.strokeStyle = 'rgba(255, 255, 255, 0.98)'
  216. ctx.lineWidth = metrics.ringWidth * 1.12
  217. ctx.arc(gpsScreenPoint.x, gpsScreenPoint.y, metrics.ringRadius * 1.06, 0, Math.PI * 2)
  218. ctx.stroke()
  219. ctx.beginPath()
  220. ctx.fillStyle = scene.gpsMarkerStyleConfig.colorHex
  221. ctx.arc(gpsScreenPoint.x, gpsScreenPoint.y, metrics.coreRadius * 0.98, 0, Math.PI * 2)
  222. ctx.fill()
  223. if (!hasBadgeLogo) {
  224. ctx.beginPath()
  225. ctx.fillStyle = 'rgba(255, 255, 255, 0.16)'
  226. ctx.arc(gpsScreenPoint.x - metrics.coreRadius * 0.16, gpsScreenPoint.y - metrics.coreRadius * 0.22, metrics.coreRadius * 0.18, 0, Math.PI * 2)
  227. ctx.fill()
  228. }
  229. } else {
  230. ctx.beginPath()
  231. ctx.fillStyle = scene.gpsMarkerStyleConfig.colorHex
  232. ctx.arc(gpsScreenPoint.x, gpsScreenPoint.y, metrics.coreRadius, 0, Math.PI * 2)
  233. ctx.fill()
  234. ctx.beginPath()
  235. ctx.strokeStyle = scene.gpsMarkerStyleConfig.ringColorHex
  236. ctx.lineWidth = metrics.ringWidth
  237. ctx.arc(gpsScreenPoint.x, gpsScreenPoint.y, metrics.ringRadius, 0, Math.PI * 2)
  238. ctx.stroke()
  239. ctx.beginPath()
  240. ctx.fillStyle = 'rgba(255, 255, 255, 0.22)'
  241. ctx.arc(gpsScreenPoint.x, gpsScreenPoint.y, metrics.coreRadius * 0.18, 0, Math.PI * 2)
  242. ctx.fill()
  243. }
  244. if (scene.gpsHeadingDeg !== null && scene.gpsMarkerStyleConfig.showHeadingIndicator) {
  245. const headingScreenRad = (scene.gpsHeadingDeg * Math.PI / 180) - scene.rotationRad
  246. const indicatorBaseDistance = metrics.ringRadius + metrics.indicatorOffset
  247. const indicatorSize = metrics.indicatorSize * indicatorScale
  248. const indicatorTipDistance = indicatorBaseDistance + indicatorSize * 0.92
  249. const tip = rotatePoint(0, -indicatorTipDistance, headingScreenRad)
  250. const left = rotatePoint(-indicatorSize * 0.56, -indicatorBaseDistance, headingScreenRad)
  251. const right = rotatePoint(indicatorSize * 0.56, -indicatorBaseDistance, headingScreenRad)
  252. const alpha = scene.gpsHeadingAlpha
  253. ctx.beginPath()
  254. ctx.fillStyle = `rgba(255, 255, 255, ${Math.max(0.42, alpha)})`
  255. ctx.moveTo(gpsScreenPoint.x + tip.x, gpsScreenPoint.y + tip.y)
  256. ctx.lineTo(gpsScreenPoint.x + left.x, gpsScreenPoint.y + left.y)
  257. ctx.lineTo(gpsScreenPoint.x + right.x, gpsScreenPoint.y + right.y)
  258. ctx.closePath()
  259. ctx.fill()
  260. const innerTip = rotatePoint(0, -(indicatorBaseDistance + indicatorSize * 0.72), headingScreenRad)
  261. const innerLeft = rotatePoint(-indicatorSize * 0.4, -(indicatorBaseDistance + 0.12), headingScreenRad)
  262. const innerRight = rotatePoint(indicatorSize * 0.4, -(indicatorBaseDistance + 0.12), headingScreenRad)
  263. ctx.beginPath()
  264. ctx.fillStyle = `rgba(${parseInt(scene.gpsMarkerStyleConfig.indicatorColorHex.slice(1, 3), 16)}, ${parseInt(scene.gpsMarkerStyleConfig.indicatorColorHex.slice(3, 5), 16)}, ${parseInt(scene.gpsMarkerStyleConfig.indicatorColorHex.slice(5, 7), 16)}, ${alpha})`
  265. ctx.moveTo(gpsScreenPoint.x + innerTip.x, gpsScreenPoint.y + innerTip.y)
  266. ctx.lineTo(gpsScreenPoint.x + innerLeft.x, gpsScreenPoint.y + innerLeft.y)
  267. ctx.lineTo(gpsScreenPoint.x + innerRight.x, gpsScreenPoint.y + innerRight.y)
  268. ctx.closePath()
  269. ctx.fill()
  270. }
  271. ctx.restore()
  272. }
  273. }