From 95856800de72f2ead15f76dcb1842be9b5acf33b Mon Sep 17 00:00:00 2001 From: elpatron Date: Fri, 29 May 2026 15:42:04 +0200 Subject: [PATCH] feat: GPS-Track auf OpenSeaMap-Karte mit Leaflet visualisieren. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Leaflet wieder eingebunden, CSS über Vite gebündelt und doppelte Karten-Initialisierung behoben. Co-authored-by: Cursor --- client/package-lock.json | 25 +++++ client/package.json | 2 + client/src/App.css | 29 ++++++ client/src/components/LogEntryEditor.tsx | 81 +++++++++-------- client/src/components/TrackMap.tsx | 111 +++++++++++++++++++++++ client/src/i18n/locales/de.json | 3 + client/src/i18n/locales/en.json | 3 + client/src/main.tsx | 1 + client/vite.config.ts | 3 + 9 files changed, 221 insertions(+), 37 deletions(-) create mode 100644 client/src/components/TrackMap.tsx diff --git a/client/package-lock.json b/client/package-lock.json index 5230d5c..cc2f065 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -15,6 +15,7 @@ "i18next": "^26.3.0", "i18next-browser-languagedetector": "^8.2.1", "jspdf": "^4.2.1", + "leaflet": "^1.9.4", "lucide-react": "^1.16.0", "react": "^19.2.6", "react-dom": "^19.2.6", @@ -22,6 +23,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@types/leaflet": "^1.9.21", "@types/node": "^24.12.3", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -2715,6 +2717,13 @@ "dev": true, "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": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -2722,6 +2731,16 @@ "dev": true, "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": { "version": "24.12.4", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz", @@ -5299,6 +5318,12 @@ "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": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", diff --git a/client/package.json b/client/package.json index fa572cb..c1c80d7 100644 --- a/client/package.json +++ b/client/package.json @@ -17,6 +17,7 @@ "i18next": "^26.3.0", "i18next-browser-languagedetector": "^8.2.1", "jspdf": "^4.2.1", + "leaflet": "^1.9.4", "lucide-react": "^1.16.0", "react": "^19.2.6", "react-dom": "^19.2.6", @@ -24,6 +25,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@types/leaflet": "^1.9.21", "@types/node": "^24.12.3", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", diff --git a/client/src/App.css b/client/src/App.css index e8d4590..7ef7d35 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -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; } diff --git a/client/src/components/LogEntryEditor.tsx b/client/src/components/LogEntryEditor.tsx index 5bd1a03..915eae9 100644 --- a/client/src/components/LogEntryEditor.tsx +++ b/client/src/components/LogEntryEditor.tsx @@ -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({
{t('logs.gps_track_upload_help')}
) : ( -
-
- - {savedTrack.filename || 'track'} - - {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 - )} - -
-
- - {!readOnly && ( + <> +
+
+ + {savedTrack.filename || 'track'} + + {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 + )} + +
+
- )} + {!readOnly && ( + + )} +
-
+ + {savedTrack.waypoints.length > 0 && ( + + )} + )} {(savedTrack || trackDistanceNm || trackSpeedMaxKn || trackSpeedAvgKn) && ( diff --git a/client/src/components/TrackMap.tsx b/client/src/components/TrackMap.tsx new file mode 100644 index 0000000..0556a6d --- /dev/null +++ b/client/src/components/TrackMap.tsx @@ -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(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] + ) + + 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: '© OpenStreetMap contributors' + }).addTo(map) + + L.tileLayer('https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png', { + maxZoom: 18, + attribution: 'Map data © OpenSeaMap 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 ( +
+ ) +} diff --git a/client/src/i18n/locales/de.json b/client/src/i18n/locales/de.json index f1a2ac2..8049daf 100644 --- a/client/src/i18n/locales/de.json +++ b/client/src/i18n/locales/de.json @@ -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", diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index eccb6a3..61dcfbb 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -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", diff --git a/client/src/main.tsx b/client/src/main.tsx index 5c1d867..706dea5 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -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' diff --git a/client/vite.config.ts b/client/vite.config.ts index b1d267b..c59e065 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -23,6 +23,9 @@ export default defineConfig({ define: { __APP_VERSION__: JSON.stringify(readAppVersion()) }, + optimizeDeps: { + include: ['leaflet'] + }, server: { port: 5173, proxy: {