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:
@@ -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) && (
|
||||
|
||||
@@ -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: '© <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 © <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')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user