Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
81387f8
feat(projects): add station, capture, occurrence and member totals to…
mihow Oct 7, 2026
b200a01
feat(ui): add a sortable table view to the projects page
mihow Oct 7, 2026
c4b89c0
feat(projects): load project totals and recent activity only for the …
mihow Oct 7, 2026
c81d4ea
feat(ui): show cover images and recent activity in the projects table
mihow Oct 7, 2026
a99951e
refactor: name the projects overview fields once and show team size o…
mihow Oct 8, 2026
7e9c242
test: keep one projects overview test per guarantee
mihow Oct 8, 2026
2d00068
feat(projects): add a taxa count to the projects table and trim the d…
mihow Oct 8, 2026
f170a8c
feat(ui): show when each project was last updated in the projects table
mihow Oct 8, 2026
c20c849
fix(ui): hide the projects table's Updated at column by default
mihow Oct 8, 2026
fd6bf93
feat(projects): always return activity dates, and name the taxa count…
mihow Oct 8, 2026
06987f5
refactor(ui): ask for project totals through the shared withCounts fe…
mihow Oct 8, 2026
ac6b1ac
test: build the projects overview test data once per class
mihow Oct 8, 2026
7e4ff78
fix(projects): show team size to anyone allowed to open the team list
mihow Oct 8, 2026
2552c8b
fix(ui): keep the projects toolbar in place when switching views
mihow Oct 8, 2026
f65d7bc
fix(ui): stop the Create new button from blinking out while projects …
mihow Oct 8, 2026
57d01ec
feat(projects): return each project's centre point in the project list
mihow Oct 7, 2026
c4596f7
feat(ui): show projects on a map on the projects page
mihow Oct 7, 2026
791e9fc
fix(ui): stop the map from crashing when a search leaves no markers
mihow Oct 7, 2026
a476ec1
feat(ui): ask for project centres only in the map view
mihow Oct 7, 2026
8459dbe
fix(ui): cap the map zoom so a single marker shows its region instead…
mihow Oct 7, 2026
80748f0
fix(ui): keep station maps zooming to their marker, cap only the proj…
mihow Oct 7, 2026
f1b5d58
feat(ui): show every project on the map without loading the table totals
mihow Oct 8, 2026
553454b
feat(ui): cap the projects map at 300 projects
mihow Oct 8, 2026
bb5ebee
feat(ui): open map marker popups on hover as well as on click or tap
mihow Oct 8, 2026
71c1e6d
refactor(projects): return a project's position as a location object …
mihow Oct 8, 2026
4f754e4
fix(ui): show an error on the projects map when the request fails
mihow Oct 8, 2026
2e03518
Merge remote-tracking branch 'origin/main' into feat/projects-map
mihow Oct 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 19 additions & 3 deletions ami/main/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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",
]


Expand Down
2 changes: 2 additions & 0 deletions ami/main/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
18 changes: 18 additions & 0 deletions ami/main/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand Down Expand Up @@ -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
Expand Down
18 changes: 17 additions & 1 deletion ami/main/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"})
Expand Down
28 changes: 23 additions & 5 deletions ui/src/components/map/multi-marker-map/multi-marker-map.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<L.Map>(null)

Expand All @@ -37,22 +50,26 @@ export const MultiMarkerMap = ({

useEffect(() => {
requestAnimationFrame(() => {
mapRef.current?.fitBounds(bounds)
mapRef.current?.fitBounds(bounds, { maxZoom })
})
}, [mapRef, bounds])
}, [mapRef, bounds, maxZoom])

if (isLoading) {
return (
<div className={styles.mapContainer}>
<div className={classNames(styles.mapContainer, className)}>
<LoadingSpinner />
</div>
)
}

return (
<MapContainer
center={bounds.getCenter()}
className={styles.mapContainer}
// Fitting the bounds on creation gives the map a view before the minimap
// reads it, also when there are no markers. The zoom cap stops a single
// marker resolving to an infinite zoom before the tile layer sets a maximum.
bounds={bounds}
boundsOptions={{ maxZoom: DEFAULT_ZOOM }}
className={classNames(styles.mapContainer, className)}
maxBounds={MAX_BOUNDS}
minZoom={MIN_ZOOM}
ref={mapRef}
Expand All @@ -64,6 +81,7 @@ export const MultiMarkerMap = ({
key={index}
position={marker.position}
interactive={!!marker.popupContent}
eventHandlers={marker.popupContent ? POPUP_EVENT_HANDLERS : undefined}
>
{marker.popupContent ? (
<Popup offset={[0, -32]}>{marker.popupContent}</Popup>
Expand Down
9 changes: 9 additions & 0 deletions ui/src/data-services/models/project.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { MarkerPosition } from 'components/map/types'
import { UserPermission } from 'utils/user/types'
import { Deployment, ServerDeployment } from './deployment'

Expand All @@ -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)
Expand Down
63 changes: 63 additions & 0 deletions ui/src/pages/projects/projects-map.tsx
Original file line number Diff line number Diff line change
@@ -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: <ProjectsMapPopupContent project={project} />,
},
]
: []
),
[projects]
)

if (error) {
return <ErrorState error={error} />
}

return (
<MultiMarkerMap
className="h-[calc(100vh-320px)] min-h-[400px]"
isLoading={isLoading}
markers={markers}
// A project marker is a mean position, so street level would mislead.
maxZoom={DEFAULT_ZOOM}
/>
)
}

const ProjectsMapPopupContent = ({ project }: { project: Project }) => (
<InfoBlock
fields={[
{
label: translate(STRING.FIELD_LABEL_PROJECT),
value: project.name,
to: APP_ROUTES.PROJECT_DETAILS({ projectId: project.id }),
},
{
label: translate(STRING.NAV_ITEM_DEPLOYMENTS),
value: project.numDeployments,
},
]}
/>
)
19 changes: 16 additions & 3 deletions ui/src/pages/projects/projects.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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',
Expand Down Expand Up @@ -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,
Comment thread
mihow marked this conversation as resolved.
filters,
sort,
// Totals are only shown, and only computed, in the table.
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -157,6 +168,8 @@ export const Projects = () => {
sortable
sortSettings={sort}
/>
) : layout === 'map' ? (
<ProjectsMap error={error} isLoading={isLoading} projects={projects} />
) : (
<ProjectGallery
error={error}
Expand All @@ -165,7 +178,7 @@ export const Projects = () => {
/>
)}
<PageFooter>
{projects?.length ? (
{projects?.length && layout !== 'map' ? (
<PaginationBar
pagination={pagination}
total={total}
Expand Down
2 changes: 2 additions & 0 deletions ui/src/utils/language.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,7 @@ export enum STRING {
TAB_ITEM_CHARTS,
TAB_ITEM_FIELDS,
TAB_ITEM_GALLERY,
TAB_ITEM_MAP,
TAB_ITEM_IDENTIFICATION,
TAB_ITEM_MY_PROJECTS,
TAB_ITEM_SESSION,
Expand Down Expand Up @@ -683,6 +684,7 @@ const ENGLISH_STRINGS: { [key in STRING]: string } = {
[STRING.TAB_ITEM_CHARTS]: 'Charts',
[STRING.TAB_ITEM_FIELDS]: 'Fields',
[STRING.TAB_ITEM_GALLERY]: 'Gallery view',
[STRING.TAB_ITEM_MAP]: 'Map view',
[STRING.TAB_ITEM_IDENTIFICATION]: 'Identification',
[STRING.TAB_ITEM_MY_PROJECTS]: 'My projects',
[STRING.TAB_ITEM_SESSION]: 'Session',
Expand Down
Loading