feat: moved from google maps to open street maps for heatmaps

Signed-off-by: Varun Raj <varun@skcript.com>
This commit is contained in:
Varun Raj
2025-08-27 08:30:42 +05:30
parent fda8edfab3
commit 0349646953
10 changed files with 199 additions and 107 deletions
-1
View File
@@ -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
-1
View File
@@ -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
BIN
View File
Binary file not shown.
+2 -1
View File
@@ -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",
+139
View File
@@ -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: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>',
subdomains: 'abcd',
maxZoom: 20
})
: L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <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;
-1
View File
@@ -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,
};
-2
View File
@@ -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;
-1
View File
@@ -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,
},
};
};
+33 -86
View File
@@ -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>
)
+11
View File
@@ -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;
}