diff --git a/ami/main/api/serializers.py b/ami/main/api/serializers.py index c46806b48..a879d00e6 100644 --- a/ami/main/api/serializers.py +++ b/ami/main/api/serializers.py @@ -5,6 +5,7 @@ from drf_spectacular.utils import extend_schema_field from guardian.shortcuts import get_perms from rest_framework import serializers +from rest_framework.fields import SkipField from rest_framework.request import Request from ami.base.fields import DateStringField @@ -305,6 +306,19 @@ class Meta: ] +class ProjectLocationSerializer(serializers.Serializer): + """Where a project's stations are, from ProjectQuerySet.with_location(); null when none has coordinates.""" + + latitude = serializers.FloatField(source="centroid_latitude") + longitude = serializers.FloatField(source="centroid_longitude") + + def get_attribute(self, instance): + # Left out of the response unless the list asked for it with ?with_location. + if not hasattr(instance, "centroid_latitude"): + raise SkipField + return instance if instance.centroid_latitude is not None else None + + class ProjectListSerializer(DefaultSerializer): deployments_count = serializers.IntegerField(read_only=True) captures_count = serializers.IntegerField(read_only=True) @@ -315,6 +329,7 @@ class ProjectListSerializer(DefaultSerializer): last_job_updated_at = serializers.DateTimeField(read_only=True) # Set on the page by add_taxa_counts(). taxa_observed_count = serializers.IntegerField(read_only=True) + location = ProjectLocationSerializer(source="*", read_only=True) class Meta: model = Project @@ -328,14 +343,15 @@ class Meta: "image", "draft", ] - # The list always adds the activity dates. The totals are added when it asks for them with - # ?with_counts and left out otherwise, except deployments_count, which falls back to the - # model method. + # The list always adds the activity dates. The totals and location are added when it asks for + # them with ?with_counts or ?with_location and left out otherwise, except deployments_count, + # which falls back to the model method. fields = [ *base_fields, *ProjectQuerySet.RECENT_ACTIVITY_FIELDS, *ProjectQuerySet.OVERVIEW_COUNT_FIELDS, "taxa_observed_count", + "location", ] diff --git a/ami/main/api/views.py b/ami/main/api/views.py index b8c0fdb88..4f8b19224 100644 --- a/ami/main/api/views.py +++ b/ami/main/api/views.py @@ -216,6 +216,8 @@ def get_queryset(self): ): # The annotated deployments_count replaces counting prefetched deployments, so skip the prefetch. qs = qs.prefetch_related(None).with_overview_counts(self.request.user) + if self.action == "list" and url_boolean_param(self.request, "with_location"): + qs = qs.with_location() return qs def paginate_queryset(self, queryset): diff --git a/ami/main/models.py b/ami/main/models.py index e7697db58..0127371e3 100644 --- a/ami/main/models.py +++ b/ami/main/models.py @@ -267,6 +267,8 @@ class ProjectQuerySet(BaseQuerySet): # serializes by these names. OVERVIEW_COUNT_FIELDS = ("deployments_count", "captures_count", "occurrences_count", "members_count") RECENT_ACTIVITY_FIELDS = ("last_capture_timestamp", "last_occurrence_updated_at", "last_job_updated_at") + # Annotated by with_location(). + LOCATION_FIELDS = ("centroid_latitude", "centroid_longitude") def filter_by_user(self, user: User): """ @@ -322,6 +324,22 @@ def latest(queryset: models.QuerySet, field: str) -> Subquery: last_job_updated_at=latest(Job.objects.all(), "updated_at"), ) + def with_location(self) -> "ProjectQuerySet": + """ + Annotate LOCATION_FIELDS: the centroid of the project's stations, as the mean of their coordinates. + + Stations without coordinates, or at (0, 0) where default stations are created, are left + out; a project with none of its own gets nulls. A plain mean is wrong for stations on + both sides of the antimeridian. + """ + located = Deployment.objects.filter(latitude__isnull=False, longitude__isnull=False).exclude( + latitude=0, longitude=0 + ) + return self.annotate( + centroid_latitude=_per_project(located, models.Avg("latitude")), + centroid_longitude=_per_project(located, models.Avg("longitude")), + ) + class ProjectManager(models.Manager.from_queryset(ProjectQuerySet)): pass diff --git a/ami/main/tests.py b/ami/main/tests.py index 7c67ea697..6c4a200de 100644 --- a/ami/main/tests.py +++ b/ami/main/tests.py @@ -2446,9 +2446,25 @@ def test_totals_are_left_out_unless_asked_for(self): row = next(row for row in response.json()["results"] if row["id"] == self.busy.pk) self.assertEqual(row["deployments_count"], 2) self.assertEqual(row["last_capture_timestamp"][:16], self.last_capture.isoformat()[:16]) - for field in ["captures_count", "members_count", "taxa_observed_count"]: + for field in ["captures_count", "members_count", "taxa_observed_count", "location"]: self.assertNotIn(field, row) + def test_location_ignores_stations_without_real_coordinates(self): + stations = list(Deployment.objects.filter(project=self.busy).order_by("name")) + Deployment.objects.filter(pk=stations[0].pk).update(latitude=45.0, longitude=-73.0) + Deployment.objects.filter(pk=stations[1].pk).update(latitude=47.0, longitude=-71.0) + Deployment.objects.create(name="Placeholder", project=self.busy, latitude=0, longitude=0) + Deployment.objects.create(name="Unknown", project=self.busy) + + # The map asks for locations without the totals. + response = self.client.get(self.endpoint, {"with_location": "true"}) + rows = {row["id"]: row for row in response.json()["results"]} + busy, empty = rows[self.busy.pk], rows[self.empty.pk] + self.assertNotIn("captures_count", busy) + self.assertAlmostEqual(busy["location"]["latitude"], 46.0) + self.assertAlmostEqual(busy["location"]["longitude"], -72.0) + self.assertIsNone(empty["location"]) + def test_sorting_by_a_total_works_without_asking_for_totals(self): """A sort carried over from the table to the gallery still gets the totals it orders by.""" response = self.client.get(self.endpoint, {"ordering": "-captures_count"}) diff --git a/ui/src/components/map/multi-marker-map/multi-marker-map.tsx b/ui/src/components/map/multi-marker-map/multi-marker-map.tsx index e5fd4420e..56d113e49 100644 --- a/ui/src/components/map/multi-marker-map/multi-marker-map.tsx +++ b/ui/src/components/map/multi-marker-map/multi-marker-map.tsx @@ -1,9 +1,11 @@ +import classNames from 'classnames' import * as L from 'leaflet' import { LoadingSpinner } from 'nova-ui-kit' import { useEffect, useMemo, useRef } from 'react' import { MapContainer, Marker, Popup, TileLayer } from 'react-leaflet' import { ATTRIBUTION, + DEFAULT_ZOOM, MAX_BOUNDS, MIN_ZOOM, TILE_LAYER_URL, @@ -15,12 +17,23 @@ import { MarkerPosition } from '../types' setup() +// Hovering opens a marker's popup. Leaflet toggles the popup on click, and a tap also +// reports a hover, so a click reopens it after the toggle to leave it open either way. +const POPUP_EVENT_HANDLERS: L.LeafletEventHandlerFnMap = { + mouseover: (e) => e.target.openPopup(), + click: (e) => setTimeout(() => e.target.openPopup()), +} + export const MultiMarkerMap = ({ + className, markers, isLoading, + maxZoom, }: { + className?: string markers: { position: MarkerPosition; popupContent?: JSX.Element }[] isLoading?: boolean + maxZoom?: number }) => { const mapRef = useRef(null) @@ -37,13 +50,13 @@ export const MultiMarkerMap = ({ useEffect(() => { requestAnimationFrame(() => { - mapRef.current?.fitBounds(bounds) + mapRef.current?.fitBounds(bounds, { maxZoom }) }) - }, [mapRef, bounds]) + }, [mapRef, bounds, maxZoom]) if (isLoading) { return ( -
+
) @@ -51,8 +64,12 @@ export const MultiMarkerMap = ({ return ( {marker.popupContent ? ( {marker.popupContent} diff --git a/ui/src/data-services/models/project.ts b/ui/src/data-services/models/project.ts index b86dc4f57..e7d3e94b3 100644 --- a/ui/src/data-services/models/project.ts +++ b/ui/src/data-services/models/project.ts @@ -1,3 +1,4 @@ +import { MarkerPosition } from 'components/map/types' import { UserPermission } from 'utils/user/types' import { Deployment, ServerDeployment } from './deployment' @@ -18,6 +19,14 @@ export class Project { return this._project.user_permissions.includes(UserPermission.Delete) } + get location(): MarkerPosition | undefined { + const { location } = this._project + + return location + ? new MarkerPosition(location.latitude, location.longitude) + : undefined + } + get createdAt(): Date | undefined { return this._project.created_at ? new Date(this._project.created_at) diff --git a/ui/src/pages/projects/projects-map.tsx b/ui/src/pages/projects/projects-map.tsx new file mode 100644 index 000000000..c7c2832f6 --- /dev/null +++ b/ui/src/pages/projects/projects-map.tsx @@ -0,0 +1,63 @@ +import { ErrorState } from 'components/error-state/error-state' +import { DEFAULT_ZOOM } from 'components/map/config' +import { MultiMarkerMap } from 'components/map/multi-marker-map/multi-marker-map' +import { Project } from 'data-services/models/project' +import { InfoBlock } from 'nova-ui-kit' +import { useMemo } from 'react' +import { APP_ROUTES } from 'utils/constants' +import { STRING, translate } from 'utils/language' + +export const ProjectsMap = ({ + error, + isLoading, + projects = [], +}: { + error?: any + isLoading: boolean + projects?: Project[] +}) => { + const markers = useMemo( + () => + projects.flatMap((project) => + project.location + ? [ + { + position: project.location, + popupContent: , + }, + ] + : [] + ), + [projects] + ) + + if (error) { + return + } + + return ( + + ) +} + +const ProjectsMapPopupContent = ({ project }: { project: Project }) => ( + +) diff --git a/ui/src/pages/projects/projects.tsx b/ui/src/pages/projects/projects.tsx index bf88eb73b..b4249454a 100644 --- a/ui/src/pages/projects/projects.tsx +++ b/ui/src/pages/projects/projects.tsx @@ -1,6 +1,6 @@ import { SearchInput } from 'components/search-input/search-input' import { useProjects } from 'data-services/hooks/projects/useProjects' -import { Grid2X2Icon, TableIcon } from 'lucide-react' +import { Grid2X2Icon, MapIcon, TableIcon } from 'lucide-react' import { Button, ColumnSettings, @@ -24,6 +24,10 @@ import { useSelectedView } from 'utils/useSelectedView' import { useSort } from 'utils/useSort' import { columns } from './project-columns' import { ProjectGallery } from './project-gallery' +import { ProjectsMap } from './projects-map' + +// The map shows every project at once rather than a page of them. +const MAP_LIMIT = 300 export const TABS = { MY_PROJECTS: 'my-projects', @@ -61,10 +65,12 @@ export const Projects = () => { ? [{ field: 'user_id', value: userInfo?.id }] : []), ...(search ? [{ field: 'search', value: search }] : []), + ...(layout === 'map' ? [{ field: 'with_location', value: 'true' }] : []), ] const { projects, total, userPermissions, isLoading, isFetching, error } = useProjects({ - pagination, + pagination: + layout === 'map' ? { page: 0, perPage: MAP_LIMIT } : pagination, filters, sort, // Totals are only shown, and only computed, in the table. @@ -119,6 +125,11 @@ export const Projects = () => { label: translate(STRING.TAB_ITEM_GALLERY), Icon: Grid2X2Icon, }, + { + value: 'map', + label: translate(STRING.TAB_ITEM_MAP), + Icon: MapIcon, + }, ]} value={layout} onValueChange={setLayout} @@ -157,6 +168,8 @@ export const Projects = () => { sortable sortSettings={sort} /> + ) : layout === 'map' ? ( + ) : ( { /> )} - {projects?.length ? ( + {projects?.length && layout !== 'map' ? (