291 lines
12 KiB
TypeScript
291 lines
12 KiB
TypeScript
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()
|
|
}
|
|
}
|