From 1e9126a3743c50afcf5bea11a51b5836bb385289 Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Wed, 25 Mar 2026 12:02:03 -0400 Subject: [PATCH 01/21] backend recording-locations endpoint --- bats_ai/core/api.py | 4 + bats_ai/core/management/commands/loadGRTS.py | 14 +- .../0035_add_grtscells_centroid_4326.py | 43 +++ bats_ai/core/models/grts_cells.py | 2 + bats_ai/core/views/__init__.py | 2 + bats_ai/core/views/grts_cells.py | 5 +- bats_ai/core/views/recording_location.py | 260 ++++++++++++++++++ 7 files changed, 325 insertions(+), 5 deletions(-) create mode 100644 bats_ai/core/migrations/0035_add_grtscells_centroid_4326.py create mode 100644 bats_ai/core/views/recording_location.py diff --git a/bats_ai/core/api.py b/bats_ai/core/api.py index 51008c2c..0d6a8d49 100644 --- a/bats_ai/core/api.py +++ b/bats_ai/core/api.py @@ -28,6 +28,10 @@ def global_auth(request): api = NinjaAPI(auth=global_auth) api.add_router("/recording/", views.recording_router) +api.add_router( + "/recording-locations/", + views.recording_locations_router, +) api.add_router("/species/", views.species_router) api.add_router("/grts/", views.grts_cells_router) api.add_router("/guano/", views.guano_metadata_router) diff --git a/bats_ai/core/management/commands/loadGRTS.py b/bats_ai/core/management/commands/loadGRTS.py index e93946dc..3f13605a 100644 --- a/bats_ai/core/management/commands/loadGRTS.py +++ b/bats_ai/core/management/commands/loadGRTS.py @@ -53,7 +53,10 @@ class Command(BaseCommand): def add_arguments(self, parser): parser.add_argument( - "--batch-size", type=int, default=5000, help="Batch size for database insertion" + "--batch-size", + type=int, + default=5000, + help="Batch size for database insertion", ) def _download_file(self, url: str, zip_path: Path) -> None: @@ -78,7 +81,8 @@ def handle(self, *args, **options): self._download_file(url, zip_path) except requests.RequestException as e: logger.warning( - "Failed to download from primary URL: %s. Attempting backup URL...", e + "Failed to download from primary URL: %s. Attempting backup URL...", + e, ) if backup_url is None: logger.warning("No backup URL provided, skipping this shapefile.") @@ -117,7 +121,9 @@ def handle(self, *args, **options): count_new = 0 for idx, row in tqdm( - gdf.iterrows(), total=len(gdf), desc=f"Importing {sample_frame_id}" + gdf.iterrows(), + total=len(gdf), + desc=f"Importing {sample_frame_id}", ): # Hard fail if GRTS_ID is missing if "GRTS_ID" not in row or row["GRTS_ID"] is None: @@ -131,6 +137,7 @@ def handle(self, *args, **options): continue geom_4326 = row.geometry.wkt + centroid_4326 = row.geometry.centroid.wkt if gdf.crs and gdf.crs.to_epsg() != 4326: grts_geom = row.geometry.to_wkt() else: @@ -142,6 +149,7 @@ def handle(self, *args, **options): sample_frame_id=sample_frame_id, grts_geom=grts_geom, geom_4326=geom_4326, + centroid_4326=centroid_4326, ) records_to_create.append(cell) count_new += 1 diff --git a/bats_ai/core/migrations/0035_add_grtscells_centroid_4326.py b/bats_ai/core/migrations/0035_add_grtscells_centroid_4326.py new file mode 100644 index 00000000..31633850 --- /dev/null +++ b/bats_ai/core/migrations/0035_add_grtscells_centroid_4326.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from django.db import migrations, models + + +def backfill_centroids(apps, schema_editor) -> None: + GRTSCells = apps.get_model("core", "GRTSCells") + + batch_size = 1000 + to_update = [] + + qs = ( + GRTSCells.objects.filter(centroid_4326__isnull=True) + .exclude(geom_4326__isnull=True) + .only("id", "grts_cell_id", "sample_frame_id", "geom_4326") + ) + + for cell in qs.iterator(chunk_size=batch_size): + # `centroid` returns a Point geometry in the same SRID. + cell.centroid_4326 = cell.geom_4326.centroid + to_update.append(cell) + + if len(to_update) >= batch_size: + GRTSCells.objects.bulk_update(to_update, ["centroid_4326"], batch_size=batch_size) + to_update.clear() + + if to_update: + GRTSCells.objects.bulk_update(to_update, ["centroid_4326"], batch_size=batch_size) + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0034_alter_spectrogramimage_type"), + ] + + operations = [ + migrations.AddField( + model_name="grtscells", + name="centroid_4326", + field=models.PointField(srid=4326, null=True, blank=True), + ), + migrations.RunPython(backfill_centroids, migrations.RunPython.noop), + ] diff --git a/bats_ai/core/models/grts_cells.py b/bats_ai/core/models/grts_cells.py index c0de5144..270f0a76 100644 --- a/bats_ai/core/models/grts_cells.py +++ b/bats_ai/core/models/grts_cells.py @@ -24,6 +24,8 @@ class GRTSCells(models.Model): grts_geom = models.GeometryField(blank=True, null=True) # continue defining all fields similarly geom_4326 = models.GeometryField() + # Precomputed centroid of `geom_4326` for faster lookup of cell centers. + centroid_4326 = models.PointField(srid=4326, blank=True, null=True) @property def sample_frame_mapping(self): diff --git a/bats_ai/core/views/__init__.py b/bats_ai/core/views/__init__.py index 70331d60..39c9fd22 100644 --- a/bats_ai/core/views/__init__.py +++ b/bats_ai/core/views/__init__.py @@ -7,6 +7,7 @@ from .processing_tasks import router as processing_task_router from .recording import router as recording_router from .recording_annotation import router as recording_annotation_router +from .recording_location import router as recording_locations_router from .recording_tag import router as recording_tag_router from .species import router as species_router from .vetting_details import router as vetting_router @@ -18,6 +19,7 @@ "guano_metadata_router", "processing_task_router", "recording_annotation_router", + "recording_locations_router", "recording_router", "recording_tag_router", "species_router", diff --git a/bats_ai/core/views/grts_cells.py b/bats_ai/core/views/grts_cells.py index 02337ad2..c335ea23 100644 --- a/bats_ai/core/views/grts_cells.py +++ b/bats_ai/core/views/grts_cells.py @@ -50,8 +50,6 @@ def custom_sort_key(cell): geom_4326 = cell.geom_4326 # Get the centroid of the entire cell polygon - center = geom_4326.centroid - if quadrant: # If quadrant is specified, divide the cell polygon into quadrants min_x, min_y, max_x, max_y = geom_4326.extent @@ -75,6 +73,9 @@ def custom_sort_key(cell): # Get the centroid of the intersected polygon center = quadrant_polygon.centroid + else: + # Prefer the stored centroid (precomputed on import/migration). + center = cell.centroid_4326 if cell.centroid_4326 is not None else geom_4326.centroid # Get the latitude and longitude of the centroid center_latitude = center.y diff --git a/bats_ai/core/views/recording_location.py b/bats_ai/core/views/recording_location.py new file mode 100644 index 00000000..e6794260 --- /dev/null +++ b/bats_ai/core/views/recording_location.py @@ -0,0 +1,260 @@ +from __future__ import annotations + +import json +import logging +from typing import TYPE_CHECKING, Any, Literal + +from django.db.models import Q, QuerySet +from ninja import Query, Router, Schema +from ninja.errors import HttpError + +from bats_ai.core.models import Configuration, GRTSCells, Recording, RecordingAnnotation + +if TYPE_CHECKING: + from django.http import HttpRequest + +logger = logging.getLogger(__name__) + +router = Router() + +_GRTS_CUSTOM_ORDER: list[int] = GRTSCells.sort_order() +_GRTS_ORDER_MAP: dict[int, int] = {frame_id: idx for idx, frame_id in enumerate(_GRTS_CUSTOM_ORDER)} + + +class RecordingLocationsQuerySchema(Schema): + # When true, exclude recordings the current user has already submitted. + exclude_submitted: bool | None = None + # Comma-separated tag texts; recording must have all listed tags. + tags: str | None = None + # Bounding box filter (lon/lat) as `[min_lon, min_lat, max_lon, max_lat]`. + bbox: str | None = None + + +class RecordingLocationsFeaturePropertiesSchema(Schema): + recording_id: int + filename: str + + +class RecordingLocationsFeatureGeometrySchema(Schema): + type: Literal["Point"] = "Point" + coordinates: list[float] + + +class RecordingLocationsFeatureSchema(Schema): + type: Literal["Feature"] = "Feature" + geometry: RecordingLocationsFeatureGeometrySchema + properties: RecordingLocationsFeaturePropertiesSchema + + +class RecordingLocationsResponseSchema(Schema): + type: Literal["FeatureCollection"] = "FeatureCollection" + features: list[RecordingLocationsFeatureSchema] + + +def _split_tags(tags: str | None) -> list[str]: + if not tags: + return [] + return [t.strip() for t in tags.split(",") if t.strip()] + + +def _apply_recording_filters_and_sort( + *, + qs: QuerySet[Recording], + request: HttpRequest, + exclude_submitted: bool | None, + tags: str | None, +) -> QuerySet[Recording]: + if exclude_submitted: + submitted_by_user = RecordingAnnotation.objects.filter( + owner=request.user, submitted=True + ).values_list("recording_id", flat=True) + qs = qs.exclude(pk__in=submitted_by_user) + + tag_list = _split_tags(tags) + if tag_list: + for tag in tag_list: + qs = qs.filter(tags__text=tag) + qs = qs.distinct() + + # Keep deterministic ordering even though we don't expose sorting params. + return qs.order_by("-created") + + +def _coords_in_bbox( + coords: list[float], + *, + min_lon: float, + min_lat: float, + max_lon: float, + max_lat: float, +) -> bool: + lon, lat = coords + return min_lon <= lon <= max_lon and min_lat <= lat <= max_lat + + +def _parse_bbox(bbox: str | None) -> tuple[float, float, float, float] | None: + if not bbox: + return None + + raw = bbox.strip() + try: + # Prefer JSON array format: bbox=[43,73,42,45] + if raw.startswith("[") and raw.endswith("]"): + values = json.loads(raw) + else: + # Allow bbox=43,73,42,45 as a convenience. + values = [v.strip() for v in raw.split(",")] + except Exception as e: # pragma: no cover + raise HttpError(400, f"Invalid bbox format: {e}") from e + + if not isinstance(values, list) or len(values) != 4: + raise HttpError(400, "bbox must contain exactly 4 numbers") + + try: + min_lon, min_lat, max_lon, max_lat = (float(v) for v in values) + except Exception as e: + raise HttpError(400, f"bbox values must be numbers: {e}") from e + + if not (-180.0 <= min_lon <= 180.0 and -180.0 <= max_lon <= 180.0): + raise HttpError(400, "bbox longitude values must be within [-180, 180]") + if not (-90.0 <= min_lat <= 90.0 and -90.0 <= max_lat <= 90.0): + raise HttpError(400, "bbox latitude values must be within [-90, 90]") + + # Normalize ordering. + if min_lon > max_lon: + min_lon, max_lon = max_lon, min_lon + if min_lat > max_lat: + min_lat, max_lat = max_lat, min_lat + + return min_lon, min_lat, max_lon, max_lat + + +def _get_recording_location_coords(recording: Recording) -> list[float] | None: + """Return `[lon, lat]` for `Recording.recording_location` if present.""" + if not recording.recording_location: + return None + + # GeoDjango geometry -> GeoJSON -> coordinates. + location_geojson = json.loads(recording.recording_location.json) + coords = location_geojson.get("coordinates") + if ( + isinstance(coords, list) + and len(coords) == 2 + and all(isinstance(v, (int, float)) for v in coords) + ): + return [float(coords[0]), float(coords[1])] + return None + + +def _precompute_grts_cell_centroids(cell_ids: set[int]) -> dict[int, list[float]]: + """Precompute centroid coordinates for each `grts_cell_id`. + + Choose the same "best" cell as `core/views/grts_cells.py` does, then compute + `[lon, lat]` from its centroid. + """ + if not cell_ids: + return {} + + centroids: dict[int, list[float]] = {} + + cells = GRTSCells.objects.filter(grts_cell_id__in=cell_ids) + cells_by_id: dict[int, list[GRTSCells]] = {} + for cell in cells: + cells_by_id.setdefault(cell.grts_cell_id, []).append(cell) + + for cell_id in cell_ids: + candidates = cells_by_id.get(cell_id, []) + if not candidates: + continue + + # Pick the same "best" cell as core/views/grts_cells.py. + best = sorted( + candidates, + key=lambda c: _GRTS_ORDER_MAP.get(c.sample_frame_id, len(_GRTS_CUSTOM_ORDER)), + )[0] + + # Prefer the stored centroid (computed during GRTS import / migrations). + if best.centroid_4326 is None: + continue + + centroids[cell_id] = [float(best.centroid_4326.x), float(best.centroid_4326.y)] + + return centroids + + +@router.get("/", response=RecordingLocationsResponseSchema) +def get_recording_locations( + request: HttpRequest, + q: Query[RecordingLocationsQuerySchema], +): + config = Configuration.objects.first() + vetting_enabled = bool(config.mark_annotations_completed_enabled) if config else False + + # Build "full" set: my + shared recordings. + my_qs = Recording.objects.filter(owner=request.user) + shared_qs = Recording.objects.filter(public=True).exclude( + Q(owner=request.user) | Q(spectrogram__isnull=True) + ) + + my_qs = _apply_recording_filters_and_sort( + qs=my_qs, + request=request, + exclude_submitted=q.exclude_submitted, + tags=q.tags, + ) + shared_qs = _apply_recording_filters_and_sort( + qs=shared_qs, + request=request, + exclude_submitted=q.exclude_submitted, + tags=q.tags, + ) + + # Evaluate querysets: we need in-Python centroid/geojson conversion. + my_list = list(my_qs) + shared_list = list(shared_qs) + recordings = my_list + shared_list + + required_cell_ids = {r.grts_cell_id for r in recordings if r.grts_cell_id is not None} + centroids_by_cell_id = _precompute_grts_cell_centroids(required_cell_ids) + + bbox = _parse_bbox(q.bbox) + + features: list[dict[str, Any]] = [] + for rec in recordings: + coords: list[float] | None = None + + if vetting_enabled: + if rec.grts_cell_id is not None: + coords = centroids_by_cell_id.get(rec.grts_cell_id) + # If we can't resolve a centroid, fall back to recording_location if present. + if coords is None: + coords = _get_recording_location_coords(rec) + else: + coords = _get_recording_location_coords(rec) + if coords is None and rec.grts_cell_id is not None: + coords = centroids_by_cell_id.get(rec.grts_cell_id) + + if coords is None: + continue + + if bbox is not None and not _coords_in_bbox( + coords, + min_lon=bbox[0], + min_lat=bbox[1], + max_lon=bbox[2], + max_lat=bbox[3], + ): + continue + + features.append( + { + "type": "Feature", + "geometry": {"type": "Point", "coordinates": coords}, + "properties": { + "recording_id": rec.id, + "filename": str(rec.audio_file), + }, + } + ) + + return {"type": "FeatureCollection", "features": features} From 8d6156c4d13db6f73b25779db898719bc310e4dd Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Wed, 25 Mar 2026 12:14:40 -0400 Subject: [PATCH 02/21] fix model reference for migration --- bats_ai/core/migrations/0035_add_grtscells_centroid_4326.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bats_ai/core/migrations/0035_add_grtscells_centroid_4326.py b/bats_ai/core/migrations/0035_add_grtscells_centroid_4326.py index 31633850..80f5fc32 100644 --- a/bats_ai/core/migrations/0035_add_grtscells_centroid_4326.py +++ b/bats_ai/core/migrations/0035_add_grtscells_centroid_4326.py @@ -1,6 +1,7 @@ from __future__ import annotations -from django.db import migrations, models +from django.contrib.gis.db import models +from django.db import migrations def backfill_centroids(apps, schema_editor) -> None: From fa658be80f6c78b5424cdc1ae193706487ef1df3 Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Wed, 25 Mar 2026 12:50:31 -0400 Subject: [PATCH 03/21] initial map view --- client/package-lock.json | 239 ++++++++++++- client/package.json | 1 + client/src/api/api.ts | 29 ++ .../src/components/RecordingLocationsMap.vue | 271 +++++++++++++++ client/src/components/RecordingTable.vue | 63 +--- client/src/main.ts | 1 + client/src/views/Recordings.vue | 317 ++++++++++++------ 7 files changed, 761 insertions(+), 160 deletions(-) create mode 100644 client/src/components/RecordingLocationsMap.vue diff --git a/client/package-lock.json b/client/package-lock.json index 2de6a2f0..325b208e 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -19,6 +19,7 @@ "django-s3-file-field": "1.1.0", "geojs": "1.19.0", "lodash": "4.17.23", + "maplibre-gl": "^5.21.1", "vue": "3.5.30", "vue-router": "5.0.4", "vuetify": "3.12.3" @@ -1873,6 +1874,111 @@ "node": ">=v12.0.0" } }, + "node_modules/@mapbox/jsonlint-lines-primitives": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz", + "integrity": "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@mapbox/point-geometry": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz", + "integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==", + "license": "ISC" + }, + "node_modules/@mapbox/tiny-sdf": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.0.7.tgz", + "integrity": "sha512-25gQLQMcpivjOSA40g3gO6qgiFPDpWRoMfd+G/GoppPIeP6JDaMMkMrEJnMZhKyyS6iKwVt5YKu02vCUyJM3Ug==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/unitbezier": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz", + "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/vector-tile": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.4.tgz", + "integrity": "sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "~1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^4.0.1" + } + }, + "node_modules/@mapbox/whoots-js": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz", + "integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@maplibre/geojson-vt": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.0.4.tgz", + "integrity": "sha512-HYv3POhMRCdhP3UPPATM/hfcy6/WuVIf5FKboH8u/ZuFMTnAIcSVlq5nfOqroLokd925w2QtE7YwquFOIacwVQ==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.0.2" + } + }, + "node_modules/@maplibre/maplibre-gl-style-spec": { + "version": "24.7.0", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.7.0.tgz", + "integrity": "sha512-Ed7rcKYU5iELfablg9Mj+TVCsXsPBgdMyXPRAxb2v7oWg9YJnpQdZ5msDs1LESu/mtXy3Z48Vdppv2t/x5kAhw==", + "license": "ISC", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "~2.0.2", + "@mapbox/unitbezier": "^0.0.1", + "json-stringify-pretty-compact": "^4.0.0", + "minimist": "^1.2.8", + "quickselect": "^3.0.0", + "rw": "^1.3.3", + "tinyqueue": "^3.0.0" + }, + "bin": { + "gl-style-format": "dist/gl-style-format.mjs", + "gl-style-migrate": "dist/gl-style-migrate.mjs", + "gl-style-validate": "dist/gl-style-validate.mjs" + } + }, + "node_modules/@maplibre/mlt": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.8.tgz", + "integrity": "sha512-8vtfYGidr1rNkv5IwIoU2lfe3Oy+Wa8HluzQYcQi9cveU9K3pweAal/poQj4GJ0K/EW4bTQp2wVAs09g2yDRZg==", + "license": "(MIT OR Apache-2.0)", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0" + } + }, + "node_modules/@maplibre/vt-pbf": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.0.tgz", + "integrity": "sha512-jIvp8F5hQCcreqOOpEt42TJMUlsrEcpf/kI1T2v85YrQRV6PPXUcEXUg5karKtH6oh47XJZ4kHu56pUkOuqA7w==", + "license": "MIT", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0", + "@mapbox/vector-tile": "^2.0.4", + "@maplibre/geojson-vt": "^5.0.4", + "@types/geojson": "^7946.0.16", + "@types/supercluster": "^7.1.3", + "pbf": "^4.0.1", + "supercluster": "^8.0.1" + } + }, + "node_modules/@maplibre/vt-pbf/node_modules/@maplibre/geojson-vt": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-5.0.4.tgz", + "integrity": "sha512-KGg9sma45S+stfH9vPCJk1J0lSDLWZgCT9Y8u8qWZJyjFlP8MNP1WGTxIMYJZjDvVT3PDn05kN1C95Sut1HpgQ==", + "license": "ISC" + }, "node_modules/@mdi/font": { "version": "7.4.47", "resolved": "https://registry.npmjs.org/@mdi/font/-/font-7.4.47.tgz", @@ -2133,9 +2239,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2156,9 +2259,6 @@ "cpu": [ "arm" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2179,9 +2279,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2202,9 +2299,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2225,9 +2319,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2248,9 +2339,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3644,6 +3732,15 @@ "integrity": "sha512-TC0dmN0K8YcWEAEfiPi5gJP14eJe30TTGjkvek3iM/1NdHHsdCA/Td6GvNndMOo/iSnIsZ4HuuhrYPDAmbxzww==", "license": "MIT" }, + "node_modules/@types/supercluster": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz", + "integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/webxr": { "version": "0.5.24", "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", @@ -6306,6 +6403,12 @@ "dev": true, "license": "MIT" }, + "node_modules/json-stringify-pretty-compact": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz", + "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==", + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -6679,6 +6782,46 @@ "url": "https://github.com/sponsors/sxzz" } }, + "node_modules/maplibre-gl": { + "version": "5.21.1", + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.21.1.tgz", + "integrity": "sha512-zto1RTnFkOpOO1bm93ElCXF1huey2N4LvXaGLMFcYAu9txh0OhGIdX1q3LZLkrMKgMxMeYduaQo+DVNzg098fg==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "^2.0.2", + "@mapbox/point-geometry": "^1.1.0", + "@mapbox/tiny-sdf": "^2.0.7", + "@mapbox/unitbezier": "^0.0.1", + "@mapbox/vector-tile": "^2.0.4", + "@mapbox/whoots-js": "^3.1.0", + "@maplibre/geojson-vt": "^6.0.4", + "@maplibre/maplibre-gl-style-spec": "^24.7.0", + "@maplibre/mlt": "^1.1.8", + "@maplibre/vt-pbf": "^4.3.0", + "@types/geojson": "^7946.0.16", + "earcut": "^3.0.2", + "gl-matrix": "^3.4.4", + "kdbush": "^4.0.2", + "murmurhash-js": "^1.0.0", + "pbf": "^4.0.1", + "potpack": "^2.1.0", + "quickselect": "^3.0.0", + "tinyqueue": "^3.0.0" + }, + "engines": { + "node": ">=16.14.0", + "npm": ">=8.1.0" + }, + "funding": { + "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1" + } + }, + "node_modules/maplibre-gl/node_modules/gl-matrix": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", + "license": "MIT" + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -6853,6 +6996,12 @@ "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", "license": "MIT" }, + "node_modules/murmurhash-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz", + "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==", + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -7117,6 +7266,18 @@ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "license": "MIT" }, + "node_modules/pbf": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz", + "integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==", + "license": "BSD-3-Clause", + "dependencies": { + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, "node_modules/perfect-debounce": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", @@ -7202,6 +7363,12 @@ "optional": true, "peer": true }, + "node_modules/potpack": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz", + "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==", + "license": "ISC" + }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -7262,6 +7429,12 @@ "url": "https://github.com/sponsors/ahocevar" } }, + "node_modules/protocol-buffers-schema": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz", + "integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==", + "license": "MIT" + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -7325,6 +7498,12 @@ ], "license": "MIT" }, + "node_modules/quickselect": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", + "license": "ISC" + }, "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -7473,6 +7652,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/resolve-protobuf-schema": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", + "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", + "license": "MIT", + "dependencies": { + "protocol-buffers-schema": "^3.3.1" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -7848,6 +8036,15 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/supercluster": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz", + "integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.0.2" + } + }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", @@ -7905,6 +8102,12 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyqueue": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", + "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", + "license": "ISC" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", diff --git a/client/package.json b/client/package.json index 5a306b27..80a80ac1 100644 --- a/client/package.json +++ b/client/package.json @@ -24,6 +24,7 @@ "django-s3-file-field": "1.1.0", "geojs": "1.19.0", "lodash": "4.17.23", + "maplibre-gl": "^5.21.1", "vue": "3.5.30", "vue-router": "5.0.4", "vuetify": "3.12.3" diff --git a/client/src/api/api.ts b/client/src/api/api.ts index e646bbc7..658f60bb 100644 --- a/client/src/api/api.ts +++ b/client/src/api/api.ts @@ -1,6 +1,7 @@ import axios from "axios"; import { AxiosError } from "axios"; import { SpectroInfo } from "@components/geoJS/geoJSUtils"; +import type { FeatureCollection, Point } from "geojson"; export interface Recording { id: number; @@ -31,6 +32,13 @@ export interface Recording { tags_text?: string[]; } +export type RecordingLocationsFeatureProperties = { + recording_id: number; + filename: string; +}; + +export type RecordingLocationsGeoJson = FeatureCollection; + export interface Species { species_code: string; family: string; @@ -455,6 +463,26 @@ async function getCellBbox(cellId: number) { return await axiosInstance.get(`/grts/${cellId}/bbox`); } +export interface RecordingLocationsParams { + exclude_submitted?: boolean; + /** Comma-separated or array of tag texts; recording must have all listed tags. */ + tags?: string | string[]; + /** Bounding box filter (lon/lat) as `[min_lon, min_lat, max_lon, max_lat]`. */ + bbox?: [number, number, number, number]; +} + +async function getRecordingLocations(params?: RecordingLocationsParams) { + const query = new URLSearchParams(); + if (params?.exclude_submitted !== undefined) query.set("exclude_submitted", String(params.exclude_submitted)); + if (params?.tags !== undefined) { + const tagStr = Array.isArray(params.tags) ? params.tags.join(",") : params.tags; + if (tagStr) query.set("tags", tagStr); + } + if (params?.bbox) query.set("bbox", JSON.stringify(params.bbox)); + const qs = query.toString(); + return axiosInstance.get(`/recording-locations/${qs ? `?${qs}` : ""}`); +} + async function getFileAnnotations(recordingId: number) { return axiosInstance.get(`recording/${recordingId}/recording-annotations`); } @@ -706,6 +734,7 @@ export { getCellLocation, getCellBbox, getCellfromLocation, + getRecordingLocations, getGuanoMetadata, getFileAnnotations, putFileAnnotation, diff --git a/client/src/components/RecordingLocationsMap.vue b/client/src/components/RecordingLocationsMap.vue new file mode 100644 index 00000000..2d3adbca --- /dev/null +++ b/client/src/components/RecordingLocationsMap.vue @@ -0,0 +1,271 @@ + + + + + + diff --git a/client/src/components/RecordingTable.vue b/client/src/components/RecordingTable.vue index 56fa8e3d..1b232da7 100644 --- a/client/src/components/RecordingTable.vue +++ b/client/src/components/RecordingTable.vue @@ -9,7 +9,6 @@ import { type PropType, } from 'vue'; import { getRecordings, type Recording, type FileAnnotation, type RecordingListParams } from '../api/api'; -import MapLocation from '@components/MapLocation.vue'; import RecordingInfoDisplay from '@components/RecordingInfoDisplay.vue'; import RecordingAnnotationSummary from '@components/RecordingAnnotationSummary.vue'; import useState from '@use/useState'; @@ -27,7 +26,6 @@ export interface RecordingTableHeader { export default defineComponent({ name: 'RecordingTable', components: { - MapLocation, RecordingInfoDisplay, RecordingAnnotationSummary, }, @@ -36,6 +34,10 @@ export default defineComponent({ type: String as PropType<'my' | 'shared'>, required: true, }, + tags: { + type: Array as PropType, + default: undefined, + }, editRecording: { type: Function as PropType<(item: Recording) => void>, default: undefined, @@ -45,8 +47,8 @@ export default defineComponent({ default: undefined, }, }, - emits: [], - setup(props, { expose }) { + emits: ['update:tags'], + setup(props, { emit, expose }) { const { configuration, showSubmittedRecordings, @@ -66,13 +68,17 @@ export default defineComponent({ let intervalRef: number | null = null; const filterTagsModel = computed({ - get: () => (props.variant === 'my' ? filterTags.value : sharedFilterTags.value), + get: () => { + if (props.tags !== undefined) return props.tags; + return props.variant === 'my' ? filterTags.value : sharedFilterTags.value; + }, set: (v: string[]) => { - if (props.variant === 'my') { - filterTags.value = v; - } else { - sharedFilterTags.value = v; + if (props.tags !== undefined) { + emit('update:tags', v); + return; } + if (props.variant === 'my') filterTags.value = v; + else sharedFilterTags.value = v; }, }); @@ -330,15 +336,6 @@ export default defineComponent({ const tableClass = computed(() => (props.variant === 'my' ? 'my-recordings' : 'shared-recordings')); - const listStyles = computed(() => { - const markEnabled = configuration.value.mark_annotations_completed_enabled; - let sectionHeight = markEnabled ? '35vh' : '40vh'; - if (props.variant === 'shared' && !configuration.value.is_admin && !configuration.value.non_admin_upload_enabled) { - sectionHeight = '75vh'; - } - return { height: sectionHeight, 'max-height': sectionHeight }; - }); - const showTotalCount = computed(() => ( totalCount.value > 0 && configuration.value.mark_annotations_completed_enabled @@ -379,7 +376,6 @@ export default defineComponent({ onUpdateOptions, onUpdateSortBy, tableClass, - listStyles, showTotalCount, rawHeaders, isColumnVisible, @@ -402,7 +398,6 @@ export default defineComponent({ density="compact" class="elevation-1" :class="tableClass" - :style="listStyles" @update:options="onUpdateOptions" @update:sort-by="onUpdateSortBy" > @@ -518,33 +513,7 @@ export default defineComponent({