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: {