完善样式系统与调试链路底座
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user