feat: moved from google maps to open street maps for heatmaps
Signed-off-by: Varun Raj <varun@skcript.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
@@ -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<LeafletHeatMapProps> = ({ filters, isDarkMode, onLoadingChange }) => {
|
||||
const mapRef = useRef<L.Map | null>(null);
|
||||
const mapContainerRef = useRef<HTMLDivElement>(null);
|
||||
const heatLayerRef = useRef<L.HeatLayer | null>(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: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>',
|
||||
subdomains: 'abcd',
|
||||
maxZoom: 20
|
||||
})
|
||||
: L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> 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 (
|
||||
<div className="relative h-full w-full">
|
||||
<div
|
||||
ref={mapContainerRef}
|
||||
className="h-full w-full"
|
||||
style={{ minHeight: '400px' }}
|
||||
/>
|
||||
|
||||
{/* Loading Overlay */}
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 bg-background/50 backdrop-blur-sm z-[1000] flex items-center justify-center">
|
||||
<div className="bg-background/90 rounded-lg p-6 border shadow-lg flex flex-col items-center gap-3">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-sm text-muted-foreground font-medium">Loading heatmap data...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LeafletHeatMap;
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -5,7 +5,6 @@ export interface ConfigContextType {
|
||||
exImmichUrl: string;
|
||||
version?: string;
|
||||
geminiEnabled: boolean;
|
||||
googleMapsApiKey: string;
|
||||
}
|
||||
|
||||
const ConfigContext = createContext<ConfigContextType>({
|
||||
@@ -13,7 +12,6 @@ const ConfigContext = createContext<ConfigContextType>({
|
||||
exImmichUrl: "",
|
||||
version: "",
|
||||
geminiEnabled: false,
|
||||
googleMapsApiKey: "",
|
||||
});
|
||||
|
||||
export default ConfigContext;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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: () => <div className="h-full w-full flex items-center justify-center">Loading map...</div>
|
||||
});
|
||||
|
||||
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 (
|
||||
<PageLayout className="!p-0 !mb-0">
|
||||
<Header leftComponent="Geo Heatmap" rightComponent={
|
||||
<Header
|
||||
leftComponent="Geo Heatmap"
|
||||
rightComponent={
|
||||
<>
|
||||
<AlbumDropdown albumIds={filters.albumIds ? [filters.albumIds] : []} onChange={(albumIds) => {
|
||||
<AlbumDropdown
|
||||
albumIds={filters.albumIds ? [filters.albumIds] : []}
|
||||
onChange={(albumIds) => {
|
||||
setFilters({
|
||||
...filters,
|
||||
albumIds: albumIds?.[0]
|
||||
})
|
||||
}} />
|
||||
<PeopleDropdown peopleIds={filters.peopleIds ? [filters.peopleIds] : []} onChange={(peopleIds) => {
|
||||
}}
|
||||
/>
|
||||
<PeopleDropdown
|
||||
peopleIds={filters.peopleIds ? [filters.peopleIds] : []}
|
||||
onChange={(peopleIds) => {
|
||||
setFilters({
|
||||
...filters,
|
||||
peopleIds: peopleIds?.[0]
|
||||
})
|
||||
}} />
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="default" size="sm"
|
||||
disabled={Object.keys(filters).length === 0}
|
||||
variant="default"
|
||||
size="sm"
|
||||
disabled={Object.keys(filters).length === 0 || isLoading}
|
||||
onClick={() => setFilters({})}>
|
||||
<X size={16} /> Clear
|
||||
</Button>
|
||||
</>
|
||||
} />
|
||||
<div className='h-full w-full'>
|
||||
{googleMapsApiKey ? (
|
||||
<APIProvider apiKey={googleMapsApiKey}>
|
||||
<Map
|
||||
defaultCenter={{ lat: 0, lng: 0 }}
|
||||
mapId={mapId}
|
||||
defaultZoom={2}
|
||||
gestureHandling={'greedy'}
|
||||
disableDefaultUI={true}
|
||||
className='h-full w-full'
|
||||
}
|
||||
/>
|
||||
<div className='h-full w-full'>
|
||||
<LeafletHeatMap
|
||||
filters={filters}
|
||||
isDarkMode={theme === 'dark'}
|
||||
onLoadingChange={setIsLoading}
|
||||
/>
|
||||
|
||||
<HeatMap filters={filters} />
|
||||
</APIProvider>
|
||||
) : (
|
||||
<div className='h-full w-full flex items-center justify-center flex-col gap-2'>
|
||||
<MapPinX className='w-10 h-10 text-muted-foreground' />
|
||||
<p className='text-muted-foreground'>No Google Maps API key found</p>
|
||||
<p className='text-muted-foreground'>
|
||||
Please create and add your Google Maps API key in the env file under <kbd className='bg-zinc-200 text-black dark:text-white px-1 py-0.5 rounded-md dark:bg-zinc-500'>GOOGLE_MAPS_API_KEY</kbd>.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PageLayout>
|
||||
)
|
||||
|
||||
@@ -99,3 +99,14 @@
|
||||
@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;
|
||||
}
|
||||
Reference in New Issue
Block a user