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 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 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)' 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 canvas: any ctx: any 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, ) => void constructor( courseLayer: CourseLayer, onDebugLog?: ( scope: string, level: MockSimulatorDebugLogLevel, message: string, payload?: Record, ) => 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, ): void { if (this.onDebugLog) { this.onDebugLog('gps-logo', level, message, payload) } } 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 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 { if (!this.ctx || !this.canvas) { return } const course = this.courseLayer.projectCourse(scene) const ctx = this.ctx this.clearCanvas(ctx) 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.gameMode === 'score-o' || 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(this.getControlLabelText(scene, 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 } assignImageSource(nextUrl) } 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 } if (scene.readyControlSequences.includes(sequence)) { return READY_LABEL_COLOR } if (scene.activeControlSequences.includes(sequence)) { return scene.controlVisualMode === 'multi-target' ? MULTI_ACTIVE_LABEL_COLOR : ACTIVE_LABEL_COLOR } if (scene.completedControlSequences.includes(sequence)) { return resolvedStyle.entry.style === 'badge' ? 'rgba(255, 255, 255, 0.96)' : rgbaToCss(resolvedStyle.color, 0.94) } if (scene.skippedControlSequences.includes(sequence)) { return resolvedStyle.entry.style === 'badge' ? 'rgba(255, 255, 255, 0.9)' : rgbaToCss(resolvedStyle.color, 0.88) } 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 } if (scene.readyControlSequences.includes(sequence)) { return READY_LABEL_COLOR } if (scene.completedControlSequences.includes(sequence)) { return resolvedStyle.entry.style === 'badge' ? 'rgba(255, 255, 255, 0.96)' : rgbaToCss(resolvedStyle.color, 0.94) } if (scene.skippedControlSequences.includes(sequence)) { return resolvedStyle.entry.style === 'badge' ? 'rgba(255, 255, 255, 0.92)' : rgbaToCss(resolvedStyle.color, 0.9) } return resolvedStyle.entry.style === 'badge' ? 'rgba(255, 255, 255, 0.98)' : rgbaToCss(resolvedStyle.color, 0.98) } getControlLabelText(scene: MapScene, sequence: number): string { if (scene.gameMode === 'score-o') { const score = scene.controlScoresBySequence[sequence] if (typeof score === 'number' && Number.isFinite(score)) { return String(score) } } return String(sequence) } 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 } }