| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443 |
- 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<string, unknown>,
- ) => void
- 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 {
- 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
- }
- }
|