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:
Generated
+25
@@ -15,6 +15,7 @@
|
|||||||
"i18next": "^26.3.0",
|
"i18next": "^26.3.0",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"jspdf": "^4.2.1",
|
"jspdf": "^4.2.1",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^1.16.0",
|
"lucide-react": "^1.16.0",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
@@ -22,6 +23,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@types/leaflet": "^1.9.21",
|
||||||
"@types/node": "^24.12.3",
|
"@types/node": "^24.12.3",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
@@ -2715,6 +2717,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/geojson": {
|
||||||
|
"version": "7946.0.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||||
|
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/json-schema": {
|
"node_modules/@types/json-schema": {
|
||||||
"version": "7.0.15",
|
"version": "7.0.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||||
@@ -2722,6 +2731,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/leaflet": {
|
||||||
|
"version": "1.9.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
|
||||||
|
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/geojson": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "24.12.4",
|
"version": "24.12.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz",
|
||||||
@@ -5299,6 +5318,12 @@
|
|||||||
"json-buffer": "3.0.1"
|
"json-buffer": "3.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/leaflet": {
|
||||||
|
"version": "1.9.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||||
|
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
"node_modules/leven": {
|
"node_modules/leven": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"i18next": "^26.3.0",
|
"i18next": "^26.3.0",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"jspdf": "^4.2.1",
|
"jspdf": "^4.2.1",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^1.16.0",
|
"lucide-react": "^1.16.0",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
@@ -24,6 +25,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@types/leaflet": "^1.9.21",
|
||||||
"@types/node": "^24.12.3",
|
"@types/node": "^24.12.3",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
|||||||
@@ -1985,6 +1985,35 @@ body:has(.theme-cupertino) {
|
|||||||
align-items: start;
|
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 {
|
.signature-grid {
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload } from 'lucide-react'
|
||||||
import PhotoCapture from './PhotoCapture.tsx'
|
import PhotoCapture from './PhotoCapture.tsx'
|
||||||
import SignaturePad from './SignaturePad.tsx'
|
import SignaturePad from './SignaturePad.tsx'
|
||||||
|
import TrackMap from './TrackMap.tsx'
|
||||||
import { useDialog } from './ModalDialog.tsx'
|
import { useDialog } from './ModalDialog.tsx'
|
||||||
import { isSignatureImage } from '../utils/signatures.js'
|
import { isSignatureImage } from '../utils/signatures.js'
|
||||||
import {
|
import {
|
||||||
@@ -1211,49 +1212,55 @@ export default function LogEntryEditor({
|
|||||||
<div className="track-upload-subtext">{t('logs.gps_track_upload_help')}</div>
|
<div className="track-upload-subtext">{t('logs.gps_track_upload_help')}</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="track-info-header">
|
<>
|
||||||
<div className="track-info-left">
|
<div className="track-info-header">
|
||||||
<Upload size={16} style={{ color: '#fbbf24' }} />
|
<div className="track-info-left">
|
||||||
<span className="track-info-name">{savedTrack.filename || 'track'}</span>
|
<Upload size={16} style={{ color: '#fbbf24' }} />
|
||||||
<span className="track-info-stats">
|
<span className="track-info-name">{savedTrack.filename || 'track'}</span>
|
||||||
{savedTrack.fileType.toUpperCase()}
|
<span className="track-info-stats">
|
||||||
{savedTrack.waypoints.length > 0 && (
|
{savedTrack.fileType.toUpperCase()}
|
||||||
<> · {savedTrack.waypoints.length} {t('logs.track_upload_points')}</>
|
{savedTrack.waypoints.length > 0 && (
|
||||||
)}
|
<> · {savedTrack.waypoints.length} {t('logs.track_upload_points')}</>
|
||||||
{trackDistanceNm && (
|
)}
|
||||||
<> · {trackDistanceNm} sm</>
|
{trackDistanceNm && (
|
||||||
)}
|
<> · {trackDistanceNm} sm</>
|
||||||
{trackSpeedMaxKn && (
|
)}
|
||||||
<> · max {trackSpeedMaxKn} kn</>
|
{trackSpeedMaxKn && (
|
||||||
)}
|
<> · max {trackSpeedMaxKn} kn</>
|
||||||
{trackSpeedAvgKn && (
|
)}
|
||||||
<> · Ø {trackSpeedAvgKn} kn</>
|
{trackSpeedAvgKn && (
|
||||||
)}
|
<> · Ø {trackSpeedAvgKn} kn</>
|
||||||
</span>
|
)}
|
||||||
</div>
|
</span>
|
||||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
</div>
|
||||||
<button
|
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||||
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 && (
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn secondary"
|
className="btn secondary"
|
||||||
onClick={handleDeleteTrack}
|
onClick={() => downloadTrackFile(savedTrack)}
|
||||||
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)' }}
|
style={{ width: 'auto', padding: '6px 12px', fontSize: '13px', display: 'flex', alignItems: 'center', gap: '4px' }}
|
||||||
>
|
>
|
||||||
<Trash2 size={14} />
|
<Download size={14} />
|
||||||
{t('logs.gps_track_delete')}
|
{t('logs.gps_tracking_btn_gpx')}
|
||||||
</button>
|
</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>
|
||||||
</div>
|
|
||||||
|
{savedTrack.waypoints.length > 0 && (
|
||||||
|
<TrackMap waypoints={savedTrack.waypoints} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(savedTrack || trackDistanceNm || trackSpeedMaxKn || trackSpeedAvgKn) && (
|
{(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')}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -169,6 +169,9 @@
|
|||||||
"track_distance": "GPS-Strecke (sm)",
|
"track_distance": "GPS-Strecke (sm)",
|
||||||
"track_speed_max": "Max. Geschwindigkeit (kn)",
|
"track_speed_max": "Max. Geschwindigkeit (kn)",
|
||||||
"track_speed_avg": "Ø Geschwindigkeit (kn)",
|
"track_speed_avg": "Ø Geschwindigkeit (kn)",
|
||||||
|
"track_map_title": "GPS-Track auf OpenSeaMap",
|
||||||
|
"track_map_start": "Start",
|
||||||
|
"track_map_end": "Ziel",
|
||||||
"exporting": "Exportiere...",
|
"exporting": "Exportiere...",
|
||||||
"share_unsupported": "Teilen wird auf diesem Gerät nicht unterstützt. Datei wurde stattdessen heruntergeladen.",
|
"share_unsupported": "Teilen wird auf diesem Gerät nicht unterstützt. Datei wurde stattdessen heruntergeladen.",
|
||||||
"invite_crew": "Crew einladen",
|
"invite_crew": "Crew einladen",
|
||||||
|
|||||||
@@ -169,6 +169,9 @@
|
|||||||
"track_distance": "GPS distance (nm)",
|
"track_distance": "GPS distance (nm)",
|
||||||
"track_speed_max": "Max speed (kn)",
|
"track_speed_max": "Max speed (kn)",
|
||||||
"track_speed_avg": "Avg 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...",
|
"exporting": "Exporting...",
|
||||||
"share_unsupported": "Web sharing is not supported on this device. File downloaded instead.",
|
"share_unsupported": "Web sharing is not supported on this device. File downloaded instead.",
|
||||||
"invite_crew": "Invite Crew",
|
"invite_crew": "Invite Crew",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import 'leaflet/dist/leaflet.css'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
import './i18n'
|
import './i18n'
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ export default defineConfig({
|
|||||||
define: {
|
define: {
|
||||||
__APP_VERSION__: JSON.stringify(readAppVersion())
|
__APP_VERSION__: JSON.stringify(readAppVersion())
|
||||||
},
|
},
|
||||||
|
optimizeDeps: {
|
||||||
|
include: ['leaflet']
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
|
|||||||
Reference in New Issue
Block a user