Files
cmr-mini/miniprogram/engine/renderer/webglVectorRenderer.ts

1496 lines
51 KiB
TypeScript

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,
}
}
}