diff --git a/client/package-lock.json b/client/package-lock.json index 459af33c..1763c702 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -13,6 +13,7 @@ "dexie": "^4.4.2", "leaflet": "^1.9.4", "lucide-react": "^0.344.0", + "mapbox-gl": "^3.22.0", "marked": "^18.0.0", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -2867,6 +2868,41 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mapbox/mapbox-gl-supported": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@mapbox/mapbox-gl-supported/-/mapbox-gl-supported-3.0.0.tgz", + "integrity": "sha512-2XghOwu16ZwPJLOFVuIOaLbN0iKMn867evzXFyf0P22dqugezfJwLmdanAgU25ITvz1TvOfVP4jsDImlDJzcWg==", + "license": "BSD-3-Clause" + }, + "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.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.1.0.tgz", + "integrity": "sha512-uFJhNh36BR4OCuWIEiWaEix9CA2WzT6CAIcqVjWYpnx8+QDtS+oC4QehRrx5cX4mgWs37MmKnwUejeHxVymzNg==", + "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/@mswjs/interceptors": { "version": "0.41.3", "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.3.tgz", @@ -3842,9 +3878,17 @@ "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/geojson-vt": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/@types/geojson-vt/-/geojson-vt-3.2.5.tgz", + "integrity": "sha512-qDO7wqtprzlpe8FfQ//ClPV9xiuoh2nkIgiouIptON9w5jvD/fA4szvP9GBlDVdJ5dldAl0kX/sy3URbWwLx0g==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/hast": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", @@ -3879,6 +3923,12 @@ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "license": "MIT" }, + "node_modules/@types/pbf": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/pbf/-/pbf-3.0.5.tgz", + "integrity": "sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==", + "license": "MIT" + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", @@ -3929,6 +3979,15 @@ "dev": true, "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/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -4749,6 +4808,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/cheap-ruler": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cheap-ruler/-/cheap-ruler-4.0.0.tgz", + "integrity": "sha512-0BJa8f4t141BYKQyn9NSQt1PguFQXMXwZiA5shfoaBYHAb2fFk2RAX+tiWMoQU+Agtzt3mdt0JtuyshAXqZ+Vw==", + "license": "ISC" + }, "node_modules/check-error": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", @@ -5066,6 +5131,12 @@ "dev": true, "license": "MIT" }, + "node_modules/csscolorparser": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/csscolorparser/-/csscolorparser-1.0.3.tgz", + "integrity": "sha512-umPSgYwZkdFoUrH5hIq5kf0wPSXiro51nPw0j2K/c83KflkPSTBGMz6NJvMB+07VlL0y7VPo6QJcDjcgKTTm3w==", + "license": "MIT" + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -5335,6 +5406,12 @@ "node": ">= 0.4" } }, + "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/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -5982,6 +6059,12 @@ "node": ">=6.9.0" } }, + "node_modules/geojson-vt": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-4.0.2.tgz", + "integrity": "sha512-AV9ROqlNqoZEIJGfm1ncNjEXfkz2hdFlZf0qkVfmkwdKa8vj7H16YUOT81rJw1rdFhyEDlN2Tds91p/glzbl5A==", + "license": "ISC" + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -6054,6 +6137,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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/glob": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", @@ -6168,6 +6257,12 @@ "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } }, + "node_modules/grid-index": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/grid-index/-/grid-index-1.1.0.tgz", + "integrity": "sha512-HZRwumpOGUrHyxO5bqKZL0B0GlUpwtCAzZ42sgxUPniu33R1LSFH5yrIcBCHjkctCAh3mtWKcKd9J4vDDdeVHA==", + "license": "ISC" + }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -7193,6 +7288,12 @@ "node": ">=0.10.0" } }, + "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", @@ -7388,6 +7489,44 @@ "node": ">=10" } }, + "node_modules/mapbox-gl": { + "version": "3.22.0", + "resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.22.0.tgz", + "integrity": "sha512-ZIpF+oAMcQoDlvABmiRkHoydyBR9zI6CyDeVRa2/iyua0/B2+rPuIzoCV/CgN14P5F0HVk53GIZw220WSqJPyA==", + "license": "SEE LICENSE IN LICENSE.txt", + "workspaces": [ + "src/style-spec", + "packages/pmtiles-provider", + "test/build/vite", + "test/build/webpack", + "test/build/typings" + ], + "dependencies": { + "@mapbox/mapbox-gl-supported": "^3.0.0", + "@mapbox/point-geometry": "^1.1.0", + "@mapbox/tiny-sdf": "^2.0.6", + "@mapbox/unitbezier": "^0.0.1", + "@mapbox/vector-tile": "^2.0.4", + "@types/geojson": "^7946.0.16", + "@types/geojson-vt": "^3.2.5", + "@types/pbf": "^3.0.5", + "@types/supercluster": "^7.1.3", + "cheap-ruler": "^4.0.0", + "csscolorparser": "~1.0.3", + "earcut": "^3.0.1", + "geojson-vt": "^4.0.2", + "gl-matrix": "^3.4.4", + "grid-index": "^1.1.0", + "kdbush": "^4.0.2", + "martinez-polygon-clipping": "^0.8.1", + "murmurhash-js": "^1.0.0", + "pbf": "^4.0.1", + "potpack": "^2.0.0", + "quickselect": "^3.0.0", + "supercluster": "^8.0.1", + "tinyqueue": "^3.0.0" + } + }, "node_modules/markdown-table": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", @@ -7410,6 +7549,17 @@ "node": ">= 20" } }, + "node_modules/martinez-polygon-clipping": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/martinez-polygon-clipping/-/martinez-polygon-clipping-0.8.1.tgz", + "integrity": "sha512-9PLLMzMPI6ihHox4Ns6LpVBLpRc7sbhULybZ/wyaY8sY3ECNe2+hxm1hA2/9bEEpRrdpjoeduBuZLg2aq1cSIQ==", + "license": "MIT", + "dependencies": { + "robust-predicates": "^2.0.4", + "splaytree": "^0.1.4", + "tinyqueue": "3.0.0" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -8411,6 +8561,12 @@ } } }, + "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/mute-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", @@ -8688,6 +8844,18 @@ "node": ">= 14.16" } }, + "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", @@ -8900,6 +9068,12 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "license": "MIT" }, + "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/pretty-bytes": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz", @@ -8956,6 +9130,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/protocol-buffers-schema": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz", + "integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==", + "license": "MIT" + }, "node_modules/proxy-from-env": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", @@ -9005,6 +9185,12 @@ ], "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/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -9457,6 +9643,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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/restructure": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz", @@ -9481,6 +9676,12 @@ "node": ">=0.10.0" } }, + "node_modules/robust-predicates": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-2.0.4.tgz", + "integrity": "sha512-l4NwboJM74Ilm4VKfbAtFeGq7aEjWL+5kVFcmgFA2MrdnQWx9iE/tUGvxY5HyMI7o/WpSIUFLbC5fbeaHgSCYg==", + "license": "Unlicense" + }, "node_modules/rollup": { "version": "4.60.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", @@ -9996,6 +10197,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/splaytree": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/splaytree/-/splaytree-0.1.4.tgz", + "integrity": "sha512-D50hKrjZgBzqD3FT2Ek53f2dcDLAQT8SSGrzj3vidNH5ISRgceeGVJ2dQIthKOuayqFXfFjXheHNo4bbt9LhRQ==", + "license": "MIT" + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -10347,6 +10554,15 @@ "node": ">=16 || 14 >=14.17" } }, + "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/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -10621,6 +10837,12 @@ "node": "^18.0.0 || >=20.0.0" } }, + "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/tinyrainbow": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", diff --git a/client/package.json b/client/package.json index 195bc235..9efbb68c 100644 --- a/client/package.json +++ b/client/package.json @@ -20,6 +20,7 @@ "dexie": "^4.4.2", "leaflet": "^1.9.4", "lucide-react": "^0.344.0", + "mapbox-gl": "^3.22.0", "marked": "^18.0.0", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 97ce6281..8ef553b2 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -343,6 +343,7 @@ export const journeyApi = { createEntry: (id: number, data: Record) => apiClient.post(`/journeys/${id}/entries`, data).then(r => r.data), updateEntry: (entryId: number, data: Record) => apiClient.patch(`/journeys/entries/${entryId}`, data).then(r => r.data), deleteEntry: (entryId: number) => apiClient.delete(`/journeys/entries/${entryId}`).then(r => r.data), + reorderEntries: (journeyId: number, orderedIds: number[]) => apiClient.put(`/journeys/${journeyId}/entries/reorder`, { orderedIds }).then(r => r.data), // Photos uploadPhotos: (entryId: number, formData: FormData) => apiClient.post(`/journeys/entries/${entryId}/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data), diff --git a/client/src/components/Journey/JourneyMap.tsx b/client/src/components/Journey/JourneyMap.tsx index de9552ea..2dd1c711 100644 --- a/client/src/components/Journey/JourneyMap.tsx +++ b/client/src/components/Journey/JourneyMap.tsx @@ -250,7 +250,7 @@ const JourneyMap = forwardRef(function JourneyMap( map.invalidateSize() if (allCoords.length > 0) { const pb = paddingBottom || 50 - map.fitBounds(L.latLngBounds(allCoords), { paddingTopLeft: [50, 50], paddingBottomRight: [50, pb], maxZoom: 14 }) + map.fitBounds(L.latLngBounds(allCoords), { paddingTopLeft: [50, 50], paddingBottomRight: [50, pb], maxZoom: 16 }) } else { map.setView([30, 0], 2) } diff --git a/client/src/components/Journey/JourneyMapAuto.tsx b/client/src/components/Journey/JourneyMapAuto.tsx new file mode 100644 index 00000000..9b126535 --- /dev/null +++ b/client/src/components/Journey/JourneyMapAuto.tsx @@ -0,0 +1,55 @@ +import { forwardRef, useImperativeHandle, useRef } from 'react' +import { useSettingsStore } from '../../store/settingsStore' +import JourneyMap, { type JourneyMapHandle } from './JourneyMap' +import JourneyMapGL, { type JourneyMapGLHandle } from './JourneyMapGL' + +// Unified handle — both providers expose the same three methods. +export type JourneyMapAutoHandle = JourneyMapHandle + +interface MapEntry { + id: string + lat: number + lng: number + title?: string | null + location_name?: string | null + mood?: string | null + entry_date: string +} + +interface Props { + checkins: unknown[] + entries: MapEntry[] + trail?: { lat: number; lng: number }[] + height?: number + dark?: boolean + activeMarkerId?: string | null + onMarkerClick?: (id: string, type?: string) => void + fullScreen?: boolean + paddingBottom?: number +} + +const JourneyMapAuto = forwardRef(function JourneyMapAuto(props, ref) { + const provider = useSettingsStore(s => s.settings.map_provider) + const token = useSettingsStore(s => s.settings.mapbox_access_token) + const leafletRef = useRef(null) + const glRef = useRef(null) + + // Fall back to Leaflet when the user selected Mapbox GL but hasn't + // supplied a token yet — otherwise the map would just show a stub. + const useGL = provider === 'mapbox-gl' && !!token + + useImperativeHandle(ref, () => ({ + highlightMarker: (id) => (useGL ? glRef.current : leafletRef.current)?.highlightMarker(id), + focusMarker: (id) => (useGL ? glRef.current : leafletRef.current)?.focusMarker(id), + invalidateSize: () => (useGL ? glRef.current : leafletRef.current)?.invalidateSize(), + }), [useGL]) + + if (useGL) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return +}) + +export default JourneyMapAuto diff --git a/client/src/components/Journey/JourneyMapGL.tsx b/client/src/components/Journey/JourneyMapGL.tsx new file mode 100644 index 00000000..60cef2c5 --- /dev/null +++ b/client/src/components/Journey/JourneyMapGL.tsx @@ -0,0 +1,464 @@ +import { useEffect, useRef, useImperativeHandle, forwardRef, useCallback } from 'react' +import mapboxgl from 'mapbox-gl' +import 'mapbox-gl/dist/mapbox-gl.css' +import { useSettingsStore } from '../../store/settingsStore' +import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup' + +export interface JourneyMapGLHandle { + highlightMarker: (id: string | null) => void + focusMarker: (id: string) => void + invalidateSize: () => void +} + +interface MapEntry { + id: string + lat: number + lng: number + title?: string | null + location_name?: string | null + mood?: string | null + entry_date: string +} + +interface Props { + checkins: unknown[] + entries: MapEntry[] + trail?: { lat: number; lng: number }[] + height?: number + dark?: boolean + activeMarkerId?: string | null + onMarkerClick?: (id: string, type?: string) => void + fullScreen?: boolean + paddingBottom?: number +} + +interface Item { + id: string + lat: number + lng: number + label: string + locationName: string + time: string +} + +const MARKER_W = 28 +const MARKER_H = 36 + +function buildItems(entries: MapEntry[]): Item[] { + const items: Item[] = [] + for (const e of entries) { + if (e.lat && e.lng) { + items.push({ + id: e.id, + lat: e.lat, + lng: e.lng, + label: e.title || '', + locationName: e.location_name || '', + time: e.entry_date, + }) + } + } + items.sort((a, b) => a.time.localeCompare(b.time)) + return items +} + +function escapeHtml(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +function formatEntryDate(iso: string): string { + if (!iso) return '' + try { + const d = new Date(iso.includes('T') ? iso : iso + 'T00:00:00') + if (Number.isNaN(d.getTime())) return iso + return new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' }).format(d) + } catch { + return iso + } +} + +// Inject the popup styles once per document. Two-line frosted-glass card in +// the Apple/Google Maps idiom — title on top, location / date subtly below. +function ensureJourneyPopupStyle() { + if (document.getElementById('trek-journey-popup-style')) return + const s = document.createElement('style') + s.id = 'trek-journey-popup-style' + s.textContent = ` + .mapboxgl-popup.trek-journey-popup { pointer-events: none; animation: trek-journey-popup-in 180ms ease-out; } + .mapboxgl-popup.trek-journey-popup .mapboxgl-popup-content { + padding: 9px 14px 10px; + border-radius: 14px; + background: rgba(255, 255, 255, 0.94); + backdrop-filter: blur(16px) saturate(180%); + -webkit-backdrop-filter: blur(16px) saturate(180%); + border: 1px solid rgba(0, 0, 0, 0.06); + box-shadow: 0 10px 32px rgba(0, 0, 0, 0.18), 0 2px 6px rgba(0, 0, 0, 0.06); + font-family: -apple-system, system-ui, sans-serif; + min-width: 160px; + max-width: 280px; + } + .mapboxgl-popup.trek-journey-popup.trek-dark .mapboxgl-popup-content { + background: rgba(24, 24, 27, 0.88); + border-color: rgba(255, 255, 255, 0.08); + color: #FAFAFA; + } + .mapboxgl-popup.trek-journey-popup .mapboxgl-popup-tip { + border-top-color: rgba(255, 255, 255, 0.94); + border-bottom-color: rgba(255, 255, 255, 0.94); + } + .mapboxgl-popup.trek-journey-popup.trek-dark .mapboxgl-popup-tip { + border-top-color: rgba(24, 24, 27, 0.88); + border-bottom-color: rgba(24, 24, 27, 0.88); + } + .mapboxgl-popup.trek-journey-popup .mapboxgl-popup-close-button { display: none; } + .trek-journey-popup-title { + font-size: 13.5px; + font-weight: 600; + letter-spacing: -0.01em; + color: #18181B; + line-height: 1.3; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .mapboxgl-popup.trek-journey-popup.trek-dark .trek-journey-popup-title { color: #FAFAFA; } + .trek-journey-popup-sub { + display: flex; + align-items: baseline; + gap: 7px; + margin-top: 3px; + font-size: 11.5px; + color: #71717A; + line-height: 1.35; + white-space: nowrap; + } + .mapboxgl-popup.trek-journey-popup.trek-dark .trek-journey-popup-sub { color: #A1A1AA; } + .trek-journey-popup-place { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + } + .trek-journey-popup-sep { + flex: 0 0 auto; + opacity: 0.55; + font-weight: 500; + } + .trek-journey-popup-date { flex: 0 0 auto; } + @keyframes trek-journey-popup-in { + from { opacity: 0; } + to { opacity: 1; } + } + ` + document.head.appendChild(s) +} + +function markerHtml(index: number, highlighted: boolean, dark: boolean): HTMLDivElement { + const fill = dark + ? (highlighted ? '#FAFAFA' : '#A1A1AA') + : (highlighted ? '#18181B' : '#52525B') + const textColor = highlighted ? (dark ? '#18181B' : '#fff') : '#fff' + const stroke = highlighted + ? (dark ? '#fff' : '#18181B') + : (dark ? '#3F3F46' : '#fff') + const shadow = highlighted + ? (dark + ? 'drop-shadow(0 0 10px rgba(255,255,255,0.35)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))' + : 'drop-shadow(0 0 10px rgba(0,0,0,0.3)) drop-shadow(0 2px 6px rgba(0,0,0,0.3))') + : 'drop-shadow(0 2px 4px rgba(0,0,0,0.25))' + const scale = highlighted ? 1.2 : 1 + const label = String(index + 1) + + // Outer wrap holds the element mapbox positions via `transform: translate(...)`. + // Anything animated (scale, filter) has to live on an inner child — otherwise + // the CSS transition would catch the map's per-frame translate updates and + // the marker smears all over the viewport while scrolling / flying. + const wrap = document.createElement('div') + wrap.style.cssText = `width:${MARKER_W}px;height:${MARKER_H}px;cursor:pointer;` + const inner = document.createElement('div') + inner.className = 'trek-journey-marker-inner' + inner.style.cssText = `width:100%;height:100%;transform:scale(${scale});transform-origin:bottom center;transition:transform 0.2s ease;filter:${shadow};` + inner.innerHTML = ` + + + ${label} + ` + wrap.appendChild(inner) + return wrap +} + +const EMPTY_TRAIL: { lat: number; lng: number }[] = [] + +const JourneyMapGL = forwardRef(function JourneyMapGL( + { entries, trail, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom }, + ref +) { + const stableTrail = trail || EMPTY_TRAIL + const mapboxStyle = useSettingsStore(s => s.settings.mapbox_style || 'mapbox://styles/mapbox/standard') + const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '') + const mapbox3d = useSettingsStore(s => s.settings.mapbox_3d_enabled !== false) + const mapboxQuality = useSettingsStore(s => s.settings.mapbox_quality_mode === true) + const containerRef = useRef(null) + const mapRef = useRef(null) + const markersRef = useRef>(new Map()) + const itemsRef = useRef([]) + const highlightedRef = useRef(null) + const popupRef = useRef(null) + const onMarkerClickRef = useRef(onMarkerClick) + onMarkerClickRef.current = onMarkerClick + const darkRef = useRef(dark) + darkRef.current = dark + + const showPopup = useCallback((id: string) => { + const item = itemsRef.current.find(i => i.id === id) + if (!item || !mapRef.current) return + ensureJourneyPopupStyle() + // Primary line: user-given title. If none, fall back to the location + // name so we always show *something* useful on the top line. + const primaryRaw = item.label || item.locationName || 'Entry' + const secondaryPlace = item.label ? item.locationName : '' + const dateStr = formatEntryDate(item.time) + const primary = escapeHtml(primaryRaw) + const place = escapeHtml(secondaryPlace) + const date = escapeHtml(dateStr) + + const subParts: string[] = [] + if (place) subParts.push(`${place}`) + if (date) subParts.push(`${date}`) + const subline = subParts.length === 2 + ? `${subParts[0]}\u00B7${subParts[1]}` + : subParts.join('') + + const html = ` +
${primary}
+ ${subline ? `
${subline}
` : ''} + ` + // Marker is bottom-anchored with a visible height of 36px (1.2× on + // highlight ≈ 44px), so -46 keeps the popup just clear of the pin top. + const offset: [number, number] = [0, -46] + if (popupRef.current) { + popupRef.current.setLngLat([item.lng, item.lat]) + popupRef.current.setHTML(html) + popupRef.current.setOffset(offset) + const el = popupRef.current.getElement() + if (el) el.classList.toggle('trek-dark', !!darkRef.current) + } else { + popupRef.current = new mapboxgl.Popup({ + closeButton: false, + closeOnClick: false, + closeOnMove: false, + anchor: 'bottom', + offset, + className: `trek-journey-popup${darkRef.current ? ' trek-dark' : ''}`, + maxWidth: '280px', + }) + .setLngLat([item.lng, item.lat]) + .setHTML(html) + .addTo(mapRef.current) + } + }, []) + + const hidePopup = useCallback(() => { + if (popupRef.current) { + try { popupRef.current.remove() } catch { /* noop */ } + popupRef.current = null + } + }, []) + + const setMarkerStyle = useCallback((id: string, highlighted: boolean) => { + const item = itemsRef.current.find(i => i.id === id) + const marker = markersRef.current.get(id) + if (!item || !marker) return + const idx = itemsRef.current.indexOf(item) + const el = marker.getElement() + const currentInner = el.querySelector('.trek-journey-marker-inner') as HTMLDivElement | null + if (!currentInner) return + // Only swap the inner element's styles/HTML. Touching `el.style.cssText` + // would wipe mapbox's positional transform and make the marker flicker. + const next = markerHtml(idx, highlighted, !!darkRef.current) + const nextInner = next.querySelector('.trek-journey-marker-inner') as HTMLDivElement + currentInner.style.cssText = nextInner.style.cssText + currentInner.innerHTML = nextInner.innerHTML + el.style.zIndex = highlighted ? '1000' : '0' + }, []) + + const highlightMarker = useCallback((id: string | null) => { + const prev = highlightedRef.current + highlightedRef.current = id + if (prev && prev !== id) setMarkerStyle(prev, false) + if (id) { + setMarkerStyle(id, true) + showPopup(id) + } else { + hidePopup() + } + }, [setMarkerStyle, showPopup, hidePopup]) + + const focusMarker = useCallback((id: string) => { + highlightMarker(id) + const marker = markersRef.current.get(id) + if (!marker || !mapRef.current) return + try { + mapRef.current.flyTo({ + center: marker.getLngLat(), + zoom: Math.max(mapRef.current.getZoom(), 14), + pitch: mapbox3d ? 45 : 0, + duration: 600, + }) + } catch { /* map not yet ready */ } + }, [highlightMarker, mapbox3d]) + + const invalidateSize = useCallback(() => { + try { mapRef.current?.resize() } catch { /* map not yet ready */ } + }, []) + + useImperativeHandle(ref, () => ({ highlightMarker, focusMarker, invalidateSize }), [highlightMarker, focusMarker, invalidateSize]) + + // Build map once per style/token change. Markers and layers are rebuilt + // inside the same effect so they stay in sync with the active style. + useEffect(() => { + if (!containerRef.current || !mapboxToken) return + mapboxgl.accessToken = mapboxToken + + const items = buildItems(entries) + itemsRef.current = items + + const bounds = new mapboxgl.LngLatBounds() + items.forEach(i => bounds.extend([i.lng, i.lat])) + stableTrail.forEach(p => bounds.extend([p.lng, p.lat])) + const hasPoints = items.length > 0 || stableTrail.length > 0 + + const map = new mapboxgl.Map({ + container: containerRef.current, + style: mapboxStyle, + center: hasPoints ? bounds.getCenter() : [0, 30], + zoom: hasPoints ? 2 : 1, + pitch: mapbox3d && fullScreen ? 45 : 0, + attributionControl: true, + antialias: mapboxQuality, + projection: mapboxQuality ? 'globe' : 'mercator', + }) + mapRef.current = map + + map.on('load', () => { + if (mapbox3d) { + if (!isStandardFamily(mapboxStyle) && wantsTerrain(mapboxStyle)) addTerrainAndSky(map) + if (supportsCustom3d(mapboxStyle)) addCustom3dBuildings(map, !!darkRef.current) + } + // Flatten Mapbox Standard's built-in DEM so HTML markers (at Z=0) + // stay pinned to their coordinates at every zoom and pitch. + if (mapboxStyle === 'mapbox://styles/mapbox/standard') { + try { map.setTerrain(null) } catch { /* noop */ } + } + + // route trail — dashed line connecting entries in time order + if (items.length > 1) { + const coords = items.map(i => [i.lng, i.lat]) + if (map.getSource('journey-route')) (map.getSource('journey-route') as mapboxgl.GeoJSONSource).setData({ + type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: coords } as GeoJSON.LineString, + }) + else { + map.addSource('journey-route', { + type: 'geojson', + data: { type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: coords } as GeoJSON.LineString }, + }) + map.addLayer({ + id: 'journey-route-line', + type: 'line', + source: 'journey-route', + paint: { + 'line-color': darkRef.current ? '#71717A' : '#A1A1AA', + 'line-width': 1.5, + 'line-opacity': 0.5, + 'line-dasharray': [2, 3], + }, + layout: { 'line-cap': 'round', 'line-join': 'round' }, + }) + } + } + + // markers + items.forEach((item, i) => { + const el = markerHtml(i, false, !!darkRef.current) + const marker = new mapboxgl.Marker({ element: el, anchor: 'bottom' }) + .setLngLat([item.lng, item.lat]) + .addTo(map) + el.addEventListener('click', (ev) => { + ev.stopPropagation() + onMarkerClickRef.current?.(item.id) + }) + markersRef.current.set(item.id, marker) + }) + + // fit bounds to all points + if (hasPoints) { + const pb = paddingBottom || 50 + try { + map.fitBounds(bounds, { + padding: { top: 50, bottom: pb, left: 50, right: 50 }, + maxZoom: 16, + pitch: mapbox3d && fullScreen ? 45 : 0, + duration: 0, + }) + } catch { /* empty bounds */ } + } + }) + + return () => { + markersRef.current.forEach(m => m.remove()) + markersRef.current.clear() + if (popupRef.current) { + try { popupRef.current.remove() } catch { /* noop */ } + popupRef.current = null + } + highlightedRef.current = null + try { map.remove() } catch { /* noop */ } + mapRef.current = null + } + }, [entries, stableTrail, mapboxStyle, mapboxToken, mapbox3d, mapboxQuality, fullScreen, paddingBottom]) + + // external activeMarkerId → highlight + flyTo + useEffect(() => { + if (!activeMarkerId || !mapRef.current) return + const t = setTimeout(() => { + highlightMarker(activeMarkerId) + const marker = markersRef.current.get(activeMarkerId) + if (!marker || !mapRef.current) return + try { + mapRef.current.flyTo({ + center: marker.getLngLat(), + zoom: Math.max(mapRef.current.getZoom(), 12), + pitch: mapbox3d && fullScreen ? 45 : 0, + duration: 500, + }) + } catch { /* map not ready */ } + }, 50) + return () => clearTimeout(t) + }, [activeMarkerId, highlightMarker, mapbox3d, fullScreen]) + + if (!mapboxToken) { + return ( +
+
+ No Mapbox access token configured.
+ Settings → Map → Mapbox GL +
+
+ ) + } + + return ( +
+
+
+ ) +}) + +export default JourneyMapGL diff --git a/client/src/components/Map/LocationButton.tsx b/client/src/components/Map/LocationButton.tsx new file mode 100644 index 00000000..b2d44678 --- /dev/null +++ b/client/src/components/Map/LocationButton.tsx @@ -0,0 +1,56 @@ +import { Navigation, LocateFixed, Locate } from 'lucide-react' +import type { TrackingMode } from '../../hooks/useGeolocation' + +interface Props { + mode: TrackingMode + error: string | null + onClick: () => void + // Offset from the bottom edge — callers push this up above the mobile + // bottom nav. Defaults to 20px for desktop. + bottomOffset?: number +} + +// Three-state FAB. Matches the Apple/Google Maps pattern: +// off → outline locate icon +// show → filled locate (blue dot is visible on the map) +// follow → filled navigation arrow (map follows + rotates with heading) +export default function LocationButton({ mode, error, onClick, bottomOffset = 20 }: Props) { + const Icon = mode === 'follow' ? Navigation : mode === 'show' ? LocateFixed : Locate + const isActive = mode !== 'off' + const title = error + ? error + : mode === 'off' + ? 'Show my location' + : mode === 'show' + ? 'Follow my location' + : 'Stop following' + + return ( + + ) +} diff --git a/client/src/components/Map/MapView.tsx b/client/src/components/Map/MapView.tsx index c79452d3..2bb1827f 100644 --- a/client/src/components/Map/MapView.tsx +++ b/client/src/components/Map/MapView.tsx @@ -278,93 +278,76 @@ function RouteLabel({ midpoint, walkingText, drivingText }: RouteLabelProps) { // Module-level photo cache shared with PlaceAvatar import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService' import { useAuthStore } from '../../store/authStore' +import { useGeolocation } from '../../hooks/useGeolocation' +import LocationButton from './LocationButton' -// Live location tracker — blue dot with pulse animation (like Apple/Google Maps) -function LocationTracker() { +// Live-location rendering inside the Leaflet map. Subscribes via the +// shared useGeolocation hook so the Leaflet and Mapbox variants behave +// identically. Heading is shown as a rotated conic SVG when available. +import type { GeoPosition, TrackingMode } from '../../hooks/useGeolocation' + +function LeafletLocationLayer({ position, mode }: { position: GeoPosition | null; mode: TrackingMode }) { const map = useMap() - const [position, setPosition] = useState<[number, number] | null>(null) - const [accuracy, setAccuracy] = useState(0) - const [tracking, setTracking] = useState(false) - const watchId = useRef(null) - const startTracking = useCallback(() => { - if (!('geolocation' in navigator)) return - setTracking(true) - watchId.current = navigator.geolocation.watchPosition( - (pos) => { - const latlng: [number, number] = [pos.coords.latitude, pos.coords.longitude] - setPosition(latlng) - setAccuracy(pos.coords.accuracy) - }, - () => setTracking(false), - { enableHighAccuracy: true, maximumAge: 5000 } - ) - }, []) - - const stopTracking = useCallback(() => { - if (watchId.current !== null) navigator.geolocation.clearWatch(watchId.current) - watchId.current = null - setTracking(false) - setPosition(null) - }, []) - - const toggleTracking = useCallback(() => { - if (tracking) { stopTracking() } else { startTracking() } - }, [tracking, startTracking, stopTracking]) - - // Center map on position when first acquired - const centered = useRef(false) + // When the user is in follow mode, keep the map centred on the dot. + // setView (no animation) is what Google Maps does during navigation — + // it feels responsive and avoids animation jitter at walking speed. useEffect(() => { - if (position && !centered.current) { - map.setView(position, 15) - centered.current = true - } - }, [position, map]) + if (mode !== 'follow' || !position) return + try { map.setView([position.lat, position.lng], Math.max(map.getZoom(), 16), { animate: true, duration: 0.35 }) } catch { /* noop */ } + }, [position, mode, map]) - // Cleanup on unmount - useEffect(() => () => { if (watchId.current !== null) navigator.geolocation.clearWatch(watchId.current) }, []) + // Once, when the user first acquires a fix in "show" mode, pan to it so + // they don't have to scroll the map. Subsequent fixes only move the dot. + const centeredRef = useRef(false) + useEffect(() => { + if (mode === 'off') { centeredRef.current = false; return } + if (!position || centeredRef.current) return + try { map.setView([position.lat, position.lng], Math.max(map.getZoom(), 15)) } catch { /* noop */ } + centeredRef.current = true + }, [position, mode, map]) + + if (!position) return null + + const headingIcon = position.heading === null || Number.isNaN(position.heading) ? null : L.divIcon({ + className: '', + iconSize: [60, 60], + iconAnchor: [30, 30], + html: `
`, + }) return ( <> - {/* Location button */} -
- -
- - {/* Blue dot + accuracy circle */} - {position && ( - <> - {accuracy < 500 && ( - - )} - - + {position.accuracy < 500 && ( + )} - - {/* Pulse animation CSS */} - {position && ( - + {headingIcon && ( + )} + ) } @@ -561,8 +544,15 @@ export const MapView = memo(function MapView({ const TooltipOverlay = hoveredPlace && tooltipPos && !isTouchDevice const CatIcon = TooltipOverlay ? getCategoryIcon(hoveredPlace.category_icon) : null + const { position: userPosition, mode: trackingMode, error: trackingError, cycleMode: cycleTrackingMode } = useGeolocation() + // Desktop browsers only get IP-based geolocation (city-level accuracy), + // so the button would be misleading. Mobile, where real GPS lives, keeps it. + const isMobile = typeof window !== 'undefined' && window.innerWidth < 768 + const locationButtonBottom = 'calc(var(--bottom-nav-h, 84px) + 12px)' + return ( <> +
- + + {isMobile && } +
{TooltipOverlay && (
s.settings.map_provider) + const token = useSettingsStore(s => s.settings.mapbox_access_token) + // Fall back to Leaflet when Mapbox is selected but no token is set, + // so trip planner never shows an empty map due to a missing token. + if (provider === 'mapbox-gl' && token) return + return +} diff --git a/client/src/components/Map/MapViewGL.tsx b/client/src/components/Map/MapViewGL.tsx new file mode 100644 index 00000000..1a2a0ca0 --- /dev/null +++ b/client/src/components/Map/MapViewGL.tsx @@ -0,0 +1,558 @@ +import { useEffect, useRef, useMemo, useState, createElement } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import mapboxgl from 'mapbox-gl' +import 'mapbox-gl/dist/mapbox-gl.css' +import { useSettingsStore } from '../../store/settingsStore' +import { useAuthStore } from '../../store/authStore' +import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService' +import { CATEGORY_ICON_MAP } from '../shared/categoryIcons' +import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from './mapboxSetup' +import { attachLocationMarker, type LocationMarkerHandle } from './locationMarkerMapbox' +import LocationButton from './LocationButton' +import { useGeolocation } from '../../hooks/useGeolocation' +import type { Place } from '../../types' + +function categoryIconSvg(iconName: string | null | undefined, size: number): string { + const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin'] + try { + return renderToStaticMarkup(createElement(IconComponent, { size, color: 'white', strokeWidth: 2.5 })) + } catch { return '' } +} + +interface RouteSegment { + mid: [number, number] + from: [number, number] + to: [number, number] + walkingText?: string + drivingText?: string +} + +interface Props { + places: Place[] + dayPlaces?: Place[] + route?: [number, number][][] | null + routeSegments?: RouteSegment[] + selectedPlaceId?: number | null + onMarkerClick?: (id: number) => void + onMapClick?: (info: { latlng: { lat: number; lng: number } }) => void + onMapContextMenu?: ((e: { latlng: { lat: number; lng: number }; originalEvent: MouseEvent }) => void) | null + center?: [number, number] + zoom?: number + fitKey?: number | null + dayOrderMap?: Record + leftWidth?: number + rightWidth?: number + hasInspector?: boolean + hasDayDetail?: boolean +} + +function createMarkerElement(place: Place & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement { + const size = selected ? 44 : 36 + const borderColor = selected ? '#111827' : 'white' + const borderWidth = selected ? 3 : 2.5 + const shadow = selected + ? '0 0 0 3px rgba(17,24,39,0.25), 0 4px 14px rgba(0,0,0,0.3)' + : '0 2px 8px rgba(0,0,0,0.22)' + const bgColor = place.category_color || '#6b7280' + + // The visual circle is `size` + 2*border on each side. To make the + // mapbox `anchor: 'center'` land on the real visual middle of the marker + // (rather than just the inner content box), the wrapper has to be the + // full outer size. If we gave the wrapper only `size`, the border would + // bleed outside it and the route lines would appear slightly off. + const outer = size + borderWidth * 2 + + let badgeHtml = '' + if (orderNumbers && orderNumbers.length > 0) { + const label = orderNumbers.join(' · ') + badgeHtml = `${label}` + } + + const wrap = document.createElement('div') + // Do NOT set `position: relative` here — mapbox-gl ships + // `.mapboxgl-marker { position: absolute }` and relies on it. An inline + // `position: relative` here overrides the class, turns every marker into + // a static block element, and stacks them in document order inside the + // canvas container. The result looks exactly like "markers drift as the + // map zooms" because each marker's transform is then applied relative + // to its stacked slot, not to the map viewport. + wrap.style.cssText = `width:${outer}px;height:${outer}px;cursor:pointer;` + + const hasPhoto = photoUrl && (photoUrl.startsWith('data:') || photoUrl.startsWith('/api/maps/place-photo/')) + if (hasPhoto) { + wrap.innerHTML = ` +
+ +
+ ${badgeHtml} + ` + } else { + wrap.innerHTML = ` +
+ ${categoryIconSvg(place.category_icon, selected ? 18 : 15)} +
+ ${badgeHtml} + ` + } + return wrap +} + +export function MapViewGL({ + places = [], + dayPlaces = [], + route = null, + selectedPlaceId = null, + onMarkerClick, + onMapClick, + onMapContextMenu = null, + center = [48.8566, 2.3522], + zoom = 10, + fitKey = 0, + dayOrderMap = {}, + leftWidth = 0, + rightWidth = 0, + hasInspector = false, + hasDayDetail = false, +}: Props) { + const mapboxStyle = useSettingsStore(s => s.settings.mapbox_style || 'mapbox://styles/mapbox/standard') + const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '') + const mapbox3d = useSettingsStore(s => s.settings.mapbox_3d_enabled !== false) + const mapboxQuality = useSettingsStore(s => s.settings.mapbox_quality_mode === true) + const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled) + const [photoUrls, setPhotoUrls] = useState>(getAllThumbs) + const containerRef = useRef(null) + const mapRef = useRef(null) + const markersRef = useRef>(new Map()) + const locationMarkerRef = useRef(null) + const { position: userPosition, mode: trackingMode, error: trackingError, cycleMode: cycleTrackingMode, setMode: setTrackingMode } = useGeolocation() + const onClickRefs = useRef({ marker: onMarkerClick, map: onMapClick, context: onMapContextMenu }) + onClickRefs.current.marker = onMarkerClick + onClickRefs.current.map = onMapClick + onClickRefs.current.context = onMapContextMenu + + // Build/rebuild the map on style/token/3d change + useEffect(() => { + if (!containerRef.current || !mapboxToken) return + mapboxgl.accessToken = mapboxToken + + const map = new mapboxgl.Map({ + container: containerRef.current, + style: mapboxStyle, + center: [center[1], center[0]], + zoom, + pitch: mapbox3d ? 45 : 0, + attributionControl: true, + antialias: mapboxQuality, + projection: mapboxQuality ? 'globe' : 'mercator', + }) + mapRef.current = map + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(window as any).__trek_map = map + + map.on('load', () => { + if (mapbox3d) { + // Terrain is only valuable on satellite styles — on clean vector + // styles it makes route lines drift off the HTML markers because + // the lines snap to DEM height while markers stay at sea level. + if (!isStandardFamily(mapboxStyle) && wantsTerrain(mapboxStyle)) addTerrainAndSky(map) + if (supportsCustom3d(mapboxStyle)) { + const dark = document.documentElement.classList.contains('dark') + addCustom3dBuildings(map, dark) + } + } + + // Mapbox Standard ships its own DEM-based terrain that kicks in + // below zoom 13.7. HTML markers project at sea level, so when the + // terrain exaggeration ramps up at lower zooms the markers drift + // away from the 3D buildings and route lines they belong to. The + // non-satellite Standard style still looks great without terrain, + // so flatten it out to keep markers pinned. (Satellite variants + // are left alone — the DEM is what gives them their character.) + if (mapboxStyle === 'mapbox://styles/mapbox/standard') { + try { map.setTerrain(null) } catch { /* noop */ } + } + // initial route source — kept around so updates can setData() cheaply + if (!map.getSource('trip-route')) { + map.addSource('trip-route', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } }) + map.addLayer({ + id: 'trip-route-line', + type: 'line', + source: 'trip-route', + paint: { + 'line-color': '#111827', + 'line-width': 3, + 'line-opacity': 0.9, + 'line-dasharray': [2, 1.5], + }, + layout: { 'line-cap': 'round', 'line-join': 'round' }, + }) + } + // gpx geometries source (place.route_geometry) + if (!map.getSource('trip-gpx')) { + map.addSource('trip-gpx', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } }) + map.addLayer({ + id: 'trip-gpx-line', + type: 'line', + source: 'trip-gpx', + paint: { + 'line-color': ['coalesce', ['get', 'color'], '#3b82f6'], + 'line-width': 3.5, + 'line-opacity': 0.75, + }, + layout: { 'line-cap': 'round', 'line-join': 'round' }, + }) + } + }) + + map.on('click', (e) => { + const t = e.originalEvent.target as HTMLElement + if (t.closest('.mapboxgl-marker')) return // markers handle their own click + onClickRefs.current.map?.({ latlng: { lat: e.lngLat.lat, lng: e.lngLat.lng } }) + }) + // In the mapbox-gl map the right mouse button is reserved for the + // built-in rotate/pitch gesture, so we bind the "add place" action + // to the middle mouse button (button === 1) instead. + const canvas = map.getCanvasContainer() + const onAuxDown = (ev: MouseEvent) => { + if (ev.button !== 1) return + ev.preventDefault() + const rect = canvas.getBoundingClientRect() + const lngLat = map.unproject([ev.clientX - rect.left, ev.clientY - rect.top]) + onClickRefs.current.context?.({ + latlng: { lat: lngLat.lat, lng: lngLat.lng }, + originalEvent: ev, + }) + } + // Also suppress the browser's native auxclick menu on middle-click. + const onAuxClick = (ev: MouseEvent) => { + if (ev.button === 1) ev.preventDefault() + } + canvas.addEventListener('mousedown', onAuxDown) + canvas.addEventListener('auxclick', onAuxClick) + + // Drop follow mode if the user pans the map manually — matches the + // Apple Maps behaviour where the blue dot stays but the map no longer + // chases it until the user taps the button again. + map.on('dragstart', () => { + setTrackingMode(prev => prev === 'follow' ? 'show' : prev) + }) + + // Keep HTML markers glued to the terrain / 3D ground. Mapbox projects + // HTML markers at altitude=0 (sea level) by default, so as soon as the + // style has a terrain DEM (Standard, Standard Satellite, custom terrain) + // the markers drift off the places when the camera pitches or zooms — + // the buildings rise from DEM height, the marker stays at sea level, + // and the pixel offset grows as the perspective changes. + // + // Pushing `[lng, lat, elevation]` through setLngLat tells mapbox to + // project the marker onto the same ground the route line sits on. + // We re-apply this every render because DEM tiles stream in async. + let lastAltUpdate = 0 + const syncMarkerAltitudes = () => { + const now = performance.now() + if (now - lastAltUpdate < 80) return // ~12Hz is plenty + lastAltUpdate = now + markersRef.current.forEach(marker => { + const ll = marker.getLngLat() + let alt = 0 + try { + const e = map.queryTerrainElevation([ll.lng, ll.lat]) + if (typeof e === 'number' && Number.isFinite(e)) alt = e + } catch { /* terrain not ready */ } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const curAlt = (ll as any).alt ?? 0 + if (Math.abs(curAlt - alt) > 0.25) { + marker.setLngLat([ll.lng, ll.lat, alt]) + } + }) + } + map.on('render', syncMarkerAltitudes) + + return () => { + canvas.removeEventListener('mousedown', onAuxDown) + canvas.removeEventListener('auxclick', onAuxClick) + markersRef.current.forEach(m => m.remove()) + markersRef.current.clear() + if (locationMarkerRef.current) { + locationMarkerRef.current.destroy() + locationMarkerRef.current = null + } + try { map.remove() } catch { /* noop */ } + mapRef.current = null + } + }, [mapboxStyle, mapboxToken, mapbox3d]) // rebuild on style changes only + + // Photo loading — mirrors the Leaflet MapView. Updates via RAF to batch + // simultaneous thumb arrivals into one re-render. + const pendingThumbsRef = useRef>({}) + const thumbRafRef = useRef(null) + const placeIds = useMemo(() => places.map(p => p.id).join(','), [places]) + useEffect(() => { + if (!places || places.length === 0 || !placesPhotosEnabled) return + const cleanups: (() => void)[] = [] + + const setThumb = (cacheKey: string, thumb: string) => { + pendingThumbsRef.current[cacheKey] = thumb + if (thumbRafRef.current !== null) return + thumbRafRef.current = requestAnimationFrame(() => { + thumbRafRef.current = null + const pending = pendingThumbsRef.current + pendingThumbsRef.current = {} + setPhotoUrls(prev => { + const hasChange = Object.entries(pending).some(([k, v]) => prev[k] !== v) + return hasChange ? { ...prev, ...pending } : prev + }) + }) + } + + for (const place of places) { + const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}` + if (!cacheKey) continue + const cached = getCached(cacheKey) + if (cached?.thumbDataUrl) { + setThumb(cacheKey, cached.thumbDataUrl) + continue + } + cleanups.push(onThumbReady(cacheKey, thumb => setThumb(cacheKey, thumb))) + if (!cached && !isLoading(cacheKey)) { + const photoId = place.image_url || place.google_place_id || place.osm_id + if (photoId || (place.lat && place.lng)) { + fetchPhoto(cacheKey, photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name) + } + } + } + + return () => { + cleanups.forEach(fn => fn()) + if (thumbRafRef.current !== null) { + cancelAnimationFrame(thumbRafRef.current) + thumbRafRef.current = null + } + } + }, [placeIds, placesPhotosEnabled]) // eslint-disable-line react-hooks/exhaustive-deps + + // Reconcile markers with places + photos. Rebuilds the DOM node when any + // visual input changes so photos, selection state and order badges stay + // in sync. + useEffect(() => { + const map = mapRef.current + if (!map) return + const ids = new Set(places.map(p => p.id)) + + markersRef.current.forEach((marker, id) => { + if (!ids.has(id)) { + marker.remove() + markersRef.current.delete(id) + } + }) + + places.forEach(place => { + if (!place.lat || !place.lng) return + const orderNumbers = dayOrderMap[place.id] ?? null + const pck = place.google_place_id || place.osm_id || `${place.lat},${place.lng}` + const photoUrl = (pck && photoUrls[pck]) || place.image_url || null + const selected = place.id === selectedPlaceId + const el = createMarkerElement(place as Place & { category_color?: string; category_icon?: string }, photoUrl, orderNumbers, selected) + el.addEventListener('click', (ev) => { + ev.stopPropagation() + onClickRefs.current.marker?.(place.id) + }) + // Recreate marker each time rather than patching internal state — + // mapbox-gl's internal _element bookkeeping breaks under DOM swaps. + const existing = markersRef.current.get(place.id) + if (existing) existing.remove() + // Default (viewport-aligned) anchors keep the marker parallel to the + // screen so its pixel centre lines up with the route line at any + // pitch. Tried `pitchAlignment: 'map'` to snap markers onto terrain, + // but it rotates the element by the pitch angle and visually offsets + // the anchor by ~100px at 45° tilt, which caused the observed drift. + const m = new mapboxgl.Marker({ element: el, anchor: 'center' }) + .setLngLat([place.lng, place.lat]) + .addTo(map) + markersRef.current.set(place.id, m) + }) + }, [places, selectedPlaceId, dayOrderMap, photoUrls]) + + // Update route geojson + useEffect(() => { + const map = mapRef.current + if (!map) return + const src = map.getSource('trip-route') as mapboxgl.GeoJSONSource | undefined + if (!src) return + const features = (route || []).filter(seg => seg && seg.length > 1).map(seg => ({ + type: 'Feature' as const, + properties: {}, + geometry: { type: 'LineString' as const, coordinates: seg.map(([lat, lng]) => [lng, lat]) }, + })) + src.setData({ type: 'FeatureCollection', features }) + }, [route]) + + // Update GPX geometries + useEffect(() => { + const map = mapRef.current + if (!map) return + const src = map.getSource('trip-gpx') as mapboxgl.GeoJSONSource | undefined + if (!src) return + const features = places.flatMap(place => { + if (!place.route_geometry) return [] + try { + const coords = JSON.parse(place.route_geometry) as [number, number][] + if (!coords || coords.length < 2) return [] + return [{ + type: 'Feature' as const, + properties: { color: (place as Place & { category_color?: string }).category_color || '#3b82f6' }, + geometry: { type: 'LineString' as const, coordinates: coords.map(([lat, lng]) => [lng, lat]) }, + }] + } catch { return [] } + }) + src.setData({ type: 'FeatureCollection', features }) + }, [places]) + + // Fit bounds on fitKey change — matches the Leaflet BoundsController + const paddingOpts = useMemo(() => { + const isMobile = typeof window !== 'undefined' && window.innerWidth < 768 + if (isMobile) return { top: 40, right: 20, bottom: 40, left: 20 } + const top = 60 + const bottom = hasInspector ? 320 : hasDayDetail ? 280 : 60 + return { top, right: rightWidth + 40, bottom, left: leftWidth + 40 } + }, [leftWidth, rightWidth, hasInspector, hasDayDetail]) + + // Also fit when the places collection changes so the initial render + // zooms to the trip instead of the default center. + const placeBoundsKey = useMemo( + () => places.filter(p => p.lat && p.lng).map(p => `${p.id}:${p.lat}:${p.lng}`).join('|'), + [places] + ) + useEffect(() => { + const map = mapRef.current + if (!map) return + const target = dayPlaces.length > 0 ? dayPlaces : places + const valid = target.filter(p => p.lat && p.lng) + if (valid.length === 0) return + const bounds = new mapboxgl.LngLatBounds() + valid.forEach(p => bounds.extend([p.lng, p.lat])) + const run = () => { + try { + map.fitBounds(bounds, { + padding: paddingOpts, + maxZoom: 15, + pitch: mapbox3d ? 45 : 0, + duration: 400, + }) + } catch { /* noop */ } + } + if (map.loaded()) run() + else map.once('load', run) + }, [fitKey, placeBoundsKey, paddingOpts, mapbox3d]) // eslint-disable-line react-hooks/exhaustive-deps + + // flyTo selected place + useEffect(() => { + const map = mapRef.current + if (!map || !selectedPlaceId) return + const target = places.find(p => p.id === selectedPlaceId) || dayPlaces.find(p => p.id === selectedPlaceId) + if (!target?.lat || !target?.lng) return + try { + map.flyTo({ + center: [target.lng, target.lat], + zoom: Math.max(map.getZoom(), 14), + pitch: mapbox3d ? 45 : 0, + duration: 400, + }) + } catch { /* noop */ } + }, [selectedPlaceId, mapbox3d]) // eslint-disable-line react-hooks/exhaustive-deps + + // External center/zoom prop changes — jump without animation + useEffect(() => { + const map = mapRef.current + if (!map) return + try { map.jumpTo({ center: [center[1], center[0]], zoom }) } catch { /* noop */ } + }, [center[0], center[1]]) // eslint-disable-line react-hooks/exhaustive-deps + + // Blue dot rendering + follow-mode camera. Attach the marker lazily the + // first time a fix arrives so the layers sit on top of everything else + // added so far, and destroy it when tracking is turned off. + useEffect(() => { + const map = mapRef.current + if (!map) return + if (trackingMode === 'off') { + if (locationMarkerRef.current) { + locationMarkerRef.current.update(null) + } + return + } + if (!userPosition) return + const apply = () => { + if (!locationMarkerRef.current) locationMarkerRef.current = attachLocationMarker(map) + locationMarkerRef.current.update(userPosition) + if (trackingMode === 'follow') { + // easeTo is gentler than flyTo for continuous updates + try { + map.easeTo({ + center: [userPosition.lng, userPosition.lat], + bearing: userPosition.heading ?? map.getBearing(), + zoom: Math.max(map.getZoom(), 16), + duration: 350, + }) + } catch { /* noop */ } + } + } + if (map.loaded()) apply() + else map.once('load', apply) + }, [userPosition, trackingMode]) + + if (!mapboxToken) { + return ( +
+
+ No Mapbox access token configured.
+ Settings → Map → Mapbox GL +
+
+ ) + } + + // Desktop browsers only get IP-based geolocation (city-level accuracy), + // so the button would be misleading. Mobile, where real GPS lives, keeps it. + const isMobile = typeof window !== 'undefined' && window.innerWidth < 768 + const buttonBottom = 'calc(var(--bottom-nav-h, 84px) + 12px)' + + return ( +
+
+ {isMobile && ( + + )} +
+ ) +} diff --git a/client/src/components/Map/locationMarkerMapbox.ts b/client/src/components/Map/locationMarkerMapbox.ts new file mode 100644 index 00000000..44abe8ae --- /dev/null +++ b/client/src/components/Map/locationMarkerMapbox.ts @@ -0,0 +1,172 @@ +import mapboxgl from 'mapbox-gl' +import type { GeoPosition } from '../../hooks/useGeolocation' + +// Build the DOM element that backs the mapbox Marker. We animate the +// heading cone via a CSS rotation so the DOM stays stable across updates +// and mapbox doesn't get confused about which element to position. +function buildLocationEl(): { root: HTMLDivElement; cone: HTMLDivElement } { + const root = document.createElement('div') + root.style.cssText = 'width:28px;height:28px;position:relative;pointer-events:none;' + // Accuracy pulse behind the dot + const pulse = document.createElement('div') + pulse.style.cssText = ` + position:absolute;inset:-14px;border-radius:50%; + background:#3b82f6;opacity:0.25; + animation:trek-location-pulse 2s ease-out infinite; + ` + // Heading cone (conic gradient fan) + const cone = document.createElement('div') + cone.style.cssText = ` + position:absolute;left:50%;top:50%;width:60px;height:60px; + transform:translate(-50%,-50%) rotate(0deg); + background:conic-gradient(from -30deg, rgba(59,130,246,0) 0deg, rgba(59,130,246,0.35) 15deg, rgba(59,130,246,0) 60deg, rgba(59,130,246,0) 360deg); + border-radius:50%; + mask:radial-gradient(circle, transparent 12px, black 13px); + -webkit-mask:radial-gradient(circle, transparent 12px, black 13px); + transition:transform 0.12s ease-out; + display:none; + ` + // Blue dot + const dot = document.createElement('div') + dot.style.cssText = ` + position:absolute;left:50%;top:50%; + transform:translate(-50%,-50%); + width:18px;height:18px;border-radius:50%; + background:#3b82f6;border:3px solid white; + box-shadow:0 0 0 1px rgba(0,0,0,0.15), 0 2px 6px rgba(0,0,0,0.3); + ` + root.appendChild(pulse) + root.appendChild(cone) + root.appendChild(dot) + return { root, cone } +} + +// Inject the pulse keyframes once per document so the animation is +// available for every map instance. +function ensurePulseStyle() { + if (document.getElementById('trek-location-style')) return + const s = document.createElement('style') + s.id = 'trek-location-style' + s.textContent = ` + @keyframes trek-location-pulse { + 0% { transform: scale(0.6); opacity: 0.35; } + 70% { transform: scale(1.6); opacity: 0; } + 100% { transform: scale(1.6); opacity: 0; } + } + ` + document.head.appendChild(s) +} + +export interface LocationMarkerHandle { + update: (p: GeoPosition | null) => void + destroy: () => void +} + +// Creates (or reuses) a location marker + accuracy circle on the given +// mapbox map. Returns a handle the caller uses to push position updates +// and clean up. Keeps its own DOM element and GeoJSON source so it can +// coexist with the regular trip markers. +export function attachLocationMarker(map: mapboxgl.Map): LocationMarkerHandle { + ensurePulseStyle() + const { root, cone } = buildLocationEl() + const marker = new mapboxgl.Marker({ element: root, anchor: 'center' }) + + const ensureAccuracyLayer = () => { + if (map.getSource('trek-location-accuracy')) return + try { + map.addSource('trek-location-accuracy', { + type: 'geojson', + data: { type: 'FeatureCollection', features: [] }, + }) + // Draw the accuracy ring as a geographic polygon: it's a real geodesic + // circle defined in meters, so mapbox automatically scales it with + // zoom the way Apple/Google Maps does — always the same real-world + // size regardless of viewport. + map.addLayer({ + id: 'trek-location-accuracy', + type: 'fill', + source: 'trek-location-accuracy', + paint: { + 'fill-color': '#3b82f6', + 'fill-opacity': 0.14, + 'fill-outline-color': '#3b82f6', + }, + }) + } catch { /* noop */ } + } + + // Build a polygon approximating a geodesic circle around (lng, lat) + // with the given radius in meters. 48 segments is plenty for a smooth + // edge without paying much CPU per fix. + const geodesicCircle = (lng: number, lat: number, radiusMeters: number): number[][] => { + const earth = 6378137 + const d = radiusMeters / earth + const lat1 = lat * Math.PI / 180 + const lng1 = lng * Math.PI / 180 + const coords: number[][] = [] + const segments = 48 + for (let i = 0; i <= segments; i++) { + const bearing = (i / segments) * 2 * Math.PI + const lat2 = Math.asin(Math.sin(lat1) * Math.cos(d) + Math.cos(lat1) * Math.sin(d) * Math.cos(bearing)) + const lng2 = lng1 + Math.atan2( + Math.sin(bearing) * Math.sin(d) * Math.cos(lat1), + Math.cos(d) - Math.sin(lat1) * Math.sin(lat2), + ) + coords.push([lng2 * 180 / Math.PI, lat2 * 180 / Math.PI]) + } + return coords + } + + const setAccuracy = (p: GeoPosition) => { + const src = map.getSource('trek-location-accuracy') as mapboxgl.GeoJSONSource | undefined + if (!src) return + if (!p.accuracy || p.accuracy < 1) { + src.setData({ type: 'FeatureCollection', features: [] }) + return + } + const ring = geodesicCircle(p.lng, p.lat, p.accuracy) + src.setData({ + type: 'FeatureCollection', + features: [{ + type: 'Feature', + properties: {}, + geometry: { type: 'Polygon', coordinates: [ring] }, + }], + }) + } + + let lastPosRef: GeoPosition | null = null + + if (map.loaded()) ensureAccuracyLayer() + else map.once('load', ensureAccuracyLayer) + + const handle: LocationMarkerHandle = { + update: (p) => { + lastPosRef = p + if (!p) { + marker.remove() + const src = map.getSource('trek-location-accuracy') as mapboxgl.GeoJSONSource | undefined + src?.setData({ type: 'FeatureCollection', features: [] }) + return + } + marker.setLngLat([p.lng, p.lat]) + if (!marker.getElement().parentElement) marker.addTo(map) + if (p.heading !== null && !Number.isNaN(p.heading)) { + cone.style.display = 'block' + cone.style.transform = `translate(-50%,-50%) rotate(${p.heading}deg)` + } else { + cone.style.display = 'none' + } + setAccuracy(p) + }, + destroy: () => { + try { marker.remove() } catch { /* noop */ } + try { + if (map.getLayer('trek-location-accuracy')) map.removeLayer('trek-location-accuracy') + if (map.getSource('trek-location-accuracy')) map.removeSource('trek-location-accuracy') + } catch { /* noop */ } + }, + } + + return handle +} diff --git a/client/src/components/Map/mapboxSetup.ts b/client/src/components/Map/mapboxSetup.ts new file mode 100644 index 00000000..b3fc9071 --- /dev/null +++ b/client/src/components/Map/mapboxSetup.ts @@ -0,0 +1,101 @@ +import type mapboxgl from 'mapbox-gl' + +// "mapbox/standard" and "mapbox/standard-satellite" ship their own 3D +// buildings and terrain. For every other style we inject a fill-extrusion +// layer against the classic `composite` vector source so the user still +// gets real 3D buildings (not just a tilted 2D view) when they toggle 3D. +export function isStandardFamily(style: string): boolean { + return style === 'mapbox://styles/mapbox/standard' || style === 'mapbox://styles/mapbox/standard-satellite' +} + +// Terrain is only genuinely useful for the satellite imagery styles — on +// clean flat styles like streets/light/dark it nudges route lines onto +// the DEM while our HTML markers stay at Z=0, which causes the visible +// offset when the map is pitched. Restrict terrain to satellite. +export function wantsTerrain(style: string): boolean { + return style === 'mapbox://styles/mapbox/satellite-v9' + || style === 'mapbox://styles/mapbox/satellite-streets-v12' +} + +// 3D can be added to every style now — the standard family has it built-in +// and for everything else we either reuse the style's own `composite` +// building layer or attach the public `mapbox-streets-v8` tileset as an +// extra source (needed for pure satellite, which has no vector data). +export function supportsCustom3d(style: string): boolean { + return !isStandardFamily(style) +} + +// Add a 3D buildings extrusion layer to a non-Standard Mapbox style. For +// the pure satellite style we lazily attach `mapbox-streets-v8` as a +// fallback source so real building volumes sit on top of the imagery — +// the Apple Maps-style "3D satellite" look the user asked for. +export function addCustom3dBuildings(map: mapboxgl.Map, dark: boolean) { + if (map.getLayer('trek-3d-buildings')) return + const baseColor = dark ? '#3b3b3f' : '#cfd2d6' + + // Styles without a `composite` source (pure satellite) need a fallback + // vector tileset for building geometry. + let sourceId = 'composite' + if (!map.getSource('composite')) { + sourceId = 'mapbox-streets-v8' + if (!map.getSource(sourceId)) { + try { + map.addSource(sourceId, { type: 'vector', url: 'mapbox://mapbox.mapbox-streets-v8' }) + } catch { return } + } + } + + try { + // Place extrusions below the first label layer so text stays readable. + const layers = map.getStyle()?.layers || [] + const firstSymbolId = layers.find(l => l.type === 'symbol')?.id + map.addLayer({ + id: 'trek-3d-buildings', + source: sourceId, + 'source-layer': 'building', + filter: ['==', 'extrude', 'true'], + type: 'fill-extrusion', + minzoom: 14, + paint: { + 'fill-extrusion-color': baseColor, + 'fill-extrusion-height': [ + 'interpolate', ['linear'], ['zoom'], + 14, 0, + 15.5, ['coalesce', ['get', 'height'], 0], + ], + 'fill-extrusion-base': [ + 'interpolate', ['linear'], ['zoom'], + 14, 0, + 15.5, ['coalesce', ['get', 'min_height'], 0], + ], + 'fill-extrusion-opacity': 0.85, + }, + }, firstSymbolId) + } catch { /* building source-layer unavailable */ } +} + +// Terrain + sky that works against any style that has the DEM source. +// The Standard family already handles terrain internally, skip there. +export function addTerrainAndSky(map: mapboxgl.Map) { + try { + if (!map.getSource('mapbox-dem')) { + map.addSource('mapbox-dem', { + type: 'raster-dem', + url: 'mapbox://mapbox.mapbox-terrain-dem-v1', + tileSize: 512, + maxzoom: 14, + }) + } + map.setTerrain({ source: 'mapbox-dem', exaggeration: 1.2 }) + if (!map.getLayer('sky')) { + map.addLayer({ + id: 'sky', + type: 'sky', + paint: { + 'sky-type': 'atmosphere', + 'sky-atmosphere-sun-intensity': 15, + } as unknown as mapboxgl.SkyLayerSpecification['paint'], + }) + } + } catch { /* style doesn't support terrain */ } +} diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index 6a43074e..7be7c1e5 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -270,6 +270,17 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ const inputRef = useRef(null) const dragDataRef = useRef(null) const initedTransportIds = useRef(new Set()) // Speichert Drag-Daten als Backup (dataTransfer geht bei Re-Render verloren) + // Remember which assignment we last auto-scrolled into view so we don't + // keep yanking the user back whenever they scroll away while the same + // place stays selected. + const lastAutoScrolledIdRef = useRef(null) + useEffect(() => { + // Reset the scroll-lock whenever selection moves, so the next selected + // row triggers a fresh scroll-into-view on its ref. + if (!selectedAssignmentId && !selectedPlaceId) { + lastAutoScrolledIdRef.current = null + } + }, [selectedAssignmentId, selectedPlaceId]) const currency = trip?.currency || 'EUR' @@ -1131,7 +1142,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ display: 'flex', alignItems: 'center', gap: 10, padding: '11px 14px 11px 16px', cursor: 'pointer', - background: isDragTarget ? 'rgba(17,24,39,0.07)' : (isSelected ? 'var(--bg-tertiary)' : 'transparent'), + background: isDragTarget ? 'rgba(17,24,39,0.07)' : (isSelected ? 'var(--bg-selected)' : 'transparent'), transition: 'background 0.12s', userSelect: 'none', outline: isDragTarget ? '2px dashed rgba(17,24,39,0.25)' : 'none', @@ -1439,6 +1450,21 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ handleMergedDrop(day.id, 'note', Number(noteId), 'place', assignment.id) } }} + ref={el => { + // Auto-scroll the selected row into view — but only on + // the transition "just became selected". Once we've + // scrolled for this assignment id, we won't scroll + // again until selection actually moves somewhere else. + if (el && isPlaceSelected && lastAutoScrolledIdRef.current !== assignment.id) { + const rect = el.getBoundingClientRect() + const nearTop = rect.top < 80 + const nearBottom = rect.bottom > window.innerHeight - 80 + if (nearTop || nearBottom) { + el.scrollIntoView({ behavior: 'smooth', block: 'center' }) + } + lastAutoScrolledIdRef.current = assignment.id + } + }} onDragEnd={() => { setDraggingId(null); setDragOverDayId(null); setDropTargetKey(null); dragDataRef.current = null }} onClick={() => { onPlaceClick(isPlaceSelected ? null : place.id, isPlaceSelected ? null : assignment.id); if (!isPlaceSelected) onSelectDay(day.id, true) }} onContextMenu={e => ctxMenu.open(e, [ @@ -1469,7 +1495,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ cursor: 'pointer', background: lockedIds.has(assignment.id) ? 'rgba(220,38,38,0.08)' - : isPlaceSelected ? 'var(--bg-hover)' : 'transparent', + : isPlaceSelected ? 'var(--bg-selected)' : 'transparent', borderLeft: lockedIds.has(assignment.id) ? '3px solid #dc2626' : '3px solid transparent', diff --git a/client/src/components/Settings/MapSettingsTab.tsx b/client/src/components/Settings/MapSettingsTab.tsx index 2d383d8b..67fb6608 100644 --- a/client/src/components/Settings/MapSettingsTab.tsx +++ b/client/src/components/Settings/MapSettingsTab.tsx @@ -1,11 +1,13 @@ -import React, { useState, useEffect, useCallback, useMemo } from 'react' -import { Map, Save } from 'lucide-react' +import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react' +import { Map, Save, Layers, Box, ChevronDown, Check } from 'lucide-react' import { useTranslation } from '../../i18n' import { useSettingsStore } from '../../store/settingsStore' import { useToast } from '../shared/Toast' import CustomSelect from '../shared/CustomSelect' import { MapView } from '../Map/MapView' +import MapboxPreview from './MapboxPreview' import Section from './Section' +import ToggleSwitch from './ToggleSwitch' import type { Place } from '../../types' interface MapPreset { @@ -21,18 +23,136 @@ const MAP_PRESETS: MapPreset[] = [ { name: 'Stadia Smooth', url: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png' }, ] +interface StylePreset { + name: string + url: string + tags: string[] +} + +const MAPBOX_STYLE_PRESETS: StylePreset[] = [ + { name: 'Mapbox Standard', url: 'mapbox://styles/mapbox/standard', tags: ['3D', 'Apple-like'] }, + { name: 'Standard Satellite', url: 'mapbox://styles/mapbox/standard-satellite', tags: ['3D', 'Satellite'] }, + { name: 'Streets', url: 'mapbox://styles/mapbox/streets-v12', tags: ['3D', 'Classic'] }, + { name: 'Outdoors', url: 'mapbox://styles/mapbox/outdoors-v12', tags: ['3D', 'Terrain'] }, + { name: 'Light', url: 'mapbox://styles/mapbox/light-v11', tags: ['3D', 'Minimal'] }, + { name: 'Dark', url: 'mapbox://styles/mapbox/dark-v11', tags: ['3D', 'Dark'] }, + { name: 'Satellite', url: 'mapbox://styles/mapbox/satellite-v9', tags: ['3D', 'Satellite'] }, + { name: 'Satellite Streets', url: 'mapbox://styles/mapbox/satellite-streets-v12', tags: ['3D', 'Satellite'] }, + { name: 'Navigation Day', url: 'mapbox://styles/mapbox/navigation-day-v1', tags: ['3D', 'Apple-like'] }, + { name: 'Navigation Night', url: 'mapbox://styles/mapbox/navigation-night-v1', tags: ['3D', 'Dark'] }, +] + +// Tag → chip color mapping. Keeps the dropdown readable at a glance so a +// user scanning the list can spot 3D / Satellite / Apple-like styles. +const TAG_STYLES: Record = { + '3D': 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/40 dark:text-indigo-300', + '2D': 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300', + 'Satellite': 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300', + 'Apple-like': 'bg-sky-100 text-sky-800 dark:bg-sky-900/40 dark:text-sky-300', + 'Modern': 'bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300', + 'Dark': 'bg-zinc-800 text-zinc-100 dark:bg-zinc-900 dark:text-zinc-300', + 'Minimal': 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300', + 'Hillshading': 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300', + 'Terrain': 'bg-lime-100 text-lime-800 dark:bg-lime-900/40 dark:text-lime-300', + 'Realistic': 'bg-teal-100 text-teal-800 dark:bg-teal-900/40 dark:text-teal-300', + 'Navigation': 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300', + 'Classic': 'bg-stone-100 text-stone-700 dark:bg-stone-800 dark:text-stone-300', + 'Hybrid': 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900/40 dark:text-cyan-300', + 'No labels': 'bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300', +} + +function TagChip({ tag }: { tag: string }) { + const cls = TAG_STYLES[tag] || 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300' + return ( + + {tag} + + ) +} + +function StyleDropdown({ value, onChange }: { value: string; onChange: (v: string) => void }) { + const [open, setOpen] = useState(false) + const ref = useRef(null) + + useEffect(() => { + if (!open) return + const onDoc = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) + } + document.addEventListener('mousedown', onDoc) + return () => document.removeEventListener('mousedown', onDoc) + }, [open]) + + const selected = MAPBOX_STYLE_PRESETS.find(p => p.url === value) + + return ( +
+ + {open && ( +
+ {MAPBOX_STYLE_PRESETS.map(preset => { + const isActive = preset.url === value + return ( + + ) + })} +
+ )} +
+ ) +} + +type Provider = 'leaflet' | 'mapbox-gl' + export default function MapSettingsTab(): React.ReactElement { const { settings, updateSettings } = useSettingsStore() const { t } = useTranslation() const toast = useToast() const [saving, setSaving] = useState(false) + const [provider, setProvider] = useState((settings.map_provider as Provider) || 'leaflet') const [mapTileUrl, setMapTileUrl] = useState(settings.map_tile_url || '') + const [mapboxToken, setMapboxToken] = useState(settings.mapbox_access_token || '') + const [mapboxStyle, setMapboxStyle] = useState(settings.mapbox_style || 'mapbox://styles/mapbox/standard') + const [mapbox3d, setMapbox3d] = useState(settings.mapbox_3d_enabled !== false) + const [mapboxQuality, setMapboxQuality] = useState(settings.mapbox_quality_mode === true) const [defaultLat, setDefaultLat] = useState(settings.default_lat || 48.8566) const [defaultLng, setDefaultLng] = useState(settings.default_lng || 2.3522) const [defaultZoom, setDefaultZoom] = useState(settings.default_zoom || 10) useEffect(() => { + setProvider((settings.map_provider as Provider) || 'leaflet') setMapTileUrl(settings.map_tile_url || '') + setMapboxToken(settings.mapbox_access_token || '') + setMapboxStyle(settings.mapbox_style || 'mapbox://styles/mapbox/standard') + setMapbox3d(settings.mapbox_3d_enabled !== false) + setMapboxQuality(settings.mapbox_quality_mode === true) setDefaultLat(settings.default_lat || 48.8566) setDefaultLng(settings.default_lng || 2.3522) setDefaultZoom(settings.default_zoom || 10) @@ -67,7 +187,12 @@ export default function MapSettingsTab(): React.ReactElement { setSaving(true) try { await updateSettings({ + map_provider: provider, map_tile_url: mapTileUrl, + mapbox_access_token: mapboxToken, + mapbox_style: mapboxStyle, + mapbox_3d_enabled: mapbox3d, + mapbox_quality_mode: mapboxQuality, default_lat: parseFloat(String(defaultLat)), default_lng: parseFloat(String(defaultLng)), default_zoom: parseInt(String(defaultZoom)), @@ -80,28 +205,155 @@ export default function MapSettingsTab(): React.ReactElement { } } + // 3D is available on every style now — pure satellite uses the + // mapbox-streets-v8 tileset as a fallback building source. + const supports3d = true + return (
+ {/* Provider picker — big cards so the choice is obvious */}
- - { if (value) setMapTileUrl(value) }} - placeholder={t('settings.mapTemplatePlaceholder.select')} - options={MAP_PRESETS.map(p => ({ value: p.url, label: p.name }))} - size="sm" - style={{ marginBottom: 8 }} - /> - ) => setMapTileUrl(e.target.value)} - placeholder="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" - className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" - /> -

{t('settings.mapDefaultHint')}

+ +
+ + +
+

+ Affects Trip Planner and Journey maps. Atlas always uses Leaflet. +

+ {/* Leaflet settings */} + {provider === 'leaflet' && ( +
+ + { if (value) setMapTileUrl(value) }} + placeholder={t('settings.mapTemplatePlaceholder.select')} + options={MAP_PRESETS.map(p => ({ value: p.url, label: p.name }))} + size="sm" + style={{ marginBottom: 8 }} + /> + ) => setMapTileUrl(e.target.value)} + placeholder="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" + className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" + /> +

{t('settings.mapDefaultHint')}

+
+ )} + + {/* Mapbox GL settings */} + {provider === 'mapbox-gl' && ( +
+
+ + setMapboxToken(e.target.value)} + placeholder="pk.eyJ1Ijoi..." + className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm font-mono focus:ring-2 focus:ring-slate-400 focus:border-transparent" + /> +

+ Public token (pk.*) from{' '} + + mapbox.com → Access tokens + +

+
+ +
+ +
+ +
+ setMapboxStyle(e.target.value)} + placeholder="mapbox://styles/mapbox/standard" + className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm font-mono focus:ring-2 focus:ring-slate-400 focus:border-transparent" + /> +

+ Preset or your own mapbox://styles/USER/ID URL +

+
+ +
+
+
3D Buildings & Terrain
+
+ Pitch + real 3D building extrusions — works on every style, including satellite. +
+
+ { if (supports3d) setMapbox3d(!mapbox3d) }} + /> +
+ +
+
+
+ High Quality Mode + + Experimental + +
+
+ Antialiasing + globe projection for sharper edges and a realistic world view.{' '} + May impact performance on lower-end devices. +
+
+ setMapboxQuality(!mapboxQuality)} /> +
+ +
+ Tip: right-click and drag to rotate/pitch the map. Middle-click to add a place (right-click is reserved for rotation). +
+
+ )} + + {/* Default map position — applies regardless of provider */}
@@ -109,7 +361,7 @@ export default function MapSettingsTab(): React.ReactElement { type="number" step="any" value={defaultLat} - onChange={(e: React.ChangeEvent) => setDefaultLat(e.target.value)} + onChange={(e) => setDefaultLat(e.target.value)} className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" />
@@ -119,7 +371,7 @@ export default function MapSettingsTab(): React.ReactElement { type="number" step="any" value={defaultLng} - onChange={(e: React.ChangeEvent) => setDefaultLng(e.target.value)} + onChange={(e) => setDefaultLng(e.target.value)} className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" />
@@ -127,25 +379,40 @@ export default function MapSettingsTab(): React.ReactElement {
- {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} - {React.createElement(MapView as any, { - places: mapPlaces, - dayPlaces: [], - route: null, - routeSegments: null, - selectedPlaceId: null, - onMarkerClick: null, - onMapClick: handleMapClick, - onMapContextMenu: null, - center: [settings.default_lat, settings.default_lng], - zoom: defaultZoom, - tileUrl: mapTileUrl, - fitKey: null, - dayOrderMap: [], - leftWidth: 0, - rightWidth: 0, - hasInspector: false, - })} + {provider === 'mapbox-gl' ? ( + { setDefaultLat(ll.lat); setDefaultLng(ll.lng) }} + /> + ) : ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + React.createElement(MapView as any, { + places: mapPlaces, + dayPlaces: [], + route: null, + routeSegments: null, + selectedPlaceId: null, + onMarkerClick: null, + onMapClick: handleMapClick, + onMapContextMenu: null, + center: [settings.default_lat, settings.default_lng], + zoom: defaultZoom, + tileUrl: mapTileUrl, + fitKey: null, + dayOrderMap: [], + leftWidth: 0, + rightWidth: 0, + hasInspector: false, + }) + )}
diff --git a/client/src/components/Settings/MapboxPreview.tsx b/client/src/components/Settings/MapboxPreview.tsx new file mode 100644 index 00000000..04ca864a --- /dev/null +++ b/client/src/components/Settings/MapboxPreview.tsx @@ -0,0 +1,77 @@ +import { useEffect, useRef } from 'react' +import mapboxgl from 'mapbox-gl' +import 'mapbox-gl/dist/mapbox-gl.css' +import { isStandardFamily, supportsCustom3d, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup' + +interface Props { + token: string + style: string + lat: number + lng: number + zoom: number + enable3d: boolean + quality?: boolean + onClick?: (latlng: { lat: number; lng: number }) => void +} + +export default function MapboxPreview({ token, style, lat, lng, zoom, enable3d, quality = false, onClick }: Props) { + const containerRef = useRef(null) + const mapRef = useRef(null) + const onClickRef = useRef(onClick) + onClickRef.current = onClick + + useEffect(() => { + if (!containerRef.current || !token) return + mapboxgl.accessToken = token + + const map = new mapboxgl.Map({ + container: containerRef.current, + style, + center: [lng, lat], + zoom, + pitch: enable3d ? 45 : 0, + attributionControl: true, + antialias: quality, + projection: quality ? 'globe' : 'mercator', + }) + mapRef.current = map + + map.on('load', () => { + if (enable3d) { + if (!isStandardFamily(style)) addTerrainAndSky(map) + if (supportsCustom3d(style)) { + const dark = document.documentElement.classList.contains('dark') + addCustom3dBuildings(map, dark) + } + } + if (style === 'mapbox://styles/mapbox/standard') { + try { map.setTerrain(null) } catch { /* noop */ } + } + }) + + map.on('click', (e) => { + onClickRef.current?.({ lat: e.lngLat.lat, lng: e.lngLat.lng }) + }) + + return () => { + try { map.remove() } catch { /* noop */ } + mapRef.current = null + } + }, [token, style, enable3d, quality]) + + // Recenter without rebuilding the map when lat/lng/zoom change externally + useEffect(() => { + if (!mapRef.current) return + try { mapRef.current.jumpTo({ center: [lng, lat], zoom }) } catch { /* noop */ } + }, [lat, lng, zoom]) + + if (!token) { + return ( +
+ Enter a Mapbox access token to preview +
+ ) + } + + return
+} diff --git a/client/src/hooks/useGeolocation.ts b/client/src/hooks/useGeolocation.ts new file mode 100644 index 00000000..70557d9e --- /dev/null +++ b/client/src/hooks/useGeolocation.ts @@ -0,0 +1,171 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +// Permission-gated orientation listener with iOS support. iOS 13+ requires +// an explicit user gesture to request permission, so the caller triggers +// this from the "enable location" button click. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type DeviceOrientationEventIOS = typeof DeviceOrientationEvent & { requestPermission?: () => Promise<'granted' | 'denied'> } + +export interface GeoPosition { + lat: number + lng: number + accuracy: number // meters + heading: number | null // 0-360°, null when unavailable (stationary, indoor, no sensor) + speed: number | null + timestamp: number +} + +export type TrackingMode = 'off' | 'show' | 'follow' + +export interface UseGeolocationReturn { + position: GeoPosition | null + mode: TrackingMode + error: string | null + /** Toggle through off → show → follow → off. Also triggers iOS orientation permission on first call. */ + cycleMode: () => Promise + /** Force-set mode. Accepts a function for derived updates like `prev => prev === 'follow' ? 'show' : prev`. */ + setMode: (m: TrackingMode | ((prev: TrackingMode) => TrackingMode)) => void +} + +// Keep a tiny EMA on heading so the compass cone doesn't jitter on every +// device orientation event. Mobile sensors fire at 60Hz and raw readings +// swing ±5° even when the phone is still — smoothing to ~0.25 weight +// gives a stable-but-responsive needle. +function smoothAngle(prev: number | null, next: number, alpha = 0.25): number { + if (prev === null) return next + // Take the shortest angular distance so we don't lerp the long way around + let delta = next - prev + if (delta > 180) delta -= 360 + if (delta < -180) delta += 360 + return (prev + delta * alpha + 360) % 360 +} + +export function useGeolocation(): UseGeolocationReturn { + const [position, setPosition] = useState(null) + const [mode, setModeState] = useState('off') + const [error, setError] = useState(null) + const watchIdRef = useRef(null) + const orientationHandlerRef = useRef<((e: DeviceOrientationEvent) => void) | null>(null) + const headingRef = useRef(null) + + const stopWatch = useCallback(() => { + if (watchIdRef.current !== null) { + try { navigator.geolocation.clearWatch(watchIdRef.current) } catch { /* noop */ } + watchIdRef.current = null + } + if (orientationHandlerRef.current) { + window.removeEventListener('deviceorientationabsolute', orientationHandlerRef.current as EventListener) + window.removeEventListener('deviceorientation', orientationHandlerRef.current as EventListener) + orientationHandlerRef.current = null + } + headingRef.current = null + }, []) + + const startWatch = useCallback(async () => { + if (!('geolocation' in navigator)) { + setError('Geolocation is not supported in this browser') + return false + } + setError(null) + + // iOS: ask for orientation permission up front; on Android and desktop + // no prompt is needed and the method is undefined. + const DOE = (window.DeviceOrientationEvent || {}) as DeviceOrientationEventIOS + if (typeof DOE.requestPermission === 'function') { + try { + const res = await DOE.requestPermission() + if (res !== 'granted') { + // Permission denied — we still enable location, just no heading cone. + } + } catch { /* older webkit throws — ignore and proceed */ } + } + + // Device orientation → compass heading. `alpha` is rotation around the + // Z-axis (0 = facing magnetic north on most devices). The webkit-only + // `webkitCompassHeading` is already geographic north + clockwise, so + // prefer it when available. + const onOrientation = (e: DeviceOrientationEvent) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const ev = e as any + let heading: number | null = null + if (typeof ev.webkitCompassHeading === 'number') { + heading = ev.webkitCompassHeading + } else if (e.absolute && typeof e.alpha === 'number') { + // alpha is CCW from North; convert to CW heading + heading = (360 - e.alpha) % 360 + } else if (typeof e.alpha === 'number') { + // Non-absolute orientation: better than nothing but drifts over time + heading = (360 - e.alpha) % 360 + } + if (heading === null || Number.isNaN(heading)) return + headingRef.current = smoothAngle(headingRef.current, heading) + // Merge into position without triggering a refetch + setPosition(p => p ? { ...p, heading: headingRef.current } : p) + } + orientationHandlerRef.current = onOrientation + // Prefer "absolute" which is tied to magnetic north; fall back to plain. + window.addEventListener('deviceorientationabsolute', onOrientation as EventListener) + window.addEventListener('deviceorientation', onOrientation as EventListener) + + watchIdRef.current = navigator.geolocation.watchPosition( + (pos) => { + setPosition({ + lat: pos.coords.latitude, + lng: pos.coords.longitude, + accuracy: pos.coords.accuracy, + // GPS heading is reliable when moving; keep compass reading + // otherwise so the arrow still points correctly when stationary. + heading: pos.coords.heading ?? headingRef.current, + speed: pos.coords.speed ?? null, + timestamp: pos.timestamp, + }) + }, + (err) => { + setError(err.message || 'Location unavailable') + // Stay subscribed so a later fix can still recover (e.g. GPS + // lock takes a while indoors). Only fully stop on permission denial. + if (err.code === err.PERMISSION_DENIED) { + stopWatch() + setModeState('off') + } + }, + { + enableHighAccuracy: true, + maximumAge: 2000, + timeout: 15000, + } + ) + return true + }, [stopWatch]) + + const setMode = useCallback((m: TrackingMode | ((prev: TrackingMode) => TrackingMode)) => { + setModeState(prev => { + const next = typeof m === 'function' ? m(prev) : m + if (next === 'off') { + stopWatch() + setPosition(null) + } else if (watchIdRef.current === null) { + // started externally but no watch yet — start it + startWatch() + } + return next + }) + }, [startWatch, stopWatch]) + + const cycleMode = useCallback(async () => { + if (mode === 'off') { + const ok = await startWatch() + if (ok) setModeState('show') + } else if (mode === 'show') { + setModeState('follow') + } else { + setModeState('off') + stopWatch() + setPosition(null) + } + }, [mode, startWatch, stopWatch]) + + useEffect(() => stopWatch, [stopWatch]) + + return { position, mode, error, cycleMode, setMode } +} diff --git a/client/src/index.css b/client/src/index.css index 6a860131..4332ffdc 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -6,6 +6,11 @@ html { height: 100%; overflow: hidden; background-color: var(--bg-primary); } body { height: 100%; overflow: auto; overscroll-behavior: none; -webkit-overflow-scrolling: touch; } +/* Journey desktop feed: hide scrollbar (right column is a sticky map, a + visible scrollbar on the left breaks the polarsteps-style reading feel). */ +.journey-feed-scroll { scrollbar-width: none; -ms-overflow-style: none; } +.journey-feed-scroll::-webkit-scrollbar { display: none; } + /* Leaflet Popups — Enter-Animation vom Anchor-Tip */ .leaflet-popup { animation: trek-popover-enter 220ms cubic-bezier(0.23, 1, 0.32, 1); @@ -447,6 +452,7 @@ input[type="number"], input[type="time"], input[type="date"], input[type="dateti --bg-card: #ffffff; --bg-input: #ffffff; --bg-hover: rgba(0,0,0,0.03); + --bg-selected: #e2e8f0; --text-primary: #111827; --text-secondary: #374151; --text-muted: #6b7280; @@ -494,6 +500,7 @@ input[type="number"], input[type="time"], input[type="date"], input[type="dateti --bg-card: #131316; --bg-input: #1c1c21; --bg-hover: rgba(255,255,255,0.06); + --bg-selected: rgba(255,255,255,0.1); --text-primary: #f4f4f5; --text-secondary: #d4d4d8; --text-muted: #a1a1aa; diff --git a/client/src/pages/JourneyDetailPage.tsx b/client/src/pages/JourneyDetailPage.tsx index 98717a22..59e8598d 100644 --- a/client/src/pages/JourneyDetailPage.tsx +++ b/client/src/pages/JourneyDetailPage.tsx @@ -1,4 +1,5 @@ import { useEffect, useState, useRef, useCallback, useMemo } from 'react' +import { createPortal } from 'react-dom' import { useParams, useNavigate } from 'react-router-dom' import { useJourneyStore } from '../store/journeyStore' import { useAuthStore } from '../store/authStore' @@ -6,8 +7,8 @@ import { useTranslation } from '../i18n' import { journeyApi, authApi, addonsApi, mapsApi } from '../api/client' import { addListener, removeListener } from '../api/websocket' import Navbar from '../components/Layout/Navbar' -import JourneyMap from '../components/Journey/JourneyMap' -import type { JourneyMapHandle } from '../components/Journey/JourneyMap' +import JourneyMap from '../components/Journey/JourneyMapAuto' +import type { JourneyMapAutoHandle as JourneyMapHandle } from '../components/Journey/JourneyMapAuto' import JournalBody from '../components/Journey/JournalBody' import MarkdownToolbar from '../components/Journey/MarkdownToolbar' import PhotoLightbox from '../components/Journey/PhotoLightbox' @@ -18,7 +19,7 @@ import { Clock, Package, Image, ChevronRight, UserPlus, Plus, Minus, Calendar, Camera, BookOpen, X, Check, ImagePlus, Trash2, Pencil, Laugh, Smile, Meh, Annoyed, Frown, - Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake, ChevronDown, Eye, EyeOff, + Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake, ChevronUp, ChevronDown, Eye, EyeOff, Archive, ArchiveRestore, } from 'lucide-react' import MobileMapTimeline from '../components/Journey/MobileMapTimeline' @@ -84,7 +85,7 @@ export default function JourneyDetailPage() { const navigate = useNavigate() const toast = useToast() const { t } = useTranslation() - const { current, loading, notFound, loadJourney, updateEntry, deleteEntry, uploadPhotos, deletePhoto } = useJourneyStore() + const { current, loading, notFound, loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos, deletePhoto } = useJourneyStore() const mapRef = useRef(null) const fullMapRef = useRef(null) const [activeLocationId, setActiveLocationId] = useState(null) @@ -96,7 +97,9 @@ export default function JourneyDetailPage() { const myRole = (current as any)?.my_role ?? 'owner' const canEditEntries = myRole === 'owner' || myRole === 'editor' const canEditJourney = myRole === 'owner' - const [view, setView] = useState<'timeline' | 'gallery' | 'map'>('timeline') + const [view, setView] = useState<'timeline' | 'gallery'>('timeline') + const [activeEntryId, setActiveEntryId] = useState(null) + const feedRef = useRef(null) const [viewingEntry, setViewingEntry] = useState(null) const [editingEntry, setEditingEntry] = useState(null) const [lightbox, setLightbox] = useState<{ photos: { id: number; src: string; caption?: string | null; provider?: string; asset_id?: string | null; owner_id?: number | null }[]; index: number } | null>(null) @@ -137,34 +140,109 @@ export default function JourneyDetailPage() { return () => removeListener(handler) }, [id]) - // scroll sync with map - const observerRef = useRef(null) - const setupObserver = useCallback(() => { - observerRef.current?.disconnect() - observerRef.current = new IntersectionObserver((entries) => { - for (const e of entries) { - if (e.isIntersecting) { - const entryId = e.target.getAttribute('data-entry-id') - if (entryId) mapRef.current?.highlightMarker(entryId) - } - } - }, { threshold: 0.5 }) + // scroll sync with map — the sticky map on the right follows whichever + // entry the user is currently reading in the feed on the left. We use + // scroll position (not IntersectionObserver) because short text-only + // entries pass through any IO band too quickly to reliably register. + const rafRef = useRef(null) + const scrollCleanupRef = useRef<(() => void) | null>(null) + // Suppress scroll-sync updates while a programmatic smooth-scroll is + // running (triggered by a marker click). The scroll-progress reference + // line doesn't align with `scrollIntoView({ block: 'center' })`, so the + // sync would otherwise pick random entries as the scroll animates past + // them and end up nowhere near the clicked marker. + const suppressScrollSyncRef = useRef(false) + const suppressTimerRef = useRef(null) + const setupScrollSync = useCallback(() => { + scrollCleanupRef.current?.() + const feed = feedRef.current + if (!feed) return - document.querySelectorAll('[data-entry-id]').forEach(el => { - observerRef.current?.observe(el) - }) + const commitWinner = () => { + if (suppressScrollSyncRef.current) return + const nodes = document.querySelectorAll('[data-entry-id]') + if (nodes.length === 0) return + const feedRect = feed.getBoundingClientRect() + // Reference line tracks scroll progress — at the top of the feed + // it sits at the top edge; at the bottom it sits at the bottom + // edge. This keeps every entry passing through the line exactly + // once even when they're too short to cross a static line before + // the feed runs out of scroll. + const maxScroll = feed.scrollHeight - feed.clientHeight + const progress = maxScroll > 0 ? feed.scrollTop / maxScroll : 0 + const referenceY = feedRect.top + feedRect.height * progress + let lastPast: { id: string; top: number } | null = null + let firstAhead: { id: string; top: number } | null = null + nodes.forEach(el => { + const entryId = el.getAttribute('data-entry-id') + if (!entryId) return + const top = el.getBoundingClientRect().top + if (top <= referenceY) { + if (!lastPast || top > lastPast.top) lastPast = { id: entryId, top } + } else { + if (!firstAhead || top < firstAhead.top) firstAhead = { id: entryId, top } + } + }) + const winner = lastPast || firstAhead + if (winner) { + setActiveEntryId(winner.id) + mapRef.current?.highlightMarker(winner.id) + } + } + const onScroll = () => { + if (rafRef.current != null) return + rafRef.current = window.requestAnimationFrame(() => { + rafRef.current = null + commitWinner() + }) + } + + feed.addEventListener('scroll', onScroll, { passive: true }) + window.addEventListener('scroll', onScroll, { passive: true }) + // prime once so the map syncs on initial load + commitWinner() + scrollCleanupRef.current = () => { + feed.removeEventListener('scroll', onScroll) + window.removeEventListener('scroll', onScroll) + if (rafRef.current != null) { + window.cancelAnimationFrame(rafRef.current) + rafRef.current = null + } + } }, []) useEffect(() => { if (current?.entries?.length) { - setTimeout(setupObserver, 300) + const t = window.setTimeout(setupScrollSync, 300) + return () => { + window.clearTimeout(t) + scrollCleanupRef.current?.() + } } - return () => observerRef.current?.disconnect() - }, [current?.entries, setupObserver]) + return () => scrollCleanupRef.current?.() + }, [current?.entries, setupScrollSync]) const handleMarkerClick = useCallback((entryId: string) => { const el = document.querySelector(`[data-entry-id="${entryId}"]`) - if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' }) + if (!el) return + // Commit the choice immediately so the highlighted marker stays pinned + // to the clicked entry even while smooth-scroll passes over others. + suppressScrollSyncRef.current = true + setActiveEntryId(entryId) + mapRef.current?.highlightMarker(entryId) + el.scrollIntoView({ behavior: 'smooth', block: 'center' }) + if (suppressTimerRef.current != null) window.clearTimeout(suppressTimerRef.current) + // Smooth scroll typically finishes within ~500ms; 750ms gives a safety + // buffer so the sync doesn't snap back to the wrong entry on the very + // last frame. + suppressTimerRef.current = window.setTimeout(() => { + suppressScrollSyncRef.current = false + suppressTimerRef.current = null + }, 750) + }, []) + + useEffect(() => () => { + if (suppressTimerRef.current != null) window.clearTimeout(suppressTimerRef.current) }, []) const handleLocationClick = useCallback((id: string) => { @@ -172,13 +250,30 @@ export default function JourneyDetailPage() { }, []) useEffect(() => { - if (view === 'map') { - requestAnimationFrame(() => fullMapRef.current?.invalidateSize()) - } + // give the sidebar map a chance to recalc its size when the view switches + // (feed column width can shift slightly if the gallery vs timeline + // renders with a different scrollbar state). + requestAnimationFrame(() => mapRef.current?.invalidateSize()) }, [view]) + // On desktop we run a two-pane layout where only the feed column scrolls; + // the body must not scroll underneath it. Restore on unmount. + useEffect(() => { + if (isMobile) return + const prev = document.body.style.overflow + document.body.style.overflow = 'hidden' + return () => { document.body.style.overflow = prev } + }, [isMobile]) + + // Map only shows real journal entries — skeletons are trip-derived + // suggestions, not something the user actually journaled at that spot. const mapEntries = useMemo( - () => (current?.entries || []).filter(e => e.location_lat && e.location_lng), + () => (current?.entries || []).filter(e => + e.location_lat && e.location_lng && + e.title !== 'Gallery' && + e.title !== '[Trip Photos]' && + e.type !== 'skeleton' + ), [current?.entries] ) @@ -187,6 +282,7 @@ export default function JourneyDetailPage() { lat: e.location_lat!, lng: e.location_lng!, title: e.title || '', + location_name: e.location_name || '', mood: e.mood, created_at: e.entry_date, entry_date: e.entry_date, @@ -320,13 +416,24 @@ export default function JourneyDetailPage() { )}
-
- - {/* Back link — desktop */} - +
+ {/* LEFT column (full width on mobile, scrollable feed on desktop) */} +
+
{/* Hero card — hidden on mobile gallery/journey views (floating top bar handles branding there) */}
@@ -340,38 +447,28 @@ export default function JourneyDetailPage() {
- {/* Desktop: badges */} -
- {lifecycle === 'live' && ( -
- - {t('journey.frontpage.live')} -
- )} - {lifecycle !== 'archived' && current.trips.length > 0 && ( -
- - {t('journey.detail.syncedWithTrips')} -
- )} - {lifecycle !== 'live' && lifecycle !== 'archived' && ( -
- {t(`journey.status.${lifecycle === 'upcoming' ? 'upcoming' : lifecycle === 'draft' ? 'draft' : 'completed'}`)} -
- )} - {lifecycle === 'archived' && ( -
- {t('journey.status.archived')} -
- )} +
+ + {/* Status badge — keep completed/upcoming/draft/archived, but drop live + synced-with-trips per UX trim */} +
+ {lifecycle !== 'live' && lifecycle !== 'archived' && ( +
+ {t(`journey.status.${lifecycle === 'upcoming' ? 'upcoming' : lifecycle === 'draft' ? 'draft' : 'completed'}`)} +
+ )} + {lifecycle === 'archived' && ( +
+ {t('journey.status.archived')} +
+ )} +
- {/* Mobile: back button on the left */} -
@@ -418,10 +515,10 @@ export default function JourneyDetailPage() {
- {/* Main grid */} -
- - {/* Left column */} + {/* Main content (was a 2-col grid with right-sidebar panels; + now single column inside the left feed — right pane is a + sticky fullscreen map further below). */} +
{/* View Controls — hidden on mobile (floating top bar has them) */}
@@ -434,7 +531,6 @@ export default function JourneyDetailPage() { : [ { id: 'timeline' as const, icon: List, label: t('journey.share.timeline') }, { id: 'gallery' as const, icon: Grid, label: t('journey.share.gallery') }, - { id: 'map' as const, icon: MapPin, label: t('journey.share.map') }, ] ).map(v => ( + +
+ )} +
+ {entry.type === 'skeleton' ? ( + setEditingEntry(entry) : undefined} /> + ) : entry.type === 'checkin' ? ( + setEditingEntry(entry) : undefined} /> + ) : ( + setEditingEntry(entry)} + onDelete={() => setDeleteTarget(entry)} + onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id })), index: idx })} + /> + )} +
+
+ ) + })}
) })} @@ -536,126 +669,29 @@ export default function JourneyDetailPage() { />
- {/* Full Map View (desktop only — mobile uses combined view) */} - {!isMobile && ( -
- -
- )}
- {/* Right sidebar — hidden on mobile */} -
- {/* Map panel */} -
+
+
+
+ + {/* RIGHT column on desktop — sticky rounded map (polarsteps-style). + Hidden on mobile; mobile gets its own chromeless combined view. */} + {!isMobile && ( +
+ + )}
@@ -1335,14 +1371,15 @@ function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: { - {menuOpen && ( + {menuOpen && createPortal( <>
setMenuOpen(false)} />
- + , + document.body, )}
)} @@ -1374,14 +1411,15 @@ function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: { - {menuOpen && ( + {menuOpen && createPortal( <>
setMenuOpen(false)} />
- + , + document.body, )}
)} @@ -2126,6 +2164,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa onDone: () => void }) { const { t } = useTranslation() + const isMobile = useIsMobile() const [title, setTitle] = useState(entry.title || '') const [story, setStory] = useState(entry.story || '') const [entryDate, setEntryDate] = useState(entry.entry_date || new Date().toISOString().split('T')[0]) @@ -2219,8 +2258,15 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa } return ( -
-
+
+ {/* The modal itself is constrained to the feed column on desktop so it + centers there — but the backdrop stays full-width (covering the map + too) for a uniform dim/blur across the whole page. */} +
+
@@ -2563,6 +2609,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
+
) } diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index ca7a9088..fe38e85b 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -4,7 +4,7 @@ import { useParams, useNavigate } from 'react-router-dom' import { useTripStore } from '../store/tripStore' import { useCanDo } from '../store/permissionsStore' import { useSettingsStore } from '../store/settingsStore' -import { MapView } from '../components/Map/MapView' +import { MapViewAuto as MapView } from '../components/Map/MapViewAuto' import { getCached, fetchPhoto } from '../services/photoService' import DayPlanSidebar from '../components/Planner/DayPlanSidebar' import PlacesSidebar from '../components/Planner/PlacesSidebar' @@ -413,10 +413,39 @@ export default function TripPlannerPage(): React.ReactElement | null { }, [selectAssignment, setSelectedPlaceId]) const handleMarkerClick = useCallback((placeId) => { - const opening = placeId !== undefined - setSelectedPlaceId(prev => prev === placeId ? null : placeId) - if (opening) { setLeftCollapsed(false); setRightCollapsed(false) } - }, []) + if (placeId === undefined) { + setSelectedPlaceId(null) + return + } + // Find every assignment for this place (same place can sit on several + // days / be planned twice in one day). Cycle through them on repeated + // marker clicks so the sidebar highlight jumps to the next occurrence + // instead of leaving the user confused. + const allAssignments = Object.values(useTripStore.getState().assignments || {}).flat() + const matching = allAssignments.filter(a => a?.place?.id === placeId) + + if (matching.length === 0) { + setSelectedPlaceId(prev => prev === placeId ? null : placeId) + } else if (matching.length === 1) { + const only = matching[0] + if (selectedAssignmentId === only.id) { + setSelectedPlaceId(null) + } else { + selectAssignment(only.id, placeId) + } + } else { + const currentIdx = matching.findIndex(a => a.id === selectedAssignmentId) + const nextIdx = currentIdx === -1 ? 0 : currentIdx + 1 + if (nextIdx >= matching.length) { + // cycled past the last occurrence — clear selection so the next + // click starts fresh at occurrence 0. + setSelectedPlaceId(null) + } else { + selectAssignment(matching[nextIdx].id, placeId) + } + } + setLeftCollapsed(false); setRightCollapsed(false) + }, [selectAssignment, selectedAssignmentId, setSelectedPlaceId]) const handleMapClick = useCallback(() => { setSelectedPlaceId(null) diff --git a/client/src/store/journeyStore.ts b/client/src/store/journeyStore.ts index 61b12665..305d9511 100644 --- a/client/src/store/journeyStore.ts +++ b/client/src/store/journeyStore.ts @@ -100,6 +100,7 @@ interface JourneyState { createEntry: (journeyId: number, data: Record) => Promise updateEntry: (entryId: number, data: Record) => Promise deleteEntry: (entryId: number) => Promise + reorderEntries: (journeyId: number, orderedIds: number[]) => Promise uploadPhotos: (entryId: number, formData: FormData) => Promise deletePhoto: (photoId: number) => Promise @@ -187,6 +188,35 @@ export const useJourneyStore = create((set, get) => ({ }) }, + reorderEntries: async (journeyId, orderedIds) => { + // Optimistic: push the new sort_order and re-sort locally so the UI + // updates immediately. Server mirrors the same ordering. On failure we + // reload the journey to recover the authoritative state. + const prev = get().current + set(s => { + if (!s.current || s.current.id !== journeyId) return s + const sortMap = new Map(orderedIds.map((id, idx) => [id, idx])) + const entries = s.current.entries.map(e => + sortMap.has(e.id) ? { ...e, sort_order: sortMap.get(e.id)! } : e + ) + entries.sort((a, b) => { + if (a.entry_date !== b.entry_date) return a.entry_date.localeCompare(b.entry_date) + const atime = a.entry_time || '' + const btime = b.entry_time || '' + if (atime !== btime) return atime.localeCompare(btime) + return (a.sort_order || 0) - (b.sort_order || 0) + }) + return { current: { ...s.current, entries } } + }) + try { + await journeyApi.reorderEntries(journeyId, orderedIds) + } catch (err) { + // Roll back to last-known-good state. + if (prev && prev.id === journeyId) set({ current: prev }) + throw err + } + }, + uploadPhotos: async (entryId, formData) => { const data = await journeyApi.uploadPhotos(entryId, formData) const photos = data.photos || [] diff --git a/client/src/store/settingsStore.ts b/client/src/store/settingsStore.ts index 2c0e62ce..2ed81e59 100644 --- a/client/src/store/settingsStore.ts +++ b/client/src/store/settingsStore.ts @@ -32,6 +32,11 @@ export const useSettingsStore = create((set, get) => ({ temperature_unit: 'fahrenheit', time_format: '12h', show_place_description: false, + map_provider: 'leaflet', + mapbox_access_token: '', + mapbox_style: 'mapbox://styles/mapbox/standard', + mapbox_3d_enabled: true, + mapbox_quality_mode: false, }, isLoaded: false, diff --git a/client/src/types.ts b/client/src/types.ts index 3e352f6d..69a06189 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -214,6 +214,11 @@ export interface Settings { route_calculation?: boolean blur_booking_codes?: boolean map_booking_labels?: boolean + map_provider?: 'leaflet' | 'mapbox-gl' + mapbox_access_token?: string + mapbox_style?: string + mapbox_3d_enabled?: boolean + mapbox_quality_mode?: boolean } export interface AssignmentsMap { diff --git a/server/src/routes/journey.ts b/server/src/routes/journey.ts index 73682878..6433ff45 100644 --- a/server/src/routes/journey.ts +++ b/server/src/routes/journey.ts @@ -257,6 +257,18 @@ router.post('/:id/entries', authenticate, (req: Request, res: Response) => { res.status(201).json(entry); }); +router.put('/:id/entries/reorder', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const orderedIds = (req.body || {}).orderedIds; + if (!Array.isArray(orderedIds) || !orderedIds.every(id => Number.isFinite(Number(id)))) { + return res.status(400).json({ error: 'orderedIds must be an array of numbers' }); + } + if (!svc.reorderEntries(Number(req.params.id), authReq.user.id, orderedIds.map(Number), req.headers['x-socket-id'] as string)) { + return res.status(403).json({ error: 'Not allowed' }); + } + res.json({ success: true }); +}); + // ── Contributors ───────────────────────────────────────────────────────── router.post('/:id/contributors', authenticate, (req: Request, res: Response) => { diff --git a/server/src/services/journeyService.ts b/server/src/services/journeyService.ts index f6763560..65745429 100644 --- a/server/src/services/journeyService.ts +++ b/server/src/services/journeyService.ts @@ -598,6 +598,31 @@ export function updateEntry(entryId: number, userId: number, data: Partial<{ return updated; } +// Reorder entries (typically within a single day). Caller passes the new +// desired order of ids; each entry's sort_order is set to its index in the +// array. Only entries owned by this journey are accepted. +export function reorderEntries(journeyId: number, userId: number, orderedIds: number[], sid?: string): boolean { + if (!canEdit(journeyId, userId)) return false; + if (!orderedIds.length) return true; + + const placeholders = orderedIds.map(() => '?').join(','); + const rows = db + .prepare(`SELECT id FROM journey_entries WHERE id IN (${placeholders}) AND journey_id = ?`) + .all(...orderedIds, journeyId) as { id: number }[]; + if (rows.length !== orderedIds.length) return false; + + const now = ts(); + const update = db.prepare('UPDATE journey_entries SET sort_order = ?, updated_at = ? WHERE id = ?'); + const tx = db.transaction(() => { + orderedIds.forEach((id, index) => update.run(index, now, id)); + db.prepare('UPDATE journeys SET updated_at = ? WHERE id = ?').run(now, journeyId); + }); + tx(); + + broadcastJourneyEvent(journeyId, 'journey:entries:reordered', { orderedIds }, sid); + return true; +} + export function deleteEntry(entryId: number, userId: number, sid?: string): boolean { const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined; if (!entry) return false;