import { calibratedLonLatToWorldTile } from '../../utils/projection' import { worldToScreen, type CameraState } from '../camera/camera' 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, effectScale: number, ): ReturnType { 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, 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, centerWorldY: scene.exactCenterWorldY, viewportWidth: scene.viewportWidth, viewportHeight: scene.viewportHeight, visibleColumns: scene.visibleColumns, rotationRad: scene.rotationRad, } } export class GpsLayer implements MapLayer { projectPoint(scene: MapScene): ScreenPoint | null { if (!scene.gpsPoint) { return null } const camera = buildVectorCamera(scene) const worldPoint = calibratedLonLatToWorldTile(scene.gpsPoint, scene.zoom, scene.gpsCalibration, scene.gpsCalibrationOrigin) return worldToScreen(camera, worldPoint, false) } getPulseRadius(pulseFrame: number): number { return 18 + 6 * (0.5 + 0.5 * Math.sin(pulseFrame / 6)) } draw(context: LayerRenderContext): void { const { ctx, scene, pulseFrame } = context if (!scene.gpsMarkerStyleConfig.visible) { return } const gpsScreenPoint = this.projectPoint(scene) if (!gpsScreenPoint) { return } 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() 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() } 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() } 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.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() } }