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:
+21
-1
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 © <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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user