diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index 71c3604..029874c 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -8,8 +8,11 @@ "name": "bordanlage-dashboard", "version": "1.0.0", "dependencies": { + "leaflet": "^1.9.4", + "maplibre-gl": "^5.21.1", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "react-leaflet": "^5.0.0" }, "devDependencies": { "@vitejs/plugin-react": "^4.3.1", @@ -739,6 +742,122 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mapbox/jsonlint-lines-primitives": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz", + "integrity": "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@mapbox/point-geometry": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz", + "integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==", + "license": "ISC" + }, + "node_modules/@mapbox/tiny-sdf": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.0.7.tgz", + "integrity": "sha512-25gQLQMcpivjOSA40g3gO6qgiFPDpWRoMfd+G/GoppPIeP6JDaMMkMrEJnMZhKyyS6iKwVt5YKu02vCUyJM3Ug==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/unitbezier": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz", + "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/vector-tile": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.4.tgz", + "integrity": "sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "~1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^4.0.1" + } + }, + "node_modules/@mapbox/whoots-js": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz", + "integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@maplibre/geojson-vt": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.0.4.tgz", + "integrity": "sha512-HYv3POhMRCdhP3UPPATM/hfcy6/WuVIf5FKboH8u/ZuFMTnAIcSVlq5nfOqroLokd925w2QtE7YwquFOIacwVQ==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.0.2" + } + }, + "node_modules/@maplibre/maplibre-gl-style-spec": { + "version": "24.7.0", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.7.0.tgz", + "integrity": "sha512-Ed7rcKYU5iELfablg9Mj+TVCsXsPBgdMyXPRAxb2v7oWg9YJnpQdZ5msDs1LESu/mtXy3Z48Vdppv2t/x5kAhw==", + "license": "ISC", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "~2.0.2", + "@mapbox/unitbezier": "^0.0.1", + "json-stringify-pretty-compact": "^4.0.0", + "minimist": "^1.2.8", + "quickselect": "^3.0.0", + "rw": "^1.3.3", + "tinyqueue": "^3.0.0" + }, + "bin": { + "gl-style-format": "dist/gl-style-format.mjs", + "gl-style-migrate": "dist/gl-style-migrate.mjs", + "gl-style-validate": "dist/gl-style-validate.mjs" + } + }, + "node_modules/@maplibre/mlt": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.8.tgz", + "integrity": "sha512-8vtfYGidr1rNkv5IwIoU2lfe3Oy+Wa8HluzQYcQi9cveU9K3pweAal/poQj4GJ0K/EW4bTQp2wVAs09g2yDRZg==", + "license": "(MIT OR Apache-2.0)", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0" + } + }, + "node_modules/@maplibre/vt-pbf": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.0.tgz", + "integrity": "sha512-jIvp8F5hQCcreqOOpEt42TJMUlsrEcpf/kI1T2v85YrQRV6PPXUcEXUg5karKtH6oh47XJZ4kHu56pUkOuqA7w==", + "license": "MIT", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0", + "@mapbox/vector-tile": "^2.0.4", + "@maplibre/geojson-vt": "^5.0.4", + "@types/geojson": "^7946.0.16", + "@types/supercluster": "^7.1.3", + "pbf": "^4.0.1", + "supercluster": "^8.0.1" + } + }, + "node_modules/@maplibre/vt-pbf/node_modules/@maplibre/geojson-vt": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-5.0.4.tgz", + "integrity": "sha512-KGg9sma45S+stfH9vPCJk1J0lSDLWZgCT9Y8u8qWZJyjFlP8MNP1WGTxIMYJZjDvVT3PDn05kN1C95Sut1HpgQ==", + "license": "ISC" + }, + "node_modules/@react-leaflet/core": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz", + "integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==", + "license": "Hippocratic-2.1", + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -1148,6 +1267,21 @@ "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==", + "license": "MIT" + }, + "node_modules/@types/supercluster": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz", + "integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -1262,6 +1396,12 @@ } } }, + "node_modules/earcut": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz", + "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==", + "license": "ISC" + }, "node_modules/electron-to-chromium": { "version": "1.5.325", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.325.tgz", @@ -1343,6 +1483,12 @@ "node": ">=6.9.0" } }, + "node_modules/gl-matrix": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -1362,6 +1508,12 @@ "node": ">=6" } }, + "node_modules/json-stringify-pretty-compact": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz", + "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==", + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -1375,6 +1527,18 @@ "node": ">=6" } }, + "node_modules/kdbush": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz", + "integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==", + "license": "ISC" + }, + "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/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -1397,6 +1561,49 @@ "yallist": "^3.0.2" } }, + "node_modules/maplibre-gl": { + "version": "5.21.1", + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.21.1.tgz", + "integrity": "sha512-zto1RTnFkOpOO1bm93ElCXF1huey2N4LvXaGLMFcYAu9txh0OhGIdX1q3LZLkrMKgMxMeYduaQo+DVNzg098fg==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "^2.0.2", + "@mapbox/point-geometry": "^1.1.0", + "@mapbox/tiny-sdf": "^2.0.7", + "@mapbox/unitbezier": "^0.0.1", + "@mapbox/vector-tile": "^2.0.4", + "@mapbox/whoots-js": "^3.1.0", + "@maplibre/geojson-vt": "^6.0.4", + "@maplibre/maplibre-gl-style-spec": "^24.7.0", + "@maplibre/mlt": "^1.1.8", + "@maplibre/vt-pbf": "^4.3.0", + "@types/geojson": "^7946.0.16", + "earcut": "^3.0.2", + "gl-matrix": "^3.4.4", + "kdbush": "^4.0.2", + "murmurhash-js": "^1.0.0", + "pbf": "^4.0.1", + "potpack": "^2.1.0", + "quickselect": "^3.0.0", + "tinyqueue": "^3.0.0" + }, + "engines": { + "node": ">=16.14.0", + "npm": ">=8.1.0" + }, + "funding": { + "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1404,6 +1611,12 @@ "dev": true, "license": "MIT" }, + "node_modules/murmurhash-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz", + "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==", + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -1430,6 +1643,18 @@ "dev": true, "license": "MIT" }, + "node_modules/pbf": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz", + "integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==", + "license": "BSD-3-Clause", + "dependencies": { + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1466,6 +1691,24 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/potpack": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz", + "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==", + "license": "ISC" + }, + "node_modules/protocol-buffers-schema": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz", + "integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==", + "license": "MIT" + }, + "node_modules/quickselect": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", + "license": "ISC" + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -1491,6 +1734,20 @@ "react": "^18.3.1" } }, + "node_modules/react-leaflet": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz", + "integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==", + "license": "Hippocratic-2.1", + "dependencies": { + "@react-leaflet/core": "^3.0.0" + }, + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -1501,6 +1758,15 @@ "node": ">=0.10.0" } }, + "node_modules/resolve-protobuf-schema": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", + "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", + "license": "MIT", + "dependencies": { + "protocol-buffers-schema": "^3.3.1" + } + }, "node_modules/rollup": { "version": "4.60.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz", @@ -1546,6 +1812,12 @@ "fsevents": "~2.3.2" } }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -1575,6 +1847,21 @@ "node": ">=0.10.0" } }, + "node_modules/supercluster": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz", + "integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.0.2" + } + }, + "node_modules/tinyqueue": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", + "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", + "license": "ISC" + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", diff --git a/dashboard/package.json b/dashboard/package.json index edd5813..9e95153 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -9,8 +9,11 @@ "preview": "vite preview" }, "dependencies": { + "leaflet": "^1.9.4", + "maplibre-gl": "^5.21.1", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "react-leaflet": "^5.0.0" }, "devDependencies": { "@vitejs/plugin-react": "^4.3.1", diff --git a/dashboard/src/components/nav/NavigationMap.jsx b/dashboard/src/components/nav/NavigationMap.jsx new file mode 100644 index 0000000..d04c6a7 --- /dev/null +++ b/dashboard/src/components/nav/NavigationMap.jsx @@ -0,0 +1,387 @@ +import { useEffect, useRef, useState } from 'react' +import maplibregl from 'maplibre-gl' +import { useNMEA } from '../../hooks/useNMEA.js' +import { getApi } from '../../mock/index.js' +import 'maplibre-gl/dist/maplibre-gl.css' + +export default function NavigationMap() { + const { lat, lon, heading, sog } = useNMEA() + const mapContainer = useRef(null) + const map = useRef(null) + const shipMarkerRef = useRef(null) + const trackSourceRef = useRef(false) + const [zoom, setZoom] = useState(11) + + const api = getApi() + const snapshot = api.signalk.getSnapshot?.() + const waypoints = api.signalk.getWaypoints?.() || [] + + const mapLat = lat ?? 55.32 + const mapLon = lon ?? 15.22 + + useEffect(() => { + if (map.current) return + + map.current = new maplibregl.Map({ + container: mapContainer.current, + style: { + version: 8, + sources: { + 'osm': { + type: 'raster', + tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'], + tileSize: 256, + attribution: '© OpenStreetMap contributors', + }, + 'seamark': { + type: 'raster', + tiles: ['https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png'], + tileSize: 256, + attribution: '© OpenSeaMap contributors', + }, + }, + layers: [ + { + id: 'osm-base', + type: 'raster', + source: 'osm', + minzoom: 0, + maxzoom: 19, + }, + { + id: 'seamark-overlay', + type: 'raster', + source: 'seamark', + minzoom: 5, + maxzoom: 19, + paint: { + 'raster-opacity': 0.8, + }, + }, + ], + glyphs: 'https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf', + }, + center: [mapLon, mapLat], + zoom: 11, + pitch: 0, + bearing: 0, + antialias: true, + }) + + // Add navigation controls + map.current.addControl(new maplibregl.NavigationControl(), 'top-right') + + // Add ship marker source and layer + map.current.on('load', () => { + if (!map.current.getSource('ship')) { + map.current.addSource('ship', { + type: 'geojson', + data: { + type: 'Feature', + geometry: { type: 'Point', coordinates: [mapLon, mapLat] }, + properties: { heading: 0 }, + }, + }) + + map.current.addLayer({ + id: 'ship-marker', + type: 'symbol', + source: 'ship', + layout: { + 'icon-image': 'marker-blue', + 'icon-size': 1.2, + 'icon-rotate': ['get', 'heading'], + 'icon-allow-overlap': true, + 'icon-ignore-placement': true, + }, + }) + } + + // Add track source for ship trail + if (!map.current.getSource('track')) { + map.current.addSource('track', { + type: 'geojson', + data: { + type: 'Feature', + geometry: { type: 'LineString', coordinates: [[mapLon, mapLat]] }, + }, + }) + + map.current.addLayer({ + id: 'track-line', + type: 'line', + source: 'track', + paint: { + 'line-color': '#0ea5e9', + 'line-width': 2, + 'line-opacity': 0.7, + 'line-dasharray': [5, 5], + }, + }) + trackSourceRef.current = true + } + + // Add waypoints + if (waypoints.length > 0) { + const waypointFeatures = waypoints.map((wp, idx) => ({ + type: 'Feature', + geometry: { type: 'Point', coordinates: [wp.lon, wp.lat] }, + properties: { index: idx, isCurrent: idx === snapshot?.currentWaypoint }, + })) + + if (!map.current.getSource('waypoints')) { + map.current.addSource('waypoints', { + type: 'geojson', + data: { + type: 'FeatureCollection', + features: waypointFeatures, + }, + }) + + map.current.addLayer({ + id: 'waypoint-circles', + type: 'circle', + source: 'waypoints', + paint: { + 'circle-radius': 8, + 'circle-color': ['case', ['get', 'isCurrent'], '#0ea5e9', '#f59e0b'], + 'circle-stroke-width': 2, + 'circle-stroke-color': '#ffffff', + }, + }) + + map.current.addLayer({ + id: 'waypoint-labels', + type: 'symbol', + source: 'waypoints', + layout: { + 'text-field': ['to-string', ['+', ['get', 'index'], 1]], + 'text-size': 12, + 'text-font': ['Open Sans Semibold'], + 'text-offset': [0, 0], + 'text-allow-overlap': true, + }, + paint: { + 'text-color': '#ffffff', + 'text-halo-color': '#000000', + 'text-halo-width': 1, + }, + }) + } else { + map.current.getSource('waypoints').setData({ + type: 'FeatureCollection', + features: waypointFeatures, + }) + } + } + }) + + map.current.on('zoom', () => setZoom(map.current.getZoom())) + + return () => { + // Cleanup is handled by React + } + }, []) + + // Update ship position + useEffect(() => { + if (!map.current || lat == null || lon == null) return + + if (map.current.getSource('ship')) { + const trackSource = map.current.getSource('track') + if (trackSource && trackSourceRef.current) { + const currentData = trackSource._data + if (currentData.geometry.coordinates.length > 500) { + currentData.geometry.coordinates.shift() // Keep last 500 points + } + currentData.geometry.coordinates.push([lon, lat]) + trackSource.setData(currentData) + } + + map.current.getSource('ship').setData({ + type: 'Feature', + geometry: { type: 'Point', coordinates: [lon, lat] }, + properties: { heading: heading ?? 0 }, + }) + + // Auto-center on ship + map.current.flyTo({ + center: [lon, lat], + zoom: zoom, + speed: 0.5, + curve: 1, + }) + } + }, [lat, lon, heading, zoom]) + + return ( +
+
+ + {/* Map Controls */} +
+ + + +
+ + {/* Info Panel */} + {lat != null && lon != null && ( +
+
+
+ Position + {lat.toFixed(5)}° +
+
+ + {lon.toFixed(5)}° +
+
+ + {heading != null && ( +
+
+ Heading + {Math.round(heading)}° +
+
+ )} + + {sog != null && ( +
+
+ Speed + {(sog * 1.943844).toFixed(1)} kn +
+
+ )} + + {waypoints.length > 0 && snapshot?.currentWaypoint != null && ( +
+
+ Route + WP {snapshot.currentWaypoint + 1} / {waypoints.length} +
+
+ )} + + {snapshot?.distanceToWaypoint != null && ( +
+
+ Distance + {(snapshot.distanceToWaypoint * 1.852).toFixed(1)} km +
+
+ )} +
+ )} + + {/* Map Style Toggle */} +
+ + SeaMarks +
+
+ ) +} + +const styles = { + container: { + position: 'relative', + flex: 1, + overflow: 'hidden', + borderRadius: 'var(--radius)', + }, + mapBox: { + width: '100%', + height: '100%', + }, + controls: { + position: 'absolute', + top: 12, + right: 12, + display: 'flex', + flexDirection: 'column', + gap: 6, + zIndex: 1000, + }, + infoPanel: { + position: 'absolute', + bottom: 12, + left: 12, + background: 'var(--glass-bg)', + backdropFilter: 'blur(var(--glass-blur))', + WebkitBackdropFilter: 'blur(var(--glass-blur))', + border: '1px solid rgba(255, 255, 255, 0.1)', + borderRadius: 'var(--radius-lg)', + padding: '10px 12px', + display: 'flex', + flexDirection: 'column', + gap: 6, + fontSize: 12, + color: 'var(--text)', + zIndex: 500, + maxWidth: 180, + animation: 'slideInUp 0.3s ease-out', + }, + infoSection: { + display: 'flex', + flexDirection: 'column', + gap: 2, + }, + infoRow: { + display: 'flex', + justifyContent: 'space-between', + gap: 8, + }, + label: { + color: 'var(--muted)', + fontWeight: 600, + fontSize: 10, + }, + value: { + fontFamily: 'var(--font-mono)', + fontWeight: 600, + color: 'var(--accent)', + textAlign: 'right', + }, + layerToggle: { + position: 'absolute', + top: 60, + right: 12, + background: 'rgba(14, 165, 233, 0.1)', + border: '1px solid rgba(14, 165, 233, 0.3)', + borderRadius: 'var(--radius)', + padding: '4px 8px', + zIndex: 500, + }, +} diff --git a/dashboard/src/mock/signalk.mock.js b/dashboard/src/mock/signalk.mock.js index 17fb893..4a942bb 100644 --- a/dashboard/src/mock/signalk.mock.js +++ b/dashboard/src/mock/signalk.mock.js @@ -69,6 +69,7 @@ export function createSignalKMock() { waterUsed: 23, wasteWater: 18, freshWater: 156, + trackPoints: [{ lat: WAYPOINTS[0].lat, lon: WAYPOINTS[0].lon }], } // Navigate to next waypoint @@ -115,6 +116,12 @@ export function createSignalKMock() { state.lat += dLat state.lon += dLon + + // Record track point (keep last 500) + state.trackPoints.push({ lat: state.lat, lon: state.lon }) + if (state.trackPoints.length > 500) { + state.trackPoints.shift() + } } function buildDelta() { diff --git a/dashboard/src/pages/Navigation.jsx b/dashboard/src/pages/Navigation.jsx index b23de78..7a69cd3 100644 --- a/dashboard/src/pages/Navigation.jsx +++ b/dashboard/src/pages/Navigation.jsx @@ -1,11 +1,11 @@ -import ChartPlaceholder from '../components/nav/ChartPlaceholder.jsx' +import NavigationMap from '../components/nav/NavigationMap.jsx' import InstrumentPanel from '../components/nav/InstrumentPanel.jsx' export default function Navigation() { return (
- +
diff --git a/package.json b/package.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/package.json @@ -0,0 +1 @@ +{}