webglVectorRenderer.ts 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727
  1. import { getTileSizePx, type CameraState } from '../camera/camera'
  2. import { worldTileToLonLat } from '../../utils/projection'
  3. import { type MapScene } from './mapRenderer'
  4. import { CourseLayer, type ProjectedCourseLayers, type ProjectedCourseLeg } from '../layer/courseLayer'
  5. import { TrackLayer } from '../layer/trackLayer'
  6. import { GpsLayer } from '../layer/gpsLayer'
  7. const COURSE_COLOR: [number, number, number, number] = [0.8, 0.0, 0.42, 0.96]
  8. const COMPLETED_ROUTE_COLOR: [number, number, number, number] = [0.48, 0.5, 0.54, 0.82]
  9. const ACTIVE_CONTROL_COLOR: [number, number, number, number] = [0.22, 1, 0.95, 1]
  10. const MULTI_ACTIVE_CONTROL_COLOR: [number, number, number, number] = [1, 0.8, 0.2, 0.98]
  11. const FOCUSED_CONTROL_COLOR: [number, number, number, number] = [0.98, 0.96, 0.98, 1]
  12. const MULTI_ACTIVE_PULSE_COLOR: [number, number, number, number] = [0.18, 1, 0.96, 0.86]
  13. const FOCUSED_PULSE_COLOR: [number, number, number, number] = [1, 0.36, 0.84, 0.88]
  14. const ACTIVE_LEG_COLOR: [number, number, number, number] = [0.18, 1, 0.94, 0.5]
  15. const EARTH_CIRCUMFERENCE_METERS = 40075016.686
  16. const CONTROL_RING_WIDTH_RATIO = 0.2
  17. const FINISH_INNER_RADIUS_RATIO = 0.6
  18. const FINISH_RING_WIDTH_RATIO = 0.2
  19. const START_RING_WIDTH_RATIO = 0.2
  20. const LEG_WIDTH_RATIO = 0.2
  21. const LEG_TRIM_TO_RING_CENTER_RATIO = 1 - CONTROL_RING_WIDTH_RATIO / 2
  22. const ACTIVE_CONTROL_PULSE_SPEED = 0.18
  23. const ACTIVE_CONTROL_PULSE_MIN_SCALE = 1.12
  24. const ACTIVE_CONTROL_PULSE_MAX_SCALE = 1.46
  25. const ACTIVE_CONTROL_PULSE_WIDTH_RATIO = 0.12
  26. const GUIDE_FLOW_COUNT = 5
  27. const GUIDE_FLOW_SPEED = 0.02
  28. const GUIDE_FLOW_TRAIL = 0.16
  29. const GUIDE_FLOW_MIN_WIDTH_RATIO = 0.12
  30. const GUIDE_FLOW_MAX_WIDTH_RATIO = 0.22
  31. const GUIDE_FLOW_HEAD_RADIUS_RATIO = 0.18
  32. type RgbaColor = [number, number, number, number]
  33. function createShader(gl: any, type: number, source: string): any {
  34. const shader = gl.createShader(type)
  35. if (!shader) {
  36. throw new Error('WebGL shader 创建失败')
  37. }
  38. gl.shaderSource(shader, source)
  39. gl.compileShader(shader)
  40. if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
  41. const message = gl.getShaderInfoLog(shader) || 'unknown shader error'
  42. gl.deleteShader(shader)
  43. throw new Error(message)
  44. }
  45. return shader
  46. }
  47. function createProgram(gl: any, vertexSource: string, fragmentSource: string): any {
  48. const vertexShader = createShader(gl, gl.VERTEX_SHADER, vertexSource)
  49. const fragmentShader = createShader(gl, gl.FRAGMENT_SHADER, fragmentSource)
  50. const program = gl.createProgram()
  51. if (!program) {
  52. throw new Error('WebGL program 创建失败')
  53. }
  54. gl.attachShader(program, vertexShader)
  55. gl.attachShader(program, fragmentShader)
  56. gl.linkProgram(program)
  57. gl.deleteShader(vertexShader)
  58. gl.deleteShader(fragmentShader)
  59. if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
  60. const message = gl.getProgramInfoLog(program) || 'unknown program error'
  61. gl.deleteProgram(program)
  62. throw new Error(message)
  63. }
  64. return program
  65. }
  66. export class WebGLVectorRenderer {
  67. canvas: any
  68. gl: any
  69. dpr: number
  70. courseLayer: CourseLayer
  71. trackLayer: TrackLayer
  72. gpsLayer: GpsLayer
  73. program: any
  74. positionBuffer: any
  75. colorBuffer: any
  76. positionLocation: number
  77. colorLocation: number
  78. constructor(courseLayer: CourseLayer, trackLayer: TrackLayer, gpsLayer: GpsLayer) {
  79. this.canvas = null
  80. this.gl = null
  81. this.dpr = 1
  82. this.courseLayer = courseLayer
  83. this.trackLayer = trackLayer
  84. this.gpsLayer = gpsLayer
  85. this.program = null
  86. this.positionBuffer = null
  87. this.colorBuffer = null
  88. this.positionLocation = -1
  89. this.colorLocation = -1
  90. }
  91. attachCanvas(canvasNode: any, width: number, height: number, dpr: number): void {
  92. this.canvas = canvasNode
  93. this.dpr = dpr || 1
  94. canvasNode.width = Math.max(1, Math.floor(width * this.dpr))
  95. canvasNode.height = Math.max(1, Math.floor(height * this.dpr))
  96. this.attachContext(canvasNode.getContext('webgl') || canvasNode.getContext('experimental-webgl'), canvasNode)
  97. }
  98. attachContext(gl: any, canvasNode: any): void {
  99. if (!gl) {
  100. throw new Error('当前环境不支持 WebGL Vector Layer')
  101. }
  102. this.canvas = canvasNode
  103. this.gl = gl
  104. this.program = createProgram(
  105. gl,
  106. 'attribute vec2 a_position; attribute vec4 a_color; varying vec4 v_color; void main() { gl_Position = vec4(a_position, 0.0, 1.0); v_color = a_color; }',
  107. 'precision mediump float; varying vec4 v_color; void main() { gl_FragColor = v_color; }',
  108. )
  109. this.positionBuffer = gl.createBuffer()
  110. this.colorBuffer = gl.createBuffer()
  111. this.positionLocation = gl.getAttribLocation(this.program, 'a_position')
  112. this.colorLocation = gl.getAttribLocation(this.program, 'a_color')
  113. gl.viewport(0, 0, canvasNode.width, canvasNode.height)
  114. gl.enable(gl.BLEND)
  115. gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
  116. }
  117. destroy(): void {
  118. if (this.gl) {
  119. if (this.program) {
  120. this.gl.deleteProgram(this.program)
  121. }
  122. if (this.positionBuffer) {
  123. this.gl.deleteBuffer(this.positionBuffer)
  124. }
  125. if (this.colorBuffer) {
  126. this.gl.deleteBuffer(this.colorBuffer)
  127. }
  128. }
  129. this.program = null
  130. this.positionBuffer = null
  131. this.colorBuffer = null
  132. this.gl = null
  133. this.canvas = null
  134. }
  135. render(scene: MapScene, pulseFrame: number): void {
  136. if (!this.gl || !this.program || !this.positionBuffer || !this.colorBuffer || !this.canvas) {
  137. return
  138. }
  139. const gl = this.gl
  140. const course = this.courseLayer.projectCourse(scene)
  141. const trackPoints = this.trackLayer.projectPoints(scene)
  142. const gpsPoint = this.gpsLayer.projectPoint(scene)
  143. const positions: number[] = []
  144. const colors: number[] = []
  145. if (course) {
  146. this.pushCourse(positions, colors, course, scene, pulseFrame)
  147. }
  148. for (let index = 1; index < trackPoints.length; index += 1) {
  149. this.pushSegment(positions, colors, trackPoints[index - 1], trackPoints[index], 6, [0.09, 0.43, 0.36, 0.96], scene)
  150. }
  151. for (const point of trackPoints) {
  152. this.pushCircle(positions, colors, point.x, point.y, 10, [0.09, 0.43, 0.36, 1], scene)
  153. this.pushCircle(positions, colors, point.x, point.y, 6.5, [0.97, 0.98, 0.95, 1], scene)
  154. }
  155. if (gpsPoint) {
  156. this.pushCircle(positions, colors, gpsPoint.x, gpsPoint.y, this.gpsLayer.getPulseRadius(pulseFrame), [0.13, 0.62, 0.74, 0.22], scene)
  157. this.pushCircle(positions, colors, gpsPoint.x, gpsPoint.y, 13, [1, 1, 1, 0.95], scene)
  158. this.pushCircle(positions, colors, gpsPoint.x, gpsPoint.y, 9, [0.13, 0.63, 0.74, 1], scene)
  159. }
  160. if (!positions.length) {
  161. return
  162. }
  163. gl.viewport(0, 0, this.canvas.width, this.canvas.height)
  164. gl.useProgram(this.program)
  165. gl.bindBuffer(gl.ARRAY_BUFFER, this.positionBuffer)
  166. gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STREAM_DRAW)
  167. gl.enableVertexAttribArray(this.positionLocation)
  168. gl.vertexAttribPointer(this.positionLocation, 2, gl.FLOAT, false, 0, 0)
  169. gl.bindBuffer(gl.ARRAY_BUFFER, this.colorBuffer)
  170. gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(colors), gl.STREAM_DRAW)
  171. gl.enableVertexAttribArray(this.colorLocation)
  172. gl.vertexAttribPointer(this.colorLocation, 4, gl.FLOAT, false, 0, 0)
  173. gl.drawArrays(gl.TRIANGLES, 0, positions.length / 2)
  174. }
  175. getPixelsPerMeter(scene: MapScene): number {
  176. const camera: CameraState = {
  177. centerWorldX: scene.exactCenterWorldX,
  178. centerWorldY: scene.exactCenterWorldY,
  179. viewportWidth: scene.viewportWidth,
  180. viewportHeight: scene.viewportHeight,
  181. visibleColumns: scene.visibleColumns,
  182. }
  183. const tileSizePx = getTileSizePx(camera)
  184. const centerLonLat = worldTileToLonLat({ x: scene.exactCenterWorldX, y: scene.exactCenterWorldY }, scene.zoom)
  185. const metersPerTile = Math.cos(centerLonLat.lat * Math.PI / 180) * EARTH_CIRCUMFERENCE_METERS / Math.pow(2, scene.zoom)
  186. if (!tileSizePx || !metersPerTile) {
  187. return 0
  188. }
  189. return tileSizePx / metersPerTile
  190. }
  191. getMetric(scene: MapScene, meters: number): number {
  192. return meters * this.getPixelsPerMeter(scene)
  193. }
  194. getControlRadiusMeters(scene: MapScene): number {
  195. return scene.cpRadiusMeters > 0 ? scene.cpRadiusMeters : 5
  196. }
  197. pushCourse(
  198. positions: number[],
  199. colors: number[],
  200. course: ProjectedCourseLayers,
  201. scene: MapScene,
  202. pulseFrame: number,
  203. ): void {
  204. const controlRadiusMeters = this.getControlRadiusMeters(scene)
  205. if (scene.revealFullCourse && scene.showCourseLegs) {
  206. for (let index = 0; index < course.legs.length; index += 1) {
  207. const leg = course.legs[index]
  208. this.pushCourseLeg(positions, colors, leg, controlRadiusMeters, this.getLegColor(scene, index), scene)
  209. if (scene.guidanceLegAnimationEnabled && scene.activeLegIndices.includes(index)) {
  210. this.pushCourseLegHighlight(positions, colors, leg, controlRadiusMeters, scene)
  211. }
  212. }
  213. const guideLeg = this.getGuideLeg(course, scene)
  214. if (guideLeg) {
  215. this.pushGuidanceFlow(positions, colors, guideLeg, controlRadiusMeters, scene, pulseFrame)
  216. }
  217. }
  218. for (const start of course.starts) {
  219. if (scene.activeStart) {
  220. this.pushActiveStartPulse(positions, colors, start.point.x, start.point.y, start.headingDeg, controlRadiusMeters, scene, pulseFrame)
  221. }
  222. this.pushStartTriangle(positions, colors, start.point.x, start.point.y, start.headingDeg, controlRadiusMeters, this.getStartColor(scene), scene)
  223. }
  224. if (!scene.revealFullCourse) {
  225. return
  226. }
  227. for (const control of course.controls) {
  228. if (scene.activeControlSequences.includes(control.sequence)) {
  229. if (scene.controlVisualMode === 'single-target') {
  230. this.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters, scene, pulseFrame)
  231. } else {
  232. this.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters, scene, pulseFrame, MULTI_ACTIVE_PULSE_COLOR)
  233. this.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters * 1.2, scene, pulseFrame + 9, [0.9, 1, 1, 0.52])
  234. }
  235. }
  236. this.pushRing(
  237. positions,
  238. colors,
  239. control.point.x,
  240. control.point.y,
  241. this.getMetric(scene, controlRadiusMeters),
  242. this.getMetric(scene, controlRadiusMeters * (1 - CONTROL_RING_WIDTH_RATIO)),
  243. this.getControlColor(scene, control.sequence),
  244. scene,
  245. )
  246. if (scene.focusedControlSequences.includes(control.sequence)) {
  247. this.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters * 1.02, scene, pulseFrame, FOCUSED_PULSE_COLOR)
  248. this.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters * 1.32, scene, pulseFrame + 15, [1, 0.86, 0.94, 0.5])
  249. this.pushRing(
  250. positions,
  251. colors,
  252. control.point.x,
  253. control.point.y,
  254. this.getMetric(scene, controlRadiusMeters * 1.24),
  255. this.getMetric(scene, controlRadiusMeters * 1.06),
  256. FOCUSED_CONTROL_COLOR,
  257. scene,
  258. )
  259. }
  260. }
  261. for (const finish of course.finishes) {
  262. if (scene.activeFinish) {
  263. this.pushActiveControlPulse(positions, colors, finish.point.x, finish.point.y, controlRadiusMeters, scene, pulseFrame)
  264. }
  265. if (scene.focusedFinish) {
  266. this.pushActiveControlPulse(positions, colors, finish.point.x, finish.point.y, controlRadiusMeters * 1.04, scene, pulseFrame, FOCUSED_PULSE_COLOR)
  267. this.pushActiveControlPulse(positions, colors, finish.point.x, finish.point.y, controlRadiusMeters * 1.34, scene, pulseFrame + 12, [1, 0.86, 0.94, 0.46])
  268. }
  269. const finishColor = this.getFinishColor(scene)
  270. this.pushRing(
  271. positions,
  272. colors,
  273. finish.point.x,
  274. finish.point.y,
  275. this.getMetric(scene, controlRadiusMeters),
  276. this.getMetric(scene, controlRadiusMeters * (1 - FINISH_RING_WIDTH_RATIO)),
  277. finishColor,
  278. scene,
  279. )
  280. this.pushRing(
  281. positions,
  282. colors,
  283. finish.point.x,
  284. finish.point.y,
  285. this.getMetric(scene, controlRadiusMeters * FINISH_INNER_RADIUS_RATIO),
  286. this.getMetric(scene, controlRadiusMeters * FINISH_INNER_RADIUS_RATIO * (1 - FINISH_RING_WIDTH_RATIO / FINISH_INNER_RADIUS_RATIO)),
  287. finishColor,
  288. scene,
  289. )
  290. }
  291. }
  292. getGuideLeg(course: ProjectedCourseLayers, scene: MapScene): ProjectedCourseLeg | null {
  293. if (!scene.guidanceLegAnimationEnabled) {
  294. return null
  295. }
  296. const activeIndex = scene.activeLegIndices.length ? scene.activeLegIndices[0] : -1
  297. if (activeIndex >= 0 && activeIndex < course.legs.length) {
  298. return course.legs[activeIndex]
  299. }
  300. return null
  301. }
  302. getLegColor(scene: MapScene, index: number): RgbaColor {
  303. return this.isCompletedLeg(scene, index) ? COMPLETED_ROUTE_COLOR : COURSE_COLOR
  304. }
  305. isCompletedLeg(scene: MapScene, index: number): boolean {
  306. return scene.completedLegIndices.includes(index)
  307. }
  308. pushCourseLeg(
  309. positions: number[],
  310. colors: number[],
  311. leg: ProjectedCourseLeg,
  312. controlRadiusMeters: number,
  313. color: RgbaColor,
  314. scene: MapScene,
  315. ): void {
  316. const trimmed = this.getTrimmedCourseLeg(leg, controlRadiusMeters, scene)
  317. if (!trimmed) {
  318. return
  319. }
  320. this.pushSegment(positions, colors, trimmed.from, trimmed.to, this.getMetric(scene, controlRadiusMeters * LEG_WIDTH_RATIO), color, scene)
  321. }
  322. pushCourseLegHighlight(
  323. positions: number[],
  324. colors: number[],
  325. leg: ProjectedCourseLeg,
  326. controlRadiusMeters: number,
  327. scene: MapScene,
  328. ): void {
  329. const trimmed = this.getTrimmedCourseLeg(leg, controlRadiusMeters, scene)
  330. if (!trimmed) {
  331. return
  332. }
  333. this.pushSegment(
  334. positions,
  335. colors,
  336. trimmed.from,
  337. trimmed.to,
  338. this.getMetric(scene, controlRadiusMeters * LEG_WIDTH_RATIO * 1.5),
  339. ACTIVE_LEG_COLOR,
  340. scene,
  341. )
  342. }
  343. pushActiveControlPulse(
  344. positions: number[],
  345. colors: number[],
  346. centerX: number,
  347. centerY: number,
  348. controlRadiusMeters: number,
  349. scene: MapScene,
  350. pulseFrame: number,
  351. pulseColor?: RgbaColor,
  352. ): void {
  353. const pulse = (Math.sin(pulseFrame * ACTIVE_CONTROL_PULSE_SPEED) + 1) / 2
  354. const pulseScale = ACTIVE_CONTROL_PULSE_MIN_SCALE + (ACTIVE_CONTROL_PULSE_MAX_SCALE - ACTIVE_CONTROL_PULSE_MIN_SCALE) * pulse
  355. const pulseWidthScale = pulseScale - ACTIVE_CONTROL_PULSE_WIDTH_RATIO
  356. const baseColor = pulseColor || ACTIVE_CONTROL_COLOR
  357. const glowAlpha = Math.min(1, baseColor[3] * (0.46 + pulse * 0.5))
  358. const glowColor: RgbaColor = [baseColor[0], baseColor[1], baseColor[2], glowAlpha]
  359. this.pushRing(
  360. positions,
  361. colors,
  362. centerX,
  363. centerY,
  364. this.getMetric(scene, controlRadiusMeters * pulseScale),
  365. this.getMetric(scene, controlRadiusMeters * Math.max(1, pulseWidthScale)),
  366. glowColor,
  367. scene,
  368. )
  369. }
  370. pushActiveStartPulse(
  371. positions: number[],
  372. colors: number[],
  373. centerX: number,
  374. centerY: number,
  375. headingDeg: number | null,
  376. controlRadiusMeters: number,
  377. scene: MapScene,
  378. pulseFrame: number,
  379. ): void {
  380. const pulse = (Math.sin(pulseFrame * ACTIVE_CONTROL_PULSE_SPEED) + 1) / 2
  381. const pulseScale = ACTIVE_CONTROL_PULSE_MIN_SCALE + (ACTIVE_CONTROL_PULSE_MAX_SCALE - ACTIVE_CONTROL_PULSE_MIN_SCALE) * pulse
  382. const pulseWidthScale = pulseScale - ACTIVE_CONTROL_PULSE_WIDTH_RATIO
  383. const glowAlpha = 0.24 + pulse * 0.34
  384. const glowColor: RgbaColor = [0.36, 1, 0.96, glowAlpha]
  385. const headingRad = ((headingDeg === null ? 0 : headingDeg) - 90) * Math.PI / 180
  386. const ringCenterX = centerX + Math.cos(headingRad) * this.getMetric(scene, controlRadiusMeters * 0.04)
  387. const ringCenterY = centerY + Math.sin(headingRad) * this.getMetric(scene, controlRadiusMeters * 0.04)
  388. this.pushRing(
  389. positions,
  390. colors,
  391. ringCenterX,
  392. ringCenterY,
  393. this.getMetric(scene, controlRadiusMeters * pulseScale),
  394. this.getMetric(scene, controlRadiusMeters * Math.max(1, pulseWidthScale)),
  395. glowColor,
  396. scene,
  397. )
  398. }
  399. getStartColor(scene: MapScene): RgbaColor {
  400. if (scene.activeStart) {
  401. return ACTIVE_CONTROL_COLOR
  402. }
  403. if (scene.completedStart) {
  404. return COMPLETED_ROUTE_COLOR
  405. }
  406. return COURSE_COLOR
  407. }
  408. getControlColor(scene: MapScene, sequence: number): RgbaColor {
  409. if (scene.activeControlSequences.includes(sequence)) {
  410. return scene.controlVisualMode === 'multi-target' ? MULTI_ACTIVE_CONTROL_COLOR : ACTIVE_CONTROL_COLOR
  411. }
  412. if (scene.completedControlSequences.includes(sequence)) {
  413. return COMPLETED_ROUTE_COLOR
  414. }
  415. return COURSE_COLOR
  416. }
  417. getFinishColor(scene: MapScene): RgbaColor {
  418. if (scene.focusedFinish) {
  419. return FOCUSED_CONTROL_COLOR
  420. }
  421. if (scene.activeFinish) {
  422. return ACTIVE_CONTROL_COLOR
  423. }
  424. if (scene.completedFinish) {
  425. return COMPLETED_ROUTE_COLOR
  426. }
  427. return COURSE_COLOR
  428. }
  429. pushGuidanceFlow(
  430. positions: number[],
  431. colors: number[],
  432. leg: ProjectedCourseLeg,
  433. controlRadiusMeters: number,
  434. scene: MapScene,
  435. pulseFrame: number,
  436. ): void {
  437. const trimmed = this.getTrimmedCourseLeg(leg, controlRadiusMeters, scene)
  438. if (!trimmed) {
  439. return
  440. }
  441. const dx = trimmed.to.x - trimmed.from.x
  442. const dy = trimmed.to.y - trimmed.from.y
  443. const length = Math.sqrt(dx * dx + dy * dy)
  444. if (!length) {
  445. return
  446. }
  447. for (let index = 0; index < GUIDE_FLOW_COUNT; index += 1) {
  448. const progress = (pulseFrame * GUIDE_FLOW_SPEED + index / GUIDE_FLOW_COUNT) % 1
  449. const tailProgress = Math.max(0, progress - GUIDE_FLOW_TRAIL)
  450. const head = {
  451. x: trimmed.from.x + dx * progress,
  452. y: trimmed.from.y + dy * progress,
  453. }
  454. const tail = {
  455. x: trimmed.from.x + dx * tailProgress,
  456. y: trimmed.from.y + dy * tailProgress,
  457. }
  458. const eased = progress * progress
  459. const width = this.getMetric(
  460. scene,
  461. controlRadiusMeters * (GUIDE_FLOW_MIN_WIDTH_RATIO + (GUIDE_FLOW_MAX_WIDTH_RATIO - GUIDE_FLOW_MIN_WIDTH_RATIO) * eased),
  462. )
  463. const outerColor = this.getGuideFlowOuterColor(eased)
  464. const innerColor = this.getGuideFlowInnerColor(eased)
  465. const headRadius = this.getMetric(scene, controlRadiusMeters * GUIDE_FLOW_HEAD_RADIUS_RATIO * (0.72 + eased * 0.42))
  466. this.pushSegment(positions, colors, tail, head, width * 1.9, outerColor, scene)
  467. this.pushSegment(positions, colors, tail, head, width, innerColor, scene)
  468. this.pushCircle(positions, colors, head.x, head.y, headRadius * 1.35, outerColor, scene)
  469. this.pushCircle(positions, colors, head.x, head.y, headRadius, innerColor, scene)
  470. }
  471. }
  472. getTrimmedCourseLeg(
  473. leg: ProjectedCourseLeg,
  474. controlRadiusMeters: number,
  475. scene: MapScene,
  476. ): { from: { x: number; y: number }; to: { x: number; y: number } } | null {
  477. return this.trimSegment(
  478. leg.from,
  479. leg.to,
  480. this.getLegTrim(leg.fromKind, controlRadiusMeters, scene),
  481. this.getLegTrim(leg.toKind, controlRadiusMeters, scene),
  482. )
  483. }
  484. getGuideFlowOuterColor(progress: number): RgbaColor {
  485. return [0.28, 0.92, 1, 0.14 + progress * 0.22]
  486. }
  487. getGuideFlowInnerColor(progress: number): RgbaColor {
  488. return [0.94, 0.99, 1, 0.38 + progress * 0.42]
  489. }
  490. getLegTrim(kind: ProjectedCourseLeg['fromKind'], controlRadiusMeters: number, scene: MapScene): number {
  491. if (kind === 'start') {
  492. return this.getMetric(scene, controlRadiusMeters * (1 - START_RING_WIDTH_RATIO / 2))
  493. }
  494. if (kind === 'finish') {
  495. return this.getMetric(scene, controlRadiusMeters * (1 - FINISH_RING_WIDTH_RATIO / 2))
  496. }
  497. return this.getMetric(scene, controlRadiusMeters * LEG_TRIM_TO_RING_CENTER_RATIO)
  498. }
  499. trimSegment(
  500. from: { x: number; y: number },
  501. to: { x: number; y: number },
  502. fromTrim: number,
  503. toTrim: number,
  504. ): { from: { x: number; y: number }; to: { x: number; y: number } } | null {
  505. const dx = to.x - from.x
  506. const dy = to.y - from.y
  507. const length = Math.sqrt(dx * dx + dy * dy)
  508. if (!length || length <= fromTrim + toTrim) {
  509. return null
  510. }
  511. const ux = dx / length
  512. const uy = dy / length
  513. return {
  514. from: {
  515. x: from.x + ux * fromTrim,
  516. y: from.y + uy * fromTrim,
  517. },
  518. to: {
  519. x: to.x - ux * toTrim,
  520. y: to.y - uy * toTrim,
  521. },
  522. }
  523. }
  524. pushStartTriangle(
  525. positions: number[],
  526. colors: number[],
  527. centerX: number,
  528. centerY: number,
  529. headingDeg: number | null,
  530. controlRadiusMeters: number,
  531. color: RgbaColor,
  532. scene: MapScene,
  533. ): void {
  534. const startRadius = this.getMetric(scene, controlRadiusMeters)
  535. const startRingWidth = this.getMetric(scene, controlRadiusMeters * START_RING_WIDTH_RATIO)
  536. const headingRad = ((headingDeg === null ? 0 : headingDeg) - 90) * Math.PI / 180
  537. const vertices = [0, 1, 2].map((index) => {
  538. const angle = headingRad + index * (Math.PI * 2 / 3)
  539. return {
  540. x: centerX + Math.cos(angle) * startRadius,
  541. y: centerY + Math.sin(angle) * startRadius,
  542. }
  543. })
  544. this.pushSegment(positions, colors, vertices[0], vertices[1], startRingWidth, color, scene)
  545. this.pushSegment(positions, colors, vertices[1], vertices[2], startRingWidth, color, scene)
  546. this.pushSegment(positions, colors, vertices[2], vertices[0], startRingWidth, color, scene)
  547. }
  548. pushRing(
  549. positions: number[],
  550. colors: number[],
  551. centerX: number,
  552. centerY: number,
  553. outerRadius: number,
  554. innerRadius: number,
  555. color: RgbaColor,
  556. scene: MapScene,
  557. ): void {
  558. const segments = 36
  559. for (let index = 0; index < segments; index += 1) {
  560. const startAngle = index / segments * Math.PI * 2
  561. const endAngle = (index + 1) / segments * Math.PI * 2
  562. const outerStart = this.toClip(centerX + Math.cos(startAngle) * outerRadius, centerY + Math.sin(startAngle) * outerRadius, scene)
  563. const outerEnd = this.toClip(centerX + Math.cos(endAngle) * outerRadius, centerY + Math.sin(endAngle) * outerRadius, scene)
  564. const innerStart = this.toClip(centerX + Math.cos(startAngle) * innerRadius, centerY + Math.sin(startAngle) * innerRadius, scene)
  565. const innerEnd = this.toClip(centerX + Math.cos(endAngle) * innerRadius, centerY + Math.sin(endAngle) * innerRadius, scene)
  566. this.pushTriangle(positions, colors, outerStart, outerEnd, innerStart, color)
  567. this.pushTriangle(positions, colors, innerStart, outerEnd, innerEnd, color)
  568. }
  569. }
  570. pushSegment(
  571. positions: number[],
  572. colors: number[],
  573. start: { x: number; y: number },
  574. end: { x: number; y: number },
  575. width: number,
  576. color: RgbaColor,
  577. scene: MapScene,
  578. ): void {
  579. const deltaX = end.x - start.x
  580. const deltaY = end.y - start.y
  581. const length = Math.sqrt(deltaX * deltaX + deltaY * deltaY)
  582. if (!length) {
  583. return
  584. }
  585. const normalX = -deltaY / length * (width / 2)
  586. const normalY = deltaX / length * (width / 2)
  587. const topLeft = this.toClip(start.x + normalX, start.y + normalY, scene)
  588. const topRight = this.toClip(end.x + normalX, end.y + normalY, scene)
  589. const bottomLeft = this.toClip(start.x - normalX, start.y - normalY, scene)
  590. const bottomRight = this.toClip(end.x - normalX, end.y - normalY, scene)
  591. this.pushTriangle(positions, colors, topLeft, topRight, bottomLeft, color)
  592. this.pushTriangle(positions, colors, bottomLeft, topRight, bottomRight, color)
  593. }
  594. pushCircle(
  595. positions: number[],
  596. colors: number[],
  597. centerX: number,
  598. centerY: number,
  599. radius: number,
  600. color: RgbaColor,
  601. scene: MapScene,
  602. ): void {
  603. const segments = 20
  604. const center = this.toClip(centerX, centerY, scene)
  605. for (let index = 0; index < segments; index += 1) {
  606. const startAngle = index / segments * Math.PI * 2
  607. const endAngle = (index + 1) / segments * Math.PI * 2
  608. const start = this.toClip(centerX + Math.cos(startAngle) * radius, centerY + Math.sin(startAngle) * radius, scene)
  609. const end = this.toClip(centerX + Math.cos(endAngle) * radius, centerY + Math.sin(endAngle) * radius, scene)
  610. this.pushTriangle(positions, colors, center, start, end, color)
  611. }
  612. }
  613. pushTriangle(
  614. positions: number[],
  615. colors: number[],
  616. first: { x: number; y: number },
  617. second: { x: number; y: number },
  618. third: { x: number; y: number },
  619. color: RgbaColor,
  620. ): void {
  621. positions.push(first.x, first.y, second.x, second.y, third.x, third.y)
  622. for (let index = 0; index < 3; index += 1) {
  623. colors.push(color[0], color[1], color[2], color[3])
  624. }
  625. }
  626. toClip(x: number, y: number, scene: MapScene): { x: number; y: number } {
  627. const previewScale = scene.previewScale || 1
  628. const originX = scene.previewOriginX || scene.viewportWidth / 2
  629. const originY = scene.previewOriginY || scene.viewportHeight / 2
  630. const scaledX = originX + (x - originX) * previewScale
  631. const scaledY = originY + (y - originY) * previewScale
  632. return {
  633. x: scaledX / scene.viewportWidth * 2 - 1,
  634. y: 1 - scaledY / scene.viewportHeight * 2,
  635. }
  636. }
  637. }