webglVectorRenderer.ts 51 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495
  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. import { type GpsMarkerStyleConfig } from '../../game/presentation/gpsMarkerStyleConfig'
  8. import {
  9. type ControlPointStyleEntry,
  10. type CourseLegStyleEntry,
  11. } from '../../game/presentation/courseStyleConfig'
  12. import { hexToRgbaColor, resolveControlStyle, resolveLegStyle, type RgbaColor } from './courseStyleResolver'
  13. const ACTIVE_CONTROL_COLOR: [number, number, number, number] = [0.22, 1, 0.95, 1]
  14. const READY_CONTROL_COLOR: [number, number, number, number] = [0.38, 1, 0.92, 1]
  15. const FOCUSED_CONTROL_COLOR: [number, number, number, number] = [0.98, 0.96, 0.98, 1]
  16. const MULTI_ACTIVE_PULSE_COLOR: [number, number, number, number] = [0.18, 1, 0.96, 0.86]
  17. const FOCUSED_PULSE_COLOR: [number, number, number, number] = [1, 0.36, 0.84, 0.88]
  18. const READY_PULSE_COLOR: [number, number, number, number] = [0.44, 1, 0.92, 0.98]
  19. const COMPLETED_SETTLE_COLOR: [number, number, number, number] = [0.86, 0.9, 0.94, 0.24]
  20. const SKIPPED_SETTLE_COLOR: [number, number, number, number] = [0.72, 0.76, 0.82, 0.18]
  21. const SKIPPED_SLASH_COLOR: [number, number, number, number] = [0.78, 0.82, 0.88, 0.9]
  22. const ACTIVE_LEG_COLOR: [number, number, number, number] = [0.18, 1, 0.94, 0.5]
  23. const EARTH_CIRCUMFERENCE_METERS = 40075016.686
  24. const CONTROL_RING_WIDTH_RATIO = 0.2
  25. const FINISH_INNER_RADIUS_RATIO = 0.6
  26. const FINISH_RING_WIDTH_RATIO = 0.2
  27. const START_RING_WIDTH_RATIO = 0.2
  28. const LEG_WIDTH_RATIO = 0.2
  29. const LEG_TRIM_TO_RING_CENTER_RATIO = 1 - CONTROL_RING_WIDTH_RATIO / 2
  30. const ACTIVE_CONTROL_PULSE_SPEED = 0.18
  31. const ACTIVE_CONTROL_PULSE_MIN_SCALE = 1.12
  32. const ACTIVE_CONTROL_PULSE_MAX_SCALE = 1.46
  33. const ACTIVE_CONTROL_PULSE_WIDTH_RATIO = 0.12
  34. const GUIDE_FLOW_COUNT = 5
  35. const GUIDE_FLOW_SPEED = 0.02
  36. const GUIDE_FLOW_TRAIL = 0.16
  37. const GUIDE_FLOW_MIN_WIDTH_RATIO = 0.12
  38. const GUIDE_FLOW_MAX_WIDTH_RATIO = 0.22
  39. const GUIDE_FLOW_HEAD_RADIUS_RATIO = 0.18
  40. const LEG_ARROW_HEAD_LENGTH_RATIO = 0.34
  41. const LEG_ARROW_HEAD_WIDTH_RATIO = 0.24
  42. function getGpsMarkerMetrics(size: GpsMarkerStyleConfig['size']) {
  43. if (size === 'small') {
  44. return {
  45. coreRadiusPx: 7,
  46. ringRadiusPx: 8.35,
  47. pulseRadiusPx: 14,
  48. indicatorOffsetPx: 1.1,
  49. indicatorSizePx: 7,
  50. ringWidthPx: 2.5,
  51. }
  52. }
  53. if (size === 'large') {
  54. return {
  55. coreRadiusPx: 11,
  56. ringRadiusPx: 12.95,
  57. pulseRadiusPx: 22,
  58. indicatorOffsetPx: 1.45,
  59. indicatorSizePx: 10,
  60. ringWidthPx: 3.5,
  61. }
  62. }
  63. return {
  64. coreRadiusPx: 9,
  65. ringRadiusPx: 10.65,
  66. pulseRadiusPx: 18,
  67. indicatorOffsetPx: 1.25,
  68. indicatorSizePx: 8.5,
  69. ringWidthPx: 3,
  70. }
  71. }
  72. function scaleGpsMarkerMetrics(
  73. metrics: ReturnType<typeof getGpsMarkerMetrics>,
  74. effectScale: number,
  75. ): ReturnType<typeof getGpsMarkerMetrics> {
  76. const safeScale = Math.max(0.88, Math.min(1.28, effectScale || 1))
  77. return {
  78. coreRadiusPx: metrics.coreRadiusPx * safeScale,
  79. ringRadiusPx: metrics.ringRadiusPx * safeScale,
  80. pulseRadiusPx: metrics.pulseRadiusPx * safeScale,
  81. indicatorOffsetPx: metrics.indicatorOffsetPx * safeScale,
  82. indicatorSizePx: metrics.indicatorSizePx * safeScale,
  83. ringWidthPx: Math.max(2, metrics.ringWidthPx * (0.96 + (safeScale - 1) * 0.35)),
  84. }
  85. }
  86. function getGpsPulsePhase(
  87. pulseFrame: number,
  88. motionState: GpsMarkerStyleConfig['motionState'],
  89. ): number {
  90. const divisor = motionState === 'idle'
  91. ? 11.5
  92. : motionState === 'moving'
  93. ? 6.2
  94. : motionState === 'fast-moving'
  95. ? 4.3
  96. : 4.8
  97. return 0.5 + 0.5 * Math.sin(pulseFrame / divisor)
  98. }
  99. function getAnimatedGpsPulseRadius(
  100. pulseFrame: number,
  101. metrics: ReturnType<typeof getGpsMarkerMetrics>,
  102. motionState: GpsMarkerStyleConfig['motionState'],
  103. pulseStrength: number,
  104. motionIntensity: number,
  105. ): number {
  106. const phase = getGpsPulsePhase(pulseFrame, motionState)
  107. const baseRadius = motionState === 'idle'
  108. ? metrics.pulseRadiusPx * 0.82
  109. : motionState === 'moving'
  110. ? metrics.pulseRadiusPx * 0.94
  111. : motionState === 'fast-moving'
  112. ? metrics.pulseRadiusPx * 1.04
  113. : metrics.pulseRadiusPx
  114. const amplitude = motionState === 'idle'
  115. ? metrics.pulseRadiusPx * 0.12
  116. : motionState === 'moving'
  117. ? metrics.pulseRadiusPx * 0.18
  118. : motionState === 'fast-moving'
  119. ? metrics.pulseRadiusPx * 0.24
  120. : metrics.pulseRadiusPx * 0.2
  121. return baseRadius + amplitude * phase * (0.8 + pulseStrength * 0.18 + motionIntensity * 0.1)
  122. }
  123. function rotatePoint(x: number, y: number, angleRad: number): { x: number; y: number } {
  124. const cos = Math.cos(angleRad)
  125. const sin = Math.sin(angleRad)
  126. return {
  127. x: x * cos - y * sin,
  128. y: x * sin + y * cos,
  129. }
  130. }
  131. function createShader(gl: any, type: number, source: string): any {
  132. const shader = gl.createShader(type)
  133. if (!shader) {
  134. throw new Error('WebGL shader 创建失败')
  135. }
  136. gl.shaderSource(shader, source)
  137. gl.compileShader(shader)
  138. if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
  139. const message = gl.getShaderInfoLog(shader) || 'unknown shader error'
  140. gl.deleteShader(shader)
  141. throw new Error(message)
  142. }
  143. return shader
  144. }
  145. function createProgram(gl: any, vertexSource: string, fragmentSource: string): any {
  146. const vertexShader = createShader(gl, gl.VERTEX_SHADER, vertexSource)
  147. const fragmentShader = createShader(gl, gl.FRAGMENT_SHADER, fragmentSource)
  148. const program = gl.createProgram()
  149. if (!program) {
  150. throw new Error('WebGL program 创建失败')
  151. }
  152. gl.attachShader(program, vertexShader)
  153. gl.attachShader(program, fragmentShader)
  154. gl.linkProgram(program)
  155. gl.deleteShader(vertexShader)
  156. gl.deleteShader(fragmentShader)
  157. if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
  158. const message = gl.getProgramInfoLog(program) || 'unknown program error'
  159. gl.deleteProgram(program)
  160. throw new Error(message)
  161. }
  162. return program
  163. }
  164. export class WebGLVectorRenderer {
  165. canvas: any
  166. gl: any
  167. dpr: number
  168. courseLayer: CourseLayer
  169. trackLayer: TrackLayer
  170. gpsLayer: GpsLayer
  171. program: any
  172. positionBuffer: any
  173. colorBuffer: any
  174. positionLocation: number
  175. colorLocation: number
  176. constructor(courseLayer: CourseLayer, trackLayer: TrackLayer, gpsLayer: GpsLayer) {
  177. this.canvas = null
  178. this.gl = null
  179. this.dpr = 1
  180. this.courseLayer = courseLayer
  181. this.trackLayer = trackLayer
  182. this.gpsLayer = gpsLayer
  183. this.program = null
  184. this.positionBuffer = null
  185. this.colorBuffer = null
  186. this.positionLocation = -1
  187. this.colorLocation = -1
  188. }
  189. attachCanvas(canvasNode: any, width: number, height: number, dpr: number): void {
  190. this.canvas = canvasNode
  191. this.dpr = dpr || 1
  192. canvasNode.width = Math.max(1, Math.floor(width * this.dpr))
  193. canvasNode.height = Math.max(1, Math.floor(height * this.dpr))
  194. this.attachContext(canvasNode.getContext('webgl') || canvasNode.getContext('experimental-webgl'), canvasNode)
  195. }
  196. attachContext(gl: any, canvasNode: any): void {
  197. if (!gl) {
  198. throw new Error('当前环境不支持 WebGL Vector Layer')
  199. }
  200. this.canvas = canvasNode
  201. this.gl = gl
  202. this.program = createProgram(
  203. gl,
  204. '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; }',
  205. 'precision mediump float; varying vec4 v_color; void main() { gl_FragColor = v_color; }',
  206. )
  207. this.positionBuffer = gl.createBuffer()
  208. this.colorBuffer = gl.createBuffer()
  209. this.positionLocation = gl.getAttribLocation(this.program, 'a_position')
  210. this.colorLocation = gl.getAttribLocation(this.program, 'a_color')
  211. gl.viewport(0, 0, canvasNode.width, canvasNode.height)
  212. gl.enable(gl.BLEND)
  213. gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
  214. }
  215. destroy(): void {
  216. if (this.gl) {
  217. if (this.program) {
  218. this.gl.deleteProgram(this.program)
  219. }
  220. if (this.positionBuffer) {
  221. this.gl.deleteBuffer(this.positionBuffer)
  222. }
  223. if (this.colorBuffer) {
  224. this.gl.deleteBuffer(this.colorBuffer)
  225. }
  226. }
  227. this.program = null
  228. this.positionBuffer = null
  229. this.colorBuffer = null
  230. this.gl = null
  231. this.canvas = null
  232. }
  233. render(scene: MapScene, pulseFrame: number): void {
  234. if (!this.gl || !this.program || !this.positionBuffer || !this.colorBuffer || !this.canvas) {
  235. return
  236. }
  237. const gl = this.gl
  238. const course = this.courseLayer.projectCourse(scene)
  239. const trackPoints = this.trackLayer.projectPoints(scene)
  240. const gpsPoint = this.gpsLayer.projectPoint(scene)
  241. const positions: number[] = []
  242. const colors: number[] = []
  243. if (course) {
  244. this.pushCourse(positions, colors, course, scene, pulseFrame)
  245. }
  246. this.pushTrack(positions, colors, trackPoints, scene)
  247. if (gpsPoint && scene.gpsMarkerStyleConfig.visible) {
  248. this.pushGpsMarker(positions, colors, gpsPoint.x, gpsPoint.y, scene, pulseFrame)
  249. }
  250. if (!positions.length) {
  251. return
  252. }
  253. gl.viewport(0, 0, this.canvas.width, this.canvas.height)
  254. gl.useProgram(this.program)
  255. gl.bindBuffer(gl.ARRAY_BUFFER, this.positionBuffer)
  256. gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STREAM_DRAW)
  257. gl.enableVertexAttribArray(this.positionLocation)
  258. gl.vertexAttribPointer(this.positionLocation, 2, gl.FLOAT, false, 0, 0)
  259. gl.bindBuffer(gl.ARRAY_BUFFER, this.colorBuffer)
  260. gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(colors), gl.STREAM_DRAW)
  261. gl.enableVertexAttribArray(this.colorLocation)
  262. gl.vertexAttribPointer(this.colorLocation, 4, gl.FLOAT, false, 0, 0)
  263. gl.drawArrays(gl.TRIANGLES, 0, positions.length / 2)
  264. }
  265. isLite(scene: MapScene): boolean {
  266. return scene.animationLevel === 'lite'
  267. }
  268. getRingSegments(scene: MapScene): number {
  269. return this.isLite(scene) ? 24 : 36
  270. }
  271. getCircleSegments(scene: MapScene): number {
  272. return this.isLite(scene) ? 14 : 20
  273. }
  274. getPixelsPerMeter(scene: MapScene): number {
  275. const camera: CameraState = {
  276. centerWorldX: scene.exactCenterWorldX,
  277. centerWorldY: scene.exactCenterWorldY,
  278. viewportWidth: scene.viewportWidth,
  279. viewportHeight: scene.viewportHeight,
  280. visibleColumns: scene.visibleColumns,
  281. }
  282. const tileSizePx = getTileSizePx(camera)
  283. const centerLonLat = worldTileToLonLat({ x: scene.exactCenterWorldX, y: scene.exactCenterWorldY }, scene.zoom)
  284. const metersPerTile = Math.cos(centerLonLat.lat * Math.PI / 180) * EARTH_CIRCUMFERENCE_METERS / Math.pow(2, scene.zoom)
  285. if (!tileSizePx || !metersPerTile) {
  286. return 0
  287. }
  288. return tileSizePx / metersPerTile
  289. }
  290. getMetric(scene: MapScene, meters: number): number {
  291. return meters * this.getPixelsPerMeter(scene)
  292. }
  293. getControlRadiusMeters(scene: MapScene): number {
  294. return scene.cpRadiusMeters > 0 ? scene.cpRadiusMeters : 5
  295. }
  296. pushCourse(
  297. positions: number[],
  298. colors: number[],
  299. course: ProjectedCourseLayers,
  300. scene: MapScene,
  301. pulseFrame: number,
  302. ): void {
  303. const controlRadiusMeters = this.getControlRadiusMeters(scene)
  304. if (scene.revealFullCourse && scene.showCourseLegs) {
  305. for (let index = 0; index < course.legs.length; index += 1) {
  306. const leg = course.legs[index]
  307. this.pushCourseLeg(positions, colors, leg, controlRadiusMeters, leg.index, scene)
  308. if (scene.guidanceLegAnimationEnabled && scene.activeLegIndices.includes(index)) {
  309. this.pushCourseLegHighlight(positions, colors, leg, controlRadiusMeters, scene)
  310. }
  311. }
  312. const guideLeg = this.getGuideLeg(course, scene)
  313. if (guideLeg) {
  314. this.pushGuidanceFlow(positions, colors, guideLeg, controlRadiusMeters, scene, pulseFrame)
  315. }
  316. }
  317. for (const start of course.starts) {
  318. if (scene.activeStart) {
  319. this.pushActiveStartPulse(positions, colors, start.point.x, start.point.y, start.headingDeg, controlRadiusMeters, scene, pulseFrame)
  320. }
  321. if (scene.completedStart) {
  322. this.pushRing(
  323. positions,
  324. colors,
  325. start.point.x,
  326. start.point.y,
  327. this.getMetric(scene, controlRadiusMeters * 1.16),
  328. this.getMetric(scene, controlRadiusMeters * 1.02),
  329. COMPLETED_SETTLE_COLOR,
  330. scene,
  331. )
  332. }
  333. const startStyle = resolveControlStyle(scene, 'start', null, start.index)
  334. this.pushStartMarker(positions, colors, start.point.x, start.point.y, start.headingDeg, controlRadiusMeters, startStyle.entry, startStyle.color, scene)
  335. }
  336. if (!scene.revealFullCourse) {
  337. return
  338. }
  339. for (const control of course.controls) {
  340. const controlStyle = resolveControlStyle(scene, 'control', control.sequence)
  341. if (scene.activeControlSequences.includes(control.sequence)) {
  342. if (scene.controlVisualMode === 'single-target') {
  343. this.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters, scene, pulseFrame)
  344. } else {
  345. this.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters, scene, pulseFrame, MULTI_ACTIVE_PULSE_COLOR)
  346. if (!this.isLite(scene)) {
  347. this.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters * 1.2, scene, pulseFrame + 9, [0.9, 1, 1, 0.52])
  348. }
  349. }
  350. }
  351. if (scene.readyControlSequences.includes(control.sequence)) {
  352. this.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters * 1.04, scene, pulseFrame, READY_PULSE_COLOR)
  353. if (!this.isLite(scene)) {
  354. this.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters * 1.22, scene, pulseFrame + 11, [0.92, 1, 1, 0.42])
  355. }
  356. this.pushRing(
  357. positions,
  358. colors,
  359. control.point.x,
  360. control.point.y,
  361. this.getMetric(scene, controlRadiusMeters * 1.16),
  362. this.getMetric(scene, controlRadiusMeters * 1.02),
  363. READY_CONTROL_COLOR,
  364. scene,
  365. )
  366. }
  367. this.pushControlShape(
  368. positions,
  369. colors,
  370. control.point.x,
  371. control.point.y,
  372. controlRadiusMeters,
  373. controlStyle.entry,
  374. controlStyle.color,
  375. scene,
  376. )
  377. if (scene.focusedControlSequences.includes(control.sequence)) {
  378. this.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters * 1.02, scene, pulseFrame, FOCUSED_PULSE_COLOR)
  379. if (!this.isLite(scene)) {
  380. this.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters * 1.32, scene, pulseFrame + 15, [1, 0.86, 0.94, 0.5])
  381. }
  382. this.pushRing(
  383. positions,
  384. colors,
  385. control.point.x,
  386. control.point.y,
  387. this.getMetric(scene, controlRadiusMeters * 1.24),
  388. this.getMetric(scene, controlRadiusMeters * 1.06),
  389. FOCUSED_CONTROL_COLOR,
  390. scene,
  391. )
  392. }
  393. if (scene.completedControlSequences.includes(control.sequence)) {
  394. this.pushRing(
  395. positions,
  396. colors,
  397. control.point.x,
  398. control.point.y,
  399. this.getMetric(scene, controlRadiusMeters * 1.14),
  400. this.getMetric(scene, controlRadiusMeters * 1.02),
  401. COMPLETED_SETTLE_COLOR,
  402. scene,
  403. )
  404. }
  405. if (this.isSkippedControl(scene, control.sequence)) {
  406. this.pushRing(
  407. positions,
  408. colors,
  409. control.point.x,
  410. control.point.y,
  411. this.getMetric(scene, controlRadiusMeters * 1.1),
  412. this.getMetric(scene, controlRadiusMeters * 1.01),
  413. SKIPPED_SETTLE_COLOR,
  414. scene,
  415. )
  416. this.pushSkippedControlSlash(positions, colors, control.point.x, control.point.y, controlRadiusMeters, scene)
  417. }
  418. }
  419. for (const finish of course.finishes) {
  420. if (scene.activeFinish) {
  421. this.pushActiveControlPulse(positions, colors, finish.point.x, finish.point.y, controlRadiusMeters, scene, pulseFrame)
  422. }
  423. if (scene.focusedFinish) {
  424. this.pushActiveControlPulse(positions, colors, finish.point.x, finish.point.y, controlRadiusMeters * 1.04, scene, pulseFrame, FOCUSED_PULSE_COLOR)
  425. if (!this.isLite(scene)) {
  426. this.pushActiveControlPulse(positions, colors, finish.point.x, finish.point.y, controlRadiusMeters * 1.34, scene, pulseFrame + 12, [1, 0.86, 0.94, 0.46])
  427. }
  428. }
  429. const finishStyle = resolveControlStyle(scene, 'finish', null, finish.index)
  430. if (scene.completedFinish) {
  431. this.pushRing(
  432. positions,
  433. colors,
  434. finish.point.x,
  435. finish.point.y,
  436. this.getMetric(scene, controlRadiusMeters * 1.18),
  437. this.getMetric(scene, controlRadiusMeters * 1.02),
  438. COMPLETED_SETTLE_COLOR,
  439. scene,
  440. )
  441. }
  442. this.pushFinishMarker(positions, colors, finish.point.x, finish.point.y, controlRadiusMeters, finishStyle.entry, finishStyle.color, scene)
  443. }
  444. }
  445. pushTrack(
  446. positions: number[],
  447. colors: number[],
  448. trackPoints: Array<{ x: number; y: number }>,
  449. scene: MapScene,
  450. ): void {
  451. if (scene.trackMode === 'none' || trackPoints.length < 2) {
  452. return
  453. }
  454. const bodyColor = hexToRgbaColor(scene.trackStyleConfig.colorHex)
  455. const headColor = hexToRgbaColor(scene.trackStyleConfig.headColorHex)
  456. const baseWidth = scene.trackStyleConfig.widthPx
  457. const headWidth = Math.max(baseWidth, scene.trackStyleConfig.headWidthPx)
  458. const glowStrength = scene.trackStyleConfig.glowStrength
  459. const displayPoints = this.smoothTrackPoints(trackPoints)
  460. if (scene.trackMode === 'full') {
  461. for (let index = 1; index < displayPoints.length; index += 1) {
  462. const from = displayPoints[index - 1]
  463. const to = displayPoints[index]
  464. if (glowStrength > 0) {
  465. this.pushSegment(positions, colors, from, to, baseWidth * (1.45 + glowStrength * 0.75), this.applyAlpha(bodyColor, 0.05 + glowStrength * 0.08), scene)
  466. }
  467. this.pushSegment(positions, colors, from, to, baseWidth, this.applyAlpha(bodyColor, 0.88), scene)
  468. }
  469. return
  470. }
  471. for (let index = 1; index < displayPoints.length; index += 1) {
  472. const from = displayPoints[index - 1]
  473. const to = displayPoints[index]
  474. const progress = index / Math.max(1, displayPoints.length - 1)
  475. const segmentWidth = baseWidth + (headWidth - baseWidth) * progress
  476. const segmentColor = this.mixTrackColor(bodyColor, headColor, progress, 0.12 + progress * 0.88)
  477. if (glowStrength > 0) {
  478. this.pushSegment(
  479. positions,
  480. colors,
  481. from,
  482. to,
  483. segmentWidth * (1.35 + glowStrength * 0.55),
  484. this.applyAlpha(segmentColor, (0.03 + progress * 0.14) * (0.7 + glowStrength * 0.38)),
  485. scene,
  486. )
  487. }
  488. this.pushSegment(positions, colors, from, to, segmentWidth, segmentColor, scene)
  489. }
  490. const head = displayPoints[displayPoints.length - 1]
  491. if (glowStrength > 0) {
  492. this.pushCircle(positions, colors, head.x, head.y, headWidth * (1.04 + glowStrength * 0.28), this.applyAlpha(headColor, 0.1 + glowStrength * 0.12), scene)
  493. }
  494. this.pushCircle(positions, colors, head.x, head.y, Math.max(3.4, headWidth * 0.46), this.applyAlpha(headColor, 0.94), scene)
  495. }
  496. pushGpsMarker(
  497. positions: number[],
  498. colors: number[],
  499. x: number,
  500. y: number,
  501. scene: MapScene,
  502. pulseFrame: number,
  503. ): void {
  504. const metrics = scaleGpsMarkerMetrics(
  505. getGpsMarkerMetrics(scene.gpsMarkerStyleConfig.size),
  506. scene.gpsMarkerStyleConfig.effectScale || 1,
  507. )
  508. const style = scene.gpsMarkerStyleConfig.style
  509. const hasBadgeLogo = style === 'badge' && !!scene.gpsMarkerStyleConfig.logoUrl
  510. const pulseStrength = Math.max(0.45, Math.min(1.85, scene.gpsMarkerStyleConfig.pulseStrength || 1))
  511. const motionState = scene.gpsMarkerStyleConfig.motionState || 'idle'
  512. const motionIntensity = Math.max(0, Math.min(1.2, scene.gpsMarkerStyleConfig.motionIntensity || 0))
  513. const wakeStrength = Math.max(0, Math.min(1, scene.gpsMarkerStyleConfig.wakeStrength || 0))
  514. const warningGlowStrength = Math.max(0, Math.min(1, scene.gpsMarkerStyleConfig.warningGlowStrength || 0))
  515. const indicatorScale = Math.max(0.86, Math.min(1.28, scene.gpsMarkerStyleConfig.indicatorScale || 1))
  516. const markerColor = hexToRgbaColor(scene.gpsMarkerStyleConfig.colorHex)
  517. const ringColor = hexToRgbaColor(scene.gpsMarkerStyleConfig.ringColorHex)
  518. const indicatorColor = hexToRgbaColor(scene.gpsMarkerStyleConfig.indicatorColorHex)
  519. if (wakeStrength > 0.05 && scene.gpsHeadingDeg !== null) {
  520. const headingScreenRad = (scene.gpsHeadingDeg * Math.PI / 180) - scene.rotationRad
  521. const wakeHeadingRad = headingScreenRad + Math.PI
  522. const wakeCount = motionState === 'fast-moving' ? 3 : 2
  523. for (let index = 0; index < wakeCount; index += 1) {
  524. const offset = metrics.coreRadiusPx * (0.85 + index * 0.64) * (0.9 + wakeStrength * 0.72)
  525. const center = rotatePoint(0, -offset, wakeHeadingRad)
  526. const radius = metrics.coreRadiusPx * Math.max(0.22, 0.58 - index * 0.12 + wakeStrength * 0.08)
  527. const alpha = Math.max(0.06, (0.14 + wakeStrength * 0.12) * (1 - index * 0.26))
  528. this.pushCircle(positions, colors, x + center.x, y + center.y, radius, [markerColor[0], markerColor[1], markerColor[2], alpha], scene)
  529. }
  530. }
  531. if (warningGlowStrength > 0.04) {
  532. const glowPhase = getGpsPulsePhase(pulseFrame, motionState)
  533. this.pushRing(
  534. positions,
  535. colors,
  536. x,
  537. y,
  538. metrics.ringRadiusPx * (1.18 + warningGlowStrength * 0.12 + glowPhase * 0.04),
  539. metrics.ringRadiusPx * (1.02 + warningGlowStrength * 0.08 + glowPhase * 0.02),
  540. [markerColor[0], markerColor[1], markerColor[2], 0.18 + warningGlowStrength * 0.18],
  541. scene,
  542. )
  543. }
  544. if (style === 'beacon' || (style === 'badge' && !hasBadgeLogo)) {
  545. const pulseRadius = getAnimatedGpsPulseRadius(pulseFrame, metrics, motionState, pulseStrength, motionIntensity)
  546. const pulseAlpha = style === 'badge'
  547. ? Math.min(0.2, 0.08 + pulseStrength * 0.06)
  548. : Math.min(0.26, 0.1 + pulseStrength * 0.08)
  549. this.pushCircle(positions, colors, x, y, pulseRadius, [1, 1, 1, pulseAlpha], scene)
  550. }
  551. if (style === 'dot') {
  552. this.pushRing(
  553. positions,
  554. colors,
  555. x,
  556. y,
  557. metrics.coreRadiusPx + metrics.ringWidthPx * 0.72,
  558. metrics.coreRadiusPx + 0.08,
  559. ringColor,
  560. scene,
  561. )
  562. this.pushCircle(positions, colors, x, y, metrics.coreRadiusPx * 0.82, markerColor, scene)
  563. } else if (style === 'disc') {
  564. this.pushRing(
  565. positions,
  566. colors,
  567. x,
  568. y,
  569. metrics.ringRadiusPx * 1.05,
  570. Math.max(metrics.coreRadiusPx + 0.04, metrics.ringRadiusPx * 1.05 - metrics.ringWidthPx * 1.18),
  571. ringColor,
  572. scene,
  573. )
  574. this.pushCircle(positions, colors, x, y, metrics.coreRadiusPx * 1.02, markerColor, scene)
  575. this.pushCircle(positions, colors, x, y, metrics.coreRadiusPx * 0.22, [1, 1, 1, 0.96], scene)
  576. } else if (style === 'badge') {
  577. this.pushRing(
  578. positions,
  579. colors,
  580. x,
  581. y,
  582. metrics.ringRadiusPx * 1.06,
  583. Math.max(metrics.coreRadiusPx + 0.12, metrics.ringRadiusPx * 1.06 - metrics.ringWidthPx * 1.12),
  584. [1, 1, 1, 0.98],
  585. scene,
  586. )
  587. this.pushCircle(positions, colors, x, y, metrics.coreRadiusPx * 0.98, markerColor, scene)
  588. if (!hasBadgeLogo) {
  589. this.pushCircle(positions, colors, x - metrics.coreRadiusPx * 0.16, y - metrics.coreRadiusPx * 0.22, metrics.coreRadiusPx * 0.18, [1, 1, 1, 0.16], scene)
  590. }
  591. } else {
  592. this.pushRing(
  593. positions,
  594. colors,
  595. x,
  596. y,
  597. metrics.ringRadiusPx,
  598. Math.max(metrics.coreRadiusPx + 0.15, metrics.ringRadiusPx - metrics.ringWidthPx),
  599. ringColor,
  600. scene,
  601. )
  602. this.pushCircle(positions, colors, x, y, metrics.coreRadiusPx, markerColor, scene)
  603. this.pushCircle(positions, colors, x, y, metrics.coreRadiusPx * 0.18, [1, 1, 1, 0.22], scene)
  604. }
  605. if (scene.gpsHeadingDeg !== null && scene.gpsMarkerStyleConfig.showHeadingIndicator) {
  606. const headingScreenRad = (scene.gpsHeadingDeg * Math.PI / 180) - scene.rotationRad
  607. const alpha = scene.gpsHeadingAlpha
  608. const indicatorBaseDistance = metrics.ringRadiusPx + metrics.indicatorOffsetPx
  609. const indicatorSize = metrics.indicatorSizePx * indicatorScale
  610. const indicatorTipDistance = indicatorBaseDistance + indicatorSize * 0.94
  611. const tip = rotatePoint(0, -indicatorTipDistance, headingScreenRad)
  612. const left = rotatePoint(-indicatorSize * 0.58, -indicatorBaseDistance, headingScreenRad)
  613. const right = rotatePoint(indicatorSize * 0.58, -indicatorBaseDistance, headingScreenRad)
  614. this.pushTriangleScreen(
  615. positions,
  616. colors,
  617. x + tip.x,
  618. y + tip.y,
  619. x + left.x,
  620. y + left.y,
  621. x + right.x,
  622. y + right.y,
  623. [1, 1, 1, Math.max(0.42, alpha)],
  624. scene,
  625. )
  626. const innerTip = rotatePoint(0, -(indicatorBaseDistance + indicatorSize * 0.72), headingScreenRad)
  627. const innerLeft = rotatePoint(-indicatorSize * 0.4, -(indicatorBaseDistance + 0.15), headingScreenRad)
  628. const innerRight = rotatePoint(indicatorSize * 0.4, -(indicatorBaseDistance + 0.15), headingScreenRad)
  629. this.pushTriangleScreen(
  630. positions,
  631. colors,
  632. x + innerTip.x,
  633. y + innerTip.y,
  634. x + innerLeft.x,
  635. y + innerLeft.y,
  636. x + innerRight.x,
  637. y + innerRight.y,
  638. [indicatorColor[0], indicatorColor[1], indicatorColor[2], alpha],
  639. scene,
  640. )
  641. }
  642. }
  643. pushTriangleScreen(
  644. positions: number[],
  645. colors: number[],
  646. x1: number,
  647. y1: number,
  648. x2: number,
  649. y2: number,
  650. x3: number,
  651. y3: number,
  652. color: RgbaColor,
  653. scene: MapScene,
  654. ): void {
  655. const p1 = this.toClip(x1, y1, scene)
  656. const p2 = this.toClip(x2, y2, scene)
  657. const p3 = this.toClip(x3, y3, scene)
  658. positions.push(
  659. p1.x, p1.y,
  660. p2.x, p2.y,
  661. p3.x, p3.y,
  662. )
  663. for (let index = 0; index < 3; index += 1) {
  664. colors.push(color[0], color[1], color[2], color[3])
  665. }
  666. }
  667. smoothTrackPoints(points: Array<{ x: number; y: number }>): Array<{ x: number; y: number }> {
  668. if (points.length < 3) {
  669. return points
  670. }
  671. const smoothed: Array<{ x: number; y: number }> = [points[0]]
  672. for (let index = 1; index < points.length - 1; index += 1) {
  673. const prev = points[index - 1]
  674. const current = points[index]
  675. const next = points[index + 1]
  676. smoothed.push({
  677. x: prev.x * 0.18 + current.x * 0.64 + next.x * 0.18,
  678. y: prev.y * 0.18 + current.y * 0.64 + next.y * 0.18,
  679. })
  680. }
  681. smoothed.push(points[points.length - 1])
  682. return smoothed
  683. }
  684. mixTrackColor(from: RgbaColor, to: RgbaColor, progress: number, alpha: number): RgbaColor {
  685. return [
  686. from[0] + (to[0] - from[0]) * progress,
  687. from[1] + (to[1] - from[1]) * progress,
  688. from[2] + (to[2] - from[2]) * progress,
  689. alpha,
  690. ]
  691. }
  692. getGuideLeg(course: ProjectedCourseLayers, scene: MapScene): ProjectedCourseLeg | null {
  693. if (!scene.guidanceLegAnimationEnabled) {
  694. return null
  695. }
  696. const activeIndex = scene.activeLegIndices.length ? scene.activeLegIndices[0] : -1
  697. if (activeIndex >= 0 && activeIndex < course.legs.length) {
  698. return course.legs[activeIndex]
  699. }
  700. return null
  701. }
  702. isCompletedLeg(scene: MapScene, index: number): boolean {
  703. return scene.completedLegIndices.includes(index)
  704. }
  705. isSkippedControl(scene: MapScene, sequence: number): boolean {
  706. return scene.skippedControlSequences.includes(sequence)
  707. }
  708. pushCourseLeg(
  709. positions: number[],
  710. colors: number[],
  711. leg: ProjectedCourseLeg,
  712. controlRadiusMeters: number,
  713. index: number,
  714. scene: MapScene,
  715. ): void {
  716. const trimmed = this.getTrimmedCourseLeg(leg, controlRadiusMeters, scene)
  717. if (!trimmed) {
  718. return
  719. }
  720. const legStyle = resolveLegStyle(scene, index)
  721. this.pushLegWithStyle(
  722. positions,
  723. colors,
  724. trimmed.from,
  725. trimmed.to,
  726. controlRadiusMeters,
  727. legStyle.entry,
  728. legStyle.color,
  729. scene,
  730. )
  731. }
  732. pushCourseLegHighlight(
  733. positions: number[],
  734. colors: number[],
  735. leg: ProjectedCourseLeg,
  736. controlRadiusMeters: number,
  737. scene: MapScene,
  738. ): void {
  739. const trimmed = this.getTrimmedCourseLeg(leg, controlRadiusMeters, scene)
  740. if (!trimmed) {
  741. return
  742. }
  743. this.pushSegment(
  744. positions,
  745. colors,
  746. trimmed.from,
  747. trimmed.to,
  748. this.getMetric(scene, controlRadiusMeters * LEG_WIDTH_RATIO * 1.5),
  749. ACTIVE_LEG_COLOR,
  750. scene,
  751. )
  752. }
  753. pushActiveControlPulse(
  754. positions: number[],
  755. colors: number[],
  756. centerX: number,
  757. centerY: number,
  758. controlRadiusMeters: number,
  759. scene: MapScene,
  760. pulseFrame: number,
  761. pulseColor?: RgbaColor,
  762. ): void {
  763. const pulse = (Math.sin(pulseFrame * ACTIVE_CONTROL_PULSE_SPEED) + 1) / 2
  764. const pulseScale = ACTIVE_CONTROL_PULSE_MIN_SCALE + (ACTIVE_CONTROL_PULSE_MAX_SCALE - ACTIVE_CONTROL_PULSE_MIN_SCALE) * pulse
  765. const pulseWidthScale = pulseScale - ACTIVE_CONTROL_PULSE_WIDTH_RATIO
  766. const baseColor = pulseColor || ACTIVE_CONTROL_COLOR
  767. const glowAlpha = Math.min(1, baseColor[3] * (0.46 + pulse * 0.5))
  768. const glowColor: RgbaColor = [baseColor[0], baseColor[1], baseColor[2], glowAlpha]
  769. this.pushRing(
  770. positions,
  771. colors,
  772. centerX,
  773. centerY,
  774. this.getMetric(scene, controlRadiusMeters * pulseScale),
  775. this.getMetric(scene, controlRadiusMeters * Math.max(1, pulseWidthScale)),
  776. glowColor,
  777. scene,
  778. )
  779. }
  780. pushSkippedControlSlash(
  781. positions: number[],
  782. colors: number[],
  783. centerX: number,
  784. centerY: number,
  785. controlRadiusMeters: number,
  786. scene: MapScene,
  787. ): void {
  788. const slashRadius = this.getMetric(scene, controlRadiusMeters * 0.72)
  789. const slashWidth = this.getMetric(scene, controlRadiusMeters * 0.08)
  790. this.pushSegment(
  791. positions,
  792. colors,
  793. { x: centerX - slashRadius, y: centerY + slashRadius },
  794. { x: centerX + slashRadius, y: centerY - slashRadius },
  795. slashWidth,
  796. SKIPPED_SLASH_COLOR,
  797. scene,
  798. )
  799. }
  800. pushActiveStartPulse(
  801. positions: number[],
  802. colors: number[],
  803. centerX: number,
  804. centerY: number,
  805. headingDeg: number | null,
  806. controlRadiusMeters: number,
  807. scene: MapScene,
  808. pulseFrame: number,
  809. ): void {
  810. const pulse = (Math.sin(pulseFrame * ACTIVE_CONTROL_PULSE_SPEED) + 1) / 2
  811. const pulseScale = ACTIVE_CONTROL_PULSE_MIN_SCALE + (ACTIVE_CONTROL_PULSE_MAX_SCALE - ACTIVE_CONTROL_PULSE_MIN_SCALE) * pulse
  812. const pulseWidthScale = pulseScale - ACTIVE_CONTROL_PULSE_WIDTH_RATIO
  813. const glowAlpha = 0.24 + pulse * 0.34
  814. const glowColor: RgbaColor = [0.36, 1, 0.96, glowAlpha]
  815. const headingRad = ((headingDeg === null ? 0 : headingDeg) - 90) * Math.PI / 180
  816. const ringCenterX = centerX + Math.cos(headingRad) * this.getMetric(scene, controlRadiusMeters * 0.04)
  817. const ringCenterY = centerY + Math.sin(headingRad) * this.getMetric(scene, controlRadiusMeters * 0.04)
  818. this.pushRing(
  819. positions,
  820. colors,
  821. ringCenterX,
  822. ringCenterY,
  823. this.getMetric(scene, controlRadiusMeters * pulseScale),
  824. this.getMetric(scene, controlRadiusMeters * Math.max(1, pulseWidthScale)),
  825. glowColor,
  826. scene,
  827. )
  828. }
  829. pushGuidanceFlow(
  830. positions: number[],
  831. colors: number[],
  832. leg: ProjectedCourseLeg,
  833. controlRadiusMeters: number,
  834. scene: MapScene,
  835. pulseFrame: number,
  836. ): void {
  837. const trimmed = this.getTrimmedCourseLeg(leg, controlRadiusMeters, scene)
  838. if (!trimmed) {
  839. return
  840. }
  841. const dx = trimmed.to.x - trimmed.from.x
  842. const dy = trimmed.to.y - trimmed.from.y
  843. const length = Math.sqrt(dx * dx + dy * dy)
  844. if (!length) {
  845. return
  846. }
  847. for (let index = 0; index < GUIDE_FLOW_COUNT; index += 1) {
  848. const progress = (pulseFrame * GUIDE_FLOW_SPEED + index / GUIDE_FLOW_COUNT) % 1
  849. const tailProgress = Math.max(0, progress - GUIDE_FLOW_TRAIL)
  850. const head = {
  851. x: trimmed.from.x + dx * progress,
  852. y: trimmed.from.y + dy * progress,
  853. }
  854. const tail = {
  855. x: trimmed.from.x + dx * tailProgress,
  856. y: trimmed.from.y + dy * tailProgress,
  857. }
  858. const eased = progress * progress
  859. const width = this.getMetric(
  860. scene,
  861. controlRadiusMeters * (GUIDE_FLOW_MIN_WIDTH_RATIO + (GUIDE_FLOW_MAX_WIDTH_RATIO - GUIDE_FLOW_MIN_WIDTH_RATIO) * eased),
  862. )
  863. const outerColor = this.getGuideFlowOuterColor(eased)
  864. const innerColor = this.getGuideFlowInnerColor(eased)
  865. const headRadius = this.getMetric(scene, controlRadiusMeters * GUIDE_FLOW_HEAD_RADIUS_RATIO * (0.72 + eased * 0.42))
  866. this.pushSegment(positions, colors, tail, head, width * 1.9, outerColor, scene)
  867. this.pushSegment(positions, colors, tail, head, width, innerColor, scene)
  868. this.pushCircle(positions, colors, head.x, head.y, headRadius * 1.35, outerColor, scene)
  869. this.pushCircle(positions, colors, head.x, head.y, headRadius, innerColor, scene)
  870. }
  871. }
  872. getTrimmedCourseLeg(
  873. leg: ProjectedCourseLeg,
  874. controlRadiusMeters: number,
  875. scene: MapScene,
  876. ): { from: { x: number; y: number }; to: { x: number; y: number } } | null {
  877. return this.trimSegment(
  878. leg.from,
  879. leg.to,
  880. this.getLegTrim(leg.fromKind, controlRadiusMeters, scene),
  881. this.getLegTrim(leg.toKind, controlRadiusMeters, scene),
  882. )
  883. }
  884. getGuideFlowOuterColor(progress: number): RgbaColor {
  885. return [0.28, 0.92, 1, 0.14 + progress * 0.22]
  886. }
  887. getGuideFlowInnerColor(progress: number): RgbaColor {
  888. return [0.94, 0.99, 1, 0.38 + progress * 0.42]
  889. }
  890. getLegTrim(kind: ProjectedCourseLeg['fromKind'], controlRadiusMeters: number, scene: MapScene): number {
  891. if (kind === 'start') {
  892. return this.getMetric(scene, controlRadiusMeters * (1 - START_RING_WIDTH_RATIO / 2))
  893. }
  894. if (kind === 'finish') {
  895. return this.getMetric(scene, controlRadiusMeters * (1 - FINISH_RING_WIDTH_RATIO / 2))
  896. }
  897. return this.getMetric(scene, controlRadiusMeters * LEG_TRIM_TO_RING_CENTER_RATIO)
  898. }
  899. trimSegment(
  900. from: { x: number; y: number },
  901. to: { x: number; y: number },
  902. fromTrim: number,
  903. toTrim: number,
  904. ): { from: { x: number; y: number }; to: { x: number; y: number } } | null {
  905. const dx = to.x - from.x
  906. const dy = to.y - from.y
  907. const length = Math.sqrt(dx * dx + dy * dy)
  908. if (!length || length <= fromTrim + toTrim) {
  909. return null
  910. }
  911. const ux = dx / length
  912. const uy = dy / length
  913. return {
  914. from: {
  915. x: from.x + ux * fromTrim,
  916. y: from.y + uy * fromTrim,
  917. },
  918. to: {
  919. x: to.x - ux * toTrim,
  920. y: to.y - uy * toTrim,
  921. },
  922. }
  923. }
  924. pushStartTriangle(
  925. positions: number[],
  926. colors: number[],
  927. centerX: number,
  928. centerY: number,
  929. headingDeg: number | null,
  930. controlRadiusMeters: number,
  931. color: RgbaColor,
  932. scene: MapScene,
  933. ): void {
  934. const startRadius = this.getMetric(scene, controlRadiusMeters)
  935. const startRingWidth = this.getMetric(scene, controlRadiusMeters * START_RING_WIDTH_RATIO)
  936. const headingRad = ((headingDeg === null ? 0 : headingDeg) - 90) * Math.PI / 180
  937. const vertices = [0, 1, 2].map((index) => {
  938. const angle = headingRad + index * (Math.PI * 2 / 3)
  939. return {
  940. x: centerX + Math.cos(angle) * startRadius,
  941. y: centerY + Math.sin(angle) * startRadius,
  942. }
  943. })
  944. this.pushSegment(positions, colors, vertices[0], vertices[1], startRingWidth, color, scene)
  945. this.pushSegment(positions, colors, vertices[1], vertices[2], startRingWidth, color, scene)
  946. this.pushSegment(positions, colors, vertices[2], vertices[0], startRingWidth, color, scene)
  947. }
  948. pushStartMarker(
  949. positions: number[],
  950. colors: number[],
  951. centerX: number,
  952. centerY: number,
  953. headingDeg: number | null,
  954. controlRadiusMeters: number,
  955. entry: ControlPointStyleEntry,
  956. color: RgbaColor,
  957. scene: MapScene,
  958. ): void {
  959. const style = entry.style
  960. const radiusMeters = controlRadiusMeters * this.getPointSizeScale(entry)
  961. const accentRingScale = this.getAccentRingScale(entry, 1.22)
  962. const glowStrength = this.getPointGlowStrength(entry)
  963. if (glowStrength > 0) {
  964. this.pushCircle(
  965. positions,
  966. colors,
  967. centerX,
  968. centerY,
  969. this.getMetric(scene, radiusMeters * Math.max(1.1, accentRingScale)),
  970. this.applyAlpha(color, 0.06 + glowStrength * 0.12),
  971. scene,
  972. )
  973. }
  974. if (style === 'double-ring' || style === 'badge' || style === 'pulse-core') {
  975. this.pushRing(
  976. positions,
  977. colors,
  978. centerX,
  979. centerY,
  980. this.getMetric(scene, radiusMeters * accentRingScale),
  981. this.getMetric(scene, radiusMeters * Math.max(1.02, accentRingScale - 0.14)),
  982. this.applyAlpha(color, 0.92),
  983. scene,
  984. )
  985. }
  986. if (style === 'badge' || style === 'pulse-core') {
  987. this.pushCircle(
  988. positions,
  989. colors,
  990. centerX,
  991. centerY,
  992. this.getMetric(scene, radiusMeters * 0.2),
  993. this.applyAlpha(color, 0.96),
  994. scene,
  995. )
  996. }
  997. this.pushStartTriangle(positions, colors, centerX, centerY, headingDeg, radiusMeters, color, scene)
  998. }
  999. pushFinishMarker(
  1000. positions: number[],
  1001. colors: number[],
  1002. centerX: number,
  1003. centerY: number,
  1004. controlRadiusMeters: number,
  1005. entry: ControlPointStyleEntry,
  1006. color: RgbaColor,
  1007. scene: MapScene,
  1008. ): void {
  1009. const style = entry.style
  1010. const radiusMeters = controlRadiusMeters * this.getPointSizeScale(entry)
  1011. const accentRingScale = this.getAccentRingScale(entry, 1.18)
  1012. const glowStrength = this.getPointGlowStrength(entry)
  1013. if (glowStrength > 0) {
  1014. this.pushCircle(
  1015. positions,
  1016. colors,
  1017. centerX,
  1018. centerY,
  1019. this.getMetric(scene, radiusMeters * Math.max(1.08, accentRingScale)),
  1020. this.applyAlpha(color, 0.05 + glowStrength * 0.11),
  1021. scene,
  1022. )
  1023. }
  1024. if (style === 'double-ring' || style === 'badge' || style === 'pulse-core') {
  1025. this.pushRing(
  1026. positions,
  1027. colors,
  1028. centerX,
  1029. centerY,
  1030. this.getMetric(scene, radiusMeters * accentRingScale),
  1031. this.getMetric(scene, radiusMeters * Math.max(1.02, accentRingScale - 0.14)),
  1032. this.applyAlpha(color, 0.92),
  1033. scene,
  1034. )
  1035. }
  1036. this.pushRing(
  1037. positions,
  1038. colors,
  1039. centerX,
  1040. centerY,
  1041. this.getMetric(scene, radiusMeters),
  1042. this.getMetric(scene, radiusMeters * (1 - FINISH_RING_WIDTH_RATIO)),
  1043. color,
  1044. scene,
  1045. )
  1046. this.pushRing(
  1047. positions,
  1048. colors,
  1049. centerX,
  1050. centerY,
  1051. this.getMetric(scene, radiusMeters * FINISH_INNER_RADIUS_RATIO),
  1052. this.getMetric(scene, radiusMeters * FINISH_INNER_RADIUS_RATIO * (1 - FINISH_RING_WIDTH_RATIO / FINISH_INNER_RADIUS_RATIO)),
  1053. color,
  1054. scene,
  1055. )
  1056. if (style === 'badge' || style === 'pulse-core') {
  1057. this.pushCircle(
  1058. positions,
  1059. colors,
  1060. centerX,
  1061. centerY,
  1062. this.getMetric(scene, radiusMeters * 0.16),
  1063. this.applyAlpha(color, 0.94),
  1064. scene,
  1065. )
  1066. }
  1067. }
  1068. pushControlShape(
  1069. positions: number[],
  1070. colors: number[],
  1071. centerX: number,
  1072. centerY: number,
  1073. controlRadiusMeters: number,
  1074. entry: ControlPointStyleEntry,
  1075. color: RgbaColor,
  1076. scene: MapScene,
  1077. ): void {
  1078. const style = entry.style
  1079. const radiusMeters = controlRadiusMeters * this.getPointSizeScale(entry)
  1080. const accentRingScale = this.getAccentRingScale(entry, 1.24)
  1081. const glowStrength = this.getPointGlowStrength(entry)
  1082. const outerRadius = this.getMetric(scene, radiusMeters)
  1083. const innerRadius = this.getMetric(scene, radiusMeters * (1 - CONTROL_RING_WIDTH_RATIO))
  1084. if (glowStrength > 0) {
  1085. this.pushCircle(
  1086. positions,
  1087. colors,
  1088. centerX,
  1089. centerY,
  1090. this.getMetric(scene, radiusMeters * Math.max(1.1, accentRingScale)),
  1091. this.applyAlpha(color, 0.05 + glowStrength * 0.1),
  1092. scene,
  1093. )
  1094. }
  1095. if (style === 'solid-dot') {
  1096. this.pushCircle(positions, colors, centerX, centerY, this.getMetric(scene, radiusMeters * 0.56), this.applyAlpha(color, 0.92), scene)
  1097. this.pushRing(positions, colors, centerX, centerY, outerRadius, innerRadius, color, scene)
  1098. return
  1099. }
  1100. if (style === 'double-ring') {
  1101. this.pushRing(positions, colors, centerX, centerY, this.getMetric(scene, radiusMeters * accentRingScale), this.getMetric(scene, radiusMeters * Math.max(1.04, accentRingScale - 0.16)), this.applyAlpha(color, 0.88), scene)
  1102. this.pushRing(positions, colors, centerX, centerY, outerRadius, innerRadius, color, scene)
  1103. return
  1104. }
  1105. if (style === 'badge') {
  1106. const borderOuterRadius = this.getMetric(scene, radiusMeters)
  1107. const borderInnerRadius = this.getMetric(scene, radiusMeters * 0.86)
  1108. if (accentRingScale > 1.04 || glowStrength > 0) {
  1109. this.pushRing(
  1110. positions,
  1111. colors,
  1112. centerX,
  1113. centerY,
  1114. this.getMetric(scene, radiusMeters * Math.max(1.04, accentRingScale)),
  1115. this.getMetric(scene, radiusMeters * Math.max(0.96, accentRingScale - 0.08)),
  1116. this.applyAlpha(color, 0.2 + glowStrength * 0.14),
  1117. scene,
  1118. )
  1119. }
  1120. this.pushRing(
  1121. positions,
  1122. colors,
  1123. centerX,
  1124. centerY,
  1125. borderOuterRadius,
  1126. borderInnerRadius,
  1127. [1, 1, 1, 0.98],
  1128. scene,
  1129. )
  1130. this.pushCircle(
  1131. positions,
  1132. colors,
  1133. centerX,
  1134. centerY,
  1135. borderInnerRadius,
  1136. this.applyAlpha(color, 0.98),
  1137. scene,
  1138. )
  1139. return
  1140. }
  1141. if (style === 'pulse-core') {
  1142. this.pushRing(positions, colors, centerX, centerY, this.getMetric(scene, radiusMeters * Math.max(1.14, accentRingScale)), this.getMetric(scene, radiusMeters * Math.max(1.02, accentRingScale - 0.12)), this.applyAlpha(color, 0.76), scene)
  1143. this.pushRing(positions, colors, centerX, centerY, outerRadius, innerRadius, color, scene)
  1144. this.pushCircle(positions, colors, centerX, centerY, this.getMetric(scene, radiusMeters * 0.18), this.applyAlpha(color, 0.98), scene)
  1145. return
  1146. }
  1147. this.pushRing(positions, colors, centerX, centerY, outerRadius, innerRadius, color, scene)
  1148. }
  1149. pushLegWithStyle(
  1150. positions: number[],
  1151. colors: number[],
  1152. from: { x: number; y: number },
  1153. to: { x: number; y: number },
  1154. controlRadiusMeters: number,
  1155. entry: CourseLegStyleEntry,
  1156. color: RgbaColor,
  1157. scene: MapScene,
  1158. ): void {
  1159. const style = entry.style
  1160. const widthScale = Math.max(0.55, entry.widthScale || 1)
  1161. const glowStrength = this.getLegGlowStrength(entry)
  1162. const baseWidth = this.getMetric(scene, controlRadiusMeters * LEG_WIDTH_RATIO * widthScale)
  1163. if (glowStrength > 0) {
  1164. this.pushSegment(
  1165. positions,
  1166. colors,
  1167. from,
  1168. to,
  1169. baseWidth * (1.5 + glowStrength * 0.9),
  1170. this.applyAlpha(color, 0.06 + glowStrength * 0.1),
  1171. scene,
  1172. )
  1173. }
  1174. if (style === 'dashed-leg') {
  1175. this.pushDashedSegment(positions, colors, from, to, baseWidth * 0.92, color, scene)
  1176. return
  1177. }
  1178. if (style === 'glow-leg') {
  1179. this.pushSegment(positions, colors, from, to, baseWidth * 2.7, this.applyAlpha(color, 0.16), scene)
  1180. this.pushSegment(positions, colors, from, to, baseWidth * 1.54, this.applyAlpha(color, 0.34), scene)
  1181. this.pushSegment(positions, colors, from, to, baseWidth, color, scene)
  1182. this.pushArrowHead(positions, colors, from, to, controlRadiusMeters, color, scene, 1.08)
  1183. return
  1184. }
  1185. if (style === 'progress-leg') {
  1186. this.pushSegment(positions, colors, from, to, baseWidth * 1.9, this.applyAlpha(color, 0.18), scene)
  1187. this.pushSegment(positions, colors, from, to, baseWidth * 1.26, color, scene)
  1188. this.pushSegment(positions, colors, from, to, baseWidth * 0.44, [1, 1, 1, 0.54], scene)
  1189. this.pushArrowHead(positions, colors, from, to, controlRadiusMeters, this.applyAlpha(color, 0.94), scene, 0.92)
  1190. return
  1191. }
  1192. this.pushSegment(positions, colors, from, to, baseWidth, color, scene)
  1193. }
  1194. getPointSizeScale(entry: ControlPointStyleEntry): number {
  1195. return Math.max(0.72, entry.sizeScale || 1)
  1196. }
  1197. getAccentRingScale(entry: ControlPointStyleEntry, fallback: number): number {
  1198. return Math.max(0.96, entry.accentRingScale || fallback)
  1199. }
  1200. getPointGlowStrength(entry: ControlPointStyleEntry): number {
  1201. return Math.max(0, Math.min(1.2, entry.glowStrength || 0))
  1202. }
  1203. getLegGlowStrength(entry: CourseLegStyleEntry): number {
  1204. return Math.max(0, Math.min(1.2, entry.glowStrength || 0))
  1205. }
  1206. pushDashedSegment(
  1207. positions: number[],
  1208. colors: number[],
  1209. start: { x: number; y: number },
  1210. end: { x: number; y: number },
  1211. width: number,
  1212. color: RgbaColor,
  1213. scene: MapScene,
  1214. ): void {
  1215. const dx = end.x - start.x
  1216. const dy = end.y - start.y
  1217. const length = Math.sqrt(dx * dx + dy * dy)
  1218. if (!length) {
  1219. return
  1220. }
  1221. const dashLength = Math.max(width * 3.1, 12)
  1222. const gapLength = Math.max(width * 1.7, 8)
  1223. let offset = 0
  1224. while (offset < length) {
  1225. const dashEnd = Math.min(length, offset + dashLength)
  1226. const fromRatio = offset / length
  1227. const toRatio = dashEnd / length
  1228. this.pushSegment(
  1229. positions,
  1230. colors,
  1231. { x: start.x + dx * fromRatio, y: start.y + dy * fromRatio },
  1232. { x: start.x + dx * toRatio, y: start.y + dy * toRatio },
  1233. width,
  1234. color,
  1235. scene,
  1236. )
  1237. offset += dashLength + gapLength
  1238. }
  1239. }
  1240. applyAlpha(color: RgbaColor, alpha: number): RgbaColor {
  1241. return [color[0], color[1], color[2], alpha]
  1242. }
  1243. pushArrowHead(
  1244. positions: number[],
  1245. colors: number[],
  1246. start: { x: number; y: number },
  1247. end: { x: number; y: number },
  1248. controlRadiusMeters: number,
  1249. color: RgbaColor,
  1250. scene: MapScene,
  1251. scale: number,
  1252. ): void {
  1253. const dx = end.x - start.x
  1254. const dy = end.y - start.y
  1255. const length = Math.sqrt(dx * dx + dy * dy)
  1256. if (!length) {
  1257. return
  1258. }
  1259. const ux = dx / length
  1260. const uy = dy / length
  1261. const nx = -uy
  1262. const ny = ux
  1263. const headLength = this.getMetric(scene, controlRadiusMeters * LEG_ARROW_HEAD_LENGTH_RATIO * scale)
  1264. const headWidth = this.getMetric(scene, controlRadiusMeters * LEG_ARROW_HEAD_WIDTH_RATIO * scale)
  1265. const baseCenterX = end.x - ux * headLength
  1266. const baseCenterY = end.y - uy * headLength
  1267. const tip = this.toClip(end.x, end.y, scene)
  1268. const left = this.toClip(baseCenterX + nx * headWidth * 0.5, baseCenterY + ny * headWidth * 0.5, scene)
  1269. const right = this.toClip(baseCenterX - nx * headWidth * 0.5, baseCenterY - ny * headWidth * 0.5, scene)
  1270. this.pushTriangle(positions, colors, tip, left, right, color)
  1271. }
  1272. pushRing(
  1273. positions: number[],
  1274. colors: number[],
  1275. centerX: number,
  1276. centerY: number,
  1277. outerRadius: number,
  1278. innerRadius: number,
  1279. color: RgbaColor,
  1280. scene: MapScene,
  1281. ): void {
  1282. const segments = this.getRingSegments(scene)
  1283. for (let index = 0; index < segments; index += 1) {
  1284. const startAngle = index / segments * Math.PI * 2
  1285. const endAngle = (index + 1) / segments * Math.PI * 2
  1286. const outerStart = this.toClip(centerX + Math.cos(startAngle) * outerRadius, centerY + Math.sin(startAngle) * outerRadius, scene)
  1287. const outerEnd = this.toClip(centerX + Math.cos(endAngle) * outerRadius, centerY + Math.sin(endAngle) * outerRadius, scene)
  1288. const innerStart = this.toClip(centerX + Math.cos(startAngle) * innerRadius, centerY + Math.sin(startAngle) * innerRadius, scene)
  1289. const innerEnd = this.toClip(centerX + Math.cos(endAngle) * innerRadius, centerY + Math.sin(endAngle) * innerRadius, scene)
  1290. this.pushTriangle(positions, colors, outerStart, outerEnd, innerStart, color)
  1291. this.pushTriangle(positions, colors, innerStart, outerEnd, innerEnd, color)
  1292. }
  1293. }
  1294. pushSegment(
  1295. positions: number[],
  1296. colors: number[],
  1297. start: { x: number; y: number },
  1298. end: { x: number; y: number },
  1299. width: number,
  1300. color: RgbaColor,
  1301. scene: MapScene,
  1302. ): void {
  1303. const deltaX = end.x - start.x
  1304. const deltaY = end.y - start.y
  1305. const length = Math.sqrt(deltaX * deltaX + deltaY * deltaY)
  1306. if (!length) {
  1307. return
  1308. }
  1309. const normalX = -deltaY / length * (width / 2)
  1310. const normalY = deltaX / length * (width / 2)
  1311. const topLeft = this.toClip(start.x + normalX, start.y + normalY, scene)
  1312. const topRight = this.toClip(end.x + normalX, end.y + normalY, scene)
  1313. const bottomLeft = this.toClip(start.x - normalX, start.y - normalY, scene)
  1314. const bottomRight = this.toClip(end.x - normalX, end.y - normalY, scene)
  1315. this.pushTriangle(positions, colors, topLeft, topRight, bottomLeft, color)
  1316. this.pushTriangle(positions, colors, bottomLeft, topRight, bottomRight, color)
  1317. }
  1318. pushCircle(
  1319. positions: number[],
  1320. colors: number[],
  1321. centerX: number,
  1322. centerY: number,
  1323. radius: number,
  1324. color: RgbaColor,
  1325. scene: MapScene,
  1326. ): void {
  1327. const segments = this.getCircleSegments(scene)
  1328. const center = this.toClip(centerX, centerY, scene)
  1329. for (let index = 0; index < segments; index += 1) {
  1330. const startAngle = index / segments * Math.PI * 2
  1331. const endAngle = (index + 1) / segments * Math.PI * 2
  1332. const start = this.toClip(centerX + Math.cos(startAngle) * radius, centerY + Math.sin(startAngle) * radius, scene)
  1333. const end = this.toClip(centerX + Math.cos(endAngle) * radius, centerY + Math.sin(endAngle) * radius, scene)
  1334. this.pushTriangle(positions, colors, center, start, end, color)
  1335. }
  1336. }
  1337. pushTriangle(
  1338. positions: number[],
  1339. colors: number[],
  1340. first: { x: number; y: number },
  1341. second: { x: number; y: number },
  1342. third: { x: number; y: number },
  1343. color: RgbaColor,
  1344. ): void {
  1345. positions.push(first.x, first.y, second.x, second.y, third.x, third.y)
  1346. for (let index = 0; index < 3; index += 1) {
  1347. colors.push(color[0], color[1], color[2], color[3])
  1348. }
  1349. }
  1350. toClip(x: number, y: number, scene: MapScene): { x: number; y: number } {
  1351. const previewScale = scene.previewScale || 1
  1352. const originX = scene.previewOriginX || scene.viewportWidth / 2
  1353. const originY = scene.previewOriginY || scene.viewportHeight / 2
  1354. const scaledX = originX + (x - originX) * previewScale
  1355. const scaledY = originY + (y - originY) * previewScale
  1356. return {
  1357. x: scaledX / scene.viewportWidth * 2 - 1,
  1358. y: 1 - scaledY / scene.viewportHeight * 2,
  1359. }
  1360. }
  1361. }