feat: GPS-Track auf OpenSeaMap-Karte mit Leaflet visualisieren.

Leaflet wieder eingebunden, CSS über Vite gebündelt und doppelte Karten-Initialisierung behoben.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-29 15:42:04 +02:00
co-authored by Cursor
parent b1b0c798b3
commit 95856800de
9 changed files with 221 additions and 37 deletions
+29
View File
@@ -1985,6 +1985,35 @@ body:has(.theme-cupertino) {
align-items: start;
}
#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;
z-index: 1;
box-sizing: border-box;
overflow: hidden;
background: #dbeafe;
}
.track-map-container.leaflet-container {
font-family: inherit;
}
.track-map-container .leaflet-tile,
.track-map-container img.leaflet-tile {
max-width: none !important;
max-height: none !important;
}
.track-map-container .leaflet-control-attribution {
font-size: 10px;
background: rgba(255, 255, 255, 0.8);
}
.signature-grid {
align-items: start;
}
+44 -37
View File
@@ -9,6 +9,7 @@ import { downloadLogbookPagePdf } from '../services/pdfExport.js'
import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload } from 'lucide-react'
import PhotoCapture from './PhotoCapture.tsx'
import SignaturePad from './SignaturePad.tsx'
import TrackMap from './TrackMap.tsx'
import { useDialog } from './ModalDialog.tsx'
import { isSignatureImage } from '../utils/signatures.js'
import {
@@ -1211,49 +1212,55 @@ export default function LogEntryEditor({
<div className="track-upload-subtext">{t('logs.gps_track_upload_help')}</div>
</div>
) : (
<div className="track-info-header">
<div className="track-info-left">
<Upload size={16} style={{ color: '#fbbf24' }} />
<span className="track-info-name">{savedTrack.filename || 'track'}</span>
<span className="track-info-stats">
{savedTrack.fileType.toUpperCase()}
{savedTrack.waypoints.length > 0 && (
<> · {savedTrack.waypoints.length} {t('logs.track_upload_points')}</>
)}
{trackDistanceNm && (
<> · {trackDistanceNm} sm</>
)}
{trackSpeedMaxKn && (
<> · max {trackSpeedMaxKn} kn</>
)}
{trackSpeedAvgKn && (
<> · Ø {trackSpeedAvgKn} kn</>
)}
</span>
</div>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<button
type="button"
className="btn secondary"
onClick={() => downloadTrackFile(savedTrack)}
style={{ width: 'auto', padding: '6px 12px', fontSize: '13px', display: 'flex', alignItems: 'center', gap: '4px' }}
>
<Download size={14} />
{t('logs.gps_tracking_btn_gpx')}
</button>
{!readOnly && (
<>
<div className="track-info-header">
<div className="track-info-left">
<Upload size={16} style={{ color: '#fbbf24' }} />
<span className="track-info-name">{savedTrack.filename || 'track'}</span>
<span className="track-info-stats">
{savedTrack.fileType.toUpperCase()}
{savedTrack.waypoints.length > 0 && (
<> · {savedTrack.waypoints.length} {t('logs.track_upload_points')}</>
)}
{trackDistanceNm && (
<> · {trackDistanceNm} sm</>
)}
{trackSpeedMaxKn && (
<> · max {trackSpeedMaxKn} kn</>
)}
{trackSpeedAvgKn && (
<> · Ø {trackSpeedAvgKn} kn</>
)}
</span>
</div>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<button
type="button"
className="btn secondary"
onClick={handleDeleteTrack}
style={{ width: 'auto', padding: '6px 12px', fontSize: '13px', display: 'flex', alignItems: 'center', gap: '4px', background: 'rgba(239, 68, 68, 0.1)', color: '#ef4444', borderColor: 'rgba(239, 68, 68, 0.2)' }}
onClick={() => downloadTrackFile(savedTrack)}
style={{ width: 'auto', padding: '6px 12px', fontSize: '13px', display: 'flex', alignItems: 'center', gap: '4px' }}
>
<Trash2 size={14} />
{t('logs.gps_track_delete')}
<Download size={14} />
{t('logs.gps_tracking_btn_gpx')}
</button>
)}
{!readOnly && (
<button
type="button"
className="btn secondary"
onClick={handleDeleteTrack}
style={{ width: 'auto', padding: '6px 12px', fontSize: '13px', display: 'flex', alignItems: 'center', gap: '4px', background: 'rgba(239, 68, 68, 0.1)', color: '#ef4444', borderColor: 'rgba(239, 68, 68, 0.2)' }}
>
<Trash2 size={14} />
{t('logs.gps_track_delete')}
</button>
)}
</div>
</div>
</div>
{savedTrack.waypoints.length > 0 && (
<TrackMap waypoints={savedTrack.waypoints} />
)}
</>
)}
{(savedTrack || trackDistanceNm || trackSpeedMaxKn || trackSpeedAvgKn) && (
+111
View File
@@ -0,0 +1,111 @@
import { useEffect, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import L from 'leaflet'
import type { TrackWaypoint } from '../services/trackUpload.js'
interface TrackMapProps {
waypoints: TrackWaypoint[]
}
export default function TrackMap({ 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]
)
useEffect(() => {
const container = containerRef.current
if (!container || mapRef.current) return
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,
attribution: '&copy; <a href="https://openstreetmap.org">OpenStreetMap</a> contributors'
}).addTo(map)
L.tileLayer('https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png', {
maxZoom: 18,
attribution: 'Map data &copy; <a href="http://openseamap.org">OpenSeaMap</a> contributors'
}).addTo(map)
const resizeTimer = window.setTimeout(() => map.invalidateSize(), 150)
return () => {
window.clearTimeout(resizeTimer)
map.remove()
mapRef.current = null
layersRef.current = { polyline: null, start: null, end: null }
}
}, [])
useEffect(() => {
const map = mapRef.current
if (!map || waypoints.length === 0) return
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], {
radius: 8,
fillColor: '#ef4444',
fillOpacity: 0.9,
color: '#ffffff',
weight: 2
})
.addTo(map)
.bindPopup(t('logs.track_map_end'))
}
layersRef.current = { polyline: newPolyline, start: newStart, end: newEnd }
map.fitBounds(newPolyline.getBounds(), { padding: [20, 20] })
const resizeTimer = window.setTimeout(() => map.invalidateSize(), 100)
return () => window.clearTimeout(resizeTimer)
}, [waypointsKey, waypoints, t])
if (!waypoints.length) return null
return (
<div
className="track-map-container"
ref={containerRef}
aria-label={t('logs.track_map_title')}
/>
)
}
+3
View File
@@ -169,6 +169,9 @@
"track_distance": "GPS-Strecke (sm)",
"track_speed_max": "Max. Geschwindigkeit (kn)",
"track_speed_avg": "Ø Geschwindigkeit (kn)",
"track_map_title": "GPS-Track auf OpenSeaMap",
"track_map_start": "Start",
"track_map_end": "Ziel",
"exporting": "Exportiere...",
"share_unsupported": "Teilen wird auf diesem Gerät nicht unterstützt. Datei wurde stattdessen heruntergeladen.",
"invite_crew": "Crew einladen",
+3
View File
@@ -169,6 +169,9 @@
"track_distance": "GPS distance (nm)",
"track_speed_max": "Max speed (kn)",
"track_speed_avg": "Avg speed (kn)",
"track_map_title": "GPS track on OpenSeaMap",
"track_map_start": "Start",
"track_map_end": "End",
"exporting": "Exporting...",
"share_unsupported": "Web sharing is not supported on this device. File downloaded instead.",
"invite_crew": "Invite Crew",
+1
View File
@@ -1,5 +1,6 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import 'leaflet/dist/leaflet.css'
import './index.css'
import App from './App.tsx'
import './i18n'