diff --git a/ui/src/components/ReportCount.tsx b/ui/src/components/ReportCount.tsx index 17ca344e..870bd41c 100644 --- a/ui/src/components/ReportCount.tsx +++ b/ui/src/components/ReportCount.tsx @@ -1,78 +1,170 @@ -import { Breadcrumbs, Button } from "@mui/material"; +import { Box, Button } from "@mui/material"; +import { useMemo } from "react"; +import { useCombinedData } from "@/hooks/useCombinedData"; import { CombinedData } from "@/types/DataTypes"; -const countCategories = ( - arr: { newCategory?: string | null }[], - cat: string, -) => { - if (!arr.length) { - return 0; - } - return arr.filter( - (d) => (d.newCategory ?? "").toLowerCase() === cat.toLowerCase(), +const countHumanWhale = (arr: CombinedData[]) => + arr.filter( + (d) => d.type === "audio" && d.source === "HUMAN" && d.category === "WHALE", ).length; -}; - -export default function ReportCount({ - detectionArray, -}: { - detectionArray: CombinedData[]; -}) { - const categories = [ - "whale (human)", - "whale (AI)", - "vessel", - "other", - "sighting", + +const countHumanVessel = (arr: CombinedData[]) => + arr.filter( + (d) => + d.type === "audio" && d.source === "HUMAN" && d.category === "VESSEL", + ).length; + +const countHumanOther = (arr: CombinedData[]) => + arr.filter( + (d) => d.type === "audio" && d.source === "HUMAN" && d.category === "OTHER", + ).length; + +const countSightings = (arr: CombinedData[]) => + arr.filter((d) => d.type === "sightings").length; + +const countMachineCategory = (arr: CombinedData[], reviewState: string) => + arr.filter( + (d) => + d.type === "ai" && + (d.reviewState ?? "").toLowerCase() === reviewState.toLowerCase(), + ).length; + +export default function ReportCount({ feedSlug }: { feedSlug?: string }) { + const { combined } = useCombinedData(); + + const combinedThisFeed = useMemo(() => { + if (!feedSlug) { + return combined; + } else { + return combined?.filter((f) => { + return feedSlug === f.feedSlug; + }); + } + }, [combined, feedSlug]); + + //// Human count string + const humanCategories = [ + { key: "whale", label: "whale", count: countHumanWhale(combinedThisFeed) }, + { + key: "vessel", + label: "vessel", + count: countHumanVessel(combinedThisFeed), + }, + { key: "other", label: "other", count: countHumanOther(combinedThisFeed) }, + { + key: "sighting", + label: "sighting", + count: countSightings(combinedThisFeed), + }, + ]; + + const humanItems = humanCategories + .map(({ key, label, count }) => { + let displayLabel = label; + + if (key === "sighting" && count !== 1) { + displayLabel += "s in audible range"; + } else if (key === "sighting") { + displayLabel += " in audible range"; + } + + return ( +
+ {count} {displayLabel} +
+ ); + }) + .filter((c) => c); // filters out the null items + + humanItems.unshift(Human); + + const humanItemString = humanItems.flatMap((item, index) => + index < humanItems.length - 1 + ? [item, ] + : [item], + ); + + //// Machine count string + const machineCategories = [ + "confirmed", + "falsepositive", + "unknown", + "unreviewed", ]; - const items = categories + const machineItems = machineCategories .map((category) => { - const count = countCategories(detectionArray, category); + const count = countMachineCategory(combinedThisFeed, category); let label = category; - if (category === "sighting" && count !== 1) { - label += "s in audible range"; - } else if (category === "sighting") { - label += " in audible range"; + if (category === "falsepositive" && count !== 1) { + label = "false positives"; + } else if (category === "falsepositive") { + label = "false positive"; + } else if (category === "confirmed") { + label = "confirmed SRKW"; + } else if (category === "unknown") { + label = "confirmed other"; } return ( -
+
{count} {label}
); }) .filter((c) => c); // filters out the null items - items.unshift(Last 7 days); + machineItems.unshift(Machine); - // Interleave with separators - const interleaved = items.flatMap((item, index) => - index < items.length - 1 + const machineItemString = machineItems.flatMap((item, index) => + index < machineItems.length - 1 ? [item, ] : [item], ); return ( <> -
+ Last 7 days ·{" "} + + {combinedThisFeed.length} detections + + + +
+ {humanItemString} +
+
+ {machineItemString} +
+
+
- - - + View all reports + ); } diff --git a/ui/src/components/layouts/MapLayout.tsx b/ui/src/components/layouts/MapLayout.tsx index baf57b6e..2896ea9f 100644 --- a/ui/src/components/layouts/MapLayout.tsx +++ b/ui/src/components/layouts/MapLayout.tsx @@ -60,7 +60,7 @@ function MapLayout({ children }: { children: ReactNode }) { // End: sightings data call - // update the currentFeed only if there's a new feed + // update the currentFeed only if there's a new feed in the route useEffect(() => { if (feed && feed.slug !== currentFeed?.slug) { setCurrentFeed(feed); diff --git a/ui/src/hooks/useAIDetections.ts b/ui/src/hooks/useAIDetections.ts new file mode 100644 index 00000000..fe9e2b7c --- /dev/null +++ b/ui/src/hooks/useAIDetections.ts @@ -0,0 +1,179 @@ +import { useQuery } from "@tanstack/react-query"; + +import { AIDetectionRaw } from "@/types/DataTypes"; +import { constructUrl } from "@/utils/dataHelpers"; + +const endpointOrcahello = + "https://aifororcasdetections.azurewebsites.net/api/detections"; + +const RECORDS_PER_PAGE = 50; +const MAX_PAGES = 100; + +export type AIDetectionsMetaData = { + requestedStart: string | null; + requestedEnd: string | null; + fetchedNewest: string | null; + fetchedOldest: string | null; + failedPage: number | null; + loadError: string | null; + partial: boolean; +}; + +export type AIDetectionTimeframe = + | "30m" + | "3h" + | "6h" + | "24h" + | "1w" + | "1m" + | "range" + | "all"; + +export type AIDetectionsOptions = { + timeframe?: AIDetectionTimeframe; + startDate?: string; + endDate?: string; + location?: string; + enabled?: boolean; +}; + +// fetches a single page of results (50 max) +const fetchOrcahelloPage = async ( + params: Record, + page: number, +): Promise => { + const url = constructUrl(endpointOrcahello, { + ...params, + page, + }); + const response = await fetch(url); + + if (!response.ok) { + throw new Error(`Orcahello request failed with ${response.status}`); + } + + const rows = (await response.json()) as AIDetectionRaw[]; + return rows; +}; + +// creates metadata object +const buildMetaData = ( + rows: AIDetectionRaw[], + requestedStart: string | null, + requestedEnd: string | null, + failedPage: number | null, + loadError: string | null, +): AIDetectionsMetaData => ({ + requestedStart, + requestedEnd, + fetchedNewest: rows[0]?.timestamp ?? null, + fetchedOldest: rows[rows.length - 1]?.timestamp ?? null, + failedPage, + loadError, + partial: failedPage !== null, +}); + +// loops over all data pages until it errors or no more records +const fetchOrcahelloData = async ({ + timeframe, + startDate, + endDate, + location, +}: AIDetectionsOptions): Promise<{ + detections: AIDetectionRaw[]; + meta: AIDetectionsMetaData; +}> => { + const params: Record = { + 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) + : [], + }; +};