= {
+ sortBy: "timestamp",
+ sortOrder: "desc",
+ timeframe: timeframe ?? "1w",
+ location: location ?? "all",
+ recordsPerPage: RECORDS_PER_PAGE,
+ };
+
+ if (timeframe === "range") {
+ if (!startDate || !endDate) {
+ throw new Error(
+ "useAIDetections requires startDate and endDate when timeframe is 'range'",
+ );
+ }
+ params.dateFrom = startDate;
+ params.dateTo = endDate;
+ }
+
+ const allRows: AIDetectionRaw[] = [];
+
+ for (let page = 1; page <= MAX_PAGES; page += 1) {
+ try {
+ const nextPage = await fetchOrcahelloPage(params, page);
+
+ if (nextPage.length === 0) {
+ break;
+ }
+
+ allRows.push(...nextPage);
+
+ if (nextPage.length < RECORDS_PER_PAGE) {
+ break;
+ }
+ } catch (error) {
+ const message =
+ error instanceof Error ? error.message : "Unknown Orcahello error";
+ return {
+ detections: allRows,
+ meta: buildMetaData(
+ allRows,
+ startDate ?? null,
+ endDate ?? null,
+ page,
+ message,
+ ),
+ };
+ }
+ }
+
+ return {
+ detections: allRows,
+ meta: buildMetaData(
+ allRows,
+ startDate ?? null,
+ endDate ?? null,
+ null,
+ null,
+ ),
+ };
+};
+
+export function useAIDetections(options: AIDetectionsOptions = {}) {
+ const timeframe = options.timeframe ?? "1w";
+ const startDate = timeframe === "range" ? options.startDate : undefined;
+ const endDate = timeframe === "range" ? options.endDate : undefined;
+ const location = options.location ?? "all";
+ const enabled = options.enabled ?? true;
+
+ const { data, isSuccess, isFetching, isPending, error } = useQuery({
+ queryKey: ["ai-detections", { timeframe, startDate, endDate, location }],
+ queryFn: () =>
+ fetchOrcahelloData({
+ timeframe,
+ startDate,
+ endDate,
+ location,
+ }),
+ staleTime: 5 * 60 * 1000,
+ refetchOnWindowFocus: false,
+ enabled,
+ });
+
+ const aiDetections = data?.detections;
+ const metaData = data?.meta;
+
+ return {
+ aiDetections,
+ metaData,
+ isSuccess,
+ isFetching,
+ isPending,
+ error,
+ };
+}
diff --git a/ui/src/hooks/useCombinedData.ts b/ui/src/hooks/useCombinedData.ts
index d2ba49e5..4169818f 100644
--- a/ui/src/hooks/useCombinedData.ts
+++ b/ui/src/hooks/useCombinedData.ts
@@ -1,24 +1,31 @@
import { useMemo } from "react";
import { Feed, useDetectionsQuery, useFeedsQuery } from "@/graphql/generated";
-import { AudioDetection, CombinedData, Sighting } from "@/types/DataTypes";
import {
+ AIDetection,
+ AudioDetection,
+ CombinedData,
+ Sighting,
+} from "@/types/DataTypes";
+import {
+ transformAIDetection,
transformAudioDetections,
transformSightings,
} from "@/utils/dataTransforms";
+import { useAIDetections } from "./useAIDetections";
import { useSightings } from "./useSightings";
-type CombinedDataObject = {
+type CombinedDataResult = {
audio: AudioDetection[];
+ ai: AIDetection[];
sightings: Sighting[];
combined: CombinedData[];
feeds: Feed[];
};
-export function useCombinedData(): CombinedDataObject {
+export function useCombinedData(): CombinedDataResult {
//// ORCASOUND
- // get feeds and detections based on live/seed toggle in development UI
const detectionsResults = useDetectionsQuery().data?.detections?.results;
const audioDetections = useMemo(
() => detectionsResults ?? [],
@@ -28,33 +35,65 @@ export function useCombinedData(): CombinedDataObject {
const seedFeeds = useFeedsQuery().data?.feeds ?? ([] as Feed[]);
const feeds = seedFeeds as Feed[];
- // standardize data
- const datasetAudio = useMemo(
- () => transformAudioDetections(audioDetections, feeds),
- [audioDetections, feeds],
+ //// Transform ORCAHELLO detections
+ const { aiDetections: aiDetectionsRaw = [], isSuccess: isSuccessOrcahello } =
+ useAIDetections();
+ const aiDetections = aiDetectionsRaw.map((d) => {
+ return transformAIDetection(d, feeds);
+ });
+
+ // Use machine detections from Orcasound API until more detailed Orcahello data comes through
+ const datasetAudio = useMemo(() => {
+ if (!isSuccessOrcahello) {
+ return transformAudioDetections(audioDetections, feeds);
+ } else {
+ return transformAudioDetections(
+ audioDetections.filter((d) => d.source === "HUMAN"),
+ feeds,
+ );
+ }
+ }, [audioDetections, feeds, isSuccessOrcahello]);
+
+ //// Filter out categories of Orcahello detections
+ const unreviewed = aiDetections?.filter((el: AIDetection) => !el.reviewed);
+ const confirmed = aiDetections?.filter(
+ (el: AIDetection) => el.reviewed && el.found === "yes",
+ );
+ const falsepositives = aiDetections?.filter(
+ (el: AIDetection) => el.reviewed && el.found === "no",
+ );
+ const unknown = aiDetections?.filter(
+ (el: AIDetection) => el.reviewed && el.found === "don't know",
);
- //// ACARTIA sightings
- // get detections
+ //// Transform ACARTIA sightings
const sightingResults = useSightings().data?.results;
- // add standardized fields to sightings data
const sightings = useMemo(
() => transformSightings(sightingResults, feeds),
[sightingResults, feeds],
);
+ //// COMBINED all three
const combined: CombinedData[] = useMemo(() => {
- return [...datasetAudio, ...sightings];
- }, [datasetAudio, sightings]);
+ return [...datasetAudio, ...sightings, ...aiDetections];
+ }, [datasetAudio, sightings, aiDetections]);
- const dataset = useMemo(() => {
+ // Construct result object
+ const dataset: CombinedDataResult = useMemo(() => {
return {
audio: datasetAudio,
sightings: sightings,
+ ai: aiDetections,
+ unreviewed: unreviewed,
+ confirmed: confirmed,
+ falsepositives: falsepositives,
+ unknown: unknown,
combined: combined,
feeds: feeds,
isSuccessSightings: !!sightingResults,
+ isSuccessOrcahello: !!aiDetections,
};
- }, [datasetAudio, sightings, combined, feeds, sightingResults]);
+ }, [datasetAudio, sightings, combined, feeds, sightingResults, aiDetections]);
+
return dataset;
}
diff --git a/ui/src/pages/listen/[feed].tsx b/ui/src/pages/listen/[feed].tsx
index b408ff53..bf8b7a5d 100644
--- a/ui/src/pages/listen/[feed].tsx
+++ b/ui/src/pages/listen/[feed].tsx
@@ -48,10 +48,8 @@ const FeedPage: NextPageWithLayout = () => {
{feed.name}
- {feed.name}
-
-
-
+ {feed.name}
+
diff --git a/ui/src/types/DataTypes.ts b/ui/src/types/DataTypes.ts
index 73cda1da..c57f7c32 100644
--- a/ui/src/types/DataTypes.ts
+++ b/ui/src/types/DataTypes.ts
@@ -16,13 +16,6 @@ export interface AudioDetection extends Omit {
standardizedFeedName: string;
feedSlug: string;
comments: string | null | undefined;
- newCategory:
- | "WHALE (HUMAN)"
- | "VESSEL"
- | "OTHER"
- | "WHALE (AI)"
- | "uncategorized";
- timestampString: string;
}
export interface CascadiaSighting {
@@ -53,12 +46,65 @@ export interface Sighting extends CascadiaSighting {
standardizedFeedName: string;
feedSlug: string;
feedId: string;
- newCategory: "SIGHTING";
timestamp: Date;
- timestampString: string;
}
-export type CombinedData = AudioDetection | Sighting;
+export interface AIDetectionLocation {
+ name: string;
+ longitude: number;
+ latitude: number;
+}
+
+export interface AIDetectionAnnotation {
+ id: number;
+ startTime: number;
+ endTime: number;
+ confidence: number;
+}
+
+export type AIDetectionFound = "yes" | "no" | "don't know" | null;
+
+export type AIDetectionReviewState =
+ | "confirmed"
+ | "falsepositive"
+ | "unknown"
+ | "unreviewed";
+
+export interface AIDetectionRaw {
+ id: string | null;
+ audioUri: string | null;
+ spectrogramUri: string | null;
+ location: AIDetectionLocation;
+ timestamp: string;
+ annotations: AIDetectionAnnotation[] | null;
+ reviewed: boolean;
+ found: string | null;
+ comments: string | null;
+ confidence: number;
+ moderator: string | null;
+ moderated: string | null;
+ tags: string | null;
+}
+
+export interface AIDetection
+ extends Omit<
+ AIDetectionRaw,
+ "id" | "timestamp" | "annotations" | "found" | "tags"
+ > {
+ id: string;
+ type: "ai";
+ standardizedFeedName: string;
+ feedSlug?: string;
+ feedId?: string;
+ comments: string | null;
+ timestamp: Date;
+ annotations: AIDetectionAnnotation[];
+ found: AIDetectionFound;
+ reviewState: AIDetectionReviewState;
+ tags: string[];
+}
+
+export type CombinedData = AudioDetection | AIDetection | Sighting;
// future type for transformed data object
// export interface Candidate {
diff --git a/ui/src/utils/dataTransforms.test.ts b/ui/src/utils/dataTransforms.test.ts
index bedeaa33..c4aa3cb3 100644
--- a/ui/src/utils/dataTransforms.test.ts
+++ b/ui/src/utils/dataTransforms.test.ts
@@ -65,7 +65,7 @@ describe("transformAudioDetections", () => {
expect(transformAudioDetections([], [])).toEqual([]);
});
- it("maps source/category to newCategory and enriches feed metadata", () => {
+ it("preserves source/category and enriches feed metadata", () => {
const feeds = [makeFeed("f-1", "North SJC", "north-sjc", 48.1, -122.75)];
const machine = makeDetection({
id: "d-machine",
@@ -81,8 +81,10 @@ describe("transformAudioDetections", () => {
const transformed = transformAudioDetections([machine, human], feeds);
expect(transformed).toHaveLength(2);
- expect(transformed[0].newCategory).toBe("WHALE (AI)");
- expect(transformed[1].newCategory).toBe("WHALE (HUMAN)");
+ expect(transformed[0].source).toBe("MACHINE");
+ expect(transformed[0].category).toBe("WHALE");
+ expect(transformed[1].source).toBe("HUMAN");
+ expect(transformed[1].category).toBe("WHALE");
expect(transformed[0].standardizedFeedName).toBe("North San Juan Channel");
expect(transformed[0].feedSlug).toBe("north-sjc");
expect(transformed[0].type).toBe("audio");
@@ -98,11 +100,12 @@ describe("transformSightings", () => {
expect(transformed).toHaveLength(1);
expect(transformed[0].type).toBe("sightings");
- expect(transformed[0].newCategory).toBe("SIGHTING");
expect(transformed[0].standardizedFeedName).toBe("North San Juan Channel");
expect(transformed[0].feedId).toBe("f-1");
expect(transformed[0].feedSlug).toBe("north-sjc");
- expect(transformed[0].timestampString).toBe("2025-01-01T17:25:00Z");
+ expect(transformed[0].timestamp.toISOString()).toBe(
+ "2025-01-01T17:25:00.000Z",
+ );
});
it("marks out-of-range sightings with fallback feed identifiers", () => {
diff --git a/ui/src/utils/dataTransforms.ts b/ui/src/utils/dataTransforms.ts
index a16e5569..6fd59aed 100644
--- a/ui/src/utils/dataTransforms.ts
+++ b/ui/src/utils/dataTransforms.ts
@@ -1,5 +1,7 @@
import { Feed } from "@/graphql/generated";
import {
+ AIDetection,
+ AIDetectionRaw,
AudioDetection,
CascadiaSighting,
DetectionsResult,
@@ -13,22 +15,6 @@ import {
standardizeFeedName,
} from "./dataHelpers";
-const toNewCategory = (
- detection: DetectionsResult,
-): AudioDetection["newCategory"] => {
- if (detection.source === "MACHINE") return "WHALE (AI)";
-
- switch (detection.category) {
- case "WHALE":
- return "WHALE (HUMAN)";
- case "VESSEL":
- case "OTHER":
- return detection.category;
- default:
- return "uncategorized";
- }
-};
-
export function transformAudioDetections(
detections: DetectionsResult[],
feeds: Feed[],
@@ -41,8 +27,6 @@ export function transformAudioDetections(
standardizedFeedName: lookupFeedName(el.feedId!, feeds),
feedSlug: lookupFeedSlug(el.feedId!, feeds),
comments: el.description,
- newCategory: toNewCategory(el),
- timestampString: el.timestamp.toString(),
}));
}
@@ -68,7 +52,7 @@ export function transformSightings(
maxLng: feed.latLng.lng + addLong(feed.latLng.lat),
}));
- const assignSightingsToHydrophones = (sighting: CascadiaSighting) => {
+ const assignSightingToHydrophone = (sighting: CascadiaSighting) => {
let hydrophone = "out of range";
feedBoundingBoxes.forEach((feed) => {
const inLatRange =
@@ -86,21 +70,62 @@ export function transformSightings(
if (!Array.isArray(sightings)) return [];
const transformedSightings: Sighting[] = sightings.map((el): Sighting => {
- const feedName = assignSightingsToHydrophones(el);
+ const feedName = assignSightingToHydrophone(el);
const feedId = lookupFeedId(feedName, feeds);
const feedSlug = lookupFeedSlug(feedId, feeds);
return {
...el,
type: "sightings",
- newCategory: "SIGHTING",
standardizedFeedName: feedName,
feedSlug: feedSlug,
feedId: feedId,
- timestampString: el.created.replace(" ", "T") + "Z",
timestamp: new Date(el.created.replace(" ", "T") + "Z"),
};
});
return transformedSightings;
}
+
+export const transformAIDetection = (
+ raw: AIDetectionRaw,
+ feeds: Feed[],
+): AIDetection => {
+ const standardizedFeedName = standardizeFeedName(
+ raw.location?.name ?? "unknown",
+ );
+ const feedId = lookupFeedId(standardizedFeedName, feeds);
+
+ return {
+ ...raw,
+ id: raw.id ?? crypto.randomUUID(),
+ type: "ai",
+ standardizedFeedName,
+ feedId,
+ feedSlug: lookupFeedSlug(feedId, feeds),
+ comments: raw.comments,
+ timestamp: new Date(raw.timestamp),
+ annotations: raw.annotations ?? [],
+ found:
+ raw.found?.toLowerCase() === "yes"
+ ? "yes"
+ : raw.found?.toLowerCase() === "no"
+ ? "no"
+ : raw.found?.toLowerCase() === "don't know"
+ ? "don't know"
+ : null,
+ reviewState: !raw.reviewed
+ ? "unreviewed"
+ : raw.found?.toLowerCase() === "yes"
+ ? "confirmed"
+ : raw.found?.toLowerCase() === "no"
+ ? "falsepositive"
+ : "unknown",
+ tags: raw.tags
+ ? raw.tags
+ .split(";")
+ .map((t) => t.trim())
+ .filter(Boolean)
+ : [],
+ };
+};