Add animated orienteering course overlays and labels

This commit is contained in:
2026-03-23 16:57:40 +08:00
parent cb190f3c66
commit 3b4b3ee3ec
11 changed files with 902 additions and 13 deletions

View File

@@ -0,0 +1,109 @@
import { lonLatToWorldTile } from '../../utils/projection'
import { worldToScreen, type CameraState } from '../camera/camera'
import { type MapLayer, type LayerRenderContext } from './mapLayer'
import { type MapScene } from '../renderer/mapRenderer'
import { type ScreenPoint } from './trackLayer'
import {
type OrienteeringCourseControl,
type OrienteeringCourseFinish,
type OrienteeringCourseLeg,
type OrienteeringCourseStart,
} from '../../utils/orienteeringCourse'
export interface ProjectedCourseLeg {
fromKind: OrienteeringCourseLeg['fromKind']
toKind: OrienteeringCourseLeg['toKind']
from: ScreenPoint
to: ScreenPoint
}
export interface ProjectedCourseStart {
label: string
point: ScreenPoint
headingDeg: number | null
}
export interface ProjectedCourseControl {
label: string
sequence: number
point: ScreenPoint
}
export interface ProjectedCourseFinish {
label: string
point: ScreenPoint
}
export interface ProjectedCourseLayers {
starts: ProjectedCourseStart[]
controls: ProjectedCourseControl[]
finishes: ProjectedCourseFinish[]
legs: ProjectedCourseLeg[]
}
function buildVectorCamera(scene: MapScene): CameraState {
return {
centerWorldX: scene.exactCenterWorldX,
centerWorldY: scene.exactCenterWorldY,
viewportWidth: scene.viewportWidth,
viewportHeight: scene.viewportHeight,
visibleColumns: scene.visibleColumns,
rotationRad: scene.rotationRad,
}
}
export class CourseLayer implements MapLayer {
projectPoint(point: { point: { lon: number; lat: number } }, scene: MapScene, camera: CameraState): ScreenPoint {
const worldPoint = lonLatToWorldTile(point.point, scene.zoom)
return worldToScreen(camera, worldPoint, false)
}
projectStarts(starts: OrienteeringCourseStart[], scene: MapScene, camera: CameraState): ProjectedCourseStart[] {
return starts.map((start) => ({
label: start.label,
point: this.projectPoint(start, scene, camera),
headingDeg: start.headingDeg,
}))
}
projectControls(controls: OrienteeringCourseControl[], scene: MapScene, camera: CameraState): ProjectedCourseControl[] {
return controls.map((control) => ({
label: control.label,
sequence: control.sequence,
point: this.projectPoint(control, scene, camera),
}))
}
projectFinishes(finishes: OrienteeringCourseFinish[], scene: MapScene, camera: CameraState): ProjectedCourseFinish[] {
return finishes.map((finish) => ({
label: finish.label,
point: this.projectPoint(finish, scene, camera),
}))
}
projectLegs(legs: OrienteeringCourseLeg[], scene: MapScene, camera: CameraState): ProjectedCourseLeg[] {
return legs.map((leg) => ({
fromKind: leg.fromKind,
toKind: leg.toKind,
from: worldToScreen(camera, lonLatToWorldTile(leg.fromPoint, scene.zoom), false),
to: worldToScreen(camera, lonLatToWorldTile(leg.toPoint, scene.zoom), false),
}))
}
projectCourse(scene: MapScene): ProjectedCourseLayers | null {
const course = scene.course
if (!course) {
return null
}
const camera = buildVectorCamera(scene)
return {
starts: this.projectStarts(course.layers.starts, scene, camera),
controls: this.projectControls(course.layers.controls, scene, camera),
finishes: this.projectFinishes(course.layers.finishes, scene, camera),
legs: this.projectLegs(course.layers.legs, scene, camera),
}
}
draw(_context: LayerRenderContext): void {}
}

View File

@@ -4,6 +4,7 @@ import { LocationController } from '../sensor/locationController'
import { WebGLMapRenderer } from '../renderer/webglMapRenderer'
import { type MapRendererStats } from '../renderer/mapRenderer'
import { lonLatToWorldTile, worldTileToLonLat, type LonLatPoint, type MapCalibration } from '../../utils/projection'
import { type OrienteeringCourseData } from '../../utils/orienteeringCourse'
import { isTileWithinBounds, type RemoteMapConfig, type TileZoomBounds } from '../../utils/remoteMapConfig'
const RENDER_MODE = 'Single WebGL Pipeline'
@@ -427,6 +428,8 @@ export class MapEngine {
currentGpsPoint: LonLatPoint | null
currentGpsTrack: LonLatPoint[]
currentGpsAccuracyMeters: number | null
courseData: OrienteeringCourseData | null
cpRadiusMeters: number
hasGpsCenteredOnce: boolean
constructor(buildVersion: string, callbacks: MapEngineCallbacks) {
@@ -477,6 +480,8 @@ export class MapEngine {
this.currentGpsPoint = null
this.currentGpsTrack = []
this.currentGpsAccuracyMeters = null
this.courseData = null
this.cpRadiusMeters = 5
this.hasGpsCenteredOnce = false
this.state = {
buildVersion: this.buildVersion,
@@ -649,8 +654,8 @@ export class MapEngine {
)
}
attachCanvas(canvasNode: any, width: number, height: number, dpr: number): void {
this.renderer.attachCanvas(canvasNode, width, height, dpr)
attachCanvas(canvasNode: any, width: number, height: number, dpr: number, labelCanvasNode?: any): void {
this.renderer.attachCanvas(canvasNode, width, height, dpr, labelCanvasNode)
this.mounted = true
this.state.mapReady = true
this.state.mapReadyText = 'READY'
@@ -672,9 +677,11 @@ export class MapEngine {
this.defaultCenterTileX = config.initialCenterTileX
this.defaultCenterTileY = config.initialCenterTileY
this.tileBoundsByZoom = config.tileBoundsByZoom
this.courseData = config.course
this.cpRadiusMeters = config.cpRadiusMeters
const statePatch: Partial<MapEngineViewState> = {
configStatusText: '远程配置已载入',
configStatusText: `远程配置已载入 / ${config.courseStatusText}`,
projectionMode: config.projectionModeText,
tileSource: config.tileSource,
sensorHeadingText: formatHeadingText(this.smoothedSensorHeadingDeg === null ? null : getCompassReferenceHeadingDeg(this.northReferenceMode, this.smoothedSensorHeadingDeg)),
@@ -1400,6 +1407,8 @@ export class MapEngine {
gpsPoint: this.currentGpsPoint,
gpsCalibration: GPS_MAP_CALIBRATION,
gpsCalibrationOrigin: worldTileToLonLat({ x: this.defaultCenterTileX, y: this.defaultCenterTileY }, this.defaultZoom),
course: this.courseData,
cpRadiusMeters: this.cpRadiusMeters,
osmReferenceEnabled: this.state.osmReferenceEnabled,
overlayOpacity: MAP_OVERLAY_OPACITY,
}
@@ -1792,6 +1801,14 @@ export class MapEngine {

View File

@@ -0,0 +1,120 @@
import { type MapScene } from './mapRenderer'
import { CourseLayer } from '../layer/courseLayer'
const EARTH_CIRCUMFERENCE_METERS = 40075016.686
const LABEL_FONT_SIZE_RATIO = 1.08
const LABEL_OFFSET_X_RATIO = 1.18
const LABEL_OFFSET_Y_RATIO = -0.68
export class CourseLabelRenderer {
courseLayer: CourseLayer
canvas: any
ctx: any
dpr: number
width: number
height: number
constructor(courseLayer: CourseLayer) {
this.courseLayer = courseLayer
this.canvas = null
this.ctx = null
this.dpr = 1
this.width = 0
this.height = 0
}
attachCanvas(canvasNode: any, width: number, height: number, dpr: number): void {
this.canvas = canvasNode
this.ctx = canvasNode.getContext('2d')
this.dpr = dpr || 1
this.width = width
this.height = height
canvasNode.width = Math.max(1, Math.floor(width * this.dpr))
canvasNode.height = Math.max(1, Math.floor(height * this.dpr))
}
destroy(): void {
this.ctx = null
this.canvas = null
this.width = 0
this.height = 0
}
render(scene: MapScene): void {
if (!this.ctx || !this.canvas) {
return
}
const course = this.courseLayer.projectCourse(scene)
const ctx = this.ctx
this.clearCanvas(ctx)
if (!course || !course.controls.length) {
return
}
const controlRadiusMeters = scene.cpRadiusMeters > 0 ? scene.cpRadiusMeters : 5
const fontSizePx = this.getMetric(scene, controlRadiusMeters * LABEL_FONT_SIZE_RATIO)
const offsetX = this.getMetric(scene, controlRadiusMeters * LABEL_OFFSET_X_RATIO)
const offsetY = this.getMetric(scene, controlRadiusMeters * LABEL_OFFSET_Y_RATIO)
this.applyPreviewTransform(ctx, scene)
ctx.save()
ctx.fillStyle = 'rgba(204, 0, 107, 0.98)'
ctx.textAlign = 'left'
ctx.textBaseline = 'middle'
ctx.font = `700 ${fontSizePx}px sans-serif`
for (const control of course.controls) {
ctx.save()
ctx.translate(control.point.x, control.point.y)
ctx.rotate(scene.rotationRad)
ctx.fillText(String(control.sequence), offsetX, offsetY)
ctx.restore()
}
ctx.restore()
}
clearCanvas(ctx: any): void {
ctx.setTransform(1, 0, 0, 1, 0, 0)
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
}
applyPreviewTransform(ctx: any, scene: MapScene): void {
const previewScale = scene.previewScale || 1
const previewOriginX = scene.previewOriginX || scene.viewportWidth / 2
const previewOriginY = scene.previewOriginY || scene.viewportHeight / 2
const translateX = (previewOriginX - previewOriginX * previewScale) * this.dpr
const translateY = (previewOriginY - previewOriginY * previewScale) * this.dpr
ctx.setTransform(
this.dpr * previewScale,
0,
0,
this.dpr * previewScale,
translateX,
translateY,
)
}
getMetric(scene: MapScene, meters: number): number {
return meters * this.getPixelsPerMeter(scene)
}
getPixelsPerMeter(scene: MapScene): number {
const tileSizePx = scene.viewportWidth / scene.visibleColumns
const centerLat = this.worldTileYToLat(scene.exactCenterWorldY, scene.zoom)
const metersPerTile = Math.cos(centerLat * Math.PI / 180) * EARTH_CIRCUMFERENCE_METERS / Math.pow(2, scene.zoom)
if (!tileSizePx || !metersPerTile) {
return 0
}
return tileSizePx / metersPerTile
}
worldTileYToLat(worldY: number, zoom: number): number {
const scale = Math.pow(2, zoom)
const latRad = Math.atan(Math.sinh(Math.PI * (1 - 2 * worldY / scale)))
return latRad * 180 / Math.PI
}
}

View File

@@ -2,6 +2,7 @@ import { type CameraState } from '../camera/camera'
import { type TileStoreStats } from '../tile/tileStore'
import { type LonLatPoint, type MapCalibration } from '../../utils/projection'
import { type TileZoomBounds } from '../../utils/remoteMapConfig'
import { type OrienteeringCourseData } from '../../utils/orienteeringCourse'
export interface MapScene {
tileSource: string
@@ -26,6 +27,8 @@ export interface MapScene {
gpsPoint: LonLatPoint | null
gpsCalibration: MapCalibration
gpsCalibrationOrigin: LonLatPoint
course: OrienteeringCourseData | null
cpRadiusMeters: number
osmReferenceEnabled: boolean
overlayOpacity: number
}
@@ -33,7 +36,7 @@ export interface MapScene {
export type MapRendererStats = TileStoreStats
export interface MapRenderer {
attachCanvas(canvasNode: any, width: number, height: number, dpr: number): void
attachCanvas(canvasNode: any, width: number, height: number, dpr: number, labelCanvasNode?: any): void
updateScene(scene: MapScene): void
setAnimationPaused(paused: boolean): void
destroy(): void

View File

@@ -1,3 +1,4 @@
import { CourseLayer } from '../layer/courseLayer'
import { TrackLayer } from '../layer/trackLayer'
import { GpsLayer } from '../layer/gpsLayer'
import { TileLayer } from '../layer/tileLayer'
@@ -5,6 +6,7 @@ import { TileStore, type TileStoreCallbacks } from '../tile/tileStore'
import { type MapRenderer, type MapRendererStats, type MapScene } from './mapRenderer'
import { WebGLTileRenderer } from './webglTileRenderer'
import { WebGLVectorRenderer } from './webglVectorRenderer'
import { CourseLabelRenderer } from './courseLabelRenderer'
const RENDER_FRAME_MS = 16
const ANIMATION_FRAME_MS = 33
@@ -14,10 +16,12 @@ export class WebGLMapRenderer implements MapRenderer {
osmTileStore: TileStore
tileLayer: TileLayer
osmTileLayer: TileLayer
courseLayer: CourseLayer
trackLayer: TrackLayer
gpsLayer: GpsLayer
tileRenderer: WebGLTileRenderer
vectorRenderer: WebGLVectorRenderer
labelRenderer: CourseLabelRenderer
scene: MapScene | null
renderTimer: number
animationTimer: number
@@ -52,10 +56,12 @@ export class WebGLMapRenderer implements MapRenderer {
} satisfies TileStoreCallbacks)
this.tileLayer = new TileLayer()
this.osmTileLayer = new TileLayer()
this.courseLayer = new CourseLayer()
this.trackLayer = new TrackLayer()
this.gpsLayer = new GpsLayer()
this.tileRenderer = new WebGLTileRenderer(this.tileLayer, this.tileStore, this.osmTileLayer, this.osmTileStore)
this.vectorRenderer = new WebGLVectorRenderer(this.trackLayer, this.gpsLayer)
this.vectorRenderer = new WebGLVectorRenderer(this.courseLayer, this.trackLayer, this.gpsLayer)
this.labelRenderer = new CourseLabelRenderer(this.courseLayer)
this.scene = null
this.renderTimer = 0
this.animationTimer = 0
@@ -73,9 +79,12 @@ export class WebGLMapRenderer implements MapRenderer {
}
}
attachCanvas(canvasNode: any, width: number, height: number, dpr: number): void {
attachCanvas(canvasNode: any, width: number, height: number, dpr: number, labelCanvasNode?: any): void {
this.tileRenderer.attachCanvas(canvasNode, width, height, dpr)
this.vectorRenderer.attachContext(this.tileRenderer.gl, canvasNode)
if (labelCanvasNode) {
this.labelRenderer.attachCanvas(labelCanvasNode, width, height, dpr)
}
this.startAnimation()
this.scheduleRender()
}
@@ -102,6 +111,7 @@ export class WebGLMapRenderer implements MapRenderer {
clearTimeout(this.animationTimer)
this.animationTimer = 0
}
this.labelRenderer.destroy()
this.vectorRenderer.destroy()
this.tileRenderer.destroy()
this.tileStore.destroy()
@@ -149,6 +159,7 @@ export class WebGLMapRenderer implements MapRenderer {
this.tileRenderer.render(this.scene)
this.vectorRenderer.render(this.scene, this.pulseFrame)
this.labelRenderer.render(this.scene)
this.emitStats(this.tileStore.getStats(this.tileLayer.lastVisibleTileCount, this.tileLayer.lastReadyTileCount))
}

View File

@@ -1,7 +1,28 @@
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) {
@@ -46,6 +67,7 @@ export class WebGLVectorRenderer {
canvas: any
gl: any
dpr: number
courseLayer: CourseLayer
trackLayer: TrackLayer
gpsLayer: GpsLayer
program: any
@@ -54,10 +76,11 @@ export class WebGLVectorRenderer {
positionLocation: number
colorLocation: number
constructor(trackLayer: TrackLayer, gpsLayer: GpsLayer) {
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
@@ -123,11 +146,16 @@ export class WebGLVectorRenderer {
}
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)
}
@@ -163,13 +191,261 @@ export class WebGLVectorRenderer {
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: [number, number, number, number],
color: RgbaColor,
scene: MapScene,
): void {
const deltaX = end.x - start.x
@@ -196,7 +472,7 @@ export class WebGLVectorRenderer {
centerX: number,
centerY: number,
radius: number,
color: [number, number, number, number],
color: RgbaColor,
scene: MapScene,
): void {
const segments = 20
@@ -216,7 +492,7 @@ export class WebGLVectorRenderer {
first: { x: number; y: number },
second: { x: number; y: number },
third: { x: number; y: number },
color: [number, number, number, 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) {
@@ -237,3 +513,5 @@ export class WebGLVectorRenderer {
}
}
}