diff --git a/.env.sample b/.env.sample index a47d796..1b86051 100644 --- a/.env.sample +++ b/.env.sample @@ -10,7 +10,6 @@ DB_DATABASE_NAME="" # Name of the database SECURE_COOKIE=false # Set to true to enable secure cookies # Optional -GOOGLE_MAPS_API_KEY="" # Google Maps API Key for heatmap GEMINI_API_KEY="" # Gemini API Key for parsing search query in "Find" # Immich Share Link diff --git a/README.md b/README.md index 3fd8e8a..d2cbcd9 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,6 @@ DB_PORT = "5432" DB_HOST = "immich_postgres" # Optional -GOOGLE_MAPS_API_KEY= # Google Maps API Key GEMINI_API_KEY= # Gemini API Key ``` Refer here for obtaining Immich API Key: https://immich.app/docs/features/command-line-interface#obtain-the-api-key diff --git a/bun.lockb b/bun.lockb index f828862..f6133ad 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index ff936fe..c02115e 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,6 @@ "@types/react-autosuggest": "^10.1.11", "@types/react-calendar-heatmap": "^1.6.7", "@types/react-window": "^1.8.8", - "@vis.gl/react-google-maps": "^1.4.2", "axios": "^1.7.4", "chrono-node": "^1.4.9", "class-variance-authority": "^0.7.0", @@ -55,6 +54,7 @@ "jsonwebtoken": "^9.0.2", "leaflet": "^1.9.4", "leaflet-defaulticon-compatibility": "0.1.2", + "leaflet.heat": "^0.2.0", "lucide-react": "^0.428.0", "next": "14.2.5", "next-themes": "^0.3.0", @@ -86,6 +86,7 @@ "@tanstack/eslint-plugin-query": "^5.68.0", "@types/jsonwebtoken": "^9.0.6", "@types/leaflet": "^1.9.15", + "@types/leaflet.heat": "^0.2.4", "@types/node": "^20", "@types/pg": "^8.11.6", "@types/react": "^18", diff --git a/src/components/LeafletHeatMap.tsx b/src/components/LeafletHeatMap.tsx new file mode 100644 index 0000000..c7a40d5 --- /dev/null +++ b/src/components/LeafletHeatMap.tsx @@ -0,0 +1,139 @@ +import React, { useEffect, useRef, useState } from 'react'; +import L from 'leaflet'; +import 'leaflet/dist/leaflet.css'; +import 'leaflet.heat'; +import { getAssetGeoHeatmap, IHeatMapParams } from '@/handlers/api/asset.handler'; +import { Loader2 } from 'lucide-react'; + +// Fix for default markers in Leaflet +import 'leaflet-defaulticon-compatibility/dist/leaflet-defaulticon-compatibility.css'; +import 'leaflet-defaulticon-compatibility'; + +interface LeafletHeatMapProps { + filters: IHeatMapParams; + isDarkMode: boolean; + onLoadingChange?: (loading: boolean) => void; +} + +const LeafletHeatMap: React.FC = ({ filters, isDarkMode, onLoadingChange }) => { + const mapRef = useRef(null); + const mapContainerRef = useRef(null); + const heatLayerRef = useRef(null); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + if (!mapContainerRef.current) return; + + // Initialize the map + const map = L.map(mapContainerRef.current, { + center: [0, 0], + zoom: 2, + zoomControl: true, + scrollWheelZoom: true, + doubleClickZoom: true, + boxZoom: true, + keyboard: true, + dragging: true, + touchZoom: true, + }); + + mapRef.current = map; + + // Add tile layer based on theme + const tileLayer = isDarkMode + ? L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { + attribution: '© OpenStreetMap contributors © CARTO', + subdomains: 'abcd', + maxZoom: 20 + }) + : L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap contributors', + maxZoom: 20 + }); + + tileLayer.addTo(map); + + return () => { + if (mapRef.current) { + mapRef.current.remove(); + mapRef.current = null; + } + }; + }, [isDarkMode]); + + useEffect(() => { + if (!mapRef.current) return; + + // Start loading + setIsLoading(true); + onLoadingChange?.(true); + + // Fetch heatmap data and update the layer + getAssetGeoHeatmap(filters).then((data) => { + // Remove existing heat layer + if (heatLayerRef.current) { + mapRef.current?.removeLayer(heatLayerRef.current); + } + + // Create new heat layer with the data + // Convert [lng, lat] to [lat, lng, intensity] format for leaflet.heat + const heatData: [number, number, number][] = data.map(([lng, lat]: [number, number]) => [ + lat, // latitude first for leaflet.heat + lng, // longitude second + 1 // intensity (weight) + ]); + + if (heatData.length > 0) { + heatLayerRef.current = L.heatLayer(heatData, { + radius: 25, + blur: 15, + maxZoom: 17, + max: 1.0, + minOpacity: 0.5, + }).addTo(mapRef.current!); + + // Fit map bounds to show all heat points + const group = new L.FeatureGroup(); + heatData.forEach(([lat, lng]) => { + L.marker([lat, lng]).addTo(group); + }); + + if (heatData.length === 1) { + // If only one point, center on it with a reasonable zoom level + mapRef.current!.setView([heatData[0][0], heatData[0][1]], 10); + } else { + // Fit bounds with some padding + mapRef.current!.fitBounds(group.getBounds(), { padding: [20, 20] }); + } + } + }).catch((error) => { + console.error('Error loading heatmap data:', error); + }).finally(() => { + // End loading + setIsLoading(false); + onLoadingChange?.(false); + }); + }, [filters, onLoadingChange]); + + return ( +
+
+ + {/* Loading Overlay */} + {isLoading && ( +
+
+ +

Loading heatmap data...

+
+
+ )} +
+ ); +}; + +export default LeafletHeatMap; diff --git a/src/config/environment.ts b/src/config/environment.ts index a903517..380495f 100644 --- a/src/config/environment.ts +++ b/src/config/environment.ts @@ -13,7 +13,6 @@ export const ENV = { SECURE_COOKIE: process.env.SECURE_COOKIE === 'true', VERSION: process.env.VERSION, GEMINI_API_KEY: process.env.GEMINI_API_KEY as string, - GOOGLE_MAPS_API_KEY: process.env.GOOGLE_MAPS_API_KEY as string, IMMICH_SHARE_LINK_KEY: process.env.IMMICH_SHARE_LINK_KEY as string, POWER_TOOLS_ENDPOINT_URL: process.env.POWER_TOOLS_ENDPOINT_URL as string, }; diff --git a/src/contexts/ConfigContext.tsx b/src/contexts/ConfigContext.tsx index cb13231..a633fb0 100644 --- a/src/contexts/ConfigContext.tsx +++ b/src/contexts/ConfigContext.tsx @@ -5,7 +5,6 @@ export interface ConfigContextType { exImmichUrl: string; version?: string; geminiEnabled: boolean; - googleMapsApiKey: string; } const ConfigContext = createContext({ @@ -13,7 +12,6 @@ const ConfigContext = createContext({ exImmichUrl: "", version: "", geminiEnabled: false, - googleMapsApiKey: "", }); export default ConfigContext; diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 4954850..2b8ea0e 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -34,7 +34,6 @@ App.getInitialProps = async () => { immichURL: ENV.IMMICH_URL, version: ENV.VERSION, geminiEnabled: !!ENV.GEMINI_API_KEY?.length, - googleMapsApiKey: ENV.GOOGLE_MAPS_API_KEY, }, }; }; diff --git a/src/pages/assets/geo-heatmap.tsx b/src/pages/assets/geo-heatmap.tsx index 94b376b..3c65ee7 100644 --- a/src/pages/assets/geo-heatmap.tsx +++ b/src/pages/assets/geo-heatmap.tsx @@ -1,126 +1,73 @@ import Header from '@/components/shared/Header' -import { useConfig } from '@/contexts/ConfigContext'; -import React, { useEffect, useMemo, useState } from 'react' -import { useMap, useMapsLibrary } from '@vis.gl/react-google-maps'; -import { APIProvider, Map } from '@vis.gl/react-google-maps'; +import React, { useState } from 'react' import PageLayout from '@/components/layouts/PageLayout'; import { getAssetGeoHeatmap, IHeatMapParams } from '@/handlers/api/asset.handler'; import { useTheme } from 'next-themes'; -import { MapPinX, X } from 'lucide-react'; +import { X } from 'lucide-react'; import AlbumDropdown from '@/components/shared/AlbumDropdown'; import PeopleDropdown from '@/components/shared/PeopleDropdown'; import { Button } from '@/components/ui/button'; +import dynamic from 'next/dynamic'; + +// Dynamically import the LeafletHeatMap component to avoid SSR issues +const LeafletHeatMap = dynamic(() => import('../../components/LeafletHeatMap'), { + ssr: false, + loading: () =>
Loading map...
+}); interface IHeatMapProps { filters: IHeatMapParams; } -const HeatMap = ({ filters }: IHeatMapProps) => { - - const map = useMap(); - const visualization = useMapsLibrary('visualization'); - - const heatmap = useMemo(() => { - if (!visualization) return null; - - return new google.maps.visualization.HeatmapLayer({ - radius: 20, - opacity: 0.5 - }); - - }, [visualization]); - - useEffect(() => { - if (!heatmap) return; - - getAssetGeoHeatmap(filters).then((data) => { - heatmap.setData([]); - heatmap.setData(data.map(([lng, lat]: [number, number]) => ({ - location: new google.maps.LatLng(lat, lng), - weight: 10 - }))); - }); - - }, [heatmap, filters]); - - useEffect(() => { - if (!heatmap) return; - - heatmap.setMap(map); - - return () => { - heatmap.setMap(null); - }; - }, [heatmap, map]); - - return null; -} - export default function GeoHeatmap() { - const { googleMapsApiKey } = useConfig(); const { theme } = useTheme(); - const [mapId, setMapId] = useState('7a9e2ebecd32a903'); const [filters, setFilters] = useState<{ albumIds?: string peopleIds?: string }>({ }); - - useEffect(() => { - if (theme === 'light') { - setMapId(""); - } else { - setMapId('7a9e2ebecd32a903'); - } - }, [theme]); + const [isLoading, setIsLoading] = useState(false); return ( -
- { - setFilters({ - ...filters, - albumIds: albumIds?.[0] - }) - }} /> - { - setFilters({ - ...filters, - peopleIds: peopleIds?.[0] - }) - }} /> - - - } /> -
- {googleMapsApiKey ? ( - - + { + setFilters({ + ...filters, + albumIds: albumIds?.[0] + }) + }} /> - - - - ) : ( -
- -

No Google Maps API key found

-

- Please create and add your Google Maps API key in the env file under GOOGLE_MAPS_API_KEY. -

-
- )} + { + setFilters({ + ...filters, + peopleIds: peopleIds?.[0] + }) + }} + /> + + + } + /> +
+
) diff --git a/src/styles/globals.scss b/src/styles/globals.scss index afc4d8c..f14c264 100644 --- a/src/styles/globals.scss +++ b/src/styles/globals.scss @@ -98,4 +98,15 @@ .ReactGridGallery_tile { @apply bg-zinc-800 !important; } +} + + +.leaflet-tile-pane { + z-index: 1 !important; +} +.leaflet-pane { + z-index: 2 !important; +} +.leaflet-control-container { + z-index: 3 !important; } \ No newline at end of file