feat: added filters in geo heatmap

Signed-off-by: Varun Raj <varun@skcript.com>
This commit is contained in:
Varun Raj
2025-02-01 16:45:14 +05:30
parent 506c5a408e
commit e51da37518
6 changed files with 200 additions and 24 deletions
+63
View File
@@ -0,0 +1,63 @@
import { listAlbums } from '@/handlers/api/album.handler';
import { IAlbum } from '@/types/album';
import React, { useEffect, useState } from 'react'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
import { X } from 'lucide-react';
interface IAlbumDropdownProps {
albumIds?: string[];
onChange: (albumIds: string[]) => void;
}
export default function AlbumDropdown({ albumIds, onChange }: IAlbumDropdownProps) {
const [albums, setAlbums] = useState<IAlbum[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [selectedAlbumIds, setSelectedAlbumIds] = useState(albumIds);
const fetchAlbums = async () => {
setLoading(true);
return listAlbums({
sortBy: "createdAt",
sortOrder: "desc",
}).then((albums) => setAlbums(albums)).catch((error) => setError(error)).finally(() => setLoading(false)) ;
}
useEffect(() => {
fetchAlbums();
}, []);
useEffect(() => {
if (albumIds) {
setSelectedAlbumIds(albumIds);
} else {
setSelectedAlbumIds([]);
}
}, [albumIds]);
return (
<div>
<Select
onValueChange={(value) => {
if (value) {
setSelectedAlbumIds([value])
onChange([value])
} else {
setSelectedAlbumIds([])
onChange([])
}
}}
value={selectedAlbumIds?.[0] ?? ""}
>
<SelectTrigger className='!p-2'>
<SelectValue placeholder={'Select album'} />
</SelectTrigger>
<SelectContent>
{albums.map((album) => (
<SelectItem key={album.id} value={album.id}>{album.albumName}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}
+63
View File
@@ -0,0 +1,63 @@
import React, { useEffect, useState } from 'react'
import { IPerson } from '@/types/person';
import { IPersonListFilters, listPeople } from '@/handlers/api/people.handler';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
interface IPeopleDropdownProps {
peopleIds?: string[];
onChange: (peopleIds: string[]) => void;
}
export default function PeopleDropdown({ peopleIds, onChange }: IPeopleDropdownProps) {
const [people, setPeople] = useState<IPerson[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [selectedPeopleIds, setSelectedPeopleIds] = useState(peopleIds);
const [filters, setFilters] = useState<IPersonListFilters>({
page: 1,
perPage: 10000,
sort: "createdAt",
sortOrder: "desc",
visibility: "visible",
type: "named",
});
const fetchPeople = async () => {
setLoading(true);
return listPeople(filters).then((people) => setPeople(people.people)).catch((error) => setError(error)).finally(() => setLoading(false));
}
useEffect(() => {
fetchPeople();
}, [filters]);
useEffect(() => {
if (peopleIds) {
setSelectedPeopleIds(peopleIds);
} else {
setSelectedPeopleIds([]);
}
}, [peopleIds]);
return (
<div>
<Select onValueChange={(value) => {
if (value) {
setSelectedPeopleIds([value])
onChange([value])
} else {
setSelectedPeopleIds([])
onChange([])
}
}} value={selectedPeopleIds?.[0] ?? ""}>
<SelectTrigger className='!p-2'>
<SelectValue placeholder={'Select people'} />
</SelectTrigger>
<SelectContent >
{people.map((person) => (
<SelectItem key={person.id} value={person.id}>{person.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}
+8 -2
View File
@@ -26,21 +26,27 @@ export interface IComboBoxProps {
onSelect?: (value: string) => void
onOpenChange?: (open: boolean) => void
closeOnSelect?: boolean
label?: string
onTextChange?: (text: string) => void
}
export function Combobox(
{ options, onSelect, onOpenChange, closeOnSelect }: IComboBoxProps
{ options, onSelect, onOpenChange, closeOnSelect, label, onTextChange }: IComboBoxProps
) {
const [open, setOpen] = React.useState(false)
const [value, setValue] = React.useState("")
return (
<Popover open={open} onOpenChange={(openState) => {
setOpen(openState)
onOpenChange?.(openState)
}}>
<PopoverTrigger asChild>
<Filter />
<div className="flex items-center text-sm gap-2 cursor-pointer">
<Filter size={16} />
<span>{label}</span>
</div>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0">
<Command>
+6 -2
View File
@@ -61,8 +61,12 @@ export const findAssets = async (query: string) => {
return API.post(FIND_ASSETS, { query });
}
export const getAssetGeoHeatmap = async () => {
return API.get(ASSET_GEO_HEATMAP_PATH);
export interface IHeatMapParams {
albumIds?: string;
peopleIds?: string;
}
export const getAssetGeoHeatmap = async (filters: IHeatMapParams) => {
return API.get(ASSET_GEO_HEATMAP_PATH, filters);
}
export const deleteAssets = async (ids: string[]) => {
+11 -3
View File
@@ -3,26 +3,34 @@ import { NextApiResponse } from "next";
import { db } from "@/config/db";
import { getCurrentUser } from "@/handlers/serverUtils/user.utils";
import { NextApiRequest } from "next";
import { assets, exif } from "@/schema";
import { and, eq, isNotNull } from "drizzle-orm";
import { assetFaces, assets, exif, person } from "@/schema";
import { and, eq, inArray, isNotNull } from "drizzle-orm";
import { albumsAssetsAssets } from "@/schema/albumAssetsAssets.schema";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const currentUser = await getCurrentUser(req);
const { albumIds, peopleIds } = req.query as { albumIds: string, peopleIds: string };
if (!currentUser) {
return res.status(401).json({ message: 'Unauthorized' });
}
const dbAssets = await db.select({
assetId: assets.id,
latitude: exif.latitude,
longitude: exif.longitude,
}).from(assets)
.innerJoin(exif, eq(assets.id, exif.assetId))
.innerJoin(albumsAssetsAssets, eq(assets.id, albumsAssetsAssets.assetsId))
.innerJoin(assetFaces, eq(albumsAssetsAssets.assetsId, assetFaces.assetId))
.innerJoin(person, eq(assetFaces.personId, person.id))
.where(
and(
eq(assets.ownerId, currentUser.id),
isNotNull(exif.latitude),
isNotNull(exif.longitude)
isNotNull(exif.longitude),
albumIds?.length > 0 ? inArray(albumsAssetsAssets.albumsId, [albumIds]) : undefined,
peopleIds?.length > 0 ? inArray(person.id, [peopleIds]) : undefined
)
);
const heatmapData = dbAssets.map((asset) => [
+49 -17
View File
@@ -4,13 +4,19 @@ 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 PageLayout from '@/components/layouts/PageLayout';
import { getAssetGeoHeatmap } from '@/handlers/api/asset.handler';
import { getAssetGeoHeatmap, IHeatMapParams } from '@/handlers/api/asset.handler';
import { useTheme } from 'next-themes';
import { MapPinX } from 'lucide-react';
import { MapPinX, X } from 'lucide-react';
import AlbumDropdown from '@/components/shared/AlbumDropdown';
import PeopleDropdown from '@/components/shared/PeopleDropdown';
import { Button } from '@/components/ui/button';
interface IHeatMapProps {
filters: IHeatMapParams;
}
const HeatMap = ({ filters }: IHeatMapProps) => {
const HeatMap = () => {
const map = useMap();
const visualization = useMapsLibrary('visualization');
@@ -27,14 +33,15 @@ const HeatMap = () => {
useEffect(() => {
if (!heatmap) return;
getAssetGeoHeatmap().then((data) => {
getAssetGeoHeatmap(filters).then((data) => {
heatmap.setData([]);
heatmap.setData(data.map(([lng, lat]: [number, number]) => ({
location: new google.maps.LatLng(lat, lng),
weight: 10
})));
});
}, [heatmap]);
}, [heatmap, filters]);
useEffect(() => {
if (!heatmap) return;
@@ -53,6 +60,10 @@ 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') {
@@ -65,21 +76,42 @@ export default function GeoHeatmap() {
return (
<PageLayout className="!p-0 !mb-0">
<Header leftComponent="Geo Heatmap" />
<Header leftComponent="Geo Heatmap" rightComponent={
<>
<AlbumDropdown albumIds={filters.albumIds ? [filters.albumIds] : []} onChange={(albumIds) => {
setFilters({
...filters,
albumIds: albumIds?.[0]
})
}} />
<PeopleDropdown peopleIds={filters.peopleIds ? [filters.peopleIds] : []} onChange={(peopleIds) => {
setFilters({
...filters,
peopleIds: peopleIds?.[0]
})
}} />
<Button
variant="default" size="sm"
disabled={Object.keys(filters).length === 0}
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'
/>
<Map
defaultCenter={{ lat: 0, lng: 0 }}
mapId={mapId}
defaultZoom={2}
gestureHandling={'greedy'}
disableDefaultUI={true}
className='h-full w-full'
/>
<HeatMap />
</APIProvider>
<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' />