feat: Geschwindigkeits-Farbverlauf auf der Track-Karte und stabiler Leaflet-Lifecycle.

Verzögertes fitBounds, Error Boundary und sauberes Map-Cleanup beheben den Absturz, der die Logbuch-Ansicht leer ließ.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-29 15:56:45 +02:00
parent 95856800de
commit 56af7a3c60
5 changed files with 286 additions and 62 deletions
+21 -1
View File
@@ -1985,11 +1985,14 @@ body:has(.theme-cupertino) {
align-items: start;
}
.track-map-wrapper {
margin-top: 12px;
}
#openseamap-container,
.track-map-container {
width: 100%;
height: 400px;
margin-top: 12px;
border-radius: 12px;
border: 1px solid rgba(212, 175, 55, 0.2);
position: relative;
@@ -1999,6 +2002,23 @@ body:has(.theme-cupertino) {
background: #dbeafe;
}
.track-map-legend {
display: flex;
align-items: center;
gap: 10px;
margin-top: 8px;
font-size: 11px;
color: #94a3b8;
}
.track-map-legend-bar {
flex: 1;
height: 8px;
border-radius: 4px;
background: linear-gradient(to right, hsl(120, 72%, 42%), hsl(60, 72%, 42%), hsl(0, 72%, 42%));
border: 1px solid rgba(255, 255, 255, 0.12);
}
.track-map-container.leaflet-container {
font-family: inherit;
}
+184 -61
View File
@@ -1,36 +1,115 @@
import { useEffect, useMemo, useRef } from 'react'
import { Component, useEffect, useMemo, useRef } from 'react'
import type { ErrorInfo, ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import L from 'leaflet'
import type { TrackWaypoint } from '../services/trackUpload.js'
import {
getSegmentSpeedsKn,
getTrackLineColor,
hasSpeedGradientData,
speedToTrackColor
} from '../utils/trackMapColors.js'
interface TrackMapProps {
waypoints: TrackWaypoint[]
}
export default function TrackMap({ waypoints }: TrackMapProps) {
const LINE_WEIGHT = 5
const LINE_OPACITY = 0.92
function isValidWaypoint(wp: TrackWaypoint): boolean {
return Number.isFinite(Number(wp.lat)) && Number.isFinite(Number(wp.lng))
}
function toLatLngs(waypoints: TrackWaypoint[]): [number, number][] {
return waypoints
.filter(isValidWaypoint)
.map((wp) => [Number(wp.lat), Number(wp.lng)] as [number, number])
}
function getTrackCenter(latLngs: [number, number][]): [number, number] {
const avgLat = latLngs.reduce((sum, point) => sum + point[0], 0) / latLngs.length
const avgLng = latLngs.reduce((sum, point) => sum + point[1], 0) / latLngs.length
return [avgLat, avgLng]
}
function scheduleFitMap(
map: L.Map,
latLngs: [number, number][],
isCancelled: () => boolean,
frameIds: number[]
) {
if (latLngs.length === 0) return
const fallbackCenter = latLngs.length === 1 ? latLngs[0] : getTrackCenter(latLngs)
const fallbackZoom = latLngs.length === 1 ? 14 : 11
frameIds.push(
requestAnimationFrame(() => {
if (isCancelled()) return
map.invalidateSize({ animate: false })
frameIds.push(
requestAnimationFrame(() => {
if (isCancelled()) return
try {
if (latLngs.length === 1) {
map.setView(L.latLng(latLngs[0]), 14, { animate: false })
return
}
const bounds = L.latLngBounds(latLngs.map(([lat, lng]) => L.latLng(lat, lng)))
if (!bounds.isValid()) {
map.setView(fallbackCenter, fallbackZoom, { animate: false })
return
}
map.fitBounds(bounds, { padding: [20, 20], maxZoom: 14, animate: false })
} catch {
map.setView(fallbackCenter, fallbackZoom, { animate: false })
}
})
)
})
)
}
function TrackMapInner({ waypoints }: TrackMapProps) {
const { t } = useTranslation()
const containerRef = useRef<HTMLDivElement | null>(null)
const mapRef = useRef<L.Map | null>(null)
const layersRef = useRef<{
polyline: L.Polyline | null
start: L.CircleMarker | null
end: L.CircleMarker | null
}>({ polyline: null, start: null, end: null })
const waypointsKey = useMemo(
() => waypoints.map((wp) => `${wp.lat},${wp.lng}`).join('|'),
[waypoints]
const validWaypoints = useMemo(() => waypoints.filter(isValidWaypoint), [waypoints])
const segmentSpeeds = useMemo(() => getSegmentSpeedsKn(validWaypoints), [validWaypoints])
const useGradient = hasSpeedGradientData(segmentSpeeds)
const speedRange = useMemo(() => {
const valid = segmentSpeeds.filter((speed) => speed > 0)
if (valid.length === 0) return { min: 0, max: 0 }
return { min: Math.min(...valid), max: Math.max(...valid) }
}, [segmentSpeeds])
const trackKey = useMemo(
() =>
validWaypoints
.map((wp, index) => {
const speed = index > 0 ? segmentSpeeds[index - 1] : 0
return `${wp.lat},${wp.lng},${speed.toFixed(1)}`
})
.join('|'),
[validWaypoints, segmentSpeeds]
)
useEffect(() => {
const container = containerRef.current
if (!container || mapRef.current) return
if (!container || validWaypoints.length === 0) return
let cancelled = false
const pendingFrames: number[] = []
const isCancelled = () => cancelled
const map = L.map(container, {
zoomControl: true,
attributionControl: true
})
mapRef.current = map
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
@@ -42,70 +121,114 @@ export default function TrackMap({ waypoints }: TrackMapProps) {
attribution: 'Map data &copy; <a href="http://openseamap.org">OpenSeaMap</a> contributors'
}).addTo(map)
const resizeTimer = window.setTimeout(() => map.invalidateSize(), 150)
const trackGroup = L.layerGroup().addTo(map)
const latLngs = toLatLngs(validWaypoints)
return () => {
window.clearTimeout(resizeTimer)
map.remove()
mapRef.current = null
layersRef.current = { polyline: null, start: null, end: null }
if (useGradient && latLngs.length >= 2) {
for (let i = 1; i < latLngs.length; i++) {
const speedKn = segmentSpeeds[i - 1] ?? 0
const color = speedToTrackColor(speedKn, speedRange.min, speedRange.max)
L.polyline([latLngs[i - 1], latLngs[i]], {
color,
weight: LINE_WEIGHT,
opacity: LINE_OPACITY,
lineCap: 'round',
lineJoin: 'round'
}).addTo(trackGroup)
}
} else if (latLngs.length >= 2) {
L.polyline(latLngs, {
color: getTrackLineColor(segmentSpeeds),
weight: LINE_WEIGHT,
opacity: LINE_OPACITY,
lineCap: 'round',
lineJoin: 'round'
}).addTo(trackGroup)
}
}, [])
useEffect(() => {
const map = mapRef.current
if (!map || waypoints.length === 0) return
if (latLngs.length > 0) {
L.circleMarker(latLngs[0], {
radius: 8,
fillColor: '#10b981',
fillOpacity: 0.9,
color: '#ffffff',
weight: 2
})
.addTo(trackGroup)
.bindPopup(t('logs.track_map_start'))
}
const { polyline, start, end } = layersRef.current
polyline?.remove()
start?.remove()
end?.remove()
const latLngs = waypoints.map((wp) => [wp.lat, wp.lng] as [number, number])
const newPolyline = L.polyline(latLngs, {
color: '#fbbf24',
weight: 4,
opacity: 0.85
}).addTo(map)
const newStart = L.circleMarker(latLngs[0], {
radius: 8,
fillColor: '#10b981',
fillOpacity: 0.9,
color: '#ffffff',
weight: 2
})
.addTo(map)
.bindPopup(t('logs.track_map_start'))
let newEnd: L.CircleMarker | null = null
if (waypoints.length > 1) {
newEnd = L.circleMarker(latLngs[latLngs.length - 1], {
if (latLngs.length > 1) {
L.circleMarker(latLngs[latLngs.length - 1], {
radius: 8,
fillColor: '#ef4444',
fillOpacity: 0.9,
color: '#ffffff',
weight: 2
})
.addTo(map)
.addTo(trackGroup)
.bindPopup(t('logs.track_map_end'))
}
layersRef.current = { polyline: newPolyline, start: newStart, end: newEnd }
map.fitBounds(newPolyline.getBounds(), { padding: [20, 20] })
scheduleFitMap(map, latLngs, isCancelled, pendingFrames)
const resizeTimer = window.setTimeout(() => map.invalidateSize(), 100)
return () => window.clearTimeout(resizeTimer)
}, [waypointsKey, waypoints, t])
return () => {
cancelled = true
pendingFrames.forEach((id) => cancelAnimationFrame(id))
map.remove()
}
}, [trackKey, validWaypoints, segmentSpeeds, speedRange.min, speedRange.max, useGradient, t])
if (!waypoints.length) return null
if (validWaypoints.length === 0) return null
return (
<div
className="track-map-container"
ref={containerRef}
aria-label={t('logs.track_map_title')}
/>
<div className="track-map-wrapper">
<div
className="track-map-container"
ref={containerRef}
aria-label={t('logs.track_map_title')}
/>
{useGradient && (
<div className="track-map-legend" aria-hidden="true">
<span>{t('logs.track_map_speed_slow')}</span>
<div className="track-map-legend-bar" />
<span>{t('logs.track_map_speed_fast')}</span>
</div>
)}
</div>
)
}
class TrackMapErrorBoundary extends Component<
{ children: ReactNode; fallback: ReactNode },
{ hasError: boolean }
> {
state = { hasError: false }
static getDerivedStateFromError() {
return { hasError: true }
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error('TrackMap render failed:', error, info)
}
render() {
if (this.state.hasError) return this.props.fallback
return this.props.children
}
}
export default function TrackMap(props: TrackMapProps) {
const { t } = useTranslation()
const remountKey = props.waypoints.filter(isValidWaypoint).length
return (
<TrackMapErrorBoundary
key={remountKey}
fallback={<div className="track-error-msg">{t('logs.track_map_error')}</div>}
>
<TrackMapInner {...props} />
</TrackMapErrorBoundary>
)
}
+3
View File
@@ -172,6 +172,9 @@
"track_map_title": "GPS-Track auf OpenSeaMap",
"track_map_start": "Start",
"track_map_end": "Ziel",
"track_map_speed_slow": "langsam",
"track_map_speed_fast": "schnell",
"track_map_error": "Karte konnte nicht geladen werden.",
"exporting": "Exportiere...",
"share_unsupported": "Teilen wird auf diesem Gerät nicht unterstützt. Datei wurde stattdessen heruntergeladen.",
"invite_crew": "Crew einladen",
+3
View File
@@ -172,6 +172,9 @@
"track_map_title": "GPS track on OpenSeaMap",
"track_map_start": "Start",
"track_map_end": "End",
"track_map_speed_slow": "slow",
"track_map_speed_fast": "fast",
"track_map_error": "Could not load map.",
"exporting": "Exporting...",
"share_unsupported": "Web sharing is not supported on this device. File downloaded instead.",
"invite_crew": "Invite Crew",
+75
View File
@@ -0,0 +1,75 @@
import type { TrackWaypoint } from '../services/trackUpload.js'
const NM_IN_METERS = 1852
const MAX_PLAUSIBLE_KNOTS = 50
const FALLBACK_GREEN = '#16a34a'
function haversineMeters(lat1: number, lon1: number, lat2: number, lon2: number): number {
const R = 6371000
const p1 = (lat1 * Math.PI) / 180
const p2 = (lat2 * Math.PI) / 180
const dLat = ((lat2 - lat1) * Math.PI) / 180
const dLon = ((lon2 - lon1) * Math.PI) / 180
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(p1) * Math.cos(p2) * Math.sin(dLon / 2) ** 2
return 2 * R * Math.asin(Math.sqrt(a))
}
function hasMeaningfulTimestamps(waypoints: TrackWaypoint[]): boolean {
if (waypoints.length < 2) return false
const first = waypoints[0].timestamp
const last = waypoints[waypoints.length - 1].timestamp
return last > first + 60_000
}
export function getSegmentSpeedsKn(waypoints: TrackWaypoint[]): number[] {
if (waypoints.length < 2) return []
const timed = hasMeaningfulTimestamps(waypoints)
const speeds: number[] = []
for (let i = 1; i < waypoints.length; i++) {
const prev = waypoints[i - 1]
const curr = waypoints[i]
let speedKn = 0
const tagged = [prev.speedKnots, curr.speedKnots].filter(
(value): value is number => value != null && value > 0
)
if (tagged.length > 0) {
speedKn = tagged.reduce((sum, value) => sum + value, 0) / tagged.length
} else if (timed) {
const dtMs = curr.timestamp - prev.timestamp
const segmentM = haversineMeters(prev.lat, prev.lng, curr.lat, curr.lng)
if (dtMs > 0 && segmentM > 0) {
speedKn = (segmentM / NM_IN_METERS) / (dtMs / 3_600_000)
}
}
if (speedKn > MAX_PLAUSIBLE_KNOTS) speedKn = 0
speeds.push(speedKn)
}
return speeds
}
export function hasSpeedGradientData(speeds: number[]): boolean {
const valid = speeds.filter((speed) => speed > 0)
if (valid.length < 2) return false
const min = Math.min(...valid)
const max = Math.max(...valid)
return max - min >= 0.3
}
/** Green (slow) → yellow → red (fast) */
export function speedToTrackColor(speedKn: number, minKn: number, maxKn: number): string {
if (speedKn <= 0 || maxKn <= minKn) return FALLBACK_GREEN
const t = Math.max(0, Math.min(1, (speedKn - minKn) / (maxKn - minKn)))
const hue = 120 - t * 120
return `hsl(${hue}, 72%, 42%)`
}
export function getTrackLineColor(speeds: number[]): string {
return hasSpeedGradientData(speeds) ? '' : FALLBACK_GREEN
}