完善样式系统与调试链路底座
This commit is contained in:
250
miniprogram/engine/debug/mockSimulatorDebugLogger.ts
Normal file
250
miniprogram/engine/debug/mockSimulatorDebugLogger.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
const DEFAULT_DEBUG_LOG_URL = 'wss://gs.gotomars.xyz/debug-log'
|
||||
const MAX_QUEUED_LOGS = 80
|
||||
|
||||
export type MockSimulatorDebugLogLevel = 'info' | 'warn' | 'error'
|
||||
|
||||
export interface MockSimulatorDebugLoggerState {
|
||||
enabled: boolean
|
||||
connected: boolean
|
||||
connecting: boolean
|
||||
url: string
|
||||
statusText: string
|
||||
}
|
||||
|
||||
export interface MockSimulatorDebugLogEntry {
|
||||
type: 'debug-log'
|
||||
timestamp: number
|
||||
scope: string
|
||||
level: MockSimulatorDebugLogLevel
|
||||
message: string
|
||||
payload?: Record<string, unknown>
|
||||
}
|
||||
|
||||
function normalizeMockSimulatorLogUrl(rawUrl: string): string {
|
||||
const trimmed = String(rawUrl || '').trim()
|
||||
if (!trimmed) {
|
||||
return DEFAULT_DEBUG_LOG_URL
|
||||
}
|
||||
|
||||
let normalized = trimmed
|
||||
if (!/^wss?:\/\//i.test(normalized)) {
|
||||
normalized = `ws://${normalized.replace(/^\/+/, '')}`
|
||||
}
|
||||
|
||||
if (!/\/debug-log(?:\?.*)?$/i.test(normalized)) {
|
||||
normalized = normalized.replace(/\/+$/, '')
|
||||
normalized = `${normalized}/debug-log`
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
export class MockSimulatorDebugLogger {
|
||||
socketTask: WechatMiniprogram.SocketTask | null
|
||||
enabled: boolean
|
||||
connected: boolean
|
||||
connecting: boolean
|
||||
url: string
|
||||
queue: MockSimulatorDebugLogEntry[]
|
||||
onStateChange?: (state: MockSimulatorDebugLoggerState) => void
|
||||
|
||||
constructor(onStateChange?: (state: MockSimulatorDebugLoggerState) => void) {
|
||||
this.socketTask = null
|
||||
this.enabled = false
|
||||
this.connected = false
|
||||
this.connecting = false
|
||||
this.url = DEFAULT_DEBUG_LOG_URL
|
||||
this.queue = []
|
||||
this.onStateChange = onStateChange
|
||||
}
|
||||
|
||||
getState(): MockSimulatorDebugLoggerState {
|
||||
return {
|
||||
enabled: this.enabled,
|
||||
connected: this.connected,
|
||||
connecting: this.connecting,
|
||||
url: this.url,
|
||||
statusText: !this.enabled
|
||||
? `已关闭 (${this.url})`
|
||||
: this.connected
|
||||
? `已连接 (${this.url})`
|
||||
: this.connecting
|
||||
? `连接中 (${this.url})`
|
||||
: `未连接 (${this.url})`,
|
||||
}
|
||||
}
|
||||
|
||||
emitState(): void {
|
||||
if (this.onStateChange) {
|
||||
this.onStateChange(this.getState())
|
||||
}
|
||||
}
|
||||
|
||||
setEnabled(enabled: boolean): void {
|
||||
if (this.enabled === enabled) {
|
||||
return
|
||||
}
|
||||
|
||||
this.enabled = enabled
|
||||
if (!enabled) {
|
||||
this.disconnect()
|
||||
this.queue = []
|
||||
this.emitState()
|
||||
return
|
||||
}
|
||||
|
||||
this.emitState()
|
||||
this.connect()
|
||||
}
|
||||
|
||||
setUrl(url: string): void {
|
||||
const nextUrl = normalizeMockSimulatorLogUrl(url)
|
||||
if (this.url === nextUrl) {
|
||||
return
|
||||
}
|
||||
|
||||
this.url = nextUrl
|
||||
if (!this.enabled) {
|
||||
this.emitState()
|
||||
return
|
||||
}
|
||||
|
||||
this.disconnect()
|
||||
this.emitState()
|
||||
this.connect()
|
||||
}
|
||||
|
||||
log(
|
||||
scope: string,
|
||||
level: MockSimulatorDebugLogLevel,
|
||||
message: string,
|
||||
payload?: Record<string, unknown>,
|
||||
): void {
|
||||
if (!this.enabled) {
|
||||
return
|
||||
}
|
||||
|
||||
const entry: MockSimulatorDebugLogEntry = {
|
||||
type: 'debug-log',
|
||||
timestamp: Date.now(),
|
||||
scope,
|
||||
level,
|
||||
message,
|
||||
...(payload ? { payload } : {}),
|
||||
}
|
||||
|
||||
if (this.connected && this.socketTask) {
|
||||
this.send(entry)
|
||||
return
|
||||
}
|
||||
|
||||
this.queue.push(entry)
|
||||
if (this.queue.length > MAX_QUEUED_LOGS) {
|
||||
this.queue.splice(0, this.queue.length - MAX_QUEUED_LOGS)
|
||||
}
|
||||
this.connect()
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
const socketTask = this.socketTask
|
||||
this.socketTask = null
|
||||
this.connected = false
|
||||
this.connecting = false
|
||||
if (socketTask) {
|
||||
try {
|
||||
socketTask.close({})
|
||||
} catch (_error) {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
this.emitState()
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.disconnect()
|
||||
this.queue = []
|
||||
}
|
||||
|
||||
connect(): void {
|
||||
if (!this.enabled || this.connected || this.connecting) {
|
||||
return
|
||||
}
|
||||
|
||||
this.connecting = true
|
||||
this.emitState()
|
||||
try {
|
||||
const socketTask = wx.connectSocket({
|
||||
url: this.url,
|
||||
})
|
||||
this.socketTask = socketTask
|
||||
|
||||
socketTask.onOpen(() => {
|
||||
this.connected = true
|
||||
this.connecting = false
|
||||
this.emitState()
|
||||
this.send({
|
||||
type: 'debug-log',
|
||||
timestamp: Date.now(),
|
||||
scope: 'logger',
|
||||
level: 'info',
|
||||
message: 'logger channel connected',
|
||||
payload: {
|
||||
url: this.url,
|
||||
},
|
||||
})
|
||||
this.flush()
|
||||
})
|
||||
|
||||
socketTask.onClose(() => {
|
||||
this.connected = false
|
||||
this.connecting = false
|
||||
this.socketTask = null
|
||||
this.emitState()
|
||||
})
|
||||
|
||||
socketTask.onError(() => {
|
||||
this.connected = false
|
||||
this.connecting = false
|
||||
this.socketTask = null
|
||||
this.emitState()
|
||||
})
|
||||
|
||||
socketTask.onMessage(() => {
|
||||
// 模拟器会广播所有消息,debug logger 不消费回包。
|
||||
})
|
||||
} catch (_error) {
|
||||
this.connected = false
|
||||
this.connecting = false
|
||||
this.socketTask = null
|
||||
this.emitState()
|
||||
}
|
||||
}
|
||||
|
||||
flush(): void {
|
||||
if (!this.connected || !this.socketTask || !this.queue.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const pending = this.queue.splice(0, this.queue.length)
|
||||
pending.forEach((entry) => {
|
||||
this.send(entry)
|
||||
})
|
||||
}
|
||||
|
||||
send(entry: MockSimulatorDebugLogEntry): void {
|
||||
if (!this.socketTask || !this.connected) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
this.socketTask.send({
|
||||
data: JSON.stringify(entry),
|
||||
})
|
||||
} catch (_error) {
|
||||
this.connected = false
|
||||
this.connecting = false
|
||||
this.socketTask = null
|
||||
this.emitState()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from '../../utils/orienteeringCourse'
|
||||
|
||||
export interface ProjectedCourseLeg {
|
||||
index: number
|
||||
fromKind: OrienteeringCourseLeg['fromKind']
|
||||
toKind: OrienteeringCourseLeg['toKind']
|
||||
from: ScreenPoint
|
||||
@@ -18,6 +19,7 @@ export interface ProjectedCourseLeg {
|
||||
}
|
||||
|
||||
export interface ProjectedCourseStart {
|
||||
index: number
|
||||
label: string
|
||||
point: ScreenPoint
|
||||
headingDeg: number | null
|
||||
@@ -30,6 +32,7 @@ export interface ProjectedCourseControl {
|
||||
}
|
||||
|
||||
export interface ProjectedCourseFinish {
|
||||
index: number
|
||||
label: string
|
||||
point: ScreenPoint
|
||||
}
|
||||
@@ -59,7 +62,8 @@ export class CourseLayer implements MapLayer {
|
||||
}
|
||||
|
||||
projectStarts(starts: OrienteeringCourseStart[], scene: MapScene, camera: CameraState): ProjectedCourseStart[] {
|
||||
return starts.map((start) => ({
|
||||
return starts.map((start, index) => ({
|
||||
index,
|
||||
label: start.label,
|
||||
point: this.projectPoint(start, scene, camera),
|
||||
headingDeg: start.headingDeg,
|
||||
@@ -75,14 +79,16 @@ export class CourseLayer implements MapLayer {
|
||||
}
|
||||
|
||||
projectFinishes(finishes: OrienteeringCourseFinish[], scene: MapScene, camera: CameraState): ProjectedCourseFinish[] {
|
||||
return finishes.map((finish) => ({
|
||||
return finishes.map((finish, index) => ({
|
||||
index,
|
||||
label: finish.label,
|
||||
point: this.projectPoint(finish, scene, camera),
|
||||
}))
|
||||
}
|
||||
|
||||
projectLegs(legs: OrienteeringCourseLeg[], scene: MapScene, camera: CameraState): ProjectedCourseLeg[] {
|
||||
return legs.map((leg) => ({
|
||||
return legs.map((leg, index) => ({
|
||||
index,
|
||||
fromKind: leg.fromKind,
|
||||
toKind: leg.toKind,
|
||||
from: worldToScreen(camera, lonLatToWorldTile(leg.fromPoint, scene.zoom), false),
|
||||
|
||||
@@ -4,6 +4,109 @@ import { type MapLayer, type LayerRenderContext } from './mapLayer'
|
||||
import { type MapScene } from '../renderer/mapRenderer'
|
||||
import { type ScreenPoint } from './trackLayer'
|
||||
|
||||
function getGpsMarkerMetrics(size: MapScene['gpsMarkerStyleConfig']['size']) {
|
||||
if (size === 'small') {
|
||||
return {
|
||||
coreRadius: 7,
|
||||
ringRadius: 8.35,
|
||||
pulseRadius: 14,
|
||||
indicatorOffset: 1.1,
|
||||
indicatorSize: 7,
|
||||
ringWidth: 2.5,
|
||||
}
|
||||
}
|
||||
if (size === 'large') {
|
||||
return {
|
||||
coreRadius: 11,
|
||||
ringRadius: 12.95,
|
||||
pulseRadius: 22,
|
||||
indicatorOffset: 1.45,
|
||||
indicatorSize: 10,
|
||||
ringWidth: 3.5,
|
||||
}
|
||||
}
|
||||
return {
|
||||
coreRadius: 9,
|
||||
ringRadius: 10.65,
|
||||
pulseRadius: 18,
|
||||
indicatorOffset: 1.25,
|
||||
indicatorSize: 8.5,
|
||||
ringWidth: 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 {
|
||||
coreRadius: metrics.coreRadius * safeScale,
|
||||
ringRadius: metrics.ringRadius * safeScale,
|
||||
pulseRadius: metrics.pulseRadius * safeScale,
|
||||
indicatorOffset: metrics.indicatorOffset * safeScale,
|
||||
indicatorSize: metrics.indicatorSize * safeScale,
|
||||
ringWidth: Math.max(2, metrics.ringWidth * (0.96 + (safeScale - 1) * 0.35)),
|
||||
}
|
||||
}
|
||||
|
||||
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 hexToRgbTuple(hex: string): [number, number, number] {
|
||||
const safeHex = /^#[0-9a-fA-F]{6}$/.test(hex) ? hex : '#ffffff'
|
||||
return [
|
||||
parseInt(safeHex.slice(1, 3), 16),
|
||||
parseInt(safeHex.slice(3, 5), 16),
|
||||
parseInt(safeHex.slice(5, 7), 16),
|
||||
]
|
||||
}
|
||||
|
||||
function getGpsPulsePhase(
|
||||
pulseFrame: number,
|
||||
motionState: MapScene['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 getAnimatedPulseRadius(
|
||||
pulseFrame: number,
|
||||
metrics: ReturnType<typeof getGpsMarkerMetrics>,
|
||||
motionState: MapScene['gpsMarkerStyleConfig']['motionState'],
|
||||
pulseStrength: number,
|
||||
motionIntensity: number,
|
||||
): number {
|
||||
const phase = getGpsPulsePhase(pulseFrame, motionState)
|
||||
const baseRadius = motionState === 'idle'
|
||||
? metrics.pulseRadius * 0.82
|
||||
: motionState === 'moving'
|
||||
? metrics.pulseRadius * 0.94
|
||||
: motionState === 'fast-moving'
|
||||
? metrics.pulseRadius * 1.04
|
||||
: metrics.pulseRadius
|
||||
const amplitude = motionState === 'idle'
|
||||
? metrics.pulseRadius * 0.12
|
||||
: motionState === 'moving'
|
||||
? metrics.pulseRadius * 0.18
|
||||
: motionState === 'fast-moving'
|
||||
? metrics.pulseRadius * 0.24
|
||||
: metrics.pulseRadius * 0.2
|
||||
return baseRadius + amplitude * phase * (0.8 + pulseStrength * 0.18 + motionIntensity * 0.1)
|
||||
}
|
||||
|
||||
function buildVectorCamera(scene: MapScene): CameraState {
|
||||
return {
|
||||
centerWorldX: scene.exactCenterWorldX,
|
||||
@@ -32,35 +135,156 @@ export class GpsLayer implements MapLayer {
|
||||
|
||||
draw(context: LayerRenderContext): void {
|
||||
const { ctx, scene, pulseFrame } = context
|
||||
if (!scene.gpsMarkerStyleConfig.visible) {
|
||||
return
|
||||
}
|
||||
const gpsScreenPoint = this.projectPoint(scene)
|
||||
if (!gpsScreenPoint) {
|
||||
return
|
||||
}
|
||||
|
||||
const pulse = this.getPulseRadius(pulseFrame)
|
||||
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 pulse = style === 'dot'
|
||||
? metrics.pulseRadius * 0.82
|
||||
: getAnimatedPulseRadius(pulseFrame, metrics, motionState, pulseStrength, motionIntensity)
|
||||
const [markerR, markerG, markerB] = hexToRgbTuple(scene.gpsMarkerStyleConfig.colorHex)
|
||||
|
||||
ctx.save()
|
||||
ctx.beginPath()
|
||||
ctx.fillStyle = 'rgba(33, 158, 188, 0.22)'
|
||||
ctx.arc(gpsScreenPoint.x, gpsScreenPoint.y, pulse, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
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.coreRadius * (0.85 + index * 0.64) * (0.9 + wakeStrength * 0.72)
|
||||
const center = rotatePoint(0, -offset, wakeHeadingRad)
|
||||
const radius = metrics.coreRadius * 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))
|
||||
ctx.beginPath()
|
||||
ctx.fillStyle = `rgba(${markerR}, ${markerG}, ${markerB}, ${alpha})`
|
||||
ctx.arc(gpsScreenPoint.x + center.x, gpsScreenPoint.y + center.y, radius, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
}
|
||||
if (warningGlowStrength > 0.04) {
|
||||
const glowPhase = getGpsPulsePhase(pulseFrame, motionState)
|
||||
ctx.beginPath()
|
||||
ctx.strokeStyle = `rgba(${markerR}, ${markerG}, ${markerB}, ${0.18 + warningGlowStrength * 0.18})`
|
||||
ctx.lineWidth = Math.max(2, metrics.ringWidth * (1.04 + warningGlowStrength * 0.2))
|
||||
ctx.arc(
|
||||
gpsScreenPoint.x,
|
||||
gpsScreenPoint.y,
|
||||
metrics.ringRadius * (1.18 + warningGlowStrength * 0.12 + glowPhase * 0.04),
|
||||
0,
|
||||
Math.PI * 2,
|
||||
)
|
||||
ctx.stroke()
|
||||
}
|
||||
if (style === 'beacon' || (style === 'badge' && !hasBadgeLogo)) {
|
||||
ctx.beginPath()
|
||||
const pulseAlpha = style === 'badge'
|
||||
? Math.min(0.2, 0.08 + pulseStrength * 0.06)
|
||||
: Math.min(0.26, 0.1 + pulseStrength * 0.08)
|
||||
ctx.fillStyle = `rgba(255, 255, 255, ${pulseAlpha})`
|
||||
ctx.arc(gpsScreenPoint.x, gpsScreenPoint.y, pulse, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.fillStyle = '#21a1bc'
|
||||
ctx.arc(gpsScreenPoint.x, gpsScreenPoint.y, 9, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
if (style === 'dot') {
|
||||
ctx.beginPath()
|
||||
ctx.fillStyle = scene.gpsMarkerStyleConfig.colorHex
|
||||
ctx.arc(gpsScreenPoint.x, gpsScreenPoint.y, metrics.coreRadius * 0.82, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
ctx.beginPath()
|
||||
ctx.strokeStyle = scene.gpsMarkerStyleConfig.ringColorHex
|
||||
ctx.lineWidth = metrics.ringWidth
|
||||
ctx.arc(gpsScreenPoint.x, gpsScreenPoint.y, metrics.coreRadius + metrics.ringWidth * 0.3, 0, Math.PI * 2)
|
||||
ctx.stroke()
|
||||
} else if (style === 'disc') {
|
||||
ctx.beginPath()
|
||||
ctx.strokeStyle = scene.gpsMarkerStyleConfig.ringColorHex
|
||||
ctx.lineWidth = metrics.ringWidth * 1.18
|
||||
ctx.arc(gpsScreenPoint.x, gpsScreenPoint.y, metrics.ringRadius * 1.05, 0, Math.PI * 2)
|
||||
ctx.stroke()
|
||||
ctx.beginPath()
|
||||
ctx.fillStyle = scene.gpsMarkerStyleConfig.colorHex
|
||||
ctx.arc(gpsScreenPoint.x, gpsScreenPoint.y, metrics.coreRadius * 1.02, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
ctx.beginPath()
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.96)'
|
||||
ctx.arc(gpsScreenPoint.x, gpsScreenPoint.y, metrics.coreRadius * 0.22, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
} else if (style === 'badge') {
|
||||
ctx.beginPath()
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.98)'
|
||||
ctx.lineWidth = metrics.ringWidth * 1.12
|
||||
ctx.arc(gpsScreenPoint.x, gpsScreenPoint.y, metrics.ringRadius * 1.06, 0, Math.PI * 2)
|
||||
ctx.stroke()
|
||||
ctx.beginPath()
|
||||
ctx.fillStyle = scene.gpsMarkerStyleConfig.colorHex
|
||||
ctx.arc(gpsScreenPoint.x, gpsScreenPoint.y, metrics.coreRadius * 0.98, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
if (!hasBadgeLogo) {
|
||||
ctx.beginPath()
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.16)'
|
||||
ctx.arc(gpsScreenPoint.x - metrics.coreRadius * 0.16, gpsScreenPoint.y - metrics.coreRadius * 0.22, metrics.coreRadius * 0.18, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
} else {
|
||||
ctx.beginPath()
|
||||
ctx.fillStyle = scene.gpsMarkerStyleConfig.colorHex
|
||||
ctx.arc(gpsScreenPoint.x, gpsScreenPoint.y, metrics.coreRadius, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
ctx.beginPath()
|
||||
ctx.strokeStyle = scene.gpsMarkerStyleConfig.ringColorHex
|
||||
ctx.lineWidth = metrics.ringWidth
|
||||
ctx.arc(gpsScreenPoint.x, gpsScreenPoint.y, metrics.ringRadius, 0, Math.PI * 2)
|
||||
ctx.stroke()
|
||||
ctx.beginPath()
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.22)'
|
||||
ctx.arc(gpsScreenPoint.x, gpsScreenPoint.y, metrics.coreRadius * 0.18, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.strokeStyle = '#ffffff'
|
||||
ctx.lineWidth = 3
|
||||
ctx.arc(gpsScreenPoint.x, gpsScreenPoint.y, 13, 0, Math.PI * 2)
|
||||
ctx.stroke()
|
||||
if (scene.gpsHeadingDeg !== null && scene.gpsMarkerStyleConfig.showHeadingIndicator) {
|
||||
const headingScreenRad = (scene.gpsHeadingDeg * Math.PI / 180) - scene.rotationRad
|
||||
const indicatorBaseDistance = metrics.ringRadius + metrics.indicatorOffset
|
||||
const indicatorSize = metrics.indicatorSize * indicatorScale
|
||||
const indicatorTipDistance = indicatorBaseDistance + indicatorSize * 0.92
|
||||
const tip = rotatePoint(0, -indicatorTipDistance, headingScreenRad)
|
||||
const left = rotatePoint(-indicatorSize * 0.56, -indicatorBaseDistance, headingScreenRad)
|
||||
const right = rotatePoint(indicatorSize * 0.56, -indicatorBaseDistance, headingScreenRad)
|
||||
const alpha = scene.gpsHeadingAlpha
|
||||
|
||||
ctx.fillStyle = '#0b3d4a'
|
||||
ctx.font = 'bold 16px sans-serif'
|
||||
ctx.textAlign = 'left'
|
||||
ctx.textBaseline = 'bottom'
|
||||
ctx.fillText('GPS', gpsScreenPoint.x + 14, gpsScreenPoint.y - 12)
|
||||
ctx.beginPath()
|
||||
ctx.fillStyle = `rgba(255, 255, 255, ${Math.max(0.42, alpha)})`
|
||||
ctx.moveTo(gpsScreenPoint.x + tip.x, gpsScreenPoint.y + tip.y)
|
||||
ctx.lineTo(gpsScreenPoint.x + left.x, gpsScreenPoint.y + left.y)
|
||||
ctx.lineTo(gpsScreenPoint.x + right.x, gpsScreenPoint.y + right.y)
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
|
||||
const innerTip = rotatePoint(0, -(indicatorBaseDistance + indicatorSize * 0.72), headingScreenRad)
|
||||
const innerLeft = rotatePoint(-indicatorSize * 0.4, -(indicatorBaseDistance + 0.12), headingScreenRad)
|
||||
const innerRight = rotatePoint(indicatorSize * 0.4, -(indicatorBaseDistance + 0.12), headingScreenRad)
|
||||
ctx.beginPath()
|
||||
ctx.fillStyle = `rgba(${parseInt(scene.gpsMarkerStyleConfig.indicatorColorHex.slice(1, 3), 16)}, ${parseInt(scene.gpsMarkerStyleConfig.indicatorColorHex.slice(3, 5), 16)}, ${parseInt(scene.gpsMarkerStyleConfig.indicatorColorHex.slice(5, 7), 16)}, ${alpha})`
|
||||
ctx.moveTo(gpsScreenPoint.x + innerTip.x, gpsScreenPoint.y + innerTip.y)
|
||||
ctx.lineTo(gpsScreenPoint.x + innerLeft.x, gpsScreenPoint.y + innerLeft.y)
|
||||
ctx.lineTo(gpsScreenPoint.x + innerRight.x, gpsScreenPoint.y + innerRight.y)
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
}
|
||||
ctx.restore()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,25 @@ export interface ScreenPoint {
|
||||
y: number
|
||||
}
|
||||
|
||||
function smoothTrackScreenPoints(points: ScreenPoint[]): ScreenPoint[] {
|
||||
if (points.length < 3) {
|
||||
return points
|
||||
}
|
||||
|
||||
const smoothed: ScreenPoint[] = [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.2 + current.x * 0.6 + next.x * 0.2,
|
||||
y: prev.y * 0.2 + current.y * 0.6 + next.y * 0.2,
|
||||
})
|
||||
}
|
||||
smoothed.push(points[points.length - 1])
|
||||
return smoothed
|
||||
}
|
||||
|
||||
function buildVectorCamera(scene: MapScene): CameraState {
|
||||
return {
|
||||
centerWorldX: scene.exactCenterWorldX,
|
||||
@@ -31,7 +50,10 @@ export class TrackLayer implements MapLayer {
|
||||
|
||||
draw(context: LayerRenderContext): void {
|
||||
const { ctx, scene } = context
|
||||
const points = this.projectPoints(scene)
|
||||
if (scene.trackMode === 'none') {
|
||||
return
|
||||
}
|
||||
const points = smoothTrackScreenPoints(this.projectPoints(scene))
|
||||
if (!points.length) {
|
||||
return
|
||||
}
|
||||
@@ -39,34 +61,42 @@ export class TrackLayer implements MapLayer {
|
||||
ctx.save()
|
||||
ctx.lineCap = 'round'
|
||||
ctx.lineJoin = 'round'
|
||||
ctx.strokeStyle = 'rgba(23, 109, 93, 0.96)'
|
||||
ctx.lineWidth = 6
|
||||
ctx.beginPath()
|
||||
|
||||
points.forEach((screenPoint, index) => {
|
||||
if (index === 0) {
|
||||
ctx.moveTo(screenPoint.x, screenPoint.y)
|
||||
return
|
||||
}
|
||||
ctx.lineTo(screenPoint.x, screenPoint.y)
|
||||
})
|
||||
ctx.stroke()
|
||||
|
||||
ctx.fillStyle = '#f7fbf2'
|
||||
ctx.strokeStyle = '#176d5d'
|
||||
ctx.lineWidth = 4
|
||||
points.forEach((screenPoint, index) => {
|
||||
if (scene.trackMode === 'tail') {
|
||||
const baseAlpha = 0.12 + scene.trackStyleConfig.glowStrength * 0.08
|
||||
points.forEach((screenPoint, index) => {
|
||||
if (index === 0) {
|
||||
return
|
||||
}
|
||||
const progress = index / Math.max(1, points.length - 1)
|
||||
ctx.strokeStyle = `rgba(84, 243, 216, ${baseAlpha + progress * 0.58})`
|
||||
ctx.lineWidth = 1.4 + progress * 4.2
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(points[index - 1].x, points[index - 1].y)
|
||||
ctx.lineTo(screenPoint.x, screenPoint.y)
|
||||
ctx.stroke()
|
||||
})
|
||||
const head = points[points.length - 1]
|
||||
ctx.fillStyle = 'rgba(84, 243, 216, 0.24)'
|
||||
ctx.beginPath()
|
||||
ctx.arc(screenPoint.x, screenPoint.y, 10, 0, Math.PI * 2)
|
||||
ctx.arc(head.x, head.y, 11, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
ctx.fillStyle = '#54f3d8'
|
||||
ctx.beginPath()
|
||||
ctx.arc(head.x, head.y, 5.2, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
} else {
|
||||
ctx.strokeStyle = 'rgba(23, 109, 93, 0.96)'
|
||||
ctx.lineWidth = 4.2
|
||||
ctx.beginPath()
|
||||
points.forEach((screenPoint, index) => {
|
||||
if (index === 0) {
|
||||
ctx.moveTo(screenPoint.x, screenPoint.y)
|
||||
return
|
||||
}
|
||||
ctx.lineTo(screenPoint.x, screenPoint.y)
|
||||
})
|
||||
ctx.stroke()
|
||||
ctx.fillStyle = '#176d5d'
|
||||
ctx.font = 'bold 14px sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
ctx.fillText(String(index + 1), screenPoint.x, screenPoint.y)
|
||||
ctx.fillStyle = '#f7fbf2'
|
||||
})
|
||||
}
|
||||
ctx.restore()
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,9 @@
|
||||
import { calibratedLonLatToWorldTile } from '../../utils/projection'
|
||||
import { worldToScreen, type CameraState } from '../camera/camera'
|
||||
import { type MapScene } from './mapRenderer'
|
||||
import { CourseLayer } from '../layer/courseLayer'
|
||||
import { resolveControlStyle } from './courseStyleResolver'
|
||||
import { type MockSimulatorDebugLogLevel } from '../debug/mockSimulatorDebugLogger'
|
||||
|
||||
const EARTH_CIRCUMFERENCE_METERS = 40075016.686
|
||||
const LABEL_FONT_SIZE_RATIO = 1.08
|
||||
@@ -7,16 +11,23 @@ const LABEL_OFFSET_X_RATIO = 1.18
|
||||
const LABEL_OFFSET_Y_RATIO = -0.68
|
||||
const SCORE_LABEL_FONT_SIZE_RATIO = 0.7
|
||||
const SCORE_LABEL_OFFSET_Y_RATIO = 0.06
|
||||
const DEFAULT_LABEL_COLOR = 'rgba(204, 0, 107, 0.98)'
|
||||
const ACTIVE_LABEL_COLOR = 'rgba(255, 219, 54, 0.98)'
|
||||
const READY_LABEL_COLOR = 'rgba(98, 255, 214, 0.98)'
|
||||
const MULTI_ACTIVE_LABEL_COLOR = 'rgba(255, 202, 72, 0.96)'
|
||||
const FOCUSED_LABEL_COLOR = 'rgba(255, 252, 255, 0.98)'
|
||||
const COMPLETED_LABEL_COLOR = 'rgba(126, 131, 138, 0.94)'
|
||||
const SKIPPED_LABEL_COLOR = 'rgba(152, 156, 162, 0.88)'
|
||||
const SCORE_LABEL_COLOR = 'rgba(255, 252, 242, 0.98)'
|
||||
const SCORE_COMPLETED_LABEL_COLOR = 'rgba(214, 218, 224, 0.94)'
|
||||
const SCORE_SKIPPED_LABEL_COLOR = 'rgba(176, 182, 188, 0.9)'
|
||||
|
||||
function rgbaToCss(color: [number, number, number, number], alphaOverride?: number): string {
|
||||
const alpha = alphaOverride !== undefined ? alphaOverride : color[3]
|
||||
return `rgba(${Math.round(color[0] * 255)}, ${Math.round(color[1] * 255)}, ${Math.round(color[2] * 255)}, ${alpha.toFixed(3)})`
|
||||
}
|
||||
|
||||
function normalizeHexColor(rawValue: string | undefined): string | null {
|
||||
if (typeof rawValue !== 'string') {
|
||||
return null
|
||||
}
|
||||
const trimmed = rawValue.trim()
|
||||
return /^#[0-9a-fA-F]{6}$/.test(trimmed) || /^#[0-9a-fA-F]{8}$/.test(trimmed) ? trimmed : null
|
||||
}
|
||||
|
||||
export class CourseLabelRenderer {
|
||||
courseLayer: CourseLayer
|
||||
@@ -25,14 +36,47 @@ export class CourseLabelRenderer {
|
||||
dpr: number
|
||||
width: number
|
||||
height: number
|
||||
gpsLogoUrl: string
|
||||
gpsLogoResolvedSrc: string
|
||||
gpsLogoImage: any
|
||||
gpsLogoStatus: 'idle' | 'loading' | 'ready' | 'error'
|
||||
onDebugLog?: (
|
||||
scope: string,
|
||||
level: MockSimulatorDebugLogLevel,
|
||||
message: string,
|
||||
payload?: Record<string, unknown>,
|
||||
) => void
|
||||
|
||||
constructor(courseLayer: CourseLayer) {
|
||||
constructor(
|
||||
courseLayer: CourseLayer,
|
||||
onDebugLog?: (
|
||||
scope: string,
|
||||
level: MockSimulatorDebugLogLevel,
|
||||
message: string,
|
||||
payload?: Record<string, unknown>,
|
||||
) => void,
|
||||
) {
|
||||
this.courseLayer = courseLayer
|
||||
this.onDebugLog = onDebugLog
|
||||
this.canvas = null
|
||||
this.ctx = null
|
||||
this.dpr = 1
|
||||
this.width = 0
|
||||
this.height = 0
|
||||
this.gpsLogoUrl = ''
|
||||
this.gpsLogoResolvedSrc = ''
|
||||
this.gpsLogoImage = null
|
||||
this.gpsLogoStatus = 'idle'
|
||||
}
|
||||
|
||||
emitDebugLog(
|
||||
level: MockSimulatorDebugLogLevel,
|
||||
message: string,
|
||||
payload?: Record<string, unknown>,
|
||||
): void {
|
||||
if (this.onDebugLog) {
|
||||
this.onDebugLog('gps-logo', level, message, payload)
|
||||
}
|
||||
}
|
||||
|
||||
attachCanvas(canvasNode: any, width: number, height: number, dpr: number): void {
|
||||
@@ -50,6 +94,18 @@ export class CourseLabelRenderer {
|
||||
this.canvas = null
|
||||
this.width = 0
|
||||
this.height = 0
|
||||
this.gpsLogoUrl = ''
|
||||
this.gpsLogoResolvedSrc = ''
|
||||
this.gpsLogoImage = null
|
||||
this.gpsLogoStatus = 'idle'
|
||||
}
|
||||
|
||||
getGpsLogoDebugInfo(): { status: string; url: string; resolvedSrc: string } {
|
||||
return {
|
||||
status: this.gpsLogoStatus,
|
||||
url: this.gpsLogoUrl,
|
||||
resolvedSrc: this.gpsLogoResolvedSrc,
|
||||
}
|
||||
}
|
||||
|
||||
render(scene: MapScene): void {
|
||||
@@ -61,51 +117,217 @@ export class CourseLabelRenderer {
|
||||
const ctx = this.ctx
|
||||
this.clearCanvas(ctx)
|
||||
|
||||
if (!course || !course.controls.length || !scene.revealFullCourse) {
|
||||
this.ensureGpsLogo(scene)
|
||||
this.applyPreviewTransform(ctx, scene)
|
||||
ctx.save()
|
||||
if (course && course.controls.length && scene.revealFullCourse) {
|
||||
const controlRadiusMeters = scene.cpRadiusMeters > 0 ? scene.cpRadiusMeters : 5
|
||||
const scoreOffsetY = this.getMetric(scene, controlRadiusMeters * SCORE_LABEL_OFFSET_Y_RATIO)
|
||||
const offsetX = this.getMetric(scene, controlRadiusMeters * LABEL_OFFSET_X_RATIO)
|
||||
const offsetY = this.getMetric(scene, controlRadiusMeters * LABEL_OFFSET_Y_RATIO)
|
||||
|
||||
if (scene.controlVisualMode === 'multi-target') {
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
|
||||
for (const control of course.controls) {
|
||||
const resolvedStyle = resolveControlStyle(scene, 'control', control.sequence)
|
||||
const labelScale = Math.max(0.72, resolvedStyle.entry.labelScale || 1)
|
||||
const scoreFontSizePx = this.getMetric(scene, controlRadiusMeters * SCORE_LABEL_FONT_SIZE_RATIO * labelScale)
|
||||
ctx.save()
|
||||
ctx.font = `900 ${scoreFontSizePx}px sans-serif`
|
||||
ctx.fillStyle = this.getScoreLabelColor(scene, control.sequence)
|
||||
ctx.translate(control.point.x, control.point.y)
|
||||
ctx.rotate(scene.rotationRad)
|
||||
ctx.fillText(String(control.sequence), 0, scoreOffsetY)
|
||||
ctx.restore()
|
||||
}
|
||||
} else {
|
||||
ctx.textAlign = 'left'
|
||||
ctx.textBaseline = 'middle'
|
||||
|
||||
for (const control of course.controls) {
|
||||
const resolvedStyle = resolveControlStyle(scene, 'control', control.sequence)
|
||||
const labelScale = Math.max(0.72, resolvedStyle.entry.labelScale || 1)
|
||||
const fontSizePx = this.getMetric(scene, controlRadiusMeters * LABEL_FONT_SIZE_RATIO * labelScale)
|
||||
ctx.save()
|
||||
ctx.font = `700 ${fontSizePx}px sans-serif`
|
||||
ctx.fillStyle = this.getLabelColor(scene, control.sequence)
|
||||
ctx.translate(control.point.x, control.point.y)
|
||||
ctx.rotate(scene.rotationRad)
|
||||
ctx.fillText(String(control.sequence), offsetX, offsetY)
|
||||
ctx.restore()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.renderGpsLogo(scene)
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
buildVectorCamera(scene: MapScene): CameraState {
|
||||
return {
|
||||
centerWorldX: scene.exactCenterWorldX,
|
||||
centerWorldY: scene.exactCenterWorldY,
|
||||
viewportWidth: scene.viewportWidth,
|
||||
viewportHeight: scene.viewportHeight,
|
||||
visibleColumns: scene.visibleColumns,
|
||||
rotationRad: scene.rotationRad,
|
||||
}
|
||||
}
|
||||
|
||||
ensureGpsLogo(scene: MapScene): void {
|
||||
const nextUrl = typeof scene.gpsMarkerStyleConfig.logoUrl === 'string'
|
||||
? scene.gpsMarkerStyleConfig.logoUrl.trim()
|
||||
: ''
|
||||
if (!nextUrl) {
|
||||
if (this.gpsLogoUrl || this.gpsLogoStatus !== 'idle') {
|
||||
this.emitDebugLog('info', 'logo not configured')
|
||||
}
|
||||
this.gpsLogoUrl = ''
|
||||
this.gpsLogoResolvedSrc = ''
|
||||
this.gpsLogoImage = null
|
||||
this.gpsLogoStatus = 'idle'
|
||||
return
|
||||
}
|
||||
if (this.gpsLogoUrl === nextUrl && (this.gpsLogoStatus === 'loading' || this.gpsLogoStatus === 'ready')) {
|
||||
return
|
||||
}
|
||||
if (!this.canvas || typeof this.canvas.createImage !== 'function') {
|
||||
this.emitDebugLog('warn', 'canvas createImage unavailable')
|
||||
return
|
||||
}
|
||||
const image = this.canvas.createImage()
|
||||
this.gpsLogoUrl = nextUrl
|
||||
this.gpsLogoResolvedSrc = ''
|
||||
this.gpsLogoImage = image
|
||||
this.gpsLogoStatus = 'loading'
|
||||
this.emitDebugLog('info', 'start loading logo', {
|
||||
src: nextUrl,
|
||||
style: scene.gpsMarkerStyleConfig.style,
|
||||
logoMode: scene.gpsMarkerStyleConfig.logoMode,
|
||||
})
|
||||
const attachImageHandlers = () => {
|
||||
image.onload = () => {
|
||||
if (this.gpsLogoUrl !== nextUrl) {
|
||||
return
|
||||
}
|
||||
this.gpsLogoStatus = 'ready'
|
||||
this.emitDebugLog('info', 'logo image ready', {
|
||||
src: nextUrl,
|
||||
resolvedSrc: this.gpsLogoResolvedSrc,
|
||||
})
|
||||
}
|
||||
image.onerror = () => {
|
||||
if (this.gpsLogoUrl !== nextUrl) {
|
||||
return
|
||||
}
|
||||
this.gpsLogoStatus = 'error'
|
||||
this.gpsLogoImage = null
|
||||
this.emitDebugLog('error', 'logo image error', {
|
||||
src: nextUrl,
|
||||
resolvedSrc: this.gpsLogoResolvedSrc,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const assignImageSource = (src: string) => {
|
||||
if (this.gpsLogoUrl !== nextUrl) {
|
||||
return
|
||||
}
|
||||
this.gpsLogoResolvedSrc = src
|
||||
this.emitDebugLog('info', 'assign image source', {
|
||||
src: nextUrl,
|
||||
resolvedSrc: src,
|
||||
})
|
||||
attachImageHandlers()
|
||||
image.src = src
|
||||
}
|
||||
|
||||
if (/^https?:\/\//i.test(nextUrl) && typeof wx !== 'undefined' && typeof wx.getImageInfo === 'function') {
|
||||
wx.getImageInfo({
|
||||
src: nextUrl,
|
||||
success: (result) => {
|
||||
if (this.gpsLogoUrl !== nextUrl || !result.path) {
|
||||
return
|
||||
}
|
||||
this.emitDebugLog('info', 'wx.getImageInfo success', {
|
||||
src: nextUrl,
|
||||
path: result.path,
|
||||
})
|
||||
assignImageSource(result.path)
|
||||
},
|
||||
fail: (error) => {
|
||||
if (this.gpsLogoUrl !== nextUrl) {
|
||||
return
|
||||
}
|
||||
this.emitDebugLog('warn', 'wx.getImageInfo failed, fallback to remote url', {
|
||||
src: nextUrl,
|
||||
error: error && typeof error === 'object' && 'errMsg' in error ? (error as { errMsg?: string }).errMsg || '' : '',
|
||||
})
|
||||
assignImageSource(nextUrl)
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const controlRadiusMeters = scene.cpRadiusMeters > 0 ? scene.cpRadiusMeters : 5
|
||||
const fontSizePx = this.getMetric(scene, controlRadiusMeters * LABEL_FONT_SIZE_RATIO)
|
||||
const scoreFontSizePx = this.getMetric(scene, controlRadiusMeters * SCORE_LABEL_FONT_SIZE_RATIO)
|
||||
const scoreOffsetY = this.getMetric(scene, controlRadiusMeters * SCORE_LABEL_OFFSET_Y_RATIO)
|
||||
const offsetX = this.getMetric(scene, controlRadiusMeters * LABEL_OFFSET_X_RATIO)
|
||||
const offsetY = this.getMetric(scene, controlRadiusMeters * LABEL_OFFSET_Y_RATIO)
|
||||
assignImageSource(nextUrl)
|
||||
}
|
||||
|
||||
this.applyPreviewTransform(ctx, scene)
|
||||
ctx.save()
|
||||
if (scene.controlVisualMode === 'multi-target') {
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
ctx.font = `900 ${scoreFontSizePx}px sans-serif`
|
||||
|
||||
for (const control of course.controls) {
|
||||
ctx.save()
|
||||
ctx.fillStyle = this.getScoreLabelColor(scene, control.sequence)
|
||||
ctx.translate(control.point.x, control.point.y)
|
||||
ctx.rotate(scene.rotationRad)
|
||||
ctx.fillText(String(control.sequence), 0, scoreOffsetY)
|
||||
ctx.restore()
|
||||
}
|
||||
} else {
|
||||
ctx.textAlign = 'left'
|
||||
ctx.textBaseline = 'middle'
|
||||
ctx.font = `700 ${fontSizePx}px sans-serif`
|
||||
|
||||
for (const control of course.controls) {
|
||||
ctx.save()
|
||||
ctx.fillStyle = this.getLabelColor(scene, control.sequence)
|
||||
ctx.translate(control.point.x, control.point.y)
|
||||
ctx.rotate(scene.rotationRad)
|
||||
ctx.fillText(String(control.sequence), offsetX, offsetY)
|
||||
ctx.restore()
|
||||
}
|
||||
getGpsLogoBadgeRadius(scene: MapScene): number {
|
||||
const base = scene.gpsMarkerStyleConfig.size === 'small'
|
||||
? 4.1
|
||||
: scene.gpsMarkerStyleConfig.size === 'large'
|
||||
? 6
|
||||
: 5
|
||||
const effectScale = Math.max(0.88, Math.min(1.28, scene.gpsMarkerStyleConfig.effectScale || 1))
|
||||
const logoScale = Math.max(0.86, Math.min(1.16, scene.gpsMarkerStyleConfig.logoScale || 1))
|
||||
return base * effectScale * logoScale
|
||||
}
|
||||
|
||||
renderGpsLogo(scene: MapScene): void {
|
||||
if (
|
||||
!scene.gpsPoint
|
||||
|| !scene.gpsMarkerStyleConfig.visible
|
||||
|| scene.gpsMarkerStyleConfig.style !== 'badge'
|
||||
|| scene.gpsMarkerStyleConfig.logoMode !== 'center-badge'
|
||||
|| !scene.gpsMarkerStyleConfig.logoUrl
|
||||
|| this.gpsLogoStatus !== 'ready'
|
||||
|| !this.gpsLogoImage
|
||||
) {
|
||||
return
|
||||
}
|
||||
const screenPoint = worldToScreen(
|
||||
this.buildVectorCamera(scene),
|
||||
calibratedLonLatToWorldTile(scene.gpsPoint, scene.zoom, scene.gpsCalibration, scene.gpsCalibrationOrigin),
|
||||
false,
|
||||
)
|
||||
const radius = this.getGpsLogoBadgeRadius(scene)
|
||||
const diameter = radius * 2
|
||||
const ctx = this.ctx
|
||||
ctx.save()
|
||||
ctx.beginPath()
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.96)'
|
||||
ctx.arc(screenPoint.x, screenPoint.y, radius + 1.15, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
ctx.beginPath()
|
||||
ctx.strokeStyle = 'rgba(12, 36, 42, 0.18)'
|
||||
ctx.lineWidth = Math.max(1.1, radius * 0.18)
|
||||
ctx.arc(screenPoint.x, screenPoint.y, radius + 0.3, 0, Math.PI * 2)
|
||||
ctx.stroke()
|
||||
ctx.beginPath()
|
||||
ctx.arc(screenPoint.x, screenPoint.y, radius, 0, Math.PI * 2)
|
||||
ctx.clip()
|
||||
ctx.drawImage(this.gpsLogoImage, screenPoint.x - radius, screenPoint.y - radius, diameter, diameter)
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
getLabelColor(scene: MapScene, sequence: number): string {
|
||||
const resolvedStyle = resolveControlStyle(scene, 'control', sequence)
|
||||
const customLabelColor = normalizeHexColor(resolvedStyle.entry.labelColorHex)
|
||||
if (customLabelColor) {
|
||||
return customLabelColor
|
||||
}
|
||||
if (scene.focusedControlSequences.includes(sequence)) {
|
||||
return FOCUSED_LABEL_COLOR
|
||||
}
|
||||
@@ -119,17 +341,28 @@ export class CourseLabelRenderer {
|
||||
}
|
||||
|
||||
if (scene.completedControlSequences.includes(sequence)) {
|
||||
return COMPLETED_LABEL_COLOR
|
||||
return resolvedStyle.entry.style === 'badge'
|
||||
? 'rgba(255, 255, 255, 0.96)'
|
||||
: rgbaToCss(resolvedStyle.color, 0.94)
|
||||
}
|
||||
|
||||
if (scene.skippedControlSequences.includes(sequence)) {
|
||||
return SKIPPED_LABEL_COLOR
|
||||
return resolvedStyle.entry.style === 'badge'
|
||||
? 'rgba(255, 255, 255, 0.9)'
|
||||
: rgbaToCss(resolvedStyle.color, 0.88)
|
||||
}
|
||||
|
||||
return DEFAULT_LABEL_COLOR
|
||||
return resolvedStyle.entry.style === 'badge'
|
||||
? 'rgba(255, 255, 255, 0.98)'
|
||||
: rgbaToCss(resolvedStyle.color, 0.98)
|
||||
}
|
||||
|
||||
getScoreLabelColor(scene: MapScene, sequence: number): string {
|
||||
const resolvedStyle = resolveControlStyle(scene, 'control', sequence)
|
||||
const customLabelColor = normalizeHexColor(resolvedStyle.entry.labelColorHex)
|
||||
if (customLabelColor) {
|
||||
return customLabelColor
|
||||
}
|
||||
if (scene.focusedControlSequences.includes(sequence)) {
|
||||
return FOCUSED_LABEL_COLOR
|
||||
}
|
||||
@@ -139,14 +372,20 @@ export class CourseLabelRenderer {
|
||||
}
|
||||
|
||||
if (scene.completedControlSequences.includes(sequence)) {
|
||||
return SCORE_COMPLETED_LABEL_COLOR
|
||||
return resolvedStyle.entry.style === 'badge'
|
||||
? 'rgba(255, 255, 255, 0.96)'
|
||||
: rgbaToCss(resolvedStyle.color, 0.94)
|
||||
}
|
||||
|
||||
if (scene.skippedControlSequences.includes(sequence)) {
|
||||
return SCORE_SKIPPED_LABEL_COLOR
|
||||
return resolvedStyle.entry.style === 'badge'
|
||||
? 'rgba(255, 255, 255, 0.92)'
|
||||
: rgbaToCss(resolvedStyle.color, 0.9)
|
||||
}
|
||||
|
||||
return SCORE_LABEL_COLOR
|
||||
return resolvedStyle.entry.style === 'badge'
|
||||
? 'rgba(255, 255, 255, 0.98)'
|
||||
: rgbaToCss(resolvedStyle.color, 0.98)
|
||||
}
|
||||
|
||||
clearCanvas(ctx: any): void {
|
||||
|
||||
142
miniprogram/engine/renderer/courseStyleResolver.ts
Normal file
142
miniprogram/engine/renderer/courseStyleResolver.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { type MapScene } from './mapRenderer'
|
||||
import { type ControlPointStyleEntry, type CourseLegStyleEntry, type ScoreBandStyleEntry } from '../../game/presentation/courseStyleConfig'
|
||||
|
||||
export type RgbaColor = [number, number, number, number]
|
||||
|
||||
export interface ResolvedControlStyle {
|
||||
entry: ControlPointStyleEntry
|
||||
color: RgbaColor
|
||||
}
|
||||
|
||||
export interface ResolvedLegStyle {
|
||||
entry: CourseLegStyleEntry
|
||||
color: RgbaColor
|
||||
}
|
||||
|
||||
export function hexToRgbaColor(hex: string, alphaOverride?: number): RgbaColor {
|
||||
const fallback: RgbaColor = [1, 1, 1, alphaOverride !== undefined ? alphaOverride : 1]
|
||||
if (typeof hex !== 'string' || !hex || hex.charAt(0) !== '#') {
|
||||
return fallback
|
||||
}
|
||||
|
||||
const normalized = hex.slice(1)
|
||||
if (normalized.length !== 6 && normalized.length !== 8) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
const red = parseInt(normalized.slice(0, 2), 16)
|
||||
const green = parseInt(normalized.slice(2, 4), 16)
|
||||
const blue = parseInt(normalized.slice(4, 6), 16)
|
||||
const alpha = normalized.length === 8 ? parseInt(normalized.slice(6, 8), 16) : 255
|
||||
if (!Number.isFinite(red) || !Number.isFinite(green) || !Number.isFinite(blue) || !Number.isFinite(alpha)) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return [
|
||||
red / 255,
|
||||
green / 255,
|
||||
blue / 255,
|
||||
alphaOverride !== undefined ? alphaOverride : alpha / 255,
|
||||
]
|
||||
}
|
||||
|
||||
function resolveScoreBandStyle(scene: MapScene, sequence: number): ScoreBandStyleEntry | null {
|
||||
const score = scene.controlScoresBySequence[sequence]
|
||||
if (score === undefined) {
|
||||
return null
|
||||
}
|
||||
|
||||
const bands = scene.courseStyleConfig.scoreO.controls.scoreBands
|
||||
for (let index = 0; index < bands.length; index += 1) {
|
||||
const band = bands[index]
|
||||
if (score >= band.min && score <= band.max) {
|
||||
return band
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function resolveControlStyle(scene: MapScene, kind: 'start' | 'control' | 'finish', sequence: number | null, index?: number): ResolvedControlStyle {
|
||||
if (kind === 'start') {
|
||||
if (index !== undefined && scene.startStyleOverrides[index]) {
|
||||
const entry = scene.startStyleOverrides[index]
|
||||
return { entry, color: hexToRgbaColor(entry.colorHex) }
|
||||
}
|
||||
const entry = scene.gameMode === 'score-o'
|
||||
? scene.courseStyleConfig.scoreO.controls.start
|
||||
: scene.courseStyleConfig.sequential.controls.start
|
||||
return { entry, color: hexToRgbaColor(entry.colorHex) }
|
||||
}
|
||||
|
||||
if (kind === 'finish') {
|
||||
if (index !== undefined && scene.finishStyleOverrides[index]) {
|
||||
const entry = scene.finishStyleOverrides[index]
|
||||
return { entry, color: hexToRgbaColor(entry.colorHex) }
|
||||
}
|
||||
const entry = scene.gameMode === 'score-o'
|
||||
? scene.courseStyleConfig.scoreO.controls.finish
|
||||
: scene.courseStyleConfig.sequential.controls.finish
|
||||
return { entry, color: hexToRgbaColor(entry.colorHex) }
|
||||
}
|
||||
|
||||
if (sequence === null) {
|
||||
const entry = scene.courseStyleConfig.sequential.controls.default
|
||||
return { entry, color: hexToRgbaColor(entry.colorHex) }
|
||||
}
|
||||
|
||||
if (scene.controlStyleOverridesBySequence[sequence]) {
|
||||
const entry = scene.controlStyleOverridesBySequence[sequence]
|
||||
return { entry, color: hexToRgbaColor(entry.colorHex) }
|
||||
}
|
||||
|
||||
if (scene.gameMode === 'score-o') {
|
||||
if (scene.completedControlSequences.includes(sequence)) {
|
||||
const entry = scene.courseStyleConfig.scoreO.controls.collected
|
||||
return { entry, color: hexToRgbaColor(entry.colorHex) }
|
||||
}
|
||||
|
||||
if (scene.focusedControlSequences.includes(sequence)) {
|
||||
const entry = scene.courseStyleConfig.scoreO.controls.focused
|
||||
return { entry, color: hexToRgbaColor(entry.colorHex) }
|
||||
}
|
||||
|
||||
const bandEntry = resolveScoreBandStyle(scene, sequence)
|
||||
const entry = bandEntry || scene.courseStyleConfig.scoreO.controls.default
|
||||
return { entry, color: hexToRgbaColor(entry.colorHex) }
|
||||
}
|
||||
|
||||
if (scene.readyControlSequences.includes(sequence) || scene.activeControlSequences.includes(sequence)) {
|
||||
const entry = scene.courseStyleConfig.sequential.controls.current
|
||||
return { entry, color: hexToRgbaColor(entry.colorHex) }
|
||||
}
|
||||
|
||||
if (scene.completedControlSequences.includes(sequence)) {
|
||||
const entry = scene.courseStyleConfig.sequential.controls.completed
|
||||
return { entry, color: hexToRgbaColor(entry.colorHex) }
|
||||
}
|
||||
|
||||
if (scene.skippedControlSequences.includes(sequence)) {
|
||||
const entry = scene.courseStyleConfig.sequential.controls.skipped
|
||||
return { entry, color: hexToRgbaColor(entry.colorHex) }
|
||||
}
|
||||
|
||||
const entry = scene.courseStyleConfig.sequential.controls.default
|
||||
return { entry, color: hexToRgbaColor(entry.colorHex) }
|
||||
}
|
||||
|
||||
export function resolveLegStyle(scene: MapScene, index: number): ResolvedLegStyle {
|
||||
if (scene.legStyleOverridesByIndex[index]) {
|
||||
const entry = scene.legStyleOverridesByIndex[index]
|
||||
return { entry, color: hexToRgbaColor(entry.colorHex) }
|
||||
}
|
||||
|
||||
if (scene.gameMode === 'score-o') {
|
||||
const entry = scene.courseStyleConfig.sequential.legs.default
|
||||
return { entry, color: hexToRgbaColor(entry.colorHex) }
|
||||
}
|
||||
|
||||
const completed = scene.completedLegIndices.includes(index)
|
||||
const entry = completed ? scene.courseStyleConfig.sequential.legs.completed : scene.courseStyleConfig.sequential.legs.default
|
||||
return { entry, color: hexToRgbaColor(entry.colorHex) }
|
||||
}
|
||||
@@ -4,6 +4,9 @@ import { type LonLatPoint, type MapCalibration } from '../../utils/projection'
|
||||
import { type TileZoomBounds } from '../../utils/remoteMapConfig'
|
||||
import { type OrienteeringCourseData } from '../../utils/orienteeringCourse'
|
||||
import { type AnimationLevel } from '../../utils/animationLevel'
|
||||
import { type ControlPointStyleEntry, type CourseLegStyleEntry, type CourseStyleConfig } from '../../game/presentation/courseStyleConfig'
|
||||
import { type GpsMarkerStyleConfig } from '../../game/presentation/gpsMarkerStyleConfig'
|
||||
import { type TrackDisplayMode, type TrackVisualizationConfig } from '../../game/presentation/trackStyleConfig'
|
||||
|
||||
export interface MapScene {
|
||||
tileSource: string
|
||||
@@ -25,12 +28,24 @@ export interface MapScene {
|
||||
previewScale: number
|
||||
previewOriginX: number
|
||||
previewOriginY: number
|
||||
trackMode: TrackDisplayMode
|
||||
trackStyleConfig: TrackVisualizationConfig
|
||||
track: LonLatPoint[]
|
||||
gpsPoint: LonLatPoint | null
|
||||
gpsMarkerStyleConfig: GpsMarkerStyleConfig
|
||||
gpsHeadingDeg: number | null
|
||||
gpsHeadingAlpha: number
|
||||
gpsCalibration: MapCalibration
|
||||
gpsCalibrationOrigin: LonLatPoint
|
||||
course: OrienteeringCourseData | null
|
||||
cpRadiusMeters: number
|
||||
gameMode: 'classic-sequential' | 'score-o'
|
||||
courseStyleConfig: CourseStyleConfig
|
||||
controlScoresBySequence: Record<number, number>
|
||||
controlStyleOverridesBySequence: Record<number, ControlPointStyleEntry>
|
||||
startStyleOverrides: ControlPointStyleEntry[]
|
||||
finishStyleOverrides: ControlPointStyleEntry[]
|
||||
legStyleOverridesByIndex: Record<number, CourseLegStyleEntry>
|
||||
controlVisualMode: 'single-target' | 'multi-target'
|
||||
showCourseLegs: boolean
|
||||
guidanceLegAnimationEnabled: boolean
|
||||
@@ -60,6 +75,7 @@ export interface MapRenderer {
|
||||
attachCanvas(canvasNode: any, width: number, height: number, dpr: number, labelCanvasNode?: any): void
|
||||
updateScene(scene: MapScene): void
|
||||
setAnimationPaused(paused: boolean): void
|
||||
getGpsLogoDebugInfo?(): { status: string; url: string; resolvedSrc: string }
|
||||
destroy(): void
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { type MapRenderer, type MapRendererStats, type MapScene } from './mapRen
|
||||
import { WebGLTileRenderer } from './webglTileRenderer'
|
||||
import { WebGLVectorRenderer } from './webglVectorRenderer'
|
||||
import { CourseLabelRenderer } from './courseLabelRenderer'
|
||||
import { type MockSimulatorDebugLogLevel } from '../debug/mockSimulatorDebugLogger'
|
||||
|
||||
const RENDER_FRAME_MS = 16
|
||||
const ANIMATION_FRAME_MS = 33
|
||||
@@ -29,12 +30,32 @@ export class WebGLMapRenderer implements MapRenderer {
|
||||
animationPaused: boolean
|
||||
pulseFrame: number
|
||||
lastStats: MapRendererStats
|
||||
lastGpsLogoDebugInfo: { status: string; url: string; resolvedSrc: string }
|
||||
onStats?: (stats: MapRendererStats) => void
|
||||
onTileError?: (message: string) => void
|
||||
onGpsLogoDebug?: (info: { status: string; url: string; resolvedSrc: string }) => void
|
||||
onDebugLog?: (
|
||||
scope: string,
|
||||
level: MockSimulatorDebugLogLevel,
|
||||
message: string,
|
||||
payload?: Record<string, unknown>,
|
||||
) => void
|
||||
|
||||
constructor(onStats?: (stats: MapRendererStats) => void, onTileError?: (message: string) => void) {
|
||||
constructor(
|
||||
onStats?: (stats: MapRendererStats) => void,
|
||||
onTileError?: (message: string) => void,
|
||||
onGpsLogoDebug?: (info: { status: string; url: string; resolvedSrc: string }) => void,
|
||||
onDebugLog?: (
|
||||
scope: string,
|
||||
level: MockSimulatorDebugLogLevel,
|
||||
message: string,
|
||||
payload?: Record<string, unknown>,
|
||||
) => void,
|
||||
) {
|
||||
this.onStats = onStats
|
||||
this.onTileError = onTileError
|
||||
this.onGpsLogoDebug = onGpsLogoDebug
|
||||
this.onDebugLog = onDebugLog
|
||||
this.tileStore = new TileStore({
|
||||
onTileReady: () => {
|
||||
this.scheduleRender()
|
||||
@@ -61,7 +82,7 @@ export class WebGLMapRenderer implements MapRenderer {
|
||||
this.gpsLayer = new GpsLayer()
|
||||
this.tileRenderer = new WebGLTileRenderer(this.tileLayer, this.tileStore, this.osmTileLayer, this.osmTileStore)
|
||||
this.vectorRenderer = new WebGLVectorRenderer(this.courseLayer, this.trackLayer, this.gpsLayer)
|
||||
this.labelRenderer = new CourseLabelRenderer(this.courseLayer)
|
||||
this.labelRenderer = new CourseLabelRenderer(this.courseLayer, onDebugLog)
|
||||
this.scene = null
|
||||
this.renderTimer = 0
|
||||
this.animationTimer = 0
|
||||
@@ -77,6 +98,11 @@ export class WebGLMapRenderer implements MapRenderer {
|
||||
diskHitCount: 0,
|
||||
networkFetchCount: 0,
|
||||
}
|
||||
this.lastGpsLogoDebugInfo = {
|
||||
status: 'idle',
|
||||
url: '',
|
||||
resolvedSrc: '',
|
||||
}
|
||||
}
|
||||
|
||||
attachCanvas(canvasNode: any, width: number, height: number, dpr: number, labelCanvasNode?: any): void {
|
||||
@@ -164,9 +190,14 @@ export class WebGLMapRenderer implements MapRenderer {
|
||||
this.tileRenderer.render(this.scene)
|
||||
this.vectorRenderer.render(this.scene, this.pulseFrame)
|
||||
this.labelRenderer.render(this.scene)
|
||||
this.emitGpsLogoDebug(this.labelRenderer.getGpsLogoDebugInfo())
|
||||
this.emitStats(this.tileStore.getStats(this.tileLayer.lastVisibleTileCount, this.tileLayer.lastReadyTileCount))
|
||||
}
|
||||
|
||||
getGpsLogoDebugInfo(): { status: string; url: string; resolvedSrc: string } {
|
||||
return this.labelRenderer.getGpsLogoDebugInfo()
|
||||
}
|
||||
|
||||
emitStats(stats: MapRendererStats): void {
|
||||
if (
|
||||
stats.visibleTileCount === this.lastStats.visibleTileCount
|
||||
@@ -185,4 +216,19 @@ export class WebGLMapRenderer implements MapRenderer {
|
||||
this.onStats(stats)
|
||||
}
|
||||
}
|
||||
|
||||
emitGpsLogoDebug(info: { status: string; url: string; resolvedSrc: string }): void {
|
||||
if (
|
||||
info.status === this.lastGpsLogoDebugInfo.status
|
||||
&& info.url === this.lastGpsLogoDebugInfo.url
|
||||
&& info.resolvedSrc === this.lastGpsLogoDebugInfo.resolvedSrc
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
this.lastGpsLogoDebugInfo = info
|
||||
if (this.onGpsLogoDebug) {
|
||||
this.onGpsLogoDebug(info)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,13 +4,15 @@ 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 COURSE_COLOR: [number, number, number, number] = [0.8, 0.0, 0.42, 0.96]
|
||||
const COMPLETED_ROUTE_COLOR: [number, number, number, number] = [0.48, 0.5, 0.54, 0.82]
|
||||
const SKIPPED_ROUTE_COLOR: [number, number, number, number] = [0.38, 0.4, 0.44, 0.72]
|
||||
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 MULTI_ACTIVE_CONTROL_COLOR: [number, number, number, number] = [1, 0.8, 0.2, 0.98]
|
||||
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]
|
||||
@@ -36,8 +38,102 @@ 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
|
||||
|
||||
type RgbaColor = [number, number, number, number]
|
||||
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)
|
||||
@@ -172,14 +268,10 @@ export class WebGLVectorRenderer {
|
||||
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)
|
||||
}
|
||||
this.pushTrack(positions, colors, trackPoints, 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 (gpsPoint && scene.gpsMarkerStyleConfig.visible) {
|
||||
this.pushGpsMarker(positions, colors, gpsPoint.x, gpsPoint.y, scene, pulseFrame)
|
||||
}
|
||||
|
||||
if (!positions.length) {
|
||||
@@ -251,7 +343,7 @@ export class WebGLVectorRenderer {
|
||||
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, this.getLegColor(scene, index), scene)
|
||||
this.pushCourseLeg(positions, colors, leg, controlRadiusMeters, leg.index, scene)
|
||||
if (scene.guidanceLegAnimationEnabled && scene.activeLegIndices.includes(index)) {
|
||||
this.pushCourseLegHighlight(positions, colors, leg, controlRadiusMeters, scene)
|
||||
}
|
||||
@@ -279,13 +371,15 @@ export class WebGLVectorRenderer {
|
||||
scene,
|
||||
)
|
||||
}
|
||||
this.pushStartTriangle(positions, colors, start.point.x, start.point.y, start.headingDeg, controlRadiusMeters, this.getStartColor(scene), 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)
|
||||
@@ -314,14 +408,14 @@ export class WebGLVectorRenderer {
|
||||
)
|
||||
}
|
||||
|
||||
this.pushRing(
|
||||
this.pushControlShape(
|
||||
positions,
|
||||
colors,
|
||||
control.point.x,
|
||||
control.point.y,
|
||||
this.getMetric(scene, controlRadiusMeters),
|
||||
this.getMetric(scene, controlRadiusMeters * (1 - CONTROL_RING_WIDTH_RATIO)),
|
||||
this.getControlColor(scene, control.sequence),
|
||||
controlRadiusMeters,
|
||||
controlStyle.entry,
|
||||
controlStyle.color,
|
||||
scene,
|
||||
)
|
||||
|
||||
@@ -381,7 +475,7 @@ export class WebGLVectorRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
const finishColor = this.getFinishColor(scene)
|
||||
const finishStyle = resolveControlStyle(scene, 'finish', null, finish.index)
|
||||
if (scene.completedFinish) {
|
||||
this.pushRing(
|
||||
positions,
|
||||
@@ -394,29 +488,273 @@ export class WebGLVectorRenderer {
|
||||
scene,
|
||||
)
|
||||
}
|
||||
this.pushRing(
|
||||
positions,
|
||||
colors,
|
||||
finish.point.x,
|
||||
finish.point.y,
|
||||
this.getMetric(scene, controlRadiusMeters),
|
||||
this.getMetric(scene, controlRadiusMeters * (1 - FINISH_RING_WIDTH_RATIO)),
|
||||
finishColor,
|
||||
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,
|
||||
)
|
||||
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)),
|
||||
finishColor,
|
||||
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
|
||||
@@ -430,10 +768,6 @@ export class WebGLVectorRenderer {
|
||||
return null
|
||||
}
|
||||
|
||||
getLegColor(scene: MapScene, index: number): RgbaColor {
|
||||
return this.isCompletedLeg(scene, index) ? COMPLETED_ROUTE_COLOR : COURSE_COLOR
|
||||
}
|
||||
|
||||
isCompletedLeg(scene: MapScene, index: number): boolean {
|
||||
return scene.completedLegIndices.includes(index)
|
||||
}
|
||||
@@ -447,7 +781,7 @@ export class WebGLVectorRenderer {
|
||||
colors: number[],
|
||||
leg: ProjectedCourseLeg,
|
||||
controlRadiusMeters: number,
|
||||
color: RgbaColor,
|
||||
index: number,
|
||||
scene: MapScene,
|
||||
): void {
|
||||
const trimmed = this.getTrimmedCourseLeg(leg, controlRadiusMeters, scene)
|
||||
@@ -455,7 +789,17 @@ export class WebGLVectorRenderer {
|
||||
return
|
||||
}
|
||||
|
||||
this.pushSegment(positions, colors, trimmed.from, trimmed.to, this.getMetric(scene, controlRadiusMeters * LEG_WIDTH_RATIO), color, scene)
|
||||
const legStyle = resolveLegStyle(scene, index)
|
||||
this.pushLegWithStyle(
|
||||
positions,
|
||||
colors,
|
||||
trimmed.from,
|
||||
trimmed.to,
|
||||
controlRadiusMeters,
|
||||
legStyle.entry,
|
||||
legStyle.color,
|
||||
scene,
|
||||
)
|
||||
}
|
||||
|
||||
pushCourseLegHighlight(
|
||||
@@ -562,55 +906,6 @@ export class WebGLVectorRenderer {
|
||||
)
|
||||
}
|
||||
|
||||
getStartColor(scene: MapScene): RgbaColor {
|
||||
if (scene.activeStart) {
|
||||
return ACTIVE_CONTROL_COLOR
|
||||
}
|
||||
|
||||
if (scene.completedStart) {
|
||||
return COMPLETED_ROUTE_COLOR
|
||||
}
|
||||
|
||||
return COURSE_COLOR
|
||||
}
|
||||
|
||||
getControlColor(scene: MapScene, sequence: number): RgbaColor {
|
||||
if (scene.readyControlSequences.includes(sequence)) {
|
||||
return READY_CONTROL_COLOR
|
||||
}
|
||||
|
||||
if (scene.activeControlSequences.includes(sequence)) {
|
||||
return scene.controlVisualMode === 'multi-target' ? MULTI_ACTIVE_CONTROL_COLOR : ACTIVE_CONTROL_COLOR
|
||||
}
|
||||
|
||||
if (scene.completedControlSequences.includes(sequence)) {
|
||||
return COMPLETED_ROUTE_COLOR
|
||||
}
|
||||
|
||||
if (this.isSkippedControl(scene, sequence)) {
|
||||
return SKIPPED_ROUTE_COLOR
|
||||
}
|
||||
|
||||
return COURSE_COLOR
|
||||
}
|
||||
|
||||
|
||||
getFinishColor(scene: MapScene): RgbaColor {
|
||||
if (scene.focusedFinish) {
|
||||
return FOCUSED_CONTROL_COLOR
|
||||
}
|
||||
|
||||
if (scene.activeFinish) {
|
||||
return ACTIVE_CONTROL_COLOR
|
||||
}
|
||||
|
||||
if (scene.completedFinish) {
|
||||
return COMPLETED_ROUTE_COLOR
|
||||
}
|
||||
|
||||
return COURSE_COLOR
|
||||
}
|
||||
|
||||
pushGuidanceFlow(
|
||||
positions: number[],
|
||||
colors: number[],
|
||||
@@ -744,6 +1039,359 @@ export class WebGLVectorRenderer {
|
||||
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[],
|
||||
|
||||
@@ -40,9 +40,9 @@ function normalizeMockBridgeUrl(rawUrl: string): string {
|
||||
normalized = `ws://${normalized.replace(/^\/+/, '')}`
|
||||
}
|
||||
|
||||
if (!/\/mock-gps(?:\?.*)?$/i.test(normalized)) {
|
||||
if (!/\/mock-hr(?:\?.*)?$/i.test(normalized)) {
|
||||
normalized = normalized.replace(/\/+$/, '')
|
||||
normalized = `${normalized}/mock-gps`
|
||||
normalized = `${normalized}/mock-hr`
|
||||
}
|
||||
|
||||
return normalized
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const DEFAULT_MOCK_HEART_RATE_BRIDGE_URL = 'wss://gs.gotomars.xyz/mock-gps'
|
||||
export const DEFAULT_MOCK_HEART_RATE_BRIDGE_URL = 'wss://gs.gotomars.xyz/mock-hr'
|
||||
|
||||
export interface MockHeartRateBridgeCallbacks {
|
||||
onOpen: () => void
|
||||
|
||||
Reference in New Issue
Block a user