diff --git a/client/src/App.css b/client/src/App.css index 7ef7d35..316ce1f 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -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; } diff --git a/client/src/components/TrackMap.tsx b/client/src/components/TrackMap.tsx index 0556a6d..4fcf98f 100644 --- a/client/src/components/TrackMap.tsx +++ b/client/src/components/TrackMap.tsx @@ -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(null) - const mapRef = useRef(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 © OpenSeaMap 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 ( -
+
+
+ {useGradient && ( + + ) +} + +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 ( + {t('logs.track_map_error')}
} + > + + ) } diff --git a/client/src/i18n/locales/de.json b/client/src/i18n/locales/de.json index 8049daf..f5332b3 100644 --- a/client/src/i18n/locales/de.json +++ b/client/src/i18n/locales/de.json @@ -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", diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index 61dcfbb..972edc7 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -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", diff --git a/client/src/utils/trackMapColors.ts b/client/src/utils/trackMapColors.ts new file mode 100644 index 0000000..1e36d3e --- /dev/null +++ b/client/src/utils/trackMapColors.ts @@ -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 +}