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, effectScale: number, ): ReturnType { 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, 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, } } }