| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290 |
- 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<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,
- 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()
- }
- }
|