diff --git a/messages/de.json b/messages/de.json index 1623c596..90ba5a7e 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3,6 +3,7 @@ "home": "Startseite", "gallery": "Galerie", "events": "Veranstaltungen", + "map": "Karte", "announcements": "Ankündigungen", "notifications": "Benachrichtigungen", "users": "Benutzer", diff --git a/messages/en.json b/messages/en.json index 7fc04645..1490913f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3,6 +3,7 @@ "home": "Home", "gallery": "Gallery", "events": "Events", + "map": "Map", "announcements": "Announcements", "notifications": "Notifications", "users": "Users", diff --git a/messages/nl.json b/messages/nl.json index 3e0ff876..9b663768 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -3,6 +3,7 @@ "home": "Startpagina", "gallery": "Galerij", "events": "Evenementen", + "map": "Kaart", "announcements": "Aankondigingen", "notifications": "Meldingen", "users": "Gebruikers", diff --git a/package.json b/package.json index 30be8a44..a19cd504 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "singleQuote": true }, "dependencies": { + "@googlemaps/markerclusterer": "^2.5.3", "@hookform/resolvers": "^3.9.0", "@icons-pack/react-simple-icons": "^9.6.0", "@radix-ui/react-accordion": "^1.2.0", @@ -47,11 +48,11 @@ "@radix-ui/react-toggle": "^1.1.0", "@radix-ui/react-toggle-group": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.2", - "@react-google-maps/api": "^2.19.3", "@sentry/nextjs": "^8.15.0", "@tailwindcss/aspect-ratio": "^0.4.2", "@tailwindcss/forms": "^0.5.7", "@tailwindcss/typography": "^0.5.13", + "@vis.gl/react-google-maps": "^1.1.0", "appwrite": "^15.0.0", "autoprefixer": "10.4.19", "class-variance-authority": "^0.7.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f05fc516..32e073fb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@googlemaps/markerclusterer': + specifier: ^2.5.3 + version: 2.5.3 '@hookform/resolvers': specifier: ^3.9.0 version: 3.9.0(react-hook-form@7.52.1(react@18.3.1)) @@ -95,9 +98,6 @@ importers: '@radix-ui/react-tooltip': specifier: ^1.1.2 version: 1.1.2(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@react-google-maps/api': - specifier: ^2.19.3 - version: 2.19.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@sentry/nextjs': specifier: ^8.15.0 version: 8.15.0(@opentelemetry/api@1.9.0)(@opentelemetry/core@1.25.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.52.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.25.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.25.1)(next@14.2.4(@babel/core@7.24.7)(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(webpack@5.92.1) @@ -110,6 +110,9 @@ importers: '@tailwindcss/typography': specifier: ^0.5.13 version: 0.5.13(tailwindcss@3.4.4) + '@vis.gl/react-google-maps': + specifier: ^1.1.0 + version: 1.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) appwrite: specifier: ^15.0.0 version: 15.0.0 @@ -381,9 +384,6 @@ packages: '@formatjs/intl-localematcher@0.5.4': resolution: {integrity: sha512-zTwEpWOzZ2CiKcB93BLngUX59hQkuZjT2+SAQEscSm52peDW/getsawMcWF1rGRpMCX6D7nSJA3CzJ8gn13N/g==} - '@googlemaps/js-api-loader@1.16.2': - resolution: {integrity: sha512-psGw5u0QM6humao48Hn4lrChOM2/rA43ZCm3tKK9qQsEj1/VzqkCqnvGfEOshDbBQflydfaRovbKwZMF4AyqbA==} - '@googlemaps/markerclusterer@2.5.3': resolution: {integrity: sha512-x7lX0R5yYOoiNectr10wLgCBasNcXFHiADIBdmn7jQllF2B5ENQw5XtZK+hIw4xnV0Df0xhN4LN98XqA5jaiOw==} @@ -1550,18 +1550,6 @@ packages: '@radix-ui/rect@1.1.0': resolution: {integrity: sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==} - '@react-google-maps/api@2.19.3': - resolution: {integrity: sha512-jiLqvuOt5lOowkLeq7d077AByTyJp+s6hZVlLhlq7SBacBD37aUNpXBz2OsazfeR6Aw4a+9RRhAEjEFvrR1f5A==} - peerDependencies: - react: ^16.8 || ^17 || ^18 - react-dom: ^16.8 || ^17 || ^18 - - '@react-google-maps/infobox@2.19.2': - resolution: {integrity: sha512-6wvBqeJsQ/eFSvoxg+9VoncQvNoVCdmxzxRpLvmjPD+nNC6mHM0vJH1xSqaKijkMrfLJT0nfkTGpovrF896jwg==} - - '@react-google-maps/marker-clusterer@2.19.2': - resolution: {integrity: sha512-x9ibmsP0ZVqzyCo1Pitbw+4b6iEXRw/r1TCy3vOUR3eKrzWLnHYZMR325BkZW2r8fnuWE/V3Fp4QZOP9qYORCw==} - '@remix-run/router@1.17.1': resolution: {integrity: sha512-mCOMec4BKd6BRGBZeSnGiIgwsbLGp3yhVqAD8H+PxiRNEHgDpZb8J1TnrSDlg97t0ySKMQJTHCWBCmBpSmkF6Q==} engines: {node: '>=14.0.0'} @@ -1867,6 +1855,12 @@ packages: '@ungap/structured-clone@1.2.0': resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} + '@vis.gl/react-google-maps@1.1.0': + resolution: {integrity: sha512-MxDIhCfPRzTQY1c6sS0GFg8Ukl40o13fkIKEaCN0cR1BIrV4LPo+EuCov9WElbe0bOo8MApx5qAbqBKOmLQyKg==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + '@webassemblyjs/ast@1.12.1': resolution: {integrity: sha512-EKfMUOPRRUTy5UII4qJDGPpqfwjOmZ5jeGFwid9mnoqIFK+e0vqoi1qH56JpmZSzEL53jKnNzScdmftJyG5xWg==} @@ -4163,10 +4157,6 @@ snapshots: dependencies: tslib: 2.6.3 - '@googlemaps/js-api-loader@1.16.2': - dependencies: - fast-deep-equal: 3.1.3 - '@googlemaps/markerclusterer@2.5.3': dependencies: fast-deep-equal: 3.1.3 @@ -5325,21 +5315,6 @@ snapshots: '@radix-ui/rect@1.1.0': {} - '@react-google-maps/api@2.19.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@googlemaps/js-api-loader': 1.16.2 - '@googlemaps/markerclusterer': 2.5.3 - '@react-google-maps/infobox': 2.19.2 - '@react-google-maps/marker-clusterer': 2.19.2 - '@types/google.maps': 3.55.2 - invariant: 2.2.4 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - - '@react-google-maps/infobox@2.19.2': {} - - '@react-google-maps/marker-clusterer@2.19.2': {} - '@remix-run/router@1.17.1': {} '@rollup/plugin-commonjs@26.0.1(rollup@3.29.4)': @@ -5762,6 +5737,13 @@ snapshots: '@ungap/structured-clone@1.2.0': {} + '@vis.gl/react-google-maps@1.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@types/google.maps': 3.55.2 + fast-deep-equal: 3.1.3 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + '@webassemblyjs/ast@1.12.1': dependencies: '@webassemblyjs/helper-numbers': 1.11.6 diff --git a/src/app/[locale]/(main)/map/page.client.tsx b/src/app/[locale]/(main)/map/page.client.tsx new file mode 100644 index 00000000..49ac1c04 --- /dev/null +++ b/src/app/[locale]/(main)/map/page.client.tsx @@ -0,0 +1,275 @@ +'use client' +import React, { useCallback, useEffect, useState } from 'react' +import { AdvancedMarker, APIProvider, Map } from '@vis.gl/react-google-maps' +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' +import { Events, Location, UserData } from '@/utils/types/models' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogTitle, +} from '@/components/ui/dialog' +import { formatDate } from '@/components/calculateTimeLeft' +import { getDocument, listDocuments } from '@/components/api/documents' +import { toast } from 'sonner' +import { client, Query } from '@/app/appwrite-client' +import { Polygon } from '@/components/map/polygon' +import { Circle } from '@/components/map/circle' + +export default function PageClient() { + const [userLocation, setUserLocation] = useState(null) + + const [events, setEvents] = useState(null) + const [friendsLocations, setFriendsLocations] = useState(null) + const [filters, setFilters] = useState({ + showEvents: true, + showFriends: true, + showCommunity: true, + }) + + const [currentUser, setCurrentUser] = useState({ + title: 'Nothing selected..', + status: '', + description: 'Please select a user on the map.', + }) + const [currentEvent, setCurrentEvent] = useState({ + title: 'Nothing selected..', + description: 'Please select an event on the map.', + date: '', + dateUntil: '', + }) + const [modalUserOpen, setModalUserOpen] = useState(false) + const [modalEventOpen, setModalEventOpen] = useState(false) + + const fetchEvents = async () => { + try { + const currentDate = new Date() + + const data: Events.EventsType = await listDocuments('events', [ + Query.orderAsc('date'), + Query.greaterThanEqual('dateUntil', currentDate.toISOString()), + Query.or([ + Query.equal('locationZoneMethod', 'circle'), + Query.equal('locationZoneMethod', 'polygon'), + ]), + ]) + + setEvents(data) + } catch (error) { + toast('Failed to fetch events. Please try again later.') + } + } + + const fetchUserLocations = async () => { + try { + let query = [] + //if (user?.current?.$id) { + // query = [Query.notEqual('$id', user?.current?.$id)] + //} + const data: Location.LocationType = await listDocuments( + 'locations', + query + ) + + const promises = data.documents.map(async (doc) => { + const userData: UserData.UserDataDocumentsType = await getDocument( + 'userdata', + `${doc.$id}` + ) + return { ...doc, userData } + }) + + const results = await Promise.all(promises) + setFriendsLocations(results) + } catch (error) { + toast('Failed to fetch locations. Please try again later.') + } + } + + const onRefresh = () => { + fetchUserLocations().then() + fetchEvents().then() + } + + let locationsSubscribed = null + useEffect(() => { + onRefresh() + handleSubscribedEvents() + return () => { + // Remove the event listener when the component is unmounted + locationsSubscribed() + } + }, []) + + function handleSubscribedEvents() { + locationsSubscribed = client.subscribe( + ['databases.hp_db.collections.locations.documents'], + async (response) => { + const eventType = response.events[0].split('.').pop() + const updatedDocument: any = response.payload + + switch (eventType) { + case 'update': + case 'create': + // Fetch userData for the updated or created document + const userData: UserData.UserDataDocumentsType = await getDocument( + 'userdata', + `${updatedDocument.$id}` + ) + const updatedLocationWithUserData = { ...updatedDocument, userData } + + setFriendsLocations( + (prevLocations: Location.LocationDocumentsType[]) => { + const locationExists = prevLocations.some( + (location) => location.$id === updatedDocument.$id + ) + if (locationExists) { + // Update existing location + return prevLocations.map((location) => + location.$id === updatedDocument.$id + ? updatedLocationWithUserData + : location + ) + } else { + // Add new location + return [...prevLocations, updatedLocationWithUserData] + } + } + ) + break + case 'delete': + // Remove the deleted document from the state + setFriendsLocations( + (prevLocations: Location.LocationDocumentsType[]) => + prevLocations.filter( + (location) => location.$id !== updatedDocument.$id + ) + ) + break + default: + console.error('Unknown event type:', eventType) + } + } + ) + } + + const getUserAvatar = (avatarId: string) => { + if (!avatarId) return + return `https://api.headpat.de/v1/storage/buckets/avatars/files/${avatarId}/preview?project=6557c1a8b6c2739b3ecf&width=100&height=100` + } + + return ( +
+ setModalUserOpen(open)} + > + + {currentUser.title} + Status: {currentUser.status} + {currentUser.description} + + + + setModalEventOpen(open)} + > + + {currentEvent?.title} + {currentEvent?.description} + +
Until: {formatDate(new Date(currentEvent?.dateUntil))}
+
Start: {formatDate(new Date(currentEvent?.date))}
+
+
+
+ + + + {friendsLocations?.map((user, index) => { + return ( + ( + setModalUserOpen(true), + setCurrentUser({ + title: user.userData?.displayName, + status: user.userData?.status, + description: user.userData?.bio, + }) + )} + > + + + CN + + + ) + })} + {events?.documents.map((event, index) => { + if (event?.locationZoneMethod === 'polygon') { + const coords = event?.coordinates.map((coord) => { + const [lat, lng] = coord.split(',').map(Number) + return { lat, lng } + }) + console.log(coords) + return ( + ( + setModalEventOpen(true), + setCurrentEvent({ + title: event.title, + description: event.description, + date: event.date, + dateUntil: event.dateUntil, + }) + )} + /> + ) + } else if (event?.locationZoneMethod === 'circle') { + // Assuming the first coordinate is the center of the circle + const [centerLatitude, centerLongitude] = event?.coordinates[0] + .split(',') + .map(Number) + return ( + ( + setModalEventOpen(true), + setCurrentEvent({ + title: event.title, + description: event.description, + date: event.date, + dateUntil: event.dateUntil, + }) + )} + /> + ) + } + })} + + +
+ ) +} diff --git a/src/app/[locale]/(main)/map/page.tsx b/src/app/[locale]/(main)/map/page.tsx new file mode 100644 index 00000000..0b5568d5 --- /dev/null +++ b/src/app/[locale]/(main)/map/page.tsx @@ -0,0 +1,10 @@ +import PageLayout from '@/components/pageLayout' +import PageClient from './page.client' + +export default async function Page() { + return ( + + + + ) +} diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx index e7c1ef08..9bdcef1d 100644 --- a/src/app/[locale]/layout.tsx +++ b/src/app/[locale]/layout.tsx @@ -11,6 +11,7 @@ export default async function LocaleLayout({ children, params: { locale } }) { home={pageNames('home')} gallery={pageNames('gallery')} events={pageNames('events')} + map={pageNames('map')} announcements={pageNames('announcements')} notifications={pageNames('notifications')} users={pageNames('users')} diff --git a/src/components/api/documents.ts b/src/components/api/documents.ts index 3e849feb..ed773ee4 100644 --- a/src/components/api/documents.ts +++ b/src/components/api/documents.ts @@ -1,19 +1,26 @@ 'use client' import { databases, Query } from '@/app/appwrite-client' +import { Models } from 'node-appwrite' -export function getDocument(collectionId: string, documentId: string) { +export function getDocument( + collectionId: string, + documentId: string +): Promise { return databases.getDocument(`hp_db`, `${collectionId}`, `${documentId}`) } -export function listDocuments(collectionId: string, query?: any) { +export function listDocuments( + collectionId: string, + query?: any +): Promise> { return databases.listDocuments(`hp_db`, `${collectionId}`, query) } -export function updateDocument( +export function updateDocument( collectionId: string, documentId: string, body: any -) { +): Promise { return databases.updateDocument( `hp_db`, `${collectionId}, ${documentId}`, diff --git a/src/components/header/data.tsx b/src/components/header/data.tsx index 1af67cdd..5a409050 100644 --- a/src/components/header/data.tsx +++ b/src/components/header/data.tsx @@ -9,6 +9,7 @@ import { HomeIcon, LayoutPanelLeftIcon, LogOutIcon, + MapIcon, MegaphoneIcon, UserSearchIcon, } from 'lucide-react' @@ -50,6 +51,13 @@ export const Nav1 = (translations: any) => [ variant: 'ghost' as const, href: `/events`, }, + { + title: translations.map, + label: '', + icon: MapIcon, + variant: 'ghost' as const, + href: `/map`, + }, { title: translations.users, label: '', diff --git a/src/components/map/circle.tsx b/src/components/map/circle.tsx new file mode 100644 index 00000000..60fef7b0 --- /dev/null +++ b/src/components/map/circle.tsx @@ -0,0 +1,135 @@ +/* eslint-disable complexity */ +import { + forwardRef, + useContext, + useEffect, + useImperativeHandle, + useRef, +} from 'react' + +import type { Ref } from 'react' +import { GoogleMapsContext, latLngEquals } from '@vis.gl/react-google-maps' + +type CircleEventProps = { + onClick?: (e: google.maps.MapMouseEvent) => void + onDrag?: (e: google.maps.MapMouseEvent) => void + onDragStart?: (e: google.maps.MapMouseEvent) => void + onDragEnd?: (e: google.maps.MapMouseEvent) => void + onMouseOver?: (e: google.maps.MapMouseEvent) => void + onMouseOut?: (e: google.maps.MapMouseEvent) => void + onRadiusChanged?: (r: ReturnType) => void + onCenterChanged?: (p: ReturnType) => void +} + +export type CircleProps = google.maps.CircleOptions & CircleEventProps + +export type CircleRef = Ref + +function useCircle(props: CircleProps) { + const { + onClick, + onDrag, + onDragStart, + onDragEnd, + onMouseOver, + onMouseOut, + onRadiusChanged, + onCenterChanged, + radius, + center, + ...circleOptions + } = props + // This is here to avoid triggering the useEffect below when the callbacks change (which happen if the user didn't memoize them) + const callbacks = useRef void>>({}) + Object.assign(callbacks.current, { + onClick, + onDrag, + onDragStart, + onDragEnd, + onMouseOver, + onMouseOut, + onRadiusChanged, + onCenterChanged, + }) + + const circle = useRef(new google.maps.Circle()).current + // update circleOptions (note the dependencies aren't properly checked + // here, we just assume that setOptions is smart enough to not waste a + // lot of time updating values that didn't change) + circle.setOptions(circleOptions) + + useEffect(() => { + if (!center) return + if (!latLngEquals(center, circle.getCenter())) circle.setCenter(center) + }, [center]) + + useEffect(() => { + if (radius === undefined || radius === null) return + if (radius !== circle.getRadius()) circle.setRadius(radius) + }, [radius]) + + const map = useContext(GoogleMapsContext)?.map + + // create circle instance and add to the map once the map is available + useEffect(() => { + if (!map) { + if (map === undefined) + console.error(' has to be inside a Map component.') + + return + } + + circle.setMap(map) + + return () => { + circle.setMap(null) + } + }, [map]) + + // attach and re-attach event-handlers when any of the properties change + useEffect(() => { + if (!circle) return + + // Add event listeners + const gme = google.maps.event + ;[ + ['click', 'onClick'], + ['drag', 'onDrag'], + ['dragstart', 'onDragStart'], + ['dragend', 'onDragEnd'], + ['mouseover', 'onMouseOver'], + ['mouseout', 'onMouseOut'], + ].forEach(([eventName, eventCallback]) => { + gme.addListener(circle, eventName, (e: google.maps.MapMouseEvent) => { + const callback = callbacks.current[eventCallback] + if (callback) callback(e) + }) + }) + gme.addListener(circle, 'radius_changed', () => { + const newRadius = circle.getRadius() + callbacks.current.onRadiusChanged?.(newRadius) + }) + gme.addListener(circle, 'center_changed', () => { + const newCenter = circle.getCenter() + callbacks.current.onCenterChanged?.(newCenter) + }) + + return () => { + gme.clearInstanceListeners(circle) + } + }, [circle]) + + return circle +} + +/** + * Component to render a circle on a map + */ +// eslint-disable-next-line react/display-name +export const Circle = forwardRef((props: CircleProps, ref: CircleRef) => { + const circle = useCircle(props) + + useImperativeHandle(ref, () => circle) + + return null +}) diff --git a/src/components/map/marker.tsx b/src/components/map/marker.tsx new file mode 100644 index 00000000..3abe1e5e --- /dev/null +++ b/src/components/map/marker.tsx @@ -0,0 +1,34 @@ +import React, { useState } from 'react' +import { + AdvancedMarker, + InfoWindow, + useAdvancedMarkerRef, +} from '@vis.gl/react-google-maps' + +export const MarkerWithInfowindow = () => { + const [infowindowOpen, setInfowindowOpen] = useState(true) + const [markerRef, marker] = useAdvancedMarkerRef() + + return ( + <> + setInfowindowOpen(true)} + position={{ lat: 28, lng: -82 }} + title={'AdvancedMarker that opens an Infowindow when clicked.'} + /> + {infowindowOpen && ( + setInfowindowOpen(false)} + className={'text-black'} + > + This is an example for the{' '} + <AdvancedMarker />{' '} + combined with an Infowindow. + + )} + + ) +} diff --git a/src/components/map/polygon.tsx b/src/components/map/polygon.tsx new file mode 100644 index 00000000..893d53ce --- /dev/null +++ b/src/components/map/polygon.tsx @@ -0,0 +1,134 @@ +/* eslint-disable complexity */ +import { + forwardRef, + useContext, + useEffect, + useImperativeHandle, + useMemo, + useRef, +} from 'react' + +import { GoogleMapsContext, useMapsLibrary } from '@vis.gl/react-google-maps' + +import type { Ref } from 'react' + +type PolygonEventProps = { + onClick?: (e: google.maps.MapMouseEvent) => void + onDrag?: (e: google.maps.MapMouseEvent) => void + onDragStart?: (e: google.maps.MapMouseEvent) => void + onDragEnd?: (e: google.maps.MapMouseEvent) => void + onMouseOver?: (e: google.maps.MapMouseEvent) => void + onMouseOut?: (e: google.maps.MapMouseEvent) => void +} + +type PolygonCustomProps = { + /** + * this is an encoded string for the path, will be decoded and used as a path + */ + encodedPaths?: string[] +} + +export type PolygonProps = google.maps.PolygonOptions & + PolygonEventProps & + PolygonCustomProps + +export type PolygonRef = Ref + +function usePolygon(props: PolygonProps) { + const { + onClick, + onDrag, + onDragStart, + onDragEnd, + onMouseOver, + onMouseOut, + encodedPaths, + ...polygonOptions + } = props + // This is here to avoid triggering the useEffect below when the callbacks change (which happen if the user didn't memoize them) + const callbacks = useRef void>>({}) + Object.assign(callbacks.current, { + onClick, + onDrag, + onDragStart, + onDragEnd, + onMouseOver, + onMouseOut, + }) + + const geometryLibrary = useMapsLibrary('geometry') + + const polygon = useRef(new google.maps.Polygon()).current + // update PolygonOptions (note the dependencies aren't properly checked + // here, we just assume that setOptions is smart enough to not waste a + // lot of time updating values that didn't change) + useMemo(() => { + polygon.setOptions(polygonOptions) + }, [polygon, polygonOptions]) + + const map = useContext(GoogleMapsContext)?.map + + // update the path with the encodedPath + useMemo(() => { + if (!encodedPaths || !geometryLibrary) return + const paths = encodedPaths.map((path) => + geometryLibrary.encoding.decodePath(path) + ) + polygon.setPaths(paths) + }, [polygon, encodedPaths, geometryLibrary]) + + // create polygon instance and add to the map once the map is available + useEffect(() => { + if (!map) { + if (map === undefined) + console.error(' has to be inside a Map component.') + + return + } + + polygon.setMap(map) + + return () => { + polygon.setMap(null) + } + }, [map]) + + // attach and re-attach event-handlers when any of the properties change + useEffect(() => { + if (!polygon) return + + // Add event listeners + const gme = google.maps.event + ;[ + ['click', 'onClick'], + ['drag', 'onDrag'], + ['dragstart', 'onDragStart'], + ['dragend', 'onDragEnd'], + ['mouseover', 'onMouseOver'], + ['mouseout', 'onMouseOut'], + ].forEach(([eventName, eventCallback]) => { + gme.addListener(polygon, eventName, (e: google.maps.MapMouseEvent) => { + const callback = callbacks.current[eventCallback] + if (callback) callback(e) + }) + }) + + return () => { + gme.clearInstanceListeners(polygon) + } + }, [polygon]) + + return polygon +} + +/** + * Component to render a polygon on a map + */ +// eslint-disable-next-line react/display-name +export const Polygon = forwardRef((props: PolygonProps, ref: PolygonRef) => { + const polygon = usePolygon(props) + + useImperativeHandle(ref, () => polygon, []) + + return null +}) diff --git a/src/components/map/polyline.tsx b/src/components/map/polyline.tsx new file mode 100644 index 00000000..a54d6739 --- /dev/null +++ b/src/components/map/polyline.tsx @@ -0,0 +1,132 @@ +/* eslint-disable complexity */ +import { + forwardRef, + useContext, + useEffect, + useImperativeHandle, + useMemo, + useRef, +} from 'react' + +import { GoogleMapsContext, useMapsLibrary } from '@vis.gl/react-google-maps' + +import type { Ref } from 'react' + +type PolylineEventProps = { + onClick?: (e: google.maps.MapMouseEvent) => void + onDrag?: (e: google.maps.MapMouseEvent) => void + onDragStart?: (e: google.maps.MapMouseEvent) => void + onDragEnd?: (e: google.maps.MapMouseEvent) => void + onMouseOver?: (e: google.maps.MapMouseEvent) => void + onMouseOut?: (e: google.maps.MapMouseEvent) => void +} + +type PolylineCustomProps = { + /** + * this is an encoded string for the path, will be decoded and used as a path + */ + encodedPath?: string +} + +export type PolylineProps = google.maps.PolylineOptions & + PolylineEventProps & + PolylineCustomProps + +export type PolylineRef = Ref + +function usePolyline(props: PolylineProps) { + const { + onClick, + onDrag, + onDragStart, + onDragEnd, + onMouseOver, + onMouseOut, + encodedPath, + ...polylineOptions + } = props + // This is here to avoid triggering the useEffect below when the callbacks change (which happen if the user didn't memoize them) + const callbacks = useRef void>>({}) + Object.assign(callbacks.current, { + onClick, + onDrag, + onDragStart, + onDragEnd, + onMouseOver, + onMouseOut, + }) + + const geometryLibrary = useMapsLibrary('geometry') + + const polyline = useRef(new google.maps.Polyline()).current + // update PolylineOptions (note the dependencies aren't properly checked + // here, we just assume that setOptions is smart enough to not waste a + // lot of time updating values that didn't change) + useMemo(() => { + polyline.setOptions(polylineOptions) + }, [polyline, polylineOptions]) + + const map = useContext(GoogleMapsContext)?.map + + // update the path with the encodedPath + useMemo(() => { + if (!encodedPath || !geometryLibrary) return + const path = geometryLibrary.encoding.decodePath(encodedPath) + polyline.setPath(path) + }, [polyline, encodedPath, geometryLibrary]) + + // create polyline instance and add to the map once the map is available + useEffect(() => { + if (!map) { + if (map === undefined) + console.error(' has to be inside a Map component.') + + return + } + + polyline.setMap(map) + + return () => { + polyline.setMap(null) + } + }, [map]) + + // attach and re-attach event-handlers when any of the properties change + useEffect(() => { + if (!polyline) return + + // Add event listeners + const gme = google.maps.event + ;[ + ['click', 'onClick'], + ['drag', 'onDrag'], + ['dragstart', 'onDragStart'], + ['dragend', 'onDragEnd'], + ['mouseover', 'onMouseOver'], + ['mouseout', 'onMouseOut'], + ].forEach(([eventName, eventCallback]) => { + gme.addListener(polyline, eventName, (e: google.maps.MapMouseEvent) => { + const callback = callbacks.current[eventCallback] + if (callback) callback(e) + }) + }) + + return () => { + gme.clearInstanceListeners(polyline) + } + }, [polyline]) + + return polyline +} + +/** + * Component to render a polyline on a map + */ +// eslint-disable-next-line react/display-name +export const Polyline = forwardRef((props: PolylineProps, ref: PolylineRef) => { + const polyline = usePolyline(props) + + useImperativeHandle(ref, () => polyline, []) + + return null +}) diff --git a/src/utils/types/models.ts b/src/utils/types/models.ts index 37ea764c..d1d7b6c0 100644 --- a/src/utils/types/models.ts +++ b/src/utils/types/models.ts @@ -112,6 +112,27 @@ export namespace UserData { } } +export namespace Location { + /** + * This data is returned from the API by calling the location endpoint. + * @see LocationDocumentsType + */ + export interface LocationType { + total: number + documents: LocationDocumentsType[] + } + + /** + * This data is returned in the friends/map view. + * @see UserDataDocumentsType + */ + export interface LocationDocumentsType extends Models.Document { + lat: number + long: number + userData: UserData.UserDataDocumentsType + } +} + export namespace Announcements { /** * This data is returned from the API by calling the getAnnouncements function.