完善样式系统与调试链路底座

This commit is contained in:
2026-03-30 18:19:05 +08:00
parent 2c0fd4c549
commit 3b9117427e
40 changed files with 7526 additions and 389 deletions

View File

@@ -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),

View File

@@ -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()
}
}

View File

@@ -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()
}
}