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' const COURSE_COLOR: [number, number, number, number] = [0.8, 0.0, 0.42, 0.96] 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 type RgbaColor = [number, number, number, number] const GUIDE_FLOW_COUNT = 6 const GUIDE_FLOW_SPEED = 0.022 const GUIDE_FLOW_MIN_RADIUS_RATIO = 0.14 const GUIDE_FLOW_MAX_RADIUS_RATIO = 0.34 const GUIDE_FLOW_OUTER_SCALE = 1.45 const GUIDE_FLOW_INNER_SCALE = 0.56 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) } for (let index = 1; index < trackPoints.length; index += 1) { this.pushSegment(positions, colors, trackPoints[index - 1], trackPoints[index], 6, [0.09, 0.43, 0.36, 0.96], scene) } for (const point of trackPoints) { this.pushCircle(positions, colors, point.x, point.y, 10, [0.09, 0.43, 0.36, 1], scene) this.pushCircle(positions, colors, point.x, point.y, 6.5, [0.97, 0.98, 0.95, 1], scene) } if (gpsPoint) { this.pushCircle(positions, colors, gpsPoint.x, gpsPoint.y, this.gpsLayer.getPulseRadius(pulseFrame), [0.13, 0.62, 0.74, 0.22], scene) this.pushCircle(positions, colors, gpsPoint.x, gpsPoint.y, 13, [1, 1, 1, 0.95], scene) this.pushCircle(positions, colors, gpsPoint.x, gpsPoint.y, 9, [0.13, 0.63, 0.74, 1], scene) } 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) } 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) for (const leg of course.legs) { this.pushCourseLeg(positions, colors, leg, controlRadiusMeters, scene) } const guideLeg = this.getGuideLeg(course) if (guideLeg) { this.pushGuidanceFlow(positions, colors, guideLeg, controlRadiusMeters, scene, pulseFrame) } for (const start of course.starts) { this.pushStartTriangle(positions, colors, start.point.x, start.point.y, start.headingDeg, controlRadiusMeters, scene) } for (const control of course.controls) { this.pushRing( positions, colors, control.point.x, control.point.y, this.getMetric(scene, controlRadiusMeters), this.getMetric(scene, controlRadiusMeters * (1 - CONTROL_RING_WIDTH_RATIO)), COURSE_COLOR, scene, ) } for (const finish of course.finishes) { this.pushRing( positions, colors, finish.point.x, finish.point.y, this.getMetric(scene, controlRadiusMeters), this.getMetric(scene, controlRadiusMeters * (1 - FINISH_RING_WIDTH_RATIO)), COURSE_COLOR, scene, ) this.pushRing( positions, colors, finish.point.x, finish.point.y, this.getMetric(scene, controlRadiusMeters * FINISH_INNER_RADIUS_RATIO), this.getMetric(scene, controlRadiusMeters * FINISH_INNER_RADIUS_RATIO * (1 - FINISH_RING_WIDTH_RATIO / FINISH_INNER_RADIUS_RATIO)), COURSE_COLOR, scene, ) } } getGuideLeg(course: ProjectedCourseLayers): ProjectedCourseLeg | null { return course.legs.length ? course.legs[0] : null } pushCourseLeg( 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), COURSE_COLOR, 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 eased = progress * progress const x = trimmed.from.x + dx * progress const y = trimmed.from.y + dy * progress const radius = this.getMetric( scene, controlRadiusMeters * (GUIDE_FLOW_MIN_RADIUS_RATIO + (GUIDE_FLOW_MAX_RADIUS_RATIO - GUIDE_FLOW_MIN_RADIUS_RATIO) * eased), ) const outerColor = this.getGuideFlowOuterColor(eased) const innerColor = this.getGuideFlowInnerColor(eased) this.pushCircle(positions, colors, x, y, radius * GUIDE_FLOW_OUTER_SCALE, outerColor, scene) this.pushCircle(positions, colors, x, y, radius * GUIDE_FLOW_INNER_SCALE, 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 [1, 0.18, 0.6, 0.16 + progress * 0.34] } getGuideFlowInnerColor(progress: number): RgbaColor { return [1, 0.95, 0.98, 0.3 + progress * 0.54] } 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, 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, COURSE_COLOR, scene) this.pushSegment(positions, colors, vertices[1], vertices[2], startRingWidth, COURSE_COLOR, scene) this.pushSegment(positions, colors, vertices[2], vertices[0], startRingWidth, COURSE_COLOR, scene) } pushRing( positions: number[], colors: number[], centerX: number, centerY: number, outerRadius: number, innerRadius: number, color: RgbaColor, scene: MapScene, ): void { const segments = 36 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 = 20 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, } } }