webglVectorRenderer.ts 29 KB

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