diff --git a/apps/photos/src/components/Search/SearchBar/searchInput/index.tsx b/apps/photos/src/components/Search/SearchBar/searchInput/index.tsx index 7db8d94ad..8b46f51ae 100644 --- a/apps/photos/src/components/Search/SearchBar/searchInput/index.tsx +++ b/apps/photos/src/components/Search/SearchBar/searchInput/index.tsx @@ -30,6 +30,7 @@ import { LocationTagData } from 'types/entity'; import { FILE_TYPE } from 'constants/file'; import { InputActionMeta } from 'react-select/src/types'; import { components } from 'react-select'; +import { City } from 'services/locationSearchService'; interface Iprops { isOpen: boolean; @@ -122,6 +123,12 @@ export default function SearchInput(props: Iprops) { }; props.setIsOpen(true); break; + case SuggestionType.CITY: + search = { + city: selectedOption.value as City, + }; + props.setIsOpen(true); + break; case SuggestionType.COLLECTION: search = { collection: selectedOption.value as number }; setValue(null); diff --git a/apps/photos/src/components/Search/SearchBar/searchInput/valueContainerWithIcon.tsx b/apps/photos/src/components/Search/SearchBar/searchInput/valueContainerWithIcon.tsx index 8bf92a310..9ebe3cd58 100644 --- a/apps/photos/src/components/Search/SearchBar/searchInput/valueContainerWithIcon.tsx +++ b/apps/photos/src/components/Search/SearchBar/searchInput/valueContainerWithIcon.tsx @@ -17,6 +17,7 @@ const getIconByType = (type: SuggestionType) => { case SuggestionType.DATE: return ; case SuggestionType.LOCATION: + case SuggestionType.CITY: return ; case SuggestionType.COLLECTION: return ; diff --git a/apps/photos/src/pages/gallery/index.tsx b/apps/photos/src/pages/gallery/index.tsx index 82731dfdc..e38ddc657 100644 --- a/apps/photos/src/pages/gallery/index.tsx +++ b/apps/photos/src/pages/gallery/index.tsx @@ -120,7 +120,7 @@ import GalleryEmptyState from 'components/GalleryEmptyState'; import AuthenticateUserModal from 'components/AuthenticateUserModal'; import useMemoSingleThreaded from '@ente/shared/hooks/useMemoSingleThreaded'; import { isArchivedFile } from 'utils/magicMetadata'; -import { isSameDayAnyYear, isInsideLocationTag } from 'utils/search'; +import { isSameDayAnyYear } from 'utils/search'; import { getSessionExpiredMessage } from 'utils/ui'; import { syncEntities } from 'services/entityService'; import { constructUserIDToEmailMap } from 'services/collectionService'; @@ -131,7 +131,10 @@ import { ClipService } from 'services/clipService'; import isElectron from 'is-electron'; import downloadManager from 'services/download'; import { APPS } from '@ente/shared/apps/constants'; -import locationSearchService from 'services/locationSearchService'; +import locationSearchService, { + isInsideCity, + isInsideLocationTag, +} from 'services/locationSearchService'; export const DeadCenter = styled('div')` flex: 1; @@ -518,6 +521,18 @@ export default function Gallery() { ) { return false; } + if ( + search?.city && + !isInsideCity( + { + latitude: item.metadata.latitude, + longitude: item.metadata.longitude, + }, + search.city + ) + ) { + return false; + } if ( search?.person && search.person.files.indexOf(item.id) === -1 diff --git a/apps/photos/src/services/locationSearchService.ts b/apps/photos/src/services/locationSearchService.ts index 377c4efed..454d07002 100644 --- a/apps/photos/src/services/locationSearchService.ts +++ b/apps/photos/src/services/locationSearchService.ts @@ -1,23 +1,68 @@ import { CITIES_URL } from '@ente/shared/constants/urls'; +import { LocationTagData } from 'types/entity'; +import { Location } from 'types/upload'; -interface City { +export interface City { city: string; country: string; lat: number; lng: number; } +const DEFAULT_CITY_RADIUS = 10; + class LocationSearchService { private cities: Array = []; + private citiesPromise: Promise; loadCities() { - fetch(CITIES_URL).then((response) => { - response.json().then((data) => { - this.cities = data; - console.log(this.cities); + if (this.citiesPromise) { + return; + } + this.citiesPromise = fetch(CITIES_URL).then((response) => { + return response.json().then((data) => { + this.cities = data['data']; }); }); } + + async searchCities(searchTerm: string) { + if (!this.citiesPromise) { + this.loadCities(); + } + await this.citiesPromise; + return this.cities.filter((city) => { + return city.city.toLowerCase().includes(searchTerm.toLowerCase()); + }); + } } export default new LocationSearchService(); + +export function isInsideLocationTag( + location: Location, + locationTag: LocationTagData +) { + const { centerPoint, aSquare, bSquare } = locationTag; + const { latitude, longitude } = location; + const x = Math.abs(centerPoint.latitude - latitude); + const y = Math.abs(centerPoint.longitude - longitude); + if ((x * x) / aSquare + (y * y) / bSquare <= 1) { + return true; + } else { + return false; + } +} + +// TODO: Verify correctness +export function isInsideCity(location: Location, city: City) { + const { lat, lng } = city; + const { latitude, longitude } = location; + const x = Math.abs(lat - latitude); + const y = Math.abs(lng - longitude); + if (x * x + y * y <= DEFAULT_CITY_RADIUS * DEFAULT_CITY_RADIUS) { + return true; + } else { + return false; + } +} diff --git a/apps/photos/src/services/searchService.ts b/apps/photos/src/services/searchService.ts index 90665dd0b..42b91614d 100644 --- a/apps/photos/src/services/searchService.ts +++ b/apps/photos/src/services/searchService.ts @@ -16,11 +16,7 @@ import { ClipSearchScores, } from 'types/search'; import ObjectService from './machineLearning/objectService'; -import { - getFormattedDate, - isInsideLocationTag, - isSameDayAnyYear, -} from 'utils/search'; +import { getFormattedDate, isSameDayAnyYear } from 'utils/search'; import { Person, Thing } from 'types/machineLearning'; import { getUniqueFiles } from 'utils/file'; import { getLatestEntities } from './entityService'; @@ -31,6 +27,11 @@ import { ClipService, computeClipMatchScore } from './clipService'; import { CustomError } from '@ente/shared/error'; import { Model } from 'types/embedding'; import { getLocalEmbeddings } from './embeddingService'; +import locationSearchService, { + City, + isInsideCity, + isInsideLocationTag, +} from './locationSearchService'; const DIGITS = new Set(['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']); @@ -61,6 +62,7 @@ export const getAutoCompleteSuggestions = getFileNameSuggestion(searchPhrase, files), getFileCaptionSuggestion(searchPhrase, files), ...(await getLocationTagSuggestions(searchPhrase)), + ...(await getCitySuggestions(searchPhrase)), ...(await getThingSuggestion(searchPhrase)), ].filter((suggestion) => !!suggestion); @@ -279,6 +281,21 @@ async function getLocationTagSuggestions(searchPhrase: string) { ); } +async function getCitySuggestions(searchPhrase: string) { + const searchResults = await locationSearchService.searchCities( + searchPhrase + ); + + return searchResults.map( + (city) => + ({ + type: SuggestionType.CITY, + value: city, + label: city.city, + } as Suggestion) + ); +} + async function getThingSuggestion(searchPhrase: string): Promise { const thingResults = await searchThing(searchPhrase); @@ -425,6 +442,15 @@ function isSearchedFile(file: EnteFile, search: Search) { search.location ); } + if (search?.city) { + return isInsideCity( + { + latitude: file.metadata.latitude, + longitude: file.metadata.longitude, + }, + search.city + ); + } if (search?.files) { return search.files.indexOf(file.id) !== -1; } @@ -460,6 +486,9 @@ function convertSuggestionToSearchQuery(option: Suggestion): Search { location: option.value as LocationTagData, }; + case SuggestionType.CITY: + return { city: option.value as City }; + case SuggestionType.COLLECTION: return { collection: option.value as number }; diff --git a/apps/photos/src/types/search/index.ts b/apps/photos/src/types/search/index.ts index 1e41d53f1..2e6c94f48 100644 --- a/apps/photos/src/types/search/index.ts +++ b/apps/photos/src/types/search/index.ts @@ -3,6 +3,7 @@ import { IndexStatus } from 'types/machineLearning/ui'; import { EnteFile } from 'types/file'; import { LocationTagData } from 'types/entity'; import { FILE_TYPE } from 'constants/file'; +import { City } from 'services/locationSearchService'; export enum SuggestionType { DATE = 'DATE', @@ -16,6 +17,7 @@ export enum SuggestionType { FILE_CAPTION = 'FILE_CAPTION', FILE_TYPE = 'FILE_TYPE', CLIP = 'CLIP', + CITY = 'CITY', } export interface DateValue { @@ -35,6 +37,7 @@ export interface Suggestion { | Thing | WordGroup | LocationTagData + | City | FILE_TYPE | ClipSearchScores; hide?: boolean; @@ -43,6 +46,7 @@ export interface Suggestion { export type Search = { date?: DateValue; location?: LocationTagData; + city?: City; collection?: number; files?: number[]; person?: Person; diff --git a/apps/photos/src/utils/search/index.ts b/apps/photos/src/utils/search/index.ts index 891c99254..6392e4840 100644 --- a/apps/photos/src/utils/search/index.ts +++ b/apps/photos/src/utils/search/index.ts @@ -1,6 +1,4 @@ -import { LocationTagData } from 'types/entity'; import { DateValue } from 'types/search'; -import { Location } from 'types/upload'; export const isSameDayAnyYear = (baseDate: DateValue) => (compareDate: Date) => { @@ -28,18 +26,3 @@ export function getFormattedDate(date: DateValue) { new Date(date.year ?? 1, date.month ?? 1, date.date ?? 1) ); } - -export function isInsideLocationTag( - location: Location, - locationTag: LocationTagData -) { - const { centerPoint, aSquare, bSquare } = locationTag; - const { latitude, longitude } = location; - const x = Math.abs(centerPoint.latitude - latitude); - const y = Math.abs(centerPoint.longitude - longitude); - if ((x * x) / aSquare + (y * y) / bSquare <= 1) { - return true; - } else { - return false; - } -}