| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495 |
- import { getTileSizePx, type CameraState } from '../camera/camera'
- import { worldTileToLonLat } from '../../utils/projection'
- import { type MapScene } from './mapRenderer'
- import { CourseLayer, type ProjectedCourseLayers, type ProjectedCourseLeg } from '../layer/courseLayer'
- import { TrackLayer } from '../layer/trackLayer'
- import { GpsLayer } from '../layer/gpsLayer'
- import { type GpsMarkerStyleConfig } from '../../game/presentation/gpsMarkerStyleConfig'
- import {
- type ControlPointStyleEntry,
- type CourseLegStyleEntry,
- } from '../../game/presentation/courseStyleConfig'
- import { hexToRgbaColor, resolveControlStyle, resolveLegStyle, type RgbaColor } from './courseStyleResolver'
- const ACTIVE_CONTROL_COLOR: [number, number, number, number] = [0.22, 1, 0.95, 1]
- const READY_CONTROL_COLOR: [number, number, number, number] = [0.38, 1, 0.92, 1]
- const FOCUSED_CONTROL_COLOR: [number, number, number, number] = [0.98, 0.96, 0.98, 1]
- const MULTI_ACTIVE_PULSE_COLOR: [number, number, number, number] = [0.18, 1, 0.96, 0.86]
- const FOCUSED_PULSE_COLOR: [number, number, number, number] = [1, 0.36, 0.84, 0.88]
- const READY_PULSE_COLOR: [number, number, number, number] = [0.44, 1, 0.92, 0.98]
- const COMPLETED_SETTLE_COLOR: [number, number, number, number] = [0.86, 0.9, 0.94, 0.24]
- const SKIPPED_SETTLE_COLOR: [number, number, number, number] = [0.72, 0.76, 0.82, 0.18]
- const SKIPPED_SLASH_COLOR: [number, number, number, number] = [0.78, 0.82, 0.88, 0.9]
- const ACTIVE_LEG_COLOR: [number, number, number, number] = [0.18, 1, 0.94, 0.5]
- const EARTH_CIRCUMFERENCE_METERS = 40075016.686
- const CONTROL_RING_WIDTH_RATIO = 0.2
- const FINISH_INNER_RADIUS_RATIO = 0.6
- const FINISH_RING_WIDTH_RATIO = 0.2
- const START_RING_WIDTH_RATIO = 0.2
- const LEG_WIDTH_RATIO = 0.2
- const LEG_TRIM_TO_RING_CENTER_RATIO = 1 - CONTROL_RING_WIDTH_RATIO / 2
- const ACTIVE_CONTROL_PULSE_SPEED = 0.18
- const ACTIVE_CONTROL_PULSE_MIN_SCALE = 1.12
- const ACTIVE_CONTROL_PULSE_MAX_SCALE = 1.46
- const ACTIVE_CONTROL_PULSE_WIDTH_RATIO = 0.12
- const GUIDE_FLOW_COUNT = 5
- const GUIDE_FLOW_SPEED = 0.02
- const GUIDE_FLOW_TRAIL = 0.16
- const GUIDE_FLOW_MIN_WIDTH_RATIO = 0.12
- const GUIDE_FLOW_MAX_WIDTH_RATIO = 0.22
- const GUIDE_FLOW_HEAD_RADIUS_RATIO = 0.18
- const LEG_ARROW_HEAD_LENGTH_RATIO = 0.34
- const LEG_ARROW_HEAD_WIDTH_RATIO = 0.24
- function getGpsMarkerMetrics(size: GpsMarkerStyleConfig['size']) {
- if (size === 'small') {
- return {
- coreRadiusPx: 7,
- ringRadiusPx: 8.35,
- pulseRadiusPx: 14,
- indicatorOffsetPx: 1.1,
- indicatorSizePx: 7,
- ringWidthPx: 2.5,
- }
- }
- if (size === 'large') {
- return {
- coreRadiusPx: 11,
- ringRadiusPx: 12.95,
- pulseRadiusPx: 22,
- indicatorOffsetPx: 1.45,
- indicatorSizePx: 10,
- ringWidthPx: 3.5,
- }
- }
- return {
- coreRadiusPx: 9,
- ringRadiusPx: 10.65,
- pulseRadiusPx: 18,
- indicatorOffsetPx: 1.25,
- indicatorSizePx: 8.5,
- ringWidthPx: 3,
- }
- }
- function scaleGpsMarkerMetrics(
- metrics: ReturnType<typeof getGpsMarkerMetrics>,
- effectScale: number,
- ): ReturnType<typeof getGpsMarkerMetrics> {
- const safeScale = Math.max(0.88, Math.min(1.28, effectScale || 1))
- return {
- coreRadiusPx: metrics.coreRadiusPx * safeScale,
- ringRadiusPx: metrics.ringRadiusPx * safeScale,
- pulseRadiusPx: metrics.pulseRadiusPx * safeScale,
- indicatorOffsetPx: metrics.indicatorOffsetPx * safeScale,
- indicatorSizePx: metrics.indicatorSizePx * safeScale,
- ringWidthPx: Math.max(2, metrics.ringWidthPx * (0.96 + (safeScale - 1) * 0.35)),
- }
- }
- function getGpsPulsePhase(
- pulseFrame: number,
- motionState: GpsMarkerStyleConfig['motionState'],
- ): number {
- const divisor = motionState === 'idle'
- ? 11.5
- : motionState === 'moving'
- ? 6.2
- : motionState === 'fast-moving'
- ? 4.3
- : 4.8
- return 0.5 + 0.5 * Math.sin(pulseFrame / divisor)
- }
- function getAnimatedGpsPulseRadius(
- pulseFrame: number,
- metrics: ReturnType<typeof getGpsMarkerMetrics>,
- motionState: GpsMarkerStyleConfig['motionState'],
- pulseStrength: number,
- motionIntensity: number,
- ): number {
- const phase = getGpsPulsePhase(pulseFrame, motionState)
- const baseRadius = motionState === 'idle'
- ? metrics.pulseRadiusPx * 0.82
- : motionState === 'moving'
- ? metrics.pulseRadiusPx * 0.94
- : motionState === 'fast-moving'
- ? metrics.pulseRadiusPx * 1.04
- : metrics.pulseRadiusPx
- const amplitude = motionState === 'idle'
- ? metrics.pulseRadiusPx * 0.12
- : motionState === 'moving'
- ? metrics.pulseRadiusPx * 0.18
- : motionState === 'fast-moving'
- ? metrics.pulseRadiusPx * 0.24
- : metrics.pulseRadiusPx * 0.2
- return baseRadius + amplitude * phase * (0.8 + pulseStrength * 0.18 + motionIntensity * 0.1)
- }
- function rotatePoint(x: number, y: number, angleRad: number): { x: number; y: number } {
- const cos = Math.cos(angleRad)
- const sin = Math.sin(angleRad)
- return {
- x: x * cos - y * sin,
- y: x * sin + y * cos,
- }
- }
- function createShader(gl: any, type: number, source: string): any {
- const shader = gl.createShader(type)
- if (!shader) {
- throw new Error('WebGL shader 创建失败')
- }
- gl.shaderSource(shader, source)
- gl.compileShader(shader)
- if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
- const message = gl.getShaderInfoLog(shader) || 'unknown shader error'
- gl.deleteShader(shader)
- throw new Error(message)
- }
- return shader
- }
- function createProgram(gl: any, vertexSource: string, fragmentSource: string): any {
- const vertexShader = createShader(gl, gl.VERTEX_SHADER, vertexSource)
- const fragmentShader = createShader(gl, gl.FRAGMENT_SHADER, fragmentSource)
- const program = gl.createProgram()
- if (!program) {
- throw new Error('WebGL program 创建失败')
- }
- gl.attachShader(program, vertexShader)
- gl.attachShader(program, fragmentShader)
- gl.linkProgram(program)
- gl.deleteShader(vertexShader)
- gl.deleteShader(fragmentShader)
- if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
- const message = gl.getProgramInfoLog(program) || 'unknown program error'
- gl.deleteProgram(program)
- throw new Error(message)
- }
- return program
- }
- export class WebGLVectorRenderer {
- canvas: any
- gl: any
- dpr: number
- courseLayer: CourseLayer
- trackLayer: TrackLayer
- gpsLayer: GpsLayer
- program: any
- positionBuffer: any
- colorBuffer: any
- positionLocation: number
- colorLocation: number
- constructor(courseLayer: CourseLayer, trackLayer: TrackLayer, gpsLayer: GpsLayer) {
- this.canvas = null
- this.gl = null
- this.dpr = 1
- this.courseLayer = courseLayer
- this.trackLayer = trackLayer
- this.gpsLayer = gpsLayer
- this.program = null
- this.positionBuffer = null
- this.colorBuffer = null
- this.positionLocation = -1
- this.colorLocation = -1
- }
- attachCanvas(canvasNode: any, width: number, height: number, dpr: number): void {
- this.canvas = canvasNode
- this.dpr = dpr || 1
- canvasNode.width = Math.max(1, Math.floor(width * this.dpr))
- canvasNode.height = Math.max(1, Math.floor(height * this.dpr))
- this.attachContext(canvasNode.getContext('webgl') || canvasNode.getContext('experimental-webgl'), canvasNode)
- }
- attachContext(gl: any, canvasNode: any): void {
- if (!gl) {
- throw new Error('当前环境不支持 WebGL Vector Layer')
- }
- this.canvas = canvasNode
- this.gl = gl
- this.program = createProgram(
- gl,
- '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; }',
- 'precision mediump float; varying vec4 v_color; void main() { gl_FragColor = v_color; }',
- )
- this.positionBuffer = gl.createBuffer()
- this.colorBuffer = gl.createBuffer()
- this.positionLocation = gl.getAttribLocation(this.program, 'a_position')
- this.colorLocation = gl.getAttribLocation(this.program, 'a_color')
- gl.viewport(0, 0, canvasNode.width, canvasNode.height)
- gl.enable(gl.BLEND)
- gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
- }
- destroy(): void {
- if (this.gl) {
- if (this.program) {
- this.gl.deleteProgram(this.program)
- }
- if (this.positionBuffer) {
- this.gl.deleteBuffer(this.positionBuffer)
- }
- if (this.colorBuffer) {
- this.gl.deleteBuffer(this.colorBuffer)
- }
- }
- this.program = null
- this.positionBuffer = null
- this.colorBuffer = null
- this.gl = null
- this.canvas = null
- }
- render(scene: MapScene, pulseFrame: number): void {
- if (!this.gl || !this.program || !this.positionBuffer || !this.colorBuffer || !this.canvas) {
- return
- }
- const gl = this.gl
- const course = this.courseLayer.projectCourse(scene)
- const trackPoints = this.trackLayer.projectPoints(scene)
- const gpsPoint = this.gpsLayer.projectPoint(scene)
- const positions: number[] = []
- const colors: number[] = []
- if (course) {
- this.pushCourse(positions, colors, course, scene, pulseFrame)
- }
- this.pushTrack(positions, colors, trackPoints, scene)
- if (gpsPoint && scene.gpsMarkerStyleConfig.visible) {
- this.pushGpsMarker(positions, colors, gpsPoint.x, gpsPoint.y, scene, pulseFrame)
- }
- if (!positions.length) {
- return
- }
- gl.viewport(0, 0, this.canvas.width, this.canvas.height)
- gl.useProgram(this.program)
- gl.bindBuffer(gl.ARRAY_BUFFER, this.positionBuffer)
- gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STREAM_DRAW)
- gl.enableVertexAttribArray(this.positionLocation)
- gl.vertexAttribPointer(this.positionLocation, 2, gl.FLOAT, false, 0, 0)
- gl.bindBuffer(gl.ARRAY_BUFFER, this.colorBuffer)
- gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(colors), gl.STREAM_DRAW)
- gl.enableVertexAttribArray(this.colorLocation)
- gl.vertexAttribPointer(this.colorLocation, 4, gl.FLOAT, false, 0, 0)
- gl.drawArrays(gl.TRIANGLES, 0, positions.length / 2)
- }
- isLite(scene: MapScene): boolean {
- return scene.animationLevel === 'lite'
- }
- getRingSegments(scene: MapScene): number {
- return this.isLite(scene) ? 24 : 36
- }
- getCircleSegments(scene: MapScene): number {
- return this.isLite(scene) ? 14 : 20
- }
- getPixelsPerMeter(scene: MapScene): number {
- const camera: CameraState = {
- centerWorldX: scene.exactCenterWorldX,
- centerWorldY: scene.exactCenterWorldY,
- viewportWidth: scene.viewportWidth,
- viewportHeight: scene.viewportHeight,
- visibleColumns: scene.visibleColumns,
- }
- const tileSizePx = getTileSizePx(camera)
- const centerLonLat = worldTileToLonLat({ x: scene.exactCenterWorldX, y: scene.exactCenterWorldY }, scene.zoom)
- const metersPerTile = Math.cos(centerLonLat.lat * Math.PI / 180) * EARTH_CIRCUMFERENCE_METERS / Math.pow(2, scene.zoom)
- if (!tileSizePx || !metersPerTile) {
- return 0
- }
- return tileSizePx / metersPerTile
- }
- getMetric(scene: MapScene, meters: number): number {
- return meters * this.getPixelsPerMeter(scene)
- }
- getControlRadiusMeters(scene: MapScene): number {
- return scene.cpRadiusMeters > 0 ? scene.cpRadiusMeters : 5
- }
- pushCourse(
- positions: number[],
- colors: number[],
- course: ProjectedCourseLayers,
- scene: MapScene,
- pulseFrame: number,
- ): void {
- const controlRadiusMeters = this.getControlRadiusMeters(scene)
- if (scene.revealFullCourse && scene.showCourseLegs) {
- for (let index = 0; index < course.legs.length; index += 1) {
- const leg = course.legs[index]
- this.pushCourseLeg(positions, colors, leg, controlRadiusMeters, leg.index, scene)
- if (scene.guidanceLegAnimationEnabled && scene.activeLegIndices.includes(index)) {
- this.pushCourseLegHighlight(positions, colors, leg, controlRadiusMeters, scene)
- }
- }
- const guideLeg = this.getGuideLeg(course, scene)
- if (guideLeg) {
- this.pushGuidanceFlow(positions, colors, guideLeg, controlRadiusMeters, scene, pulseFrame)
- }
- }
- for (const start of course.starts) {
- if (scene.activeStart) {
- this.pushActiveStartPulse(positions, colors, start.point.x, start.point.y, start.headingDeg, controlRadiusMeters, scene, pulseFrame)
- }
- if (scene.completedStart) {
- this.pushRing(
- positions,
- colors,
- start.point.x,
- start.point.y,
- this.getMetric(scene, controlRadiusMeters * 1.16),
- this.getMetric(scene, controlRadiusMeters * 1.02),
- COMPLETED_SETTLE_COLOR,
- scene,
- )
- }
- const startStyle = resolveControlStyle(scene, 'start', null, start.index)
- this.pushStartMarker(positions, colors, start.point.x, start.point.y, start.headingDeg, controlRadiusMeters, startStyle.entry, startStyle.color, scene)
- }
- if (!scene.revealFullCourse) {
- return
- }
- for (const control of course.controls) {
- const controlStyle = resolveControlStyle(scene, 'control', control.sequence)
- if (scene.activeControlSequences.includes(control.sequence)) {
- if (scene.controlVisualMode === 'single-target') {
- this.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters, scene, pulseFrame)
- } else {
- this.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters, scene, pulseFrame, MULTI_ACTIVE_PULSE_COLOR)
- if (!this.isLite(scene)) {
- this.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters * 1.2, scene, pulseFrame + 9, [0.9, 1, 1, 0.52])
- }
- }
- }
- if (scene.readyControlSequences.includes(control.sequence)) {
- this.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters * 1.04, scene, pulseFrame, READY_PULSE_COLOR)
- if (!this.isLite(scene)) {
- this.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters * 1.22, scene, pulseFrame + 11, [0.92, 1, 1, 0.42])
- }
- this.pushRing(
- positions,
- colors,
- control.point.x,
- control.point.y,
- this.getMetric(scene, controlRadiusMeters * 1.16),
- this.getMetric(scene, controlRadiusMeters * 1.02),
- READY_CONTROL_COLOR,
- scene,
- )
- }
- this.pushControlShape(
- positions,
- colors,
- control.point.x,
- control.point.y,
- controlRadiusMeters,
- controlStyle.entry,
- controlStyle.color,
- scene,
- )
- if (scene.focusedControlSequences.includes(control.sequence)) {
- this.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters * 1.02, scene, pulseFrame, FOCUSED_PULSE_COLOR)
- if (!this.isLite(scene)) {
- this.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters * 1.32, scene, pulseFrame + 15, [1, 0.86, 0.94, 0.5])
- }
- this.pushRing(
- positions,
- colors,
- control.point.x,
- control.point.y,
- this.getMetric(scene, controlRadiusMeters * 1.24),
- this.getMetric(scene, controlRadiusMeters * 1.06),
- FOCUSED_CONTROL_COLOR,
- scene,
- )
- }
- if (scene.completedControlSequences.includes(control.sequence)) {
- this.pushRing(
- positions,
- colors,
- control.point.x,
- control.point.y,
- this.getMetric(scene, controlRadiusMeters * 1.14),
- this.getMetric(scene, controlRadiusMeters * 1.02),
- COMPLETED_SETTLE_COLOR,
- scene,
- )
- }
- if (this.isSkippedControl(scene, control.sequence)) {
- this.pushRing(
- positions,
- colors,
- control.point.x,
- control.point.y,
- this.getMetric(scene, controlRadiusMeters * 1.1),
- this.getMetric(scene, controlRadiusMeters * 1.01),
- SKIPPED_SETTLE_COLOR,
- scene,
- )
- this.pushSkippedControlSlash(positions, colors, control.point.x, control.point.y, controlRadiusMeters, scene)
- }
- }
- for (const finish of course.finishes) {
- if (scene.activeFinish) {
- this.pushActiveControlPulse(positions, colors, finish.point.x, finish.point.y, controlRadiusMeters, scene, pulseFrame)
- }
- if (scene.focusedFinish) {
- this.pushActiveControlPulse(positions, colors, finish.point.x, finish.point.y, controlRadiusMeters * 1.04, scene, pulseFrame, FOCUSED_PULSE_COLOR)
- if (!this.isLite(scene)) {
- this.pushActiveControlPulse(positions, colors, finish.point.x, finish.point.y, controlRadiusMeters * 1.34, scene, pulseFrame + 12, [1, 0.86, 0.94, 0.46])
- }
- }
- const finishStyle = resolveControlStyle(scene, 'finish', null, finish.index)
- if (scene.completedFinish) {
- this.pushRing(
- positions,
- colors,
- finish.point.x,
- finish.point.y,
- this.getMetric(scene, controlRadiusMeters * 1.18),
- this.getMetric(scene, controlRadiusMeters * 1.02),
- COMPLETED_SETTLE_COLOR,
- scene,
- )
- }
- this.pushFinishMarker(positions, colors, finish.point.x, finish.point.y, controlRadiusMeters, finishStyle.entry, finishStyle.color, scene)
- }
- }
- pushTrack(
- positions: number[],
- colors: number[],
- trackPoints: Array<{ x: number; y: number }>,
- scene: MapScene,
- ): void {
- if (scene.trackMode === 'none' || trackPoints.length < 2) {
- return
- }
- const bodyColor = hexToRgbaColor(scene.trackStyleConfig.colorHex)
- const headColor = hexToRgbaColor(scene.trackStyleConfig.headColorHex)
- const baseWidth = scene.trackStyleConfig.widthPx
- const headWidth = Math.max(baseWidth, scene.trackStyleConfig.headWidthPx)
- const glowStrength = scene.trackStyleConfig.glowStrength
- const displayPoints = this.smoothTrackPoints(trackPoints)
- if (scene.trackMode === 'full') {
- for (let index = 1; index < displayPoints.length; index += 1) {
- const from = displayPoints[index - 1]
- const to = displayPoints[index]
- if (glowStrength > 0) {
- this.pushSegment(positions, colors, from, to, baseWidth * (1.45 + glowStrength * 0.75), this.applyAlpha(bodyColor, 0.05 + glowStrength * 0.08), scene)
- }
- this.pushSegment(positions, colors, from, to, baseWidth, this.applyAlpha(bodyColor, 0.88), scene)
- }
- return
- }
- for (let index = 1; index < displayPoints.length; index += 1) {
- const from = displayPoints[index - 1]
- const to = displayPoints[index]
- const progress = index / Math.max(1, displayPoints.length - 1)
- const segmentWidth = baseWidth + (headWidth - baseWidth) * progress
- const segmentColor = this.mixTrackColor(bodyColor, headColor, progress, 0.12 + progress * 0.88)
- if (glowStrength > 0) {
- this.pushSegment(
- positions,
- colors,
- from,
- to,
- segmentWidth * (1.35 + glowStrength * 0.55),
- this.applyAlpha(segmentColor, (0.03 + progress * 0.14) * (0.7 + glowStrength * 0.38)),
- scene,
- )
- }
- this.pushSegment(positions, colors, from, to, segmentWidth, segmentColor, scene)
- }
- const head = displayPoints[displayPoints.length - 1]
- if (glowStrength > 0) {
- this.pushCircle(positions, colors, head.x, head.y, headWidth * (1.04 + glowStrength * 0.28), this.applyAlpha(headColor, 0.1 + glowStrength * 0.12), scene)
- }
- this.pushCircle(positions, colors, head.x, head.y, Math.max(3.4, headWidth * 0.46), this.applyAlpha(headColor, 0.94), scene)
- }
- pushGpsMarker(
- positions: number[],
- colors: number[],
- x: number,
- y: number,
- scene: MapScene,
- pulseFrame: number,
- ): void {
- const metrics = scaleGpsMarkerMetrics(
- getGpsMarkerMetrics(scene.gpsMarkerStyleConfig.size),
- scene.gpsMarkerStyleConfig.effectScale || 1,
- )
- const style = scene.gpsMarkerStyleConfig.style
- const hasBadgeLogo = style === 'badge' && !!scene.gpsMarkerStyleConfig.logoUrl
- const pulseStrength = Math.max(0.45, Math.min(1.85, scene.gpsMarkerStyleConfig.pulseStrength || 1))
- const motionState = scene.gpsMarkerStyleConfig.motionState || 'idle'
- const motionIntensity = Math.max(0, Math.min(1.2, scene.gpsMarkerStyleConfig.motionIntensity || 0))
- const wakeStrength = Math.max(0, Math.min(1, scene.gpsMarkerStyleConfig.wakeStrength || 0))
- const warningGlowStrength = Math.max(0, Math.min(1, scene.gpsMarkerStyleConfig.warningGlowStrength || 0))
- const indicatorScale = Math.max(0.86, Math.min(1.28, scene.gpsMarkerStyleConfig.indicatorScale || 1))
- const markerColor = hexToRgbaColor(scene.gpsMarkerStyleConfig.colorHex)
- const ringColor = hexToRgbaColor(scene.gpsMarkerStyleConfig.ringColorHex)
- const indicatorColor = hexToRgbaColor(scene.gpsMarkerStyleConfig.indicatorColorHex)
- if (wakeStrength > 0.05 && scene.gpsHeadingDeg !== null) {
- const headingScreenRad = (scene.gpsHeadingDeg * Math.PI / 180) - scene.rotationRad
- const wakeHeadingRad = headingScreenRad + Math.PI
- const wakeCount = motionState === 'fast-moving' ? 3 : 2
- for (let index = 0; index < wakeCount; index += 1) {
- const offset = metrics.coreRadiusPx * (0.85 + index * 0.64) * (0.9 + wakeStrength * 0.72)
- const center = rotatePoint(0, -offset, wakeHeadingRad)
- const radius = metrics.coreRadiusPx * Math.max(0.22, 0.58 - index * 0.12 + wakeStrength * 0.08)
- const alpha = Math.max(0.06, (0.14 + wakeStrength * 0.12) * (1 - index * 0.26))
- this.pushCircle(positions, colors, x + center.x, y + center.y, radius, [markerColor[0], markerColor[1], markerColor[2], alpha], scene)
- }
- }
- if (warningGlowStrength > 0.04) {
- const glowPhase = getGpsPulsePhase(pulseFrame, motionState)
- this.pushRing(
- positions,
- colors,
- x,
- y,
- metrics.ringRadiusPx * (1.18 + warningGlowStrength * 0.12 + glowPhase * 0.04),
- metrics.ringRadiusPx * (1.02 + warningGlowStrength * 0.08 + glowPhase * 0.02),
- [markerColor[0], markerColor[1], markerColor[2], 0.18 + warningGlowStrength * 0.18],
- scene,
- )
- }
- if (style === 'beacon' || (style === 'badge' && !hasBadgeLogo)) {
- const pulseRadius = getAnimatedGpsPulseRadius(pulseFrame, metrics, motionState, pulseStrength, motionIntensity)
- const pulseAlpha = style === 'badge'
- ? Math.min(0.2, 0.08 + pulseStrength * 0.06)
- : Math.min(0.26, 0.1 + pulseStrength * 0.08)
- this.pushCircle(positions, colors, x, y, pulseRadius, [1, 1, 1, pulseAlpha], scene)
- }
- if (style === 'dot') {
- this.pushRing(
- positions,
- colors,
- x,
- y,
- metrics.coreRadiusPx + metrics.ringWidthPx * 0.72,
- metrics.coreRadiusPx + 0.08,
- ringColor,
- scene,
- )
- this.pushCircle(positions, colors, x, y, metrics.coreRadiusPx * 0.82, markerColor, scene)
- } else if (style === 'disc') {
- this.pushRing(
- positions,
- colors,
- x,
- y,
- metrics.ringRadiusPx * 1.05,
- Math.max(metrics.coreRadiusPx + 0.04, metrics.ringRadiusPx * 1.05 - metrics.ringWidthPx * 1.18),
- ringColor,
- scene,
- )
- this.pushCircle(positions, colors, x, y, metrics.coreRadiusPx * 1.02, markerColor, scene)
- this.pushCircle(positions, colors, x, y, metrics.coreRadiusPx * 0.22, [1, 1, 1, 0.96], scene)
- } else if (style === 'badge') {
- this.pushRing(
- positions,
- colors,
- x,
- y,
- metrics.ringRadiusPx * 1.06,
- Math.max(metrics.coreRadiusPx + 0.12, metrics.ringRadiusPx * 1.06 - metrics.ringWidthPx * 1.12),
- [1, 1, 1, 0.98],
- scene,
- )
- this.pushCircle(positions, colors, x, y, metrics.coreRadiusPx * 0.98, markerColor, scene)
- if (!hasBadgeLogo) {
- this.pushCircle(positions, colors, x - metrics.coreRadiusPx * 0.16, y - metrics.coreRadiusPx * 0.22, metrics.coreRadiusPx * 0.18, [1, 1, 1, 0.16], scene)
- }
- } else {
- this.pushRing(
- positions,
- colors,
- x,
- y,
- metrics.ringRadiusPx,
- Math.max(metrics.coreRadiusPx + 0.15, metrics.ringRadiusPx - metrics.ringWidthPx),
- ringColor,
- scene,
- )
- this.pushCircle(positions, colors, x, y, metrics.coreRadiusPx, markerColor, scene)
- this.pushCircle(positions, colors, x, y, metrics.coreRadiusPx * 0.18, [1, 1, 1, 0.22], scene)
- }
- if (scene.gpsHeadingDeg !== null && scene.gpsMarkerStyleConfig.showHeadingIndicator) {
- const headingScreenRad = (scene.gpsHeadingDeg * Math.PI / 180) - scene.rotationRad
- const alpha = scene.gpsHeadingAlpha
- const indicatorBaseDistance = metrics.ringRadiusPx + metrics.indicatorOffsetPx
- const indicatorSize = metrics.indicatorSizePx * indicatorScale
- const indicatorTipDistance = indicatorBaseDistance + indicatorSize * 0.94
- const tip = rotatePoint(0, -indicatorTipDistance, headingScreenRad)
- const left = rotatePoint(-indicatorSize * 0.58, -indicatorBaseDistance, headingScreenRad)
- const right = rotatePoint(indicatorSize * 0.58, -indicatorBaseDistance, headingScreenRad)
- this.pushTriangleScreen(
- positions,
- colors,
- x + tip.x,
- y + tip.y,
- x + left.x,
- y + left.y,
- x + right.x,
- y + right.y,
- [1, 1, 1, Math.max(0.42, alpha)],
- scene,
- )
- const innerTip = rotatePoint(0, -(indicatorBaseDistance + indicatorSize * 0.72), headingScreenRad)
- const innerLeft = rotatePoint(-indicatorSize * 0.4, -(indicatorBaseDistance + 0.15), headingScreenRad)
- const innerRight = rotatePoint(indicatorSize * 0.4, -(indicatorBaseDistance + 0.15), headingScreenRad)
- this.pushTriangleScreen(
- positions,
- colors,
- x + innerTip.x,
- y + innerTip.y,
- x + innerLeft.x,
- y + innerLeft.y,
- x + innerRight.x,
- y + innerRight.y,
- [indicatorColor[0], indicatorColor[1], indicatorColor[2], alpha],
- scene,
- )
- }
- }
- pushTriangleScreen(
- positions: number[],
- colors: number[],
- x1: number,
- y1: number,
- x2: number,
- y2: number,
- x3: number,
- y3: number,
- color: RgbaColor,
- scene: MapScene,
- ): void {
- const p1 = this.toClip(x1, y1, scene)
- const p2 = this.toClip(x2, y2, scene)
- const p3 = this.toClip(x3, y3, scene)
- positions.push(
- p1.x, p1.y,
- p2.x, p2.y,
- p3.x, p3.y,
- )
- for (let index = 0; index < 3; index += 1) {
- colors.push(color[0], color[1], color[2], color[3])
- }
- }
- smoothTrackPoints(points: Array<{ x: number; y: number }>): Array<{ x: number; y: number }> {
- if (points.length < 3) {
- return points
- }
- const smoothed: Array<{ x: number; y: number }> = [points[0]]
- for (let index = 1; index < points.length - 1; index += 1) {
- const prev = points[index - 1]
- const current = points[index]
- const next = points[index + 1]
- smoothed.push({
- x: prev.x * 0.18 + current.x * 0.64 + next.x * 0.18,
- y: prev.y * 0.18 + current.y * 0.64 + next.y * 0.18,
- })
- }
- smoothed.push(points[points.length - 1])
- return smoothed
- }
- mixTrackColor(from: RgbaColor, to: RgbaColor, progress: number, alpha: number): RgbaColor {
- return [
- from[0] + (to[0] - from[0]) * progress,
- from[1] + (to[1] - from[1]) * progress,
- from[2] + (to[2] - from[2]) * progress,
- alpha,
- ]
- }
- getGuideLeg(course: ProjectedCourseLayers, scene: MapScene): ProjectedCourseLeg | null {
- if (!scene.guidanceLegAnimationEnabled) {
- return null
- }
- const activeIndex = scene.activeLegIndices.length ? scene.activeLegIndices[0] : -1
- if (activeIndex >= 0 && activeIndex < course.legs.length) {
- return course.legs[activeIndex]
- }
- return null
- }
- isCompletedLeg(scene: MapScene, index: number): boolean {
- return scene.completedLegIndices.includes(index)
- }
- isSkippedControl(scene: MapScene, sequence: number): boolean {
- return scene.skippedControlSequences.includes(sequence)
- }
- pushCourseLeg(
- positions: number[],
- colors: number[],
- leg: ProjectedCourseLeg,
- controlRadiusMeters: number,
- index: number,
- scene: MapScene,
- ): void {
- const trimmed = this.getTrimmedCourseLeg(leg, controlRadiusMeters, scene)
- if (!trimmed) {
- return
- }
- const legStyle = resolveLegStyle(scene, index)
- this.pushLegWithStyle(
- positions,
- colors,
- trimmed.from,
- trimmed.to,
- controlRadiusMeters,
- legStyle.entry,
- legStyle.color,
- scene,
- )
- }
- pushCourseLegHighlight(
- positions: number[],
- colors: number[],
- leg: ProjectedCourseLeg,
- controlRadiusMeters: number,
- scene: MapScene,
- ): void {
- const trimmed = this.getTrimmedCourseLeg(leg, controlRadiusMeters, scene)
- if (!trimmed) {
- return
- }
- this.pushSegment(
- positions,
- colors,
- trimmed.from,
- trimmed.to,
- this.getMetric(scene, controlRadiusMeters * LEG_WIDTH_RATIO * 1.5),
- ACTIVE_LEG_COLOR,
- scene,
- )
- }
- pushActiveControlPulse(
- positions: number[],
- colors: number[],
- centerX: number,
- centerY: number,
- controlRadiusMeters: number,
- scene: MapScene,
- pulseFrame: number,
- pulseColor?: RgbaColor,
- ): void {
- const pulse = (Math.sin(pulseFrame * ACTIVE_CONTROL_PULSE_SPEED) + 1) / 2
- const pulseScale = ACTIVE_CONTROL_PULSE_MIN_SCALE + (ACTIVE_CONTROL_PULSE_MAX_SCALE - ACTIVE_CONTROL_PULSE_MIN_SCALE) * pulse
- const pulseWidthScale = pulseScale - ACTIVE_CONTROL_PULSE_WIDTH_RATIO
- const baseColor = pulseColor || ACTIVE_CONTROL_COLOR
- const glowAlpha = Math.min(1, baseColor[3] * (0.46 + pulse * 0.5))
- const glowColor: RgbaColor = [baseColor[0], baseColor[1], baseColor[2], glowAlpha]
- this.pushRing(
- positions,
- colors,
- centerX,
- centerY,
- this.getMetric(scene, controlRadiusMeters * pulseScale),
- this.getMetric(scene, controlRadiusMeters * Math.max(1, pulseWidthScale)),
- glowColor,
- scene,
- )
- }
- pushSkippedControlSlash(
- positions: number[],
- colors: number[],
- centerX: number,
- centerY: number,
- controlRadiusMeters: number,
- scene: MapScene,
- ): void {
- const slashRadius = this.getMetric(scene, controlRadiusMeters * 0.72)
- const slashWidth = this.getMetric(scene, controlRadiusMeters * 0.08)
- this.pushSegment(
- positions,
- colors,
- { x: centerX - slashRadius, y: centerY + slashRadius },
- { x: centerX + slashRadius, y: centerY - slashRadius },
- slashWidth,
- SKIPPED_SLASH_COLOR,
- scene,
- )
- }
- pushActiveStartPulse(
- positions: number[],
- colors: number[],
- centerX: number,
- centerY: number,
- headingDeg: number | null,
- controlRadiusMeters: number,
- scene: MapScene,
- pulseFrame: number,
- ): void {
- const pulse = (Math.sin(pulseFrame * ACTIVE_CONTROL_PULSE_SPEED) + 1) / 2
- const pulseScale = ACTIVE_CONTROL_PULSE_MIN_SCALE + (ACTIVE_CONTROL_PULSE_MAX_SCALE - ACTIVE_CONTROL_PULSE_MIN_SCALE) * pulse
- const pulseWidthScale = pulseScale - ACTIVE_CONTROL_PULSE_WIDTH_RATIO
- const glowAlpha = 0.24 + pulse * 0.34
- const glowColor: RgbaColor = [0.36, 1, 0.96, glowAlpha]
- const headingRad = ((headingDeg === null ? 0 : headingDeg) - 90) * Math.PI / 180
- const ringCenterX = centerX + Math.cos(headingRad) * this.getMetric(scene, controlRadiusMeters * 0.04)
- const ringCenterY = centerY + Math.sin(headingRad) * this.getMetric(scene, controlRadiusMeters * 0.04)
- this.pushRing(
- positions,
- colors,
- ringCenterX,
- ringCenterY,
- this.getMetric(scene, controlRadiusMeters * pulseScale),
- this.getMetric(scene, controlRadiusMeters * Math.max(1, pulseWidthScale)),
- glowColor,
- scene,
- )
- }
- pushGuidanceFlow(
- positions: number[],
- colors: number[],
- leg: ProjectedCourseLeg,
- controlRadiusMeters: number,
- scene: MapScene,
- pulseFrame: number,
- ): void {
- const trimmed = this.getTrimmedCourseLeg(leg, controlRadiusMeters, scene)
- if (!trimmed) {
- return
- }
- const dx = trimmed.to.x - trimmed.from.x
- const dy = trimmed.to.y - trimmed.from.y
- const length = Math.sqrt(dx * dx + dy * dy)
- if (!length) {
- return
- }
- for (let index = 0; index < GUIDE_FLOW_COUNT; index += 1) {
- const progress = (pulseFrame * GUIDE_FLOW_SPEED + index / GUIDE_FLOW_COUNT) % 1
- const tailProgress = Math.max(0, progress - GUIDE_FLOW_TRAIL)
- const head = {
- x: trimmed.from.x + dx * progress,
- y: trimmed.from.y + dy * progress,
- }
- const tail = {
- x: trimmed.from.x + dx * tailProgress,
- y: trimmed.from.y + dy * tailProgress,
- }
- const eased = progress * progress
- const width = this.getMetric(
- scene,
- controlRadiusMeters * (GUIDE_FLOW_MIN_WIDTH_RATIO + (GUIDE_FLOW_MAX_WIDTH_RATIO - GUIDE_FLOW_MIN_WIDTH_RATIO) * eased),
- )
- const outerColor = this.getGuideFlowOuterColor(eased)
- const innerColor = this.getGuideFlowInnerColor(eased)
- const headRadius = this.getMetric(scene, controlRadiusMeters * GUIDE_FLOW_HEAD_RADIUS_RATIO * (0.72 + eased * 0.42))
- this.pushSegment(positions, colors, tail, head, width * 1.9, outerColor, scene)
- this.pushSegment(positions, colors, tail, head, width, innerColor, scene)
- this.pushCircle(positions, colors, head.x, head.y, headRadius * 1.35, outerColor, scene)
- this.pushCircle(positions, colors, head.x, head.y, headRadius, innerColor, scene)
- }
- }
- getTrimmedCourseLeg(
- leg: ProjectedCourseLeg,
- controlRadiusMeters: number,
- scene: MapScene,
- ): { from: { x: number; y: number }; to: { x: number; y: number } } | null {
- return this.trimSegment(
- leg.from,
- leg.to,
- this.getLegTrim(leg.fromKind, controlRadiusMeters, scene),
- this.getLegTrim(leg.toKind, controlRadiusMeters, scene),
- )
- }
- getGuideFlowOuterColor(progress: number): RgbaColor {
- return [0.28, 0.92, 1, 0.14 + progress * 0.22]
- }
- getGuideFlowInnerColor(progress: number): RgbaColor {
- return [0.94, 0.99, 1, 0.38 + progress * 0.42]
- }
- getLegTrim(kind: ProjectedCourseLeg['fromKind'], controlRadiusMeters: number, scene: MapScene): number {
- if (kind === 'start') {
- return this.getMetric(scene, controlRadiusMeters * (1 - START_RING_WIDTH_RATIO / 2))
- }
- if (kind === 'finish') {
- return this.getMetric(scene, controlRadiusMeters * (1 - FINISH_RING_WIDTH_RATIO / 2))
- }
- return this.getMetric(scene, controlRadiusMeters * LEG_TRIM_TO_RING_CENTER_RATIO)
- }
- trimSegment(
- from: { x: number; y: number },
- to: { x: number; y: number },
- fromTrim: number,
- toTrim: number,
- ): { from: { x: number; y: number }; to: { x: number; y: number } } | null {
- const dx = to.x - from.x
- const dy = to.y - from.y
- const length = Math.sqrt(dx * dx + dy * dy)
- if (!length || length <= fromTrim + toTrim) {
- return null
- }
- const ux = dx / length
- const uy = dy / length
- return {
- from: {
- x: from.x + ux * fromTrim,
- y: from.y + uy * fromTrim,
- },
- to: {
- x: to.x - ux * toTrim,
- y: to.y - uy * toTrim,
- },
- }
- }
- pushStartTriangle(
- positions: number[],
- colors: number[],
- centerX: number,
- centerY: number,
- headingDeg: number | null,
- controlRadiusMeters: number,
- color: RgbaColor,
- scene: MapScene,
- ): void {
- const startRadius = this.getMetric(scene, controlRadiusMeters)
- const startRingWidth = this.getMetric(scene, controlRadiusMeters * START_RING_WIDTH_RATIO)
- const headingRad = ((headingDeg === null ? 0 : headingDeg) - 90) * Math.PI / 180
- const vertices = [0, 1, 2].map((index) => {
- const angle = headingRad + index * (Math.PI * 2 / 3)
- return {
- x: centerX + Math.cos(angle) * startRadius,
- y: centerY + Math.sin(angle) * startRadius,
- }
- })
- this.pushSegment(positions, colors, vertices[0], vertices[1], startRingWidth, color, scene)
- this.pushSegment(positions, colors, vertices[1], vertices[2], startRingWidth, color, scene)
- this.pushSegment(positions, colors, vertices[2], vertices[0], startRingWidth, color, scene)
- }
- pushStartMarker(
- positions: number[],
- colors: number[],
- centerX: number,
- centerY: number,
- headingDeg: number | null,
- controlRadiusMeters: number,
- entry: ControlPointStyleEntry,
- color: RgbaColor,
- scene: MapScene,
- ): void {
- const style = entry.style
- const radiusMeters = controlRadiusMeters * this.getPointSizeScale(entry)
- const accentRingScale = this.getAccentRingScale(entry, 1.22)
- const glowStrength = this.getPointGlowStrength(entry)
- if (glowStrength > 0) {
- this.pushCircle(
- positions,
- colors,
- centerX,
- centerY,
- this.getMetric(scene, radiusMeters * Math.max(1.1, accentRingScale)),
- this.applyAlpha(color, 0.06 + glowStrength * 0.12),
- scene,
- )
- }
- if (style === 'double-ring' || style === 'badge' || style === 'pulse-core') {
- this.pushRing(
- positions,
- colors,
- centerX,
- centerY,
- this.getMetric(scene, radiusMeters * accentRingScale),
- this.getMetric(scene, radiusMeters * Math.max(1.02, accentRingScale - 0.14)),
- this.applyAlpha(color, 0.92),
- scene,
- )
- }
- if (style === 'badge' || style === 'pulse-core') {
- this.pushCircle(
- positions,
- colors,
- centerX,
- centerY,
- this.getMetric(scene, radiusMeters * 0.2),
- this.applyAlpha(color, 0.96),
- scene,
- )
- }
- this.pushStartTriangle(positions, colors, centerX, centerY, headingDeg, radiusMeters, color, scene)
- }
- pushFinishMarker(
- positions: number[],
- colors: number[],
- centerX: number,
- centerY: number,
- controlRadiusMeters: number,
- entry: ControlPointStyleEntry,
- color: RgbaColor,
- scene: MapScene,
- ): void {
- const style = entry.style
- const radiusMeters = controlRadiusMeters * this.getPointSizeScale(entry)
- const accentRingScale = this.getAccentRingScale(entry, 1.18)
- const glowStrength = this.getPointGlowStrength(entry)
- if (glowStrength > 0) {
- this.pushCircle(
- positions,
- colors,
- centerX,
- centerY,
- this.getMetric(scene, radiusMeters * Math.max(1.08, accentRingScale)),
- this.applyAlpha(color, 0.05 + glowStrength * 0.11),
- scene,
- )
- }
- if (style === 'double-ring' || style === 'badge' || style === 'pulse-core') {
- this.pushRing(
- positions,
- colors,
- centerX,
- centerY,
- this.getMetric(scene, radiusMeters * accentRingScale),
- this.getMetric(scene, radiusMeters * Math.max(1.02, accentRingScale - 0.14)),
- this.applyAlpha(color, 0.92),
- scene,
- )
- }
- this.pushRing(
- positions,
- colors,
- centerX,
- centerY,
- this.getMetric(scene, radiusMeters),
- this.getMetric(scene, radiusMeters * (1 - FINISH_RING_WIDTH_RATIO)),
- color,
- scene,
- )
- this.pushRing(
- positions,
- colors,
- centerX,
- centerY,
- this.getMetric(scene, radiusMeters * FINISH_INNER_RADIUS_RATIO),
- this.getMetric(scene, radiusMeters * FINISH_INNER_RADIUS_RATIO * (1 - FINISH_RING_WIDTH_RATIO / FINISH_INNER_RADIUS_RATIO)),
- color,
- scene,
- )
- if (style === 'badge' || style === 'pulse-core') {
- this.pushCircle(
- positions,
- colors,
- centerX,
- centerY,
- this.getMetric(scene, radiusMeters * 0.16),
- this.applyAlpha(color, 0.94),
- scene,
- )
- }
- }
- pushControlShape(
- positions: number[],
- colors: number[],
- centerX: number,
- centerY: number,
- controlRadiusMeters: number,
- entry: ControlPointStyleEntry,
- color: RgbaColor,
- scene: MapScene,
- ): void {
- const style = entry.style
- const radiusMeters = controlRadiusMeters * this.getPointSizeScale(entry)
- const accentRingScale = this.getAccentRingScale(entry, 1.24)
- const glowStrength = this.getPointGlowStrength(entry)
- const outerRadius = this.getMetric(scene, radiusMeters)
- const innerRadius = this.getMetric(scene, radiusMeters * (1 - CONTROL_RING_WIDTH_RATIO))
- if (glowStrength > 0) {
- this.pushCircle(
- positions,
- colors,
- centerX,
- centerY,
- this.getMetric(scene, radiusMeters * Math.max(1.1, accentRingScale)),
- this.applyAlpha(color, 0.05 + glowStrength * 0.1),
- scene,
- )
- }
- if (style === 'solid-dot') {
- this.pushCircle(positions, colors, centerX, centerY, this.getMetric(scene, radiusMeters * 0.56), this.applyAlpha(color, 0.92), scene)
- this.pushRing(positions, colors, centerX, centerY, outerRadius, innerRadius, color, scene)
- return
- }
- if (style === 'double-ring') {
- 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)
- this.pushRing(positions, colors, centerX, centerY, outerRadius, innerRadius, color, scene)
- return
- }
- if (style === 'badge') {
- const borderOuterRadius = this.getMetric(scene, radiusMeters)
- const borderInnerRadius = this.getMetric(scene, radiusMeters * 0.86)
- if (accentRingScale > 1.04 || glowStrength > 0) {
- this.pushRing(
- positions,
- colors,
- centerX,
- centerY,
- this.getMetric(scene, radiusMeters * Math.max(1.04, accentRingScale)),
- this.getMetric(scene, radiusMeters * Math.max(0.96, accentRingScale - 0.08)),
- this.applyAlpha(color, 0.2 + glowStrength * 0.14),
- scene,
- )
- }
- this.pushRing(
- positions,
- colors,
- centerX,
- centerY,
- borderOuterRadius,
- borderInnerRadius,
- [1, 1, 1, 0.98],
- scene,
- )
- this.pushCircle(
- positions,
- colors,
- centerX,
- centerY,
- borderInnerRadius,
- this.applyAlpha(color, 0.98),
- scene,
- )
- return
- }
- if (style === 'pulse-core') {
- 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)
- this.pushRing(positions, colors, centerX, centerY, outerRadius, innerRadius, color, scene)
- this.pushCircle(positions, colors, centerX, centerY, this.getMetric(scene, radiusMeters * 0.18), this.applyAlpha(color, 0.98), scene)
- return
- }
- this.pushRing(positions, colors, centerX, centerY, outerRadius, innerRadius, color, scene)
- }
- pushLegWithStyle(
- positions: number[],
- colors: number[],
- from: { x: number; y: number },
- to: { x: number; y: number },
- controlRadiusMeters: number,
- entry: CourseLegStyleEntry,
- color: RgbaColor,
- scene: MapScene,
- ): void {
- const style = entry.style
- const widthScale = Math.max(0.55, entry.widthScale || 1)
- const glowStrength = this.getLegGlowStrength(entry)
- const baseWidth = this.getMetric(scene, controlRadiusMeters * LEG_WIDTH_RATIO * widthScale)
- if (glowStrength > 0) {
- this.pushSegment(
- positions,
- colors,
- from,
- to,
- baseWidth * (1.5 + glowStrength * 0.9),
- this.applyAlpha(color, 0.06 + glowStrength * 0.1),
- scene,
- )
- }
- if (style === 'dashed-leg') {
- this.pushDashedSegment(positions, colors, from, to, baseWidth * 0.92, color, scene)
- return
- }
- if (style === 'glow-leg') {
- this.pushSegment(positions, colors, from, to, baseWidth * 2.7, this.applyAlpha(color, 0.16), scene)
- this.pushSegment(positions, colors, from, to, baseWidth * 1.54, this.applyAlpha(color, 0.34), scene)
- this.pushSegment(positions, colors, from, to, baseWidth, color, scene)
- this.pushArrowHead(positions, colors, from, to, controlRadiusMeters, color, scene, 1.08)
- return
- }
- if (style === 'progress-leg') {
- this.pushSegment(positions, colors, from, to, baseWidth * 1.9, this.applyAlpha(color, 0.18), scene)
- this.pushSegment(positions, colors, from, to, baseWidth * 1.26, color, scene)
- this.pushSegment(positions, colors, from, to, baseWidth * 0.44, [1, 1, 1, 0.54], scene)
- this.pushArrowHead(positions, colors, from, to, controlRadiusMeters, this.applyAlpha(color, 0.94), scene, 0.92)
- return
- }
- this.pushSegment(positions, colors, from, to, baseWidth, color, scene)
- }
- getPointSizeScale(entry: ControlPointStyleEntry): number {
- return Math.max(0.72, entry.sizeScale || 1)
- }
- getAccentRingScale(entry: ControlPointStyleEntry, fallback: number): number {
- return Math.max(0.96, entry.accentRingScale || fallback)
- }
- getPointGlowStrength(entry: ControlPointStyleEntry): number {
- return Math.max(0, Math.min(1.2, entry.glowStrength || 0))
- }
- getLegGlowStrength(entry: CourseLegStyleEntry): number {
- return Math.max(0, Math.min(1.2, entry.glowStrength || 0))
- }
- pushDashedSegment(
- positions: number[],
- colors: number[],
- start: { x: number; y: number },
- end: { x: number; y: number },
- width: number,
- color: RgbaColor,
- scene: MapScene,
- ): void {
- const dx = end.x - start.x
- const dy = end.y - start.y
- const length = Math.sqrt(dx * dx + dy * dy)
- if (!length) {
- return
- }
- const dashLength = Math.max(width * 3.1, 12)
- const gapLength = Math.max(width * 1.7, 8)
- let offset = 0
- while (offset < length) {
- const dashEnd = Math.min(length, offset + dashLength)
- const fromRatio = offset / length
- const toRatio = dashEnd / length
- this.pushSegment(
- positions,
- colors,
- { x: start.x + dx * fromRatio, y: start.y + dy * fromRatio },
- { x: start.x + dx * toRatio, y: start.y + dy * toRatio },
- width,
- color,
- scene,
- )
- offset += dashLength + gapLength
- }
- }
- applyAlpha(color: RgbaColor, alpha: number): RgbaColor {
- return [color[0], color[1], color[2], alpha]
- }
- pushArrowHead(
- positions: number[],
- colors: number[],
- start: { x: number; y: number },
- end: { x: number; y: number },
- controlRadiusMeters: number,
- color: RgbaColor,
- scene: MapScene,
- scale: number,
- ): void {
- const dx = end.x - start.x
- const dy = end.y - start.y
- const length = Math.sqrt(dx * dx + dy * dy)
- if (!length) {
- return
- }
- const ux = dx / length
- const uy = dy / length
- const nx = -uy
- const ny = ux
- const headLength = this.getMetric(scene, controlRadiusMeters * LEG_ARROW_HEAD_LENGTH_RATIO * scale)
- const headWidth = this.getMetric(scene, controlRadiusMeters * LEG_ARROW_HEAD_WIDTH_RATIO * scale)
- const baseCenterX = end.x - ux * headLength
- const baseCenterY = end.y - uy * headLength
- const tip = this.toClip(end.x, end.y, scene)
- const left = this.toClip(baseCenterX + nx * headWidth * 0.5, baseCenterY + ny * headWidth * 0.5, scene)
- const right = this.toClip(baseCenterX - nx * headWidth * 0.5, baseCenterY - ny * headWidth * 0.5, scene)
- this.pushTriangle(positions, colors, tip, left, right, color)
- }
- pushRing(
- positions: number[],
- colors: number[],
- centerX: number,
- centerY: number,
- outerRadius: number,
- innerRadius: number,
- color: RgbaColor,
- scene: MapScene,
- ): void {
- const segments = this.getRingSegments(scene)
- for (let index = 0; index < segments; index += 1) {
- const startAngle = index / segments * Math.PI * 2
- const endAngle = (index + 1) / segments * Math.PI * 2
- const outerStart = this.toClip(centerX + Math.cos(startAngle) * outerRadius, centerY + Math.sin(startAngle) * outerRadius, scene)
- const outerEnd = this.toClip(centerX + Math.cos(endAngle) * outerRadius, centerY + Math.sin(endAngle) * outerRadius, scene)
- const innerStart = this.toClip(centerX + Math.cos(startAngle) * innerRadius, centerY + Math.sin(startAngle) * innerRadius, scene)
- const innerEnd = this.toClip(centerX + Math.cos(endAngle) * innerRadius, centerY + Math.sin(endAngle) * innerRadius, scene)
- this.pushTriangle(positions, colors, outerStart, outerEnd, innerStart, color)
- this.pushTriangle(positions, colors, innerStart, outerEnd, innerEnd, color)
- }
- }
- pushSegment(
- positions: number[],
- colors: number[],
- start: { x: number; y: number },
- end: { x: number; y: number },
- width: number,
- color: RgbaColor,
- scene: MapScene,
- ): void {
- const deltaX = end.x - start.x
- const deltaY = end.y - start.y
- const length = Math.sqrt(deltaX * deltaX + deltaY * deltaY)
- if (!length) {
- return
- }
- const normalX = -deltaY / length * (width / 2)
- const normalY = deltaX / length * (width / 2)
- const topLeft = this.toClip(start.x + normalX, start.y + normalY, scene)
- const topRight = this.toClip(end.x + normalX, end.y + normalY, scene)
- const bottomLeft = this.toClip(start.x - normalX, start.y - normalY, scene)
- const bottomRight = this.toClip(end.x - normalX, end.y - normalY, scene)
- this.pushTriangle(positions, colors, topLeft, topRight, bottomLeft, color)
- this.pushTriangle(positions, colors, bottomLeft, topRight, bottomRight, color)
- }
- pushCircle(
- positions: number[],
- colors: number[],
- centerX: number,
- centerY: number,
- radius: number,
- color: RgbaColor,
- scene: MapScene,
- ): void {
- const segments = this.getCircleSegments(scene)
- const center = this.toClip(centerX, centerY, scene)
- for (let index = 0; index < segments; index += 1) {
- const startAngle = index / segments * Math.PI * 2
- const endAngle = (index + 1) / segments * Math.PI * 2
- const start = this.toClip(centerX + Math.cos(startAngle) * radius, centerY + Math.sin(startAngle) * radius, scene)
- const end = this.toClip(centerX + Math.cos(endAngle) * radius, centerY + Math.sin(endAngle) * radius, scene)
- this.pushTriangle(positions, colors, center, start, end, color)
- }
- }
- pushTriangle(
- positions: number[],
- colors: number[],
- first: { x: number; y: number },
- second: { x: number; y: number },
- third: { x: number; y: number },
- color: RgbaColor,
- ): void {
- positions.push(first.x, first.y, second.x, second.y, third.x, third.y)
- for (let index = 0; index < 3; index += 1) {
- colors.push(color[0], color[1], color[2], color[3])
- }
- }
- toClip(x: number, y: number, scene: MapScene): { x: number; y: number } {
- const previewScale = scene.previewScale || 1
- const originX = scene.previewOriginX || scene.viewportWidth / 2
- const originY = scene.previewOriginY || scene.viewportHeight / 2
- const scaledX = originX + (x - originX) * previewScale
- const scaledY = originY + (y - originY) * previewScale
- return {
- x: scaledX / scene.viewportWidth * 2 - 1,
- y: 1 - scaledY / scene.viewportHeight * 2,
- }
- }
- }
|