From 37eb44a6dffa35c9e00e7b73729251f8643491e4 Mon Sep 17 00:00:00 2001 From: Skander Mzali Date: Thu, 14 Nov 2024 14:50:03 -0800 Subject: [PATCH 01/40] Add blank new bout page, non-null category on bout --- server/lib/orcasite/radio/bout.ex | 1 + ui/src/pages/bouts/new.tsx | 17 +++++++++++++++++ 2 files changed, 18 insertions(+) create mode 100644 ui/src/pages/bouts/new.tsx diff --git a/server/lib/orcasite/radio/bout.ex b/server/lib/orcasite/radio/bout.ex index a5dda588..ba11f182 100644 --- a/server/lib/orcasite/radio/bout.ex +++ b/server/lib/orcasite/radio/bout.ex @@ -27,6 +27,7 @@ defmodule Orcasite.Radio.Bout do attribute :category, Orcasite.Types.AudioCategory do public? true + allow_nil? false end create_timestamp :inserted_at diff --git a/ui/src/pages/bouts/new.tsx b/ui/src/pages/bouts/new.tsx new file mode 100644 index 00000000..9b9e31c3 --- /dev/null +++ b/ui/src/pages/bouts/new.tsx @@ -0,0 +1,17 @@ +import { useRouter } from "next/router"; + +import { getReportsLayout } from "@/components/layouts/ReportsLayout"; +import type { NextPageWithLayout } from "@/pages/_app"; + +const NewBoutPage: NextPageWithLayout = () => { + const router = useRouter(); + const { feedId, category } = router.query; + + // Get feed, detections, recent spectrograms. + + return

New Bout

; +}; + +NewBoutPage.getLayout = getReportsLayout; + +export default NewBoutPage; From a29780d4ccb8d6f1e58deca3381a01ad3f9cc77a Mon Sep 17 00:00:00 2001 From: Skander Mzali Date: Thu, 21 Nov 2024 11:43:11 -0800 Subject: [PATCH 02/40] Rename ReportsLayout to SimpleLayout --- .../layouts/{ReportsLayout.tsx => SimpleLayout.tsx} | 6 +++--- ui/src/pages/bouts/index.tsx | 4 ++-- ui/src/pages/bouts/new.tsx | 4 ++-- ui/src/pages/reports/[candidateId].tsx | 4 ++-- ui/src/pages/reports/index.tsx | 4 ++-- 5 files changed, 11 insertions(+), 11 deletions(-) rename ui/src/components/layouts/{ReportsLayout.tsx => SimpleLayout.tsx} (80%) diff --git a/ui/src/components/layouts/ReportsLayout.tsx b/ui/src/components/layouts/SimpleLayout.tsx similarity index 80% rename from ui/src/components/layouts/ReportsLayout.tsx rename to ui/src/components/layouts/SimpleLayout.tsx index b4444800..48872c88 100644 --- a/ui/src/components/layouts/ReportsLayout.tsx +++ b/ui/src/components/layouts/SimpleLayout.tsx @@ -3,7 +3,7 @@ import { ReactElement } from "react"; import Header from "@/components/Header"; -function ReportsLayout({ children }: { children: React.ReactNode }) { +function SimpleLayout({ children }: { children: React.ReactNode }) { return ( {page}; +export function getSimpleLayout(page: ReactElement) { + return {page}; } diff --git a/ui/src/pages/bouts/index.tsx b/ui/src/pages/bouts/index.tsx index 409c17de..72d675ca 100644 --- a/ui/src/pages/bouts/index.tsx +++ b/ui/src/pages/bouts/index.tsx @@ -10,7 +10,7 @@ import Head from "next/head"; import { useCallback, useMemo, useState } from "react"; import FeedItem from "@/components/Bouts/FeedItem"; -import { getReportsLayout } from "@/components/layouts/ReportsLayout"; +import { getSimpleLayout } from "@/components/layouts/SimpleLayout"; import { useFeedsQuery } from "@/graphql/generated"; import type { NextPageWithLayout } from "@/pages/_app"; @@ -88,6 +88,6 @@ const BoutsPage: NextPageWithLayout = () => { ); }; -BoutsPage.getLayout = getReportsLayout; +BoutsPage.getLayout = getSimpleLayout; export default BoutsPage; diff --git a/ui/src/pages/bouts/new.tsx b/ui/src/pages/bouts/new.tsx index 9b9e31c3..31cfc626 100644 --- a/ui/src/pages/bouts/new.tsx +++ b/ui/src/pages/bouts/new.tsx @@ -1,6 +1,6 @@ import { useRouter } from "next/router"; -import { getReportsLayout } from "@/components/layouts/ReportsLayout"; +import { getSimpleLayout } from "@/components/layouts/SimpleLayout"; import type { NextPageWithLayout } from "@/pages/_app"; const NewBoutPage: NextPageWithLayout = () => { @@ -12,6 +12,6 @@ const NewBoutPage: NextPageWithLayout = () => { return

New Bout

; }; -NewBoutPage.getLayout = getReportsLayout; +NewBoutPage.getLayout = getSimpleLayout; export default NewBoutPage; diff --git a/ui/src/pages/reports/[candidateId].tsx b/ui/src/pages/reports/[candidateId].tsx index b63edf9a..27eebc98 100644 --- a/ui/src/pages/reports/[candidateId].tsx +++ b/ui/src/pages/reports/[candidateId].tsx @@ -3,7 +3,7 @@ import Head from "next/head"; import { useRouter } from "next/router"; import DetectionsTable from "@/components/DetectionsTable"; -import { getReportsLayout } from "@/components/layouts/ReportsLayout"; +import { getSimpleLayout } from "@/components/layouts/SimpleLayout"; import { useCandidateQuery, useGetCurrentUserQuery } from "@/graphql/generated"; import type { NextPageWithLayout } from "@/pages/_app"; import { analytics } from "@/utils/analytics"; @@ -69,6 +69,6 @@ const CandidatePage: NextPageWithLayout = () => { ); }; -CandidatePage.getLayout = getReportsLayout; +CandidatePage.getLayout = getSimpleLayout; export default CandidatePage; diff --git a/ui/src/pages/reports/index.tsx b/ui/src/pages/reports/index.tsx index 6a069d03..610f8462 100644 --- a/ui/src/pages/reports/index.tsx +++ b/ui/src/pages/reports/index.tsx @@ -14,7 +14,7 @@ import Head from "next/head"; import Link from "next/link"; import { useMemo, useState } from "react"; -import { getReportsLayout } from "@/components/layouts/ReportsLayout"; +import { getSimpleLayout } from "@/components/layouts/SimpleLayout"; import { CandidatesQuery, useCandidatesQuery, @@ -163,6 +163,6 @@ const DetectionsPage: NextPageWithLayout = () => { ); }; -DetectionsPage.getLayout = getReportsLayout; +DetectionsPage.getLayout = getSimpleLayout; export default DetectionsPage; From e7bab08c8f12b1b201c8482288605010a52744ad Mon Sep 17 00:00:00 2001 From: Skander Mzali Date: Mon, 25 Nov 2024 12:16:58 -0800 Subject: [PATCH 03/40] Add loading spinner, start of audio_images gql queries --- server/lib/orcasite/radio/audio_image.ex | 17 +++++++ ui/src/components/LoadingSpinner.tsx | 9 ++++ ui/src/pages/bouts/[feedSlug]/new.tsx | 56 ++++++++++++++++++++++++ ui/src/pages/bouts/new.tsx | 17 ------- 4 files changed, 82 insertions(+), 17 deletions(-) create mode 100644 ui/src/components/LoadingSpinner.tsx create mode 100644 ui/src/pages/bouts/[feedSlug]/new.tsx delete mode 100644 ui/src/pages/bouts/new.tsx diff --git a/server/lib/orcasite/radio/audio_image.ex b/server/lib/orcasite/radio/audio_image.ex index 41750411..68f72795 100644 --- a/server/lib/orcasite/radio/audio_image.ex +++ b/server/lib/orcasite/radio/audio_image.ex @@ -78,6 +78,18 @@ defmodule Orcasite.Radio.AudioImage do actions do defaults [:read, :destroy, create: :*, update: :*] + read :for_feed do + argument :feed_id, :string, allow_nil?: false + + pagination do + offset? true + countable true + default_limit 100 + end + + filter expr(feed_id == ^arg(:feed_id)) + end + create :for_feed_segment do upsert? true upsert_identity :unique_audio_image @@ -220,5 +232,10 @@ defmodule Orcasite.Radio.AudioImage do graphql do type :audio_image attribute_types [feed_id: :id] + + queries do + list :audio_images, :for_feed + end + end end diff --git a/ui/src/components/LoadingSpinner.tsx b/ui/src/components/LoadingSpinner.tsx new file mode 100644 index 00000000..c64996be --- /dev/null +++ b/ui/src/components/LoadingSpinner.tsx @@ -0,0 +1,9 @@ +import { Box, BoxProps, CircularProgress } from "@mui/material"; + +export default function LoadingSpinner(params: BoxProps) { + return ( + + + + ); +} diff --git a/ui/src/pages/bouts/[feedSlug]/new.tsx b/ui/src/pages/bouts/[feedSlug]/new.tsx new file mode 100644 index 00000000..8dd52baa --- /dev/null +++ b/ui/src/pages/bouts/[feedSlug]/new.tsx @@ -0,0 +1,56 @@ +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import Head from "next/head"; +import { useParams, useSearchParams } from "next/navigation"; + +import { getSimpleLayout } from "@/components/layouts/SimpleLayout"; +import LoadingSpinner from "@/components/LoadingSpinner"; +import { + AudioCategory, + DetectionFilterFeedId, + useDetectionsQuery, + useFeedQuery, +} from "@/graphql/generated"; +import type { NextPageWithLayout } from "@/pages/_app"; + +const NewBoutPage: NextPageWithLayout = () => { + const params = useParams<{ feedSlug?: string }>(); + const feedSlug = params?.feedSlug; + const searchParams = useSearchParams(); + const audioCategory = searchParams.get("category") as AudioCategory; + const feedQueryResult = useFeedQuery( + { slug: feedSlug || "" }, + { enabled: !!feedSlug }, + ); + const feed = feedQueryResult.data?.feed; + + const detectionQueryResult = useDetectionsQuery( + { filter: { feedId: feed?.id as DetectionFilterFeedId } }, + { enabled: !!feed?.id }, + ); + + if (!feedSlug || feedQueryResult.isLoading) return ; + if (!feed) return

Feed not found

; + return ( +
+ + New Bout | Orcasound + + +
+ + + + New Bout + + {feed.name} + + +
+
+ ); +}; + +NewBoutPage.getLayout = getSimpleLayout; + +export default NewBoutPage; diff --git a/ui/src/pages/bouts/new.tsx b/ui/src/pages/bouts/new.tsx deleted file mode 100644 index 31cfc626..00000000 --- a/ui/src/pages/bouts/new.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { useRouter } from "next/router"; - -import { getSimpleLayout } from "@/components/layouts/SimpleLayout"; -import type { NextPageWithLayout } from "@/pages/_app"; - -const NewBoutPage: NextPageWithLayout = () => { - const router = useRouter(); - const { feedId, category } = router.query; - - // Get feed, detections, recent spectrograms. - - return

New Bout

; -}; - -NewBoutPage.getLayout = getSimpleLayout; - -export default NewBoutPage; From b735f887ba9c2d74b196ea036b31db579cbf8f27 Mon Sep 17 00:00:00 2001 From: Skander Mzali Date: Fri, 29 Nov 2024 12:29:37 -0800 Subject: [PATCH 04/40] Add skeleton for new bout page, scrollable spectrogram element, feed_stream/segments populate function --- server/config/dev.exs | 2 + server/config/prod.exs | 2 + server/config/runtime.exs | 4 +- server/config/test.exs | 2 + server/lib/orcasite/global_setup.ex | 36 ++++ server/lib/orcasite/radio/graphql_client.ex | 76 +++++++++ .../components/Bouts/SpectrogramTimeline.tsx | 125 ++++++++++++++ ui/src/components/Player/BoutPlayer.tsx | 155 ++++++++++++++++++ ui/src/pages/bouts/[feedSlug]/new.tsx | 9 + 9 files changed, 410 insertions(+), 1 deletion(-) create mode 100644 server/lib/orcasite/radio/graphql_client.ex create mode 100644 ui/src/components/Bouts/SpectrogramTimeline.tsx create mode 100644 ui/src/components/Player/BoutPlayer.tsx diff --git a/server/config/dev.exs b/server/config/dev.exs index c7f16ab5..abee34e8 100644 --- a/server/config/dev.exs +++ b/server/config/dev.exs @@ -113,3 +113,5 @@ config :ex_aws, config :orcasite, audio_image_bucket: System.get_env("ORCASITE_AUDIO_IMAGE_BUCKET", "dev-audio-viz"), audio_image_bucket_region: System.get_env("ORCASITE_AUDIO_IMAGE_BUCKET_REGION", "us-west-2") + +config :orcasite, :env, :dev diff --git a/server/config/prod.exs b/server/config/prod.exs index 03f8c237..3db59e75 100644 --- a/server/config/prod.exs +++ b/server/config/prod.exs @@ -80,3 +80,5 @@ else config :hammer, backend: {Hammer.Backend.ETS, [expiry_ms: 60_000 * 60 * 4, cleanup_interval_ms: 60_000 * 10]} end + +config :orcasite, :env, :prod diff --git a/server/config/runtime.exs b/server/config/runtime.exs index aae1057c..e24552b4 100644 --- a/server/config/runtime.exs +++ b/server/config/runtime.exs @@ -21,6 +21,8 @@ if System.get_env("PHX_SERVER") do config :orcasite, OrcasiteWeb.Endpoint, server: true end +config :orcasite, :prod_host, System.get_env("HOST_URL", "live.orcasound.net") + if config_env() == :prod do database_url = System.get_env("DATABASE_URL") || @@ -49,7 +51,7 @@ if config_env() == :prod do You can generate one by calling: mix phx.gen.secret """ - host = System.get_env("HOST_URL") || "live.orcasite.com" + host = System.get_env("HOST_URL") || "live.orcasound.net" port = String.to_integer(System.get_env("PORT") || "4000") if System.get_env("FEED_STREAM_QUEUE_URL", "") != "" do diff --git a/server/config/test.exs b/server/config/test.exs index 3d304351..5a485bfb 100644 --- a/server/config/test.exs +++ b/server/config/test.exs @@ -51,3 +51,5 @@ config :ex_aws, :instance_role ], region: "us-west-2" + +config :orcasite, :env, :test diff --git a/server/lib/orcasite/global_setup.ex b/server/lib/orcasite/global_setup.ex index 10f90468..7670a945 100644 --- a/server/lib/orcasite/global_setup.ex +++ b/server/lib/orcasite/global_setup.ex @@ -21,6 +21,7 @@ defmodule Orcasite.GlobalSetup do |> case do {:ok, %{timestamps: [_ | _] = timestamps}} -> timestamp = List.last(timestamps) + {:ok, feed_stream} = Orcasite.Radio.FeedStream |> Ash.Changeset.for_create(:create, %{feed: feed, playlist_timestamp: timestamp}) @@ -34,4 +35,39 @@ defmodule Orcasite.GlobalSetup do :ok end end + + def populate_latest_feed_streams(feed, minutes_ago \\ 10) do + if Application.get_env(:orcasite, :env) != :prod do + # Get prod feed id for feed + {:ok, feed_resp} = Orcasite.Radio.GraphqlClient.get_feed(feed.slug) + feed_id = feed_resp |> get_in(["data", "feed", "id"]) + + now = DateTime.utc_now() + minutes_ago_datetime = now |> DateTime.add(-minutes_ago, :minute) + + # Get stream for the last `minutes` minutes + {:ok, feed_streams_response} = + Orcasite.Radio.GraphqlClient.get_feed_stream(feed_id, minutes_ago_datetime, now) + + feed_streams = get_in(feed_streams_response, ["data", "feedStreams", "results"]) + + feed_streams + |> Enum.map( + &%{ + m3u8_path: Map.get(&1, "playlistM3u8Path"), + bucket: Map.get(&1, "bucket"), + update_segments?: true, + link_streams?: true + } + ) + |> Ash.bulk_create( + Orcasite.Radio.FeedStream, + :from_m3u8_path, + return_errors?: true, + stop_on_error?: true, + upsert?: true, + upsert_identity: :feed_stream_timestamp + ) + end + end end diff --git a/server/lib/orcasite/radio/graphql_client.ex b/server/lib/orcasite/radio/graphql_client.ex new file mode 100644 index 00000000..d52eec94 --- /dev/null +++ b/server/lib/orcasite/radio/graphql_client.ex @@ -0,0 +1,76 @@ +defmodule Orcasite.Radio.GraphqlClient do + def get_feed(feed_slug) do + ~s| + { + feed(slug: "#{feed_slug}") { + id + slug + } + } + | + |> submit() + end + + + def get_feed_stream(feed_id, from_datetime, to_datetime) do + day_before = from_datetime |> DateTime.add(-1, :day) + ~s| + { + feedStreams( + feedId: "#{feed_id}", + filter: { + and: [ + {startTime: {lessThanOrEqual: "#{DateTime.to_iso8601(from_datetime)}"}}, + {startTime: {greaterThanOrEqual: "#{DateTime.to_iso8601(day_before)}"}} + ], + or: [{endTime: {isNil: true}}, {endTime: {greaterThanOrEqual: "#{DateTime.to_iso8601(to_datetime)}"}}] + }, + sort: {field: START_TIME, order: DESC}, + limit: 2 + ) { + count + results { + id + startTime + endTime + duration + bucket + bucketRegion + cloudfrontUrl + playlistTimestamp + playlistPath + playlistM3u8Path + } + } + } + | + |> submit() + end + + def submit(query) do + Finch.build( + :post, + gql_url(), + [{"content-type", "application/json"}], + Jason.encode!(%{ + query: query + }) + ) + |> Finch.request(Orcasite.Finch) + |> case do + {:ok, %{body: body}} -> Jason.decode(body) + resp -> resp + end + end + + def gql_url() do + Application.get_env(:orcasite, :prod_host) + |> case do + "https://" <> _host = url -> url + "http://" <> _host = url -> url + host -> "https://" <> host + end + |> String.trim_trailing("/") + |> then(&(&1 <> "/graphql")) + end +end diff --git a/ui/src/components/Bouts/SpectrogramTimeline.tsx b/ui/src/components/Bouts/SpectrogramTimeline.tsx new file mode 100644 index 00000000..d531ce5b --- /dev/null +++ b/ui/src/components/Bouts/SpectrogramTimeline.tsx @@ -0,0 +1,125 @@ +import { Box } from "@mui/material"; +import { useEffect, useRef, useState } from "react"; + +export default function SpectrogramTimeline({ + playerTime, +}: { + playerTime?: Date; +}) { + const spectrogramContainer = useRef(null); + const [spectrogramTime, setSpectrogramTime] = useState(); + const [isDragging, setIsDragging] = useState(false); + const [zoomLevel, setZoomLevel] = useState(10); + + const visibleContainerStartX = useRef(0); + const containerScrollX = useRef(0); + + const pixelsPerMinute = 50 * zoomLevel; + + useEffect(() => { + if (spectrogramTime === undefined && playerTime !== undefined) { + setSpectrogramTime(playerTime); + } + }, [playerTime, spectrogramTime]); + + const handleTouchStart = (e: React.TouchEvent) => { + setIsDragging(true); + visibleContainerStartX.current = + e.touches[0].pageX - (spectrogramContainer.current?.offsetLeft ?? 0); + containerScrollX.current = spectrogramContainer.current?.scrollLeft ?? 0; + }; + + const handleTouchMove = (e: React.TouchEvent) => { + if (!isDragging || !spectrogramContainer.current) return; + e.preventDefault(); + const containerCursorX = + e.touches[0].pageX - spectrogramContainer.current.offsetLeft; + const move = containerCursorX - visibleContainerStartX.current; + spectrogramContainer.current.scrollLeft = containerScrollX.current - move; + }; + + const handleMouseDown = (e: React.MouseEvent) => { + setIsDragging(true); + visibleContainerStartX.current = + e.pageX - (spectrogramContainer.current?.offsetLeft ?? 0); + containerScrollX.current = spectrogramContainer.current?.scrollLeft ?? 0; + }; + + const handleMouseLeave = () => { + setIsDragging(false); + }; + + const handleMouseUp = () => { + setIsDragging(false); + }; + + const handleMouseMove = (e: React.MouseEvent) => { + if (!isDragging || !spectrogramContainer.current) return; + e.preventDefault(); + const containerCursorX = e.pageX - spectrogramContainer.current.offsetLeft; + const move = containerCursorX - visibleContainerStartX.current; + spectrogramContainer.current.scrollLeft = containerScrollX.current - move; + }; + + const handleWheel = (e: React.WheelEvent) => { + e.preventDefault(); + setZoomLevel((zoom) => Math.min(Math.max(5, zoom + e.deltaY * -0.01), 15)); + // if (!spectrogramContainer.current) return; + // spectrogramContainer.current.scrollLeft -= e.deltaY; + }; + + useEffect(() => { + const container = spectrogramContainer.current; + if (container) { + container.addEventListener("mouseleave", handleMouseLeave); + container.addEventListener("mouseup", handleMouseUp); + return () => { + container.removeEventListener("mouseleave", handleMouseLeave); + container.removeEventListener("mouseup", handleMouseUp); + }; + } + }, []); + + return ( + <> + {zoomLevel} + + {Array(10) + .fill(0) + .map((_, idx) => ( + + spectrogram {idx} + + ))} + + ({JSON.stringify(playerTime)}) + + ); +} diff --git a/ui/src/components/Player/BoutPlayer.tsx b/ui/src/components/Player/BoutPlayer.tsx new file mode 100644 index 00000000..91ee0f98 --- /dev/null +++ b/ui/src/components/Player/BoutPlayer.tsx @@ -0,0 +1,155 @@ +import { Box, Typography } from "@mui/material"; +import dynamic from "next/dynamic"; +import { useCallback, useMemo, useRef, useState } from "react"; + +import { type PlayerStatus } from "./Player"; +import PlayPauseButton from "./PlayPauseButton"; +import { type VideoJSPlayer } from "./VideoJS"; + +const VideoJS = dynamic(() => import("./VideoJS")); + +const playerOffsetToDateTime = (playlistDatetime: Date, playerOffset: number) => + new Date(playlistDatetime.valueOf() + playerOffset * 1000); + +export function BoutPlayer({ + onPlayerTimeUpdate, +}: { + onPlayerTimeUpdate?: (time: Date) => void; +}) { + const playlistTimestamp = "1732665619"; + const playlistDatetime = new Date(Number(playlistTimestamp) * 1000); + const hlsURI = `https://audio-orcasound-net.s3.amazonaws.com/rpi_port_townsend/hls/${playlistTimestamp}/live.m3u8`; + const now = useMemo(() => new Date(), []); + const [playerStatus, setPlayerStatus] = useState("idle"); + const playerRef = useRef(null); + const [playerOffset, setPlayerOffset] = useState( + now.valueOf() / 1000 - Number(playlistTimestamp), + ); + + const playerDateTime = useMemo( + () => playerOffsetToDateTime(playlistDatetime, playerOffset), + [playlistDatetime, playerOffset], + ); + + const playerOptions = useMemo( + () => ({ + autoplay: false, + flash: { + hls: { + overrideNative: true, + }, + }, + html5: { + hls: { + overrideNative: true, + }, + }, + sources: [ + { + // If hlsURI isn't set, use a dummy URI to trigger an error + // The dummy URI doesn't actually exist, it should return 404 + // This is the only way to get videojs to throw an error, otherwise + // it just won't initialize (if src is undefined/null/empty)) + src: hlsURI, + type: "application/x-mpegurl", + }, + ], + }), + [hlsURI], //, feed?.nodeName], + ); + + const handleReady = useCallback( + (player: VideoJSPlayer) => { + playerRef.current = player; + + player.on("playing", () => { + setPlayerStatus("playing"); + // const currentTime = player.currentTime() ?? 0; + // if (currentTime < startOffset || currentTime > endOffset) { + // player.currentTime(startOffset); + // setPlayerOffset(endOffset); + // } + }); + player.on("pause", () => setPlayerStatus("paused")); + player.on("waiting", () => setPlayerStatus("loading")); + player.on("error", () => setPlayerStatus("error")); + // player.currentTime(startOffset); + + player.on("timeupdate", () => { + const currentTime = player.currentTime() ?? 0; + // if (currentTime > endOffset) { + // player.currentTime(startOffset); + // setPlayerOffset(startOffset); + // } else { + // setPlayerTime(currentTime); + // } + setPlayerOffset(currentTime); + if (onPlayerTimeUpdate !== undefined) { + onPlayerTimeUpdate( + playerOffsetToDateTime(playlistDatetime, currentTime), + ); + } + }); + }, + // [startOffset, endOffset], + [onPlayerTimeUpdate], + ); + const handlePlayPauseClick = () => { + const player = playerRef.current; + + if (playerStatus === "error") { + setPlayerStatus("idle"); + return; + } + + if (!player) { + setPlayerStatus("error"); + return; + } + + try { + if (playerStatus === "loading" || playerStatus === "playing") { + player.pause(); + } else { + player.play(); + } + } catch (e) { + console.error(e); + // AbortError is thrown if pause() is called while play() is still loading (e.g. if segments are 404ing) + // It's not important, so don't show this error to the user + if (e instanceof DOMException && e.name === "AbortError") return; + setPlayerStatus("error"); + } + }; + return ( +
+ + + + + + + + + + {playerDateTime !== undefined && + playerDateTime.toLocaleTimeString()} + + + {playerDateTime !== undefined && + playerDateTime.toLocaleDateString()} + + + +
+ ); +} diff --git a/ui/src/pages/bouts/[feedSlug]/new.tsx b/ui/src/pages/bouts/[feedSlug]/new.tsx index 8dd52baa..b8f88753 100644 --- a/ui/src/pages/bouts/[feedSlug]/new.tsx +++ b/ui/src/pages/bouts/[feedSlug]/new.tsx @@ -2,9 +2,12 @@ import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; import Head from "next/head"; import { useParams, useSearchParams } from "next/navigation"; +import { useState } from "react"; +import SpectrogramTimeline from "@/components/Bouts/SpectrogramTimeline"; import { getSimpleLayout } from "@/components/layouts/SimpleLayout"; import LoadingSpinner from "@/components/LoadingSpinner"; +import { BoutPlayer } from "@/components/Player/BoutPlayer"; import { AudioCategory, DetectionFilterFeedId, @@ -17,6 +20,7 @@ const NewBoutPage: NextPageWithLayout = () => { const params = useParams<{ feedSlug?: string }>(); const feedSlug = params?.feedSlug; const searchParams = useSearchParams(); + const [playerTime, setPlayerTime] = useState(); const audioCategory = searchParams.get("category") as AudioCategory; const feedQueryResult = useFeedQuery( { slug: feedSlug || "" }, @@ -31,6 +35,7 @@ const NewBoutPage: NextPageWithLayout = () => { if (!feedSlug || feedQueryResult.isLoading) return ; if (!feed) return

Feed not found

; + return (
@@ -46,6 +51,10 @@ const NewBoutPage: NextPageWithLayout = () => { {feed.name} + + + +
); From 3f3546782814a5eb69d9e08b4326a04a8bd3031a Mon Sep 17 00:00:00 2001 From: Skander Mzali Date: Tue, 3 Dec 2024 14:36:51 -0800 Subject: [PATCH 05/40] Add feedStreams query to frontend, update feed_streams gql client with feedSegments --- server/lib/orcasite/radio/detection.ex | 8 +- server/lib/orcasite/radio/feed_stream.ex | 21 +- server/lib/orcasite/radio/graphql_client.ex | 27 ++- ui/src/graphql/generated/index.ts | 180 +++++++++++++++++- ui/src/graphql/queries/listDetections.graphql | 9 +- .../graphql/queries/listFeedStreams.graphql | 29 +++ ui/src/pages/bouts/[feedSlug]/new.tsx | 23 ++- 7 files changed, 282 insertions(+), 15 deletions(-) create mode 100644 ui/src/graphql/queries/listFeedStreams.graphql diff --git a/server/lib/orcasite/radio/detection.ex b/server/lib/orcasite/radio/detection.ex index 9d7e7d6d..b5a77ec8 100644 --- a/server/lib/orcasite/radio/detection.ex +++ b/server/lib/orcasite/radio/detection.ex @@ -51,7 +51,10 @@ defmodule Orcasite.Radio.Detection do relationships do belongs_to :candidate, Candidate, public?: true - belongs_to :feed, Feed, public?: true + belongs_to :feed, Feed do + public? true + + end belongs_to :user, Orcasite.Accounts.User end @@ -103,6 +106,9 @@ defmodule Orcasite.Radio.Detection do default_limit 100 end + argument :feed_id, :string + + filter expr(if not is_nil(^arg(:feed_id)), do: feed_id == ^arg(:feed_id), else: true) prepare build(load: [:uuid], sort: [inserted_at: :desc]) end diff --git a/server/lib/orcasite/radio/feed_stream.ex b/server/lib/orcasite/radio/feed_stream.ex index e44fee92..ec6d7914 100644 --- a/server/lib/orcasite/radio/feed_stream.ex +++ b/server/lib/orcasite/radio/feed_stream.ex @@ -57,14 +57,26 @@ defmodule Orcasite.Radio.FeedStream do end relationships do - belongs_to :feed, Orcasite.Radio.Feed - belongs_to :prev_feed_stream, Orcasite.Radio.FeedStream - belongs_to :next_feed_stream, Orcasite.Radio.FeedStream + belongs_to :feed, Orcasite.Radio.Feed do + public? true + end + + belongs_to :prev_feed_stream, Orcasite.Radio.FeedStream do + public? true + end + + belongs_to :next_feed_stream, Orcasite.Radio.FeedStream do + public? true + end + + has_many :feed_segments, Orcasite.Radio.FeedSegment do + public? true + end - has_many :feed_segments, Orcasite.Radio.FeedSegment has_many :bout_feed_streams, Orcasite.Radio.BoutFeedStream many_to_many :bouts, Orcasite.Radio.Bout do + public? true through Orcasite.Radio.BoutFeedStream end end @@ -413,6 +425,7 @@ defmodule Orcasite.Radio.FeedStream do graphql do type :feed_stream + attribute_types feed_id: :string, prev_feed_stream_id: :string, next_feed_stream_id: :string queries do list :feed_streams, :index diff --git a/server/lib/orcasite/radio/graphql_client.ex b/server/lib/orcasite/radio/graphql_client.ex index d52eec94..061b1447 100644 --- a/server/lib/orcasite/radio/graphql_client.ex +++ b/server/lib/orcasite/radio/graphql_client.ex @@ -11,9 +11,9 @@ defmodule Orcasite.Radio.GraphqlClient do |> submit() end - - def get_feed_stream(feed_id, from_datetime, to_datetime) do + def get_feed_streams_with_segments(feed_id, from_datetime, to_datetime) do day_before = from_datetime |> DateTime.add(-1, :day) + ~s| { feedStreams( @@ -40,6 +40,29 @@ defmodule Orcasite.Radio.GraphqlClient do playlistTimestamp playlistPath playlistM3u8Path + + feedSegments( + filter: { + and: [ + {startTime: {lessThanOrEqual: "#{DateTime.to_iso8601(from_datetime)}"}}, + {startTime: {greaterThanOrEqual: "#{DateTime.to_iso8601(day_before)}"}} + ], + or: [{endTime: {isNil: true}}, {endTime: {greaterThanOrEqual: "#{DateTime.to_iso8601(to_datetime)}"}}] + }, + sort: {field: START_TIME, order: DESC}, + ) { + startTime + endTime + duration + bucket + bucketRegion + cloudfrontUrl + fileName + playlistM3u8Path + playlistPath + playlistTimestamp + segmentPath + } } } } diff --git a/ui/src/graphql/generated/index.ts b/ui/src/graphql/generated/index.ts index 57a97f7e..4bf354b8 100644 --- a/ui/src/graphql/generated/index.ts +++ b/ui/src/graphql/generated/index.ts @@ -289,7 +289,7 @@ export type AudioImageSortInput = { export type Bout = { __typename?: "Bout"; - category?: Maybe; + category: AudioCategory; duration?: Maybe; endTime?: Maybe; id: Scalars["ID"]["output"]; @@ -301,7 +301,7 @@ export type BoutFilterCategory = { eq?: InputMaybe; greaterThan?: InputMaybe; greaterThanOrEqual?: InputMaybe; - in?: InputMaybe>>; + in?: InputMaybe>; isNil?: InputMaybe; lessThan?: InputMaybe; lessThanOrEqual?: InputMaybe; @@ -1184,21 +1184,43 @@ export type FeedSortInput = { export type FeedStream = { __typename?: "FeedStream"; + bouts: Array; bucket?: Maybe; bucketRegion?: Maybe; cloudfrontUrl?: Maybe; duration?: Maybe; endTime?: Maybe; + feed?: Maybe; + feedId?: Maybe; + feedSegments: Array; id: Scalars["ID"]["output"]; + nextFeedStream?: Maybe; + nextFeedStreamId?: Maybe; /** S3 object path for playlist file (e.g. /rpi_orcasound_lab/hls/1541027406/live.m3u8) */ playlistM3u8Path?: Maybe; /** S3 object path for playlist dir (e.g. /rpi_orcasound_lab/hls/1541027406/) */ playlistPath?: Maybe; /** UTC Unix epoch for playlist start (e.g. 1541027406) */ playlistTimestamp?: Maybe; + prevFeedStream?: Maybe; + prevFeedStreamId?: Maybe; startTime?: Maybe; }; +export type FeedStreamBoutsArgs = { + filter?: InputMaybe; + limit?: InputMaybe; + offset?: InputMaybe; + sort?: InputMaybe>>; +}; + +export type FeedStreamFeedSegmentsArgs = { + filter?: InputMaybe; + limit?: InputMaybe; + offset?: InputMaybe; + sort?: InputMaybe>>; +}; + export type FeedStreamFilterBucket = { eq?: InputMaybe; greaterThan?: InputMaybe; @@ -1260,18 +1282,28 @@ export type FeedStreamFilterEndTime = { notEq?: InputMaybe; }; +export type FeedStreamFilterFeedId = { + isNil?: InputMaybe; +}; + export type FeedStreamFilterId = { isNil?: InputMaybe; }; export type FeedStreamFilterInput = { and?: InputMaybe>; + bouts?: InputMaybe; bucket?: InputMaybe; bucketRegion?: InputMaybe; cloudfrontUrl?: InputMaybe; duration?: InputMaybe; endTime?: InputMaybe; + feed?: InputMaybe; + feedId?: InputMaybe; + feedSegments?: InputMaybe; id?: InputMaybe; + nextFeedStream?: InputMaybe; + nextFeedStreamId?: InputMaybe; not?: InputMaybe>; or?: InputMaybe>; /** S3 object path for playlist file (e.g. /rpi_orcasound_lab/hls/1541027406/live.m3u8) */ @@ -1280,9 +1312,15 @@ export type FeedStreamFilterInput = { playlistPath?: InputMaybe; /** UTC Unix epoch for playlist start (e.g. 1541027406) */ playlistTimestamp?: InputMaybe; + prevFeedStream?: InputMaybe; + prevFeedStreamId?: InputMaybe; startTime?: InputMaybe; }; +export type FeedStreamFilterNextFeedStreamId = { + isNil?: InputMaybe; +}; + export type FeedStreamFilterPlaylistM3u8Path = { eq?: InputMaybe; greaterThan?: InputMaybe; @@ -1322,6 +1360,10 @@ export type FeedStreamFilterPlaylistTimestamp = { notEq?: InputMaybe; }; +export type FeedStreamFilterPrevFeedStreamId = { + isNil?: InputMaybe; +}; + export type FeedStreamFilterStartTime = { eq?: InputMaybe; greaterThan?: InputMaybe; @@ -1339,10 +1381,13 @@ export type FeedStreamSortField = | "CLOUDFRONT_URL" | "DURATION" | "END_TIME" + | "FEED_ID" | "ID" + | "NEXT_FEED_STREAM_ID" | "PLAYLIST_M3U8_PATH" | "PLAYLIST_PATH" | "PLAYLIST_TIMESTAMP" + | "PREV_FEED_STREAM_ID" | "START_TIME"; export type FeedStreamSortInput = { @@ -1555,6 +1600,17 @@ export type NotifyConfirmedCandidateResult = { result?: Maybe; }; +/** A page of :audio_image */ +export type PageOfAudioImage = { + __typename?: "PageOfAudioImage"; + /** Total count on all pages */ + count?: Maybe; + /** Whether or not there is a next page */ + hasNextPage: Scalars["Boolean"]["output"]; + /** The records contained in the page */ + results?: Maybe>; +}; + /** A page of :bout */ export type PageOfBout = { __typename?: "PageOfBout"; @@ -1717,6 +1773,7 @@ export type RootMutationTypeSubmitDetectionArgs = { export type RootQueryType = { __typename?: "RootQueryType"; + audioImages?: Maybe; bouts?: Maybe; candidate?: Maybe; candidates?: Maybe; @@ -1730,6 +1787,14 @@ export type RootQueryType = { notificationsForCandidate: Array; }; +export type RootQueryTypeAudioImagesArgs = { + feedId: Scalars["String"]["input"]; + filter?: InputMaybe; + limit?: InputMaybe; + offset?: InputMaybe; + sort?: InputMaybe>>; +}; + export type RootQueryTypeBoutsArgs = { feedId?: InputMaybe; filter?: InputMaybe; @@ -1758,6 +1823,7 @@ export type RootQueryTypeDetectionArgs = { }; export type RootQueryTypeDetectionsArgs = { + feedId?: InputMaybe; filter?: InputMaybe; limit?: InputMaybe; offset?: InputMaybe; @@ -2335,6 +2401,7 @@ export type CandidatesQuery = { }; export type DetectionsQueryVariables = Exact<{ + feedId?: InputMaybe; filter?: InputMaybe; limit?: InputMaybe; offset?: InputMaybe; @@ -2364,6 +2431,37 @@ export type DetectionsQuery = { } | null; }; +export type ListFeedStreamsQueryVariables = Exact<{ + feedId?: InputMaybe; + filter?: InputMaybe; + limit?: InputMaybe; + offset?: InputMaybe; + sort?: InputMaybe< + Array> | InputMaybe + >; +}>; + +export type ListFeedStreamsQuery = { + __typename?: "RootQueryType"; + feedStreams?: { + __typename?: "PageOfFeedStream"; + count?: number | null; + hasNextPage: boolean; + results?: Array<{ + __typename?: "FeedStream"; + id: string; + startTime?: Date | null; + endTime?: Date | null; + duration?: number | null; + bucket?: string | null; + bucketRegion?: string | null; + cloudfrontUrl?: string | null; + playlistM3u8Path?: string | null; + playlistPath?: string | null; + }> | null; + } | null; +}; + export type FeedsQueryVariables = Exact<{ sort?: InputMaybe< Array> | InputMaybe @@ -3239,8 +3337,14 @@ useCandidatesQuery.fetcher = ( ); export const DetectionsDocument = ` - query detections($filter: DetectionFilterInput, $limit: Int, $offset: Int, $sort: [DetectionSortInput]) { - detections(filter: $filter, limit: $limit, offset: $offset, sort: $sort) { + query detections($feedId: String, $filter: DetectionFilterInput, $limit: Int, $offset: Int, $sort: [DetectionSortInput]) { + detections( + feedId: $feedId + filter: $filter + limit: $limit + offset: $offset + sort: $sort + ) { count hasNextPage results { @@ -3295,6 +3399,74 @@ useDetectionsQuery.fetcher = ( options, ); +export const ListFeedStreamsDocument = ` + query listFeedStreams($feedId: String, $filter: FeedStreamFilterInput, $limit: Int, $offset: Int, $sort: [FeedStreamSortInput]) { + feedStreams( + feedId: $feedId + filter: $filter + limit: $limit + offset: $offset + sort: $sort + ) { + count + hasNextPage + results { + id + startTime + endTime + duration + bucket + bucketRegion + cloudfrontUrl + playlistM3u8Path + playlistPath + } + } +} + `; + +export const useListFeedStreamsQuery = < + TData = ListFeedStreamsQuery, + TError = unknown, +>( + variables?: ListFeedStreamsQueryVariables, + options?: Omit< + UseQueryOptions, + "queryKey" + > & { + queryKey?: UseQueryOptions["queryKey"]; + }, +) => { + return useQuery({ + queryKey: + variables === undefined + ? ["listFeedStreams"] + : ["listFeedStreams", variables], + queryFn: fetcher( + ListFeedStreamsDocument, + variables, + ), + ...options, + }); +}; + +useListFeedStreamsQuery.document = ListFeedStreamsDocument; + +useListFeedStreamsQuery.getKey = (variables?: ListFeedStreamsQueryVariables) => + variables === undefined + ? ["listFeedStreams"] + : ["listFeedStreams", variables]; + +useListFeedStreamsQuery.fetcher = ( + variables?: ListFeedStreamsQueryVariables, + options?: RequestInit["headers"], +) => + fetcher( + ListFeedStreamsDocument, + variables, + options, + ); + export const FeedsDocument = ` query feeds($sort: [FeedSortInput]) { feeds(sort: $sort) { diff --git a/ui/src/graphql/queries/listDetections.graphql b/ui/src/graphql/queries/listDetections.graphql index 7f4523f0..75a26cdd 100644 --- a/ui/src/graphql/queries/listDetections.graphql +++ b/ui/src/graphql/queries/listDetections.graphql @@ -1,10 +1,17 @@ query detections( + $feedId: String $filter: DetectionFilterInput $limit: Int $offset: Int $sort: [DetectionSortInput] ) { - detections(filter: $filter, limit: $limit, offset: $offset, sort: $sort) { + detections( + feedId: $feedId + filter: $filter + limit: $limit + offset: $offset + sort: $sort + ) { count hasNextPage results { diff --git a/ui/src/graphql/queries/listFeedStreams.graphql b/ui/src/graphql/queries/listFeedStreams.graphql new file mode 100644 index 00000000..51dcee65 --- /dev/null +++ b/ui/src/graphql/queries/listFeedStreams.graphql @@ -0,0 +1,29 @@ +query listFeedStreams( + $feedId: String + $filter: FeedStreamFilterInput + $limit: Int + $offset: Int + $sort: [FeedStreamSortInput] +) { + feedStreams( + feedId: $feedId + filter: $filter + limit: $limit + offset: $offset + sort: $sort + ) { + count + hasNextPage + results { + id + startTime + endTime + duration + bucket + bucketRegion + cloudfrontUrl + playlistM3u8Path + playlistPath + } + } +} diff --git a/ui/src/pages/bouts/[feedSlug]/new.tsx b/ui/src/pages/bouts/[feedSlug]/new.tsx index b8f88753..e53d09e8 100644 --- a/ui/src/pages/bouts/[feedSlug]/new.tsx +++ b/ui/src/pages/bouts/[feedSlug]/new.tsx @@ -2,7 +2,7 @@ import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; import Head from "next/head"; import { useParams, useSearchParams } from "next/navigation"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import SpectrogramTimeline from "@/components/Bouts/SpectrogramTimeline"; import { getSimpleLayout } from "@/components/layouts/SimpleLayout"; @@ -10,9 +10,9 @@ import LoadingSpinner from "@/components/LoadingSpinner"; import { BoutPlayer } from "@/components/Player/BoutPlayer"; import { AudioCategory, - DetectionFilterFeedId, useDetectionsQuery, useFeedQuery, + useListFeedStreamsQuery, } from "@/graphql/generated"; import type { NextPageWithLayout } from "@/pages/_app"; @@ -28,14 +28,31 @@ const NewBoutPage: NextPageWithLayout = () => { ); const feed = feedQueryResult.data?.feed; + const now = useMemo(() => new Date(), []); + + // If feed is present, and there's no pre-set time, + // get latest stream and last 10 minutes of segments. + // Set time to end of last segment + const feedStreamQueryResult = useListFeedStreamsQuery( + { + feedId: feed?.id, + sort: { field: "START_TIME", order: "DESC" }, + limit: 1, + }, + { enabled: !!feed?.id }, + ); + const detectionQueryResult = useDetectionsQuery( - { filter: { feedId: feed?.id as DetectionFilterFeedId } }, + { feedId: feed?.id }, { enabled: !!feed?.id }, ); if (!feedSlug || feedQueryResult.isLoading) return ; if (!feed) return

Feed not found

; + const feedStreams = feedStreamQueryResult.data?.feedStreams?.results ?? []; + const feedStream = feedStreams[0]; + return (
From 3ccb7003ab4664b6071f76bf869800911045f172 Mon Sep 17 00:00:00 2001 From: Skander Mzali Date: Tue, 3 Dec 2024 14:37:17 -0800 Subject: [PATCH 06/40] Update feed_streams client query call in GlobalSetup --- server/lib/orcasite/global_setup.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/lib/orcasite/global_setup.ex b/server/lib/orcasite/global_setup.ex index 7670a945..781cf513 100644 --- a/server/lib/orcasite/global_setup.ex +++ b/server/lib/orcasite/global_setup.ex @@ -47,7 +47,7 @@ defmodule Orcasite.GlobalSetup do # Get stream for the last `minutes` minutes {:ok, feed_streams_response} = - Orcasite.Radio.GraphqlClient.get_feed_stream(feed_id, minutes_ago_datetime, now) + Orcasite.Radio.GraphqlClient.get_feed_streams_with_segments(feed_id, minutes_ago_datetime, now) feed_streams = get_in(feed_streams_response, ["data", "feedStreams", "results"]) From ad66cd7ea8c69b091033351d4c151c05be5adf77 Mon Sep 17 00:00:00 2001 From: Skander Mzali Date: Fri, 6 Dec 2024 12:05:47 -0800 Subject: [PATCH 07/40] Update stream/segment populate script and fix create actions --- server/lib/orcasite/global_setup.ex | 35 ++++++++++++++------- server/lib/orcasite/radio/feed_segment.ex | 2 +- server/lib/orcasite/radio/feed_stream.ex | 35 +++++++++++++++++++++ server/lib/orcasite/radio/graphql_client.ex | 8 ++--- server/mix.exs | 3 +- server/mix.lock | 1 + 6 files changed, 67 insertions(+), 17 deletions(-) diff --git a/server/lib/orcasite/global_setup.ex b/server/lib/orcasite/global_setup.ex index 781cf513..cf89a7cd 100644 --- a/server/lib/orcasite/global_setup.ex +++ b/server/lib/orcasite/global_setup.ex @@ -47,26 +47,39 @@ defmodule Orcasite.GlobalSetup do # Get stream for the last `minutes` minutes {:ok, feed_streams_response} = - Orcasite.Radio.GraphqlClient.get_feed_streams_with_segments(feed_id, minutes_ago_datetime, now) + Orcasite.Radio.GraphqlClient.get_feed_streams_with_segments( + feed_id, + minutes_ago_datetime, + now + ) feed_streams = get_in(feed_streams_response, ["data", "feedStreams", "results"]) feed_streams - |> Enum.map( - &%{ - m3u8_path: Map.get(&1, "playlistM3u8Path"), - bucket: Map.get(&1, "bucket"), - update_segments?: true, - link_streams?: true - } - ) + |> Recase.Enumerable.convert_keys(&Recase.to_snake/1) + |> Enum.map(fn feed_stream -> + feed_stream + |> Map.drop(["id"]) + |> Map.put("feed", feed) + |> Map.update( + "feed_segments", + [], + &Enum.map(&1, fn seg -> + seg + |> Map.drop(["id"]) + |> Map.put("feed", feed) + |> Recase.Enumerable.atomize_keys() + end) + ) + |> Recase.Enumerable.atomize_keys() + end) |> Ash.bulk_create( Orcasite.Radio.FeedStream, - :from_m3u8_path, + :populate_with_segments, return_errors?: true, stop_on_error?: true, upsert?: true, - upsert_identity: :feed_stream_timestamp + upsert_identity: :playlist_m3u8_path ) end end diff --git a/server/lib/orcasite/radio/feed_segment.ex b/server/lib/orcasite/radio/feed_segment.ex index 518b859e..c58342ff 100644 --- a/server/lib/orcasite/radio/feed_segment.ex +++ b/server/lib/orcasite/radio/feed_segment.ex @@ -158,7 +158,7 @@ defmodule Orcasite.Radio.FeedSegment do ] argument :feed, :map, allow_nil?: false - argument :feed_stream, :map, allow_nil?: false + argument :feed_stream, :map argument :segment_path, :string do allow_nil? false diff --git a/server/lib/orcasite/radio/feed_stream.ex b/server/lib/orcasite/radio/feed_stream.ex index 4ad2f032..cfc07a2b 100644 --- a/server/lib/orcasite/radio/feed_stream.ex +++ b/server/lib/orcasite/radio/feed_stream.ex @@ -288,6 +288,41 @@ defmodule Orcasite.Radio.FeedStream do end end + create :populate_with_segments do + upsert? true + upsert_identity :playlist_m3u8_path + accept [ + :start_time, + :end_time, + :duration, + :bucket, + :bucket_region, + :cloudfront_url, + :playlist_path, + :playlist_timestamp, + :playlist_m3u8_path + ] + + upsert_fields [ + :start_time, + :end_time, + :duration, + :bucket, + :bucket_region, + :cloudfront_url, + :playlist_path, + :playlist_timestamp, + :playlist_m3u8_path, + :updated_at + ] + + argument :feed_segments, {:array, :map} + argument :feed, :map + + change manage_relationship(:feed_segments, type: :create) + change manage_relationship(:feed, type: :append) + end + update :update_segments do description "Pulls contents of m3u8 file and creates a FeedSegment per new entry" require_atomic? false diff --git a/server/lib/orcasite/radio/graphql_client.ex b/server/lib/orcasite/radio/graphql_client.ex index 061b1447..6f63e816 100644 --- a/server/lib/orcasite/radio/graphql_client.ex +++ b/server/lib/orcasite/radio/graphql_client.ex @@ -20,10 +20,10 @@ defmodule Orcasite.Radio.GraphqlClient do feedId: "#{feed_id}", filter: { and: [ - {startTime: {lessThanOrEqual: "#{DateTime.to_iso8601(from_datetime)}"}}, + {startTime: {lessThanOrEqual: "#{DateTime.to_iso8601(to_datetime)}"}}, {startTime: {greaterThanOrEqual: "#{DateTime.to_iso8601(day_before)}"}} ], - or: [{endTime: {isNil: true}}, {endTime: {greaterThanOrEqual: "#{DateTime.to_iso8601(to_datetime)}"}}] + or: [{endTime: {isNil: true}}, {endTime: {greaterThanOrEqual: "#{DateTime.to_iso8601(from_datetime)}"}}] }, sort: {field: START_TIME, order: DESC}, limit: 2 @@ -44,10 +44,10 @@ defmodule Orcasite.Radio.GraphqlClient do feedSegments( filter: { and: [ - {startTime: {lessThanOrEqual: "#{DateTime.to_iso8601(from_datetime)}"}}, + {startTime: {lessThanOrEqual: "#{DateTime.to_iso8601(to_datetime)}"}}, {startTime: {greaterThanOrEqual: "#{DateTime.to_iso8601(day_before)}"}} ], - or: [{endTime: {isNil: true}}, {endTime: {greaterThanOrEqual: "#{DateTime.to_iso8601(to_datetime)}"}}] + or: [{endTime: {isNil: true}}, {endTime: {greaterThanOrEqual: "#{DateTime.to_iso8601(from_datetime)}"}}] }, sort: {field: START_TIME, order: DESC}, ) { diff --git a/server/mix.exs b/server/mix.exs index b756c698..920ba021 100644 --- a/server/mix.exs +++ b/server/mix.exs @@ -109,7 +109,8 @@ defmodule Orcasite.Mixfile do {:configparser_ex, "~> 4.0", only: :dev}, {:broadway_sqs, "~> 0.7"}, {:recon, "~> 2.5"}, - {:ecto_psql_extras, "~> 0.6"} + {:ecto_psql_extras, "~> 0.6"}, + {:recase, "~> 0.5"} ] end diff --git a/server/mix.lock b/server/mix.lock index a46323a2..a14cf6d7 100644 --- a/server/mix.lock +++ b/server/mix.lock @@ -104,6 +104,7 @@ "postgrex": {:hex, :postgrex, "0.19.3", "a0bda6e3bc75ec07fca5b0a89bffd242ca209a4822a9533e7d3e84ee80707e19", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "d31c28053655b78f47f948c85bb1cf86a9c1f8ead346ba1aa0d0df017fa05b61"}, "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, "reactor": {:hex, :reactor, "0.10.1", "7aad41c6f88c5214c5f878c597bc64d0f9983af3003bf2a6dbece031630a71b7", [:mix], [{:igniter, "~> 0.2", [hex: :igniter, repo: "hexpm", optional: false]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b1aca34c0eafaefa98163778fc8252273638e36d0a70165b752910015161b6a8"}, + "recase": {:hex, :recase, "0.8.1", "ab98cd35857a86fa5ca99036f575241d71d77d9c2ab0c39aacf1c9b61f6f7d1d", [:mix], [], "hexpm", "9fd8d63e7e43bd9ea385b12364e305778b2bbd92537e95c4b2e26fc507d5e4c2"}, "recon": {:hex, :recon, "2.5.6", "9052588e83bfedfd9b72e1034532aee2a5369d9d9343b61aeb7fbce761010741", [:mix, :rebar3], [], "hexpm", "96c6799792d735cc0f0fd0f86267e9d351e63339cbe03df9d162010cefc26bb0"}, "redix": {:hex, :redix, "1.5.2", "ab854435a663f01ce7b7847f42f5da067eea7a3a10c0a9d560fa52038fd7ab48", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:nimble_options, "~> 0.5.0 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "78538d184231a5d6912f20567d76a49d1be7d3fca0e1aaaa20f4df8e1142dcb8"}, "redoc_ui_plug": {:hex, :redoc_ui_plug, "0.2.1", "5e9760c17ed450fc9df671d5fbc70a6f06179c41d9d04ae3c33f16baca3a5b19", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "7be01db31f210887e9fc18f8fbccc7788de32c482b204623556e415ed1fe714b"}, From 58defa555246f3d19a24bd50e9160c613b152de3 Mon Sep 17 00:00:00 2001 From: Skander Mzali Date: Wed, 11 Dec 2024 15:15:41 -0800 Subject: [PATCH 08/40] Update populate script. Add target time for bout player --- server/lib/orcasite/global_setup.ex | 73 ++++++----- server/lib/orcasite/radio/graphql_client.ex | 2 +- ui/package-lock.json | 25 +++- ui/package.json | 1 + ui/src/components/Player/BoutPlayer.tsx | 41 +++++-- ui/src/graphql/generated/index.ts | 113 ++++++++++++++---- .../graphql/queries/listFeedStreams.graphql | 50 ++++++-- ui/src/pages/bouts/[feedSlug]/new.tsx | 20 +++- 8 files changed, 240 insertions(+), 85 deletions(-) diff --git a/server/lib/orcasite/global_setup.ex b/server/lib/orcasite/global_setup.ex index cf89a7cd..a3482f1b 100644 --- a/server/lib/orcasite/global_setup.ex +++ b/server/lib/orcasite/global_setup.ex @@ -40,47 +40,54 @@ defmodule Orcasite.GlobalSetup do if Application.get_env(:orcasite, :env) != :prod do # Get prod feed id for feed {:ok, feed_resp} = Orcasite.Radio.GraphqlClient.get_feed(feed.slug) - feed_id = feed_resp |> get_in(["data", "feed", "id"]) - now = DateTime.utc_now() - minutes_ago_datetime = now |> DateTime.add(-minutes_ago, :minute) + feed_resp + |> get_in(["data", "feed", "id"]) + |> case do + nil -> + {:error, :feed_not_found} - # Get stream for the last `minutes` minutes - {:ok, feed_streams_response} = - Orcasite.Radio.GraphqlClient.get_feed_streams_with_segments( - feed_id, - minutes_ago_datetime, - now - ) + feed_id -> + now = DateTime.utc_now() + minutes_ago_datetime = now |> DateTime.add(-minutes_ago, :minute) - feed_streams = get_in(feed_streams_response, ["data", "feedStreams", "results"]) + # Get stream for the last `minutes` minutes + {:ok, feed_streams_response} = + Orcasite.Radio.GraphqlClient.get_feed_streams_with_segments( + feed_id, + minutes_ago_datetime, + now + ) - feed_streams - |> Recase.Enumerable.convert_keys(&Recase.to_snake/1) - |> Enum.map(fn feed_stream -> - feed_stream - |> Map.drop(["id"]) - |> Map.put("feed", feed) - |> Map.update( - "feed_segments", - [], - &Enum.map(&1, fn seg -> - seg + feed_streams = get_in(feed_streams_response, ["data", "feedStreams", "results"]) + + feed_streams + |> Recase.Enumerable.convert_keys(&Recase.to_snake/1) + |> Enum.map(fn feed_stream -> + feed_stream |> Map.drop(["id"]) |> Map.put("feed", feed) + |> Map.update( + "feed_segments", + [], + &Enum.map(&1, fn seg -> + seg + |> Map.drop(["id"]) + |> Map.put("feed", feed) + |> Recase.Enumerable.atomize_keys() + end) + ) |> Recase.Enumerable.atomize_keys() end) - ) - |> Recase.Enumerable.atomize_keys() - end) - |> Ash.bulk_create( - Orcasite.Radio.FeedStream, - :populate_with_segments, - return_errors?: true, - stop_on_error?: true, - upsert?: true, - upsert_identity: :playlist_m3u8_path - ) + |> Ash.bulk_create( + Orcasite.Radio.FeedStream, + :populate_with_segments, + return_errors?: true, + stop_on_error?: true, + upsert?: true, + upsert_identity: :playlist_m3u8_path + ) + end end end end diff --git a/server/lib/orcasite/radio/graphql_client.ex b/server/lib/orcasite/radio/graphql_client.ex index 6f63e816..9c474e3b 100644 --- a/server/lib/orcasite/radio/graphql_client.ex +++ b/server/lib/orcasite/radio/graphql_client.ex @@ -47,7 +47,7 @@ defmodule Orcasite.Radio.GraphqlClient do {startTime: {lessThanOrEqual: "#{DateTime.to_iso8601(to_datetime)}"}}, {startTime: {greaterThanOrEqual: "#{DateTime.to_iso8601(day_before)}"}} ], - or: [{endTime: {isNil: true}}, {endTime: {greaterThanOrEqual: "#{DateTime.to_iso8601(from_datetime)}"}}] + endTime: {greaterThanOrEqual: "#{DateTime.to_iso8601(from_datetime)}"} }, sort: {field: START_TIME, order: DESC}, ) { diff --git a/ui/package-lock.json b/ui/package-lock.json index cff8f577..c9dbbf0b 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -19,6 +19,7 @@ "@mui/material-nextjs": "^6.1.4", "@tanstack/react-query": "^5.59.15", "clsx": "^2.1.0", + "date-fns": "^4.1.0", "dotenv-cli": "^7.4.2", "graphql": "^16.9.0", "leaflet": "^1.9.4", @@ -4820,9 +4821,10 @@ } }, "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -5169,6 +5171,16 @@ "integrity": "sha512-8YnDaaf7N3k/q5HnTJVuzSyLETjoZjVmHc4AeKAzOvKHEFQKcn64OKBfzHYtE9zGjctNM7V9I0MfnUVLpi7M5g==", "dev": true }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debounce": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz", @@ -8678,15 +8690,16 @@ } }, "node_modules/nanoid": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", - "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", + "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "bin": { "nanoid": "bin/nanoid.cjs" }, diff --git a/ui/package.json b/ui/package.json index b4e8d92e..d744797f 100644 --- a/ui/package.json +++ b/ui/package.json @@ -27,6 +27,7 @@ "@mui/material-nextjs": "^6.1.4", "@tanstack/react-query": "^5.59.15", "clsx": "^2.1.0", + "date-fns": "^4.1.0", "dotenv-cli": "^7.4.2", "graphql": "^16.9.0", "leaflet": "^1.9.4", diff --git a/ui/src/components/Player/BoutPlayer.tsx b/ui/src/components/Player/BoutPlayer.tsx index 91ee0f98..f32d03e8 100644 --- a/ui/src/components/Player/BoutPlayer.tsx +++ b/ui/src/components/Player/BoutPlayer.tsx @@ -1,7 +1,11 @@ import { Box, Typography } from "@mui/material"; +import { differenceInSeconds } from "date-fns"; import dynamic from "next/dynamic"; import { useCallback, useMemo, useRef, useState } from "react"; +import { Feed, FeedStream } from "@/graphql/generated"; +import { getHlsURI } from "@/hooks/useTimestampFetcher"; + import { type PlayerStatus } from "./Player"; import PlayPauseButton from "./PlayPauseButton"; import { type VideoJSPlayer } from "./VideoJS"; @@ -12,20 +16,37 @@ const playerOffsetToDateTime = (playlistDatetime: Date, playerOffset: number) => new Date(playlistDatetime.valueOf() + playerOffset * 1000); export function BoutPlayer({ + feed, + feedStream, + targetTime, onPlayerTimeUpdate, }: { + feed: Pick; + feedStream: Pick; + targetTime: Date; onPlayerTimeUpdate?: (time: Date) => void; }) { - const playlistTimestamp = "1732665619"; - const playlistDatetime = new Date(Number(playlistTimestamp) * 1000); - const hlsURI = `https://audio-orcasound-net.s3.amazonaws.com/rpi_port_townsend/hls/${playlistTimestamp}/live.m3u8`; + const hlsURI = getHlsURI( + feed.bucket, + feed.nodeName, + Number(feedStream.playlistTimestamp), + ); + const playlistTimestamp = feedStream.playlistTimestamp; + const playlistDatetime = useMemo( + () => new Date(Number(playlistTimestamp) * 1000), + [playlistTimestamp], + ); + const now = useMemo(() => new Date(), []); const [playerStatus, setPlayerStatus] = useState("idle"); const playerRef = useRef(null); + const targetOffset = useMemo( + () => differenceInSeconds(targetTime, playlistDatetime), + [targetTime, playlistDatetime], + ); const [playerOffset, setPlayerOffset] = useState( - now.valueOf() / 1000 - Number(playlistTimestamp), + targetOffset ?? now.valueOf() / 1000 - Number(playlistTimestamp), ); - const playerDateTime = useMemo( () => playerOffsetToDateTime(playlistDatetime, playerOffset), [playlistDatetime, playerOffset], @@ -76,7 +97,7 @@ export function BoutPlayer({ // player.currentTime(startOffset); player.on("timeupdate", () => { - const currentTime = player.currentTime() ?? 0; + const currentTime = player.currentTime() ?? targetOffset ?? 0; // if (currentTime > endOffset) { // player.currentTime(startOffset); // setPlayerOffset(startOffset); @@ -90,9 +111,15 @@ export function BoutPlayer({ ); } }); + + player.on("loadedmetadata", () => { + // On initial load, set target time + console.log("offset", targetOffset); + player.currentTime(targetOffset); + }); }, // [startOffset, endOffset], - [onPlayerTimeUpdate], + [onPlayerTimeUpdate, playlistDatetime, targetOffset], ); const handlePlayPauseClick = () => { const player = playerRef.current; diff --git a/ui/src/graphql/generated/index.ts b/ui/src/graphql/generated/index.ts index 4bf354b8..0238c81b 100644 --- a/ui/src/graphql/generated/index.ts +++ b/ui/src/graphql/generated/index.ts @@ -297,6 +297,37 @@ export type Bout = { startTime?: Maybe; }; +/** Join table between Bout and FeedStream */ +export type BoutFeedStream = { + __typename?: "BoutFeedStream"; + id: Scalars["ID"]["output"]; +}; + +export type BoutFeedStreamFilterId = { + eq?: InputMaybe; + greaterThan?: InputMaybe; + greaterThanOrEqual?: InputMaybe; + in?: InputMaybe>; + isNil?: InputMaybe; + lessThan?: InputMaybe; + lessThanOrEqual?: InputMaybe; + notEq?: InputMaybe; +}; + +export type BoutFeedStreamFilterInput = { + and?: InputMaybe>; + id?: InputMaybe; + not?: InputMaybe>; + or?: InputMaybe>; +}; + +export type BoutFeedStreamSortField = "ID"; + +export type BoutFeedStreamSortInput = { + field: BoutFeedStreamSortField; + order?: InputMaybe; +}; + export type BoutFilterCategory = { eq?: InputMaybe; greaterThan?: InputMaybe; @@ -1184,6 +1215,7 @@ export type FeedSortInput = { export type FeedStream = { __typename?: "FeedStream"; + boutFeedStreams: Array; bouts: Array; bucket?: Maybe; bucketRegion?: Maybe; @@ -1207,6 +1239,13 @@ export type FeedStream = { startTime?: Maybe; }; +export type FeedStreamBoutFeedStreamsArgs = { + filter?: InputMaybe; + limit?: InputMaybe; + offset?: InputMaybe; + sort?: InputMaybe>>; +}; + export type FeedStreamBoutsArgs = { filter?: InputMaybe; limit?: InputMaybe; @@ -1292,6 +1331,7 @@ export type FeedStreamFilterId = { export type FeedStreamFilterInput = { and?: InputMaybe>; + boutFeedStreams?: InputMaybe; bouts?: InputMaybe; bucket?: InputMaybe; bucketRegion?: InputMaybe; @@ -2433,12 +2473,9 @@ export type DetectionsQuery = { export type ListFeedStreamsQueryVariables = Exact<{ feedId?: InputMaybe; - filter?: InputMaybe; - limit?: InputMaybe; - offset?: InputMaybe; - sort?: InputMaybe< - Array> | InputMaybe - >; + fromDateTime: Scalars["DateTime"]["input"]; + toDateTime: Scalars["DateTime"]["input"]; + dayBeforeFromDateTime: Scalars["DateTime"]["input"]; }>; export type ListFeedStreamsQuery = { @@ -2446,7 +2483,6 @@ export type ListFeedStreamsQuery = { feedStreams?: { __typename?: "PageOfFeedStream"; count?: number | null; - hasNextPage: boolean; results?: Array<{ __typename?: "FeedStream"; id: string; @@ -2456,8 +2492,23 @@ export type ListFeedStreamsQuery = { bucket?: string | null; bucketRegion?: string | null; cloudfrontUrl?: string | null; - playlistM3u8Path?: string | null; + playlistTimestamp?: string | null; playlistPath?: string | null; + playlistM3u8Path?: string | null; + feedSegments: Array<{ + __typename?: "FeedSegment"; + startTime?: Date | null; + endTime?: Date | null; + duration?: number | null; + bucket?: string | null; + bucketRegion?: string | null; + cloudfrontUrl?: string | null; + fileName: string; + playlistM3u8Path?: string | null; + playlistPath?: string | null; + playlistTimestamp?: string | null; + segmentPath?: string | null; + }>; }> | null; } | null; }; @@ -3400,16 +3451,14 @@ useDetectionsQuery.fetcher = ( ); export const ListFeedStreamsDocument = ` - query listFeedStreams($feedId: String, $filter: FeedStreamFilterInput, $limit: Int, $offset: Int, $sort: [FeedStreamSortInput]) { + query listFeedStreams($feedId: String, $fromDateTime: DateTime!, $toDateTime: DateTime!, $dayBeforeFromDateTime: DateTime!) { feedStreams( feedId: $feedId - filter: $filter - limit: $limit - offset: $offset - sort: $sort + filter: {and: [{startTime: {lessThanOrEqual: $toDateTime}}, {startTime: {greaterThanOrEqual: $dayBeforeFromDateTime}}], or: [{endTime: {isNil: true}}, {endTime: {greaterThanOrEqual: $fromDateTime}}]} + sort: {field: START_TIME, order: DESC} + limit: 2 ) { count - hasNextPage results { id startTime @@ -3418,8 +3467,25 @@ export const ListFeedStreamsDocument = ` bucket bucketRegion cloudfrontUrl - playlistM3u8Path + playlistTimestamp playlistPath + playlistM3u8Path + feedSegments( + filter: {and: [{startTime: {lessThanOrEqual: $toDateTime}}, {startTime: {greaterThanOrEqual: $dayBeforeFromDateTime}}], endTime: {greaterThanOrEqual: $fromDateTime}} + sort: {field: START_TIME, order: DESC} + ) { + startTime + endTime + duration + bucket + bucketRegion + cloudfrontUrl + fileName + playlistM3u8Path + playlistPath + playlistTimestamp + segmentPath + } } } } @@ -3429,7 +3495,7 @@ export const useListFeedStreamsQuery = < TData = ListFeedStreamsQuery, TError = unknown, >( - variables?: ListFeedStreamsQueryVariables, + variables: ListFeedStreamsQueryVariables, options?: Omit< UseQueryOptions, "queryKey" @@ -3438,10 +3504,7 @@ export const useListFeedStreamsQuery = < }, ) => { return useQuery({ - queryKey: - variables === undefined - ? ["listFeedStreams"] - : ["listFeedStreams", variables], + queryKey: ["listFeedStreams", variables], queryFn: fetcher( ListFeedStreamsDocument, variables, @@ -3452,13 +3515,13 @@ export const useListFeedStreamsQuery = < useListFeedStreamsQuery.document = ListFeedStreamsDocument; -useListFeedStreamsQuery.getKey = (variables?: ListFeedStreamsQueryVariables) => - variables === undefined - ? ["listFeedStreams"] - : ["listFeedStreams", variables]; +useListFeedStreamsQuery.getKey = (variables: ListFeedStreamsQueryVariables) => [ + "listFeedStreams", + variables, +]; useListFeedStreamsQuery.fetcher = ( - variables?: ListFeedStreamsQueryVariables, + variables: ListFeedStreamsQueryVariables, options?: RequestInit["headers"], ) => fetcher( diff --git a/ui/src/graphql/queries/listFeedStreams.graphql b/ui/src/graphql/queries/listFeedStreams.graphql index 51dcee65..bbee310e 100644 --- a/ui/src/graphql/queries/listFeedStreams.graphql +++ b/ui/src/graphql/queries/listFeedStreams.graphql @@ -1,19 +1,25 @@ query listFeedStreams( $feedId: String - $filter: FeedStreamFilterInput - $limit: Int - $offset: Int - $sort: [FeedStreamSortInput] + $fromDateTime: DateTime! + $toDateTime: DateTime! + $dayBeforeFromDateTime: DateTime! ) { feedStreams( feedId: $feedId - filter: $filter - limit: $limit - offset: $offset - sort: $sort + filter: { + and: [ + { startTime: { lessThanOrEqual: $toDateTime } } + { startTime: { greaterThanOrEqual: $dayBeforeFromDateTime } } + ] + or: [ + { endTime: { isNil: true } } + { endTime: { greaterThanOrEqual: $fromDateTime } } + ] + } + sort: { field: START_TIME, order: DESC } + limit: 2 ) { count - hasNextPage results { id startTime @@ -22,8 +28,32 @@ query listFeedStreams( bucket bucketRegion cloudfrontUrl - playlistM3u8Path + playlistTimestamp playlistPath + playlistM3u8Path + + feedSegments( + filter: { + and: [ + { startTime: { lessThanOrEqual: $toDateTime } } + { startTime: { greaterThanOrEqual: $dayBeforeFromDateTime } } + ] + endTime: { greaterThanOrEqual: $fromDateTime } + } + sort: { field: START_TIME, order: DESC } + ) { + startTime + endTime + duration + bucket + bucketRegion + cloudfrontUrl + fileName + playlistM3u8Path + playlistPath + playlistTimestamp + segmentPath + } } } } diff --git a/ui/src/pages/bouts/[feedSlug]/new.tsx b/ui/src/pages/bouts/[feedSlug]/new.tsx index e53d09e8..8fd993d6 100644 --- a/ui/src/pages/bouts/[feedSlug]/new.tsx +++ b/ui/src/pages/bouts/[feedSlug]/new.tsx @@ -1,5 +1,6 @@ import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; +import { addMinutes, subDays, subMinutes } from "date-fns"; import Head from "next/head"; import { useParams, useSearchParams } from "next/navigation"; import { useMemo, useState } from "react"; @@ -17,6 +18,11 @@ import { import type { NextPageWithLayout } from "@/pages/_app"; const NewBoutPage: NextPageWithLayout = () => { + const targetTime = new Date("2024-12-11 19:55:44.013Z"); + const targetTimePlus10Minutes = addMinutes(targetTime, 10); + const targetTimeMinus10Minutes = subMinutes(targetTime, 10); + const targetTimeMinusADay = subDays(targetTime, 1); + const params = useParams<{ feedSlug?: string }>(); const feedSlug = params?.feedSlug; const searchParams = useSearchParams(); @@ -36,8 +42,9 @@ const NewBoutPage: NextPageWithLayout = () => { const feedStreamQueryResult = useListFeedStreamsQuery( { feedId: feed?.id, - sort: { field: "START_TIME", order: "DESC" }, - limit: 1, + fromDateTime: targetTime, + toDateTime: targetTimeMinus10Minutes, + dayBeforeFromDateTime: targetTimeMinusADay, }, { enabled: !!feed?.id }, ); @@ -69,7 +76,14 @@ const NewBoutPage: NextPageWithLayout = () => { - + {feedStream && ( + + )} From 79f4fd5d9bc7f87b4c1e65844618fdb5d1dfafd5 Mon Sep 17 00:00:00 2001 From: Skander Mzali Date: Thu, 12 Dec 2024 16:18:52 -0800 Subject: [PATCH 09/40] Add spectrogram layers, tick marks, update feed stream queries --- server/config/config.exs | 2 +- .../workers/send_notification_email.ex | 12 +- server/lib/orcasite/radio/audio_image.ex | 9 +- .../radio/workers/generate_spectrogram.ex | 31 +- server/lib/orcasite/rate_limiter.ex | 161 +-------- server/lib/orcasite/repo.ex | 4 + .../lib/orcasite/types/audio_image_status.ex | 2 +- server/lib/orcasite_web.ex | 4 +- .../components/core_components.ex | 2 +- server/lib/orcasite_web/gettext.ex | 2 +- .../components/Bouts/SpectrogramTimeline.tsx | 320 ++++++++++++++++-- ui/src/components/Player/BoutPlayer.tsx | 4 +- .../graphql/fragments/AudioImageParts.graphql | 12 + .../fragments/FeedSegmentParts.graphql | 14 + .../graphql/fragments/FeedStreamParts.graphql | 12 + ui/src/graphql/generated/index.ts | 132 ++++++-- .../graphql/queries/listFeedStreams.graphql | 26 +- ui/src/hooks/useTimestampFetcher.ts | 3 +- ui/src/pages/bouts/[feedSlug]/new.tsx | 50 ++- 19 files changed, 537 insertions(+), 265 deletions(-) create mode 100644 ui/src/graphql/fragments/AudioImageParts.graphql create mode 100644 ui/src/graphql/fragments/FeedSegmentParts.graphql create mode 100644 ui/src/graphql/fragments/FeedStreamParts.graphql diff --git a/server/config/config.exs b/server/config/config.exs index 0d103a73..676ab159 100644 --- a/server/config/config.exs +++ b/server/config/config.exs @@ -91,7 +91,7 @@ config :orcasite, Oban, repo: Orcasite.Repo, # 7 day job retention plugins: [{Oban.Plugins.Pruner, max_age: 7 * 24 * 60 * 60}], - queues: [default: 10, email: 10, feeds: 10] + queues: [default: 10, email: 10, feeds: 10, audio_images: 5] config :spark, :formatter, remove_parens?: true, diff --git a/server/lib/orcasite/notifications/workers/send_notification_email.ex b/server/lib/orcasite/notifications/workers/send_notification_email.ex index 1dae0f31..e671c901 100644 --- a/server/lib/orcasite/notifications/workers/send_notification_email.ex +++ b/server/lib/orcasite/notifications/workers/send_notification_email.ex @@ -39,7 +39,7 @@ defmodule Orcasite.Notifications.Workers.SendNotificationEmail do end |> Enum.filter(&(&1.id != notification_id)) - :ok = continue?() + :ok = Orcasite.RateLimiter.continue?("ses_email", 1_000, 14) %{meta: params} |> Map.merge(%{ @@ -95,14 +95,4 @@ defmodule Orcasite.Notifications.Workers.SendNotificationEmail do end) end - def continue?() do - case Hammer.check_rate("ses_email", 1_000, 14) do - {:allow, _count} -> - :ok - - {:deny, _limit} -> - Process.sleep(250) - continue?() - end - end end diff --git a/server/lib/orcasite/radio/audio_image.ex b/server/lib/orcasite/radio/audio_image.ex index 68f72795..11515e6e 100644 --- a/server/lib/orcasite/radio/audio_image.ex +++ b/server/lib/orcasite/radio/audio_image.ex @@ -213,7 +213,7 @@ defmodule Orcasite.Radio.AudioImage do {:error, error} -> image |> Ash.Changeset.for_update(:update, %{ - status: :failed + status: :errored }) |> Ash.Changeset.force_change_attribute(:last_error, inspect(error)) |> Ash.update(authorize?: false) @@ -227,15 +227,18 @@ defmodule Orcasite.Radio.AudioImage do prepend?: true ) end + + update :set_failed do + change set_attribute(:status, :failed) + end end graphql do type :audio_image - attribute_types [feed_id: :id] + attribute_types feed_id: :id queries do list :audio_images, :for_feed end - end end diff --git a/server/lib/orcasite/radio/workers/generate_spectrogram.ex b/server/lib/orcasite/radio/workers/generate_spectrogram.ex index 32800ef2..a9b2aa2c 100644 --- a/server/lib/orcasite/radio/workers/generate_spectrogram.ex +++ b/server/lib/orcasite/radio/workers/generate_spectrogram.ex @@ -1,6 +1,6 @@ defmodule Orcasite.Radio.Workers.GenerateSpectrogram do use Oban.Worker, - queue: :feeds, + queue: :audio_images, unique: [ keys: [:audio_image_id], period: :infinity, @@ -9,10 +9,33 @@ defmodule Orcasite.Radio.Workers.GenerateSpectrogram do max_attempts: 3 @impl Oban.Worker - def perform(%Oban.Job{args: %{"audio_image_id" => audio_image_id}}) do - Orcasite.Radio.AudioImage - |> Ash.get!(audio_image_id) + def perform(%Oban.Job{args: %{"audio_image_id" => audio_image_id, attempt: attempt}}) do + # 900/min equivalent in a second + :ok = Orcasite.RateLimiter.continue?(:generate_spectrogram, 1_000, 15) + + audio_image = Orcasite.Radio.AudioImage |> Ash.get!(audio_image_id) + + audio_image |> Ash.Changeset.for_update(:generate_spectrogram) |> Ash.update(authorize?: false) + |> case do + {:ok, %{status: :complete}} -> :ok + {:ok, %{last_error: error}} -> {:error, {:not_complete, error}} + {:error, err} -> {:error, err} + end + |> case do + :ok -> + :ok + + error -> + if attempt >= 3 do + audio_image + |> Ash.reload!() + |> Ash.Changeset.for_update(:set_failed) + |> Ash.update(authorize?: false) + end + + error + end end end diff --git a/server/lib/orcasite/rate_limiter.ex b/server/lib/orcasite/rate_limiter.ex index aa25f435..33bfabfa 100644 --- a/server/lib/orcasite/rate_limiter.ex +++ b/server/lib/orcasite/rate_limiter.ex @@ -1,153 +1,22 @@ defmodule Orcasite.RateLimiter do - @moduledoc """ - Based on Broadway's Broadway.Topology.RateLimiter, but uses the Registry - to make it distributed - """ + def continue?(keys, time, number) when is_tuple(keys), + do: continue?(Tuple.to_list(keys), time, number) - use GenServer + def continue?(keys, time, number) when is_list(keys), + do: continue?(Enum.join(keys, ":"), time, number) - @atomics_index 1 + def continue?(key, time, number) when is_atom(key), + do: continue?(Atom.to_string(key), time, number) - def continue?(rate_limiter, count) do - with %{remaining: remaining} when remaining > 0 <- - call_rate_limit(rate_limiter, count) do - :ok - else - %{remaining: _remaining} = _state -> - Process.sleep(250) - continue?(rate_limiter, count) - end - end - - def start_link(opts) do - name = Keyword.fetch!(opts, :name) - rate_limiting_opts = Keyword.fetch!(opts, :rate_limiting) - args = {name, rate_limiting_opts} - GenServer.start_link(__MODULE__, args, name: local_name(name)) - end - - def local_name(name) do - Module.concat(__MODULE__, name) - end - - def global_name(name) do - {:via, :syn, {:rate_limiters, name}} - end - - def syn_register(rate_limiter) do - GenServer.cast(local_name(rate_limiter), {:register_syn, rate_limiter}) - end - - def rate_limit(counter, amount) - when is_reference(counter) and is_integer(amount) and amount > 0 do - :atomics.sub_get(counter, @atomics_index, amount) - end - - def call_rate_limit(rate_limiter, amount) do - GenServer.call(global_name(rate_limiter), {:call_rate_limit, amount}) - end - - def get_currently_allowed(counter) when is_reference(counter) do - :atomics.get(counter, @atomics_index) - end - - def update_rate_limiting(rate_limiter, opts) do - GenServer.call(global_name(rate_limiter), {:update_rate_limiting, opts}) - end - - def get_rate_limiting(rate_limiter) do - GenServer.call(global_name(rate_limiter), :get_rate_limiting) - end - - def get_rate_limiter_ref(rate_limiter) do - GenServer.call(global_name(rate_limiter), :get_rate_limiter_ref) - end - - @impl GenServer - def init({name, rate_limiting_opts}) do - interval = Keyword.fetch!(rate_limiting_opts, :interval) - allowed = Keyword.fetch!(rate_limiting_opts, :allowed_messages) - - counter = :atomics.new(@atomics_index, []) - :atomics.put(counter, @atomics_index, allowed) - - _ = schedule_next_reset(interval) - _ = schedule_syn_register() - - state = %{ - interval: interval, - allowed: allowed, - counter: counter, - remaining: allowed, - name: name - } + def continue?(key, time, number) do + case Hammer.check_rate(key, time, number) do + {:allow, _count} -> + :ok - {:ok, state, {:continue, {:register_syn, name}}} - end - - @impl GenServer - def handle_continue({:register_syn, name}, state) do - :syn.register(:rate_limiters, name, self()) - {:noreply, state} - end - - @impl GenServer - def handle_cast({:register_syn, name}, state) do - :syn.register(:rate_limiters, name, self()) - {:noreply, state} - end - - @impl GenServer - def handle_call({:call_rate_limit, amount}, _from, %{counter: counter} = state) do - remaining = rate_limit(counter, amount) - {:reply, %{remaining: remaining}, %{state | remaining: remaining}} - end - - @impl GenServer - def handle_call({:update_rate_limiting, opts}, _from, state) do - %{interval: interval, allowed: allowed} = state - - state = %{ - state - | interval: Keyword.get(opts, :interval, interval), - allowed: Keyword.get(opts, :allowed_messages, allowed) - } - - {:reply, :ok, state} - end - - def handle_call(:get_rate_limiting, _from, state) do - %{interval: interval, allowed: allowed} = state - {:reply, %{interval: interval, allowed_messages: allowed}, state} - end - - def handle_call(:get_rate_limiter_ref, _from, %{counter: counter} = state) do - {:reply, counter, state} - end - - @impl GenServer - def handle_info(:reset_limit, state) do - %{interval: interval, allowed: allowed, counter: counter} = state - - :atomics.put(counter, @atomics_index, allowed) - - _ = schedule_next_reset(interval) - - {:noreply, %{state | remaining: allowed}} - end - - @impl GenServer - def handle_info(:syn_register, %{name: name} = state) do - :syn.register(:rate_limiters, name, self()) - _ = schedule_syn_register() - {:noreply, state} - end - - defp schedule_next_reset(interval) do - _ref = Process.send_after(self(), :reset_limit, interval) - end - - defp schedule_syn_register() do - _ref = Process.send_after(self(), :syn_register, 1000) + {:deny, _limit} -> + # 100ms + Process.sleep(100) + continue?(key, time, number) + end end end diff --git a/server/lib/orcasite/repo.ex b/server/lib/orcasite/repo.ex index 2af21c51..43fa30d2 100644 --- a/server/lib/orcasite/repo.ex +++ b/server/lib/orcasite/repo.ex @@ -19,4 +19,8 @@ defmodule Orcasite.Repo do "ash-functions" ] end + + def min_pg_version() do + %Version{major: 14, minor: 0, patch: 0} + end end diff --git a/server/lib/orcasite/types/audio_image_status.ex b/server/lib/orcasite/types/audio_image_status.ex index 38303426..5b1e9221 100644 --- a/server/lib/orcasite/types/audio_image_status.ex +++ b/server/lib/orcasite/types/audio_image_status.ex @@ -1,3 +1,3 @@ defmodule Orcasite.Types.AudioImageStatus do - use Ash.Type.Enum, values: [:new, :processing, :complete, :failed] + use Ash.Type.Enum, values: [:new, :processing, :errored, :complete, :failed] end diff --git a/server/lib/orcasite_web.ex b/server/lib/orcasite_web.ex index 52f9b1e9..c78e8298 100644 --- a/server/lib/orcasite_web.ex +++ b/server/lib/orcasite_web.ex @@ -42,7 +42,7 @@ defmodule OrcasiteWeb do layouts: [html: OrcasiteWeb.Layouts] import Plug.Conn - import OrcasiteWeb.Gettext + use Gettext, backend: OrcasiteWeb.Gettext unquote(verified_routes()) end end @@ -83,7 +83,7 @@ defmodule OrcasiteWeb do import Phoenix.HTML # Core UI components and translation import OrcasiteWeb.CoreComponents - import OrcasiteWeb.Gettext + use Gettext, backend: OrcasiteWeb.Gettext # Shortcut for generating JS commands alias Phoenix.LiveView.JS diff --git a/server/lib/orcasite_web/components/core_components.ex b/server/lib/orcasite_web/components/core_components.ex index a1e2c235..677e5358 100644 --- a/server/lib/orcasite_web/components/core_components.ex +++ b/server/lib/orcasite_web/components/core_components.ex @@ -17,7 +17,7 @@ defmodule OrcasiteWeb.CoreComponents do use Phoenix.Component alias Phoenix.LiveView.JS - import OrcasiteWeb.Gettext + use Gettext, backend: OrcasiteWeb.Gettext @doc """ Renders a modal. diff --git a/server/lib/orcasite_web/gettext.ex b/server/lib/orcasite_web/gettext.ex index d21d2efd..157e0ad0 100644 --- a/server/lib/orcasite_web/gettext.ex +++ b/server/lib/orcasite_web/gettext.ex @@ -20,5 +20,5 @@ defmodule OrcasiteWeb.Gettext do See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage. """ - use Gettext, otp_app: :orcasite + use Gettext.Backend, otp_app: :orcasite end diff --git a/ui/src/components/Bouts/SpectrogramTimeline.tsx b/ui/src/components/Bouts/SpectrogramTimeline.tsx index d531ce5b..48de457a 100644 --- a/ui/src/components/Bouts/SpectrogramTimeline.tsx +++ b/ui/src/components/Bouts/SpectrogramTimeline.tsx @@ -1,48 +1,94 @@ -import { Box } from "@mui/material"; +import { Box, Typography } from "@mui/material"; +import { + addMinutes, + differenceInMilliseconds, + differenceInMinutes, + format, +} from "date-fns"; import { useEffect, useRef, useState } from "react"; +import { AudioImage, FeedSegment } from "@/graphql/generated"; + +const TICKER_HEIGHT = 30; +const SPECTROGRAM_HEIGHT = 300; + +function timeToOffset( + time: Date, + timelineStartTime: Date, + pixelsPerMinute: number, +) { + const diffSeconds = differenceInMilliseconds(time, timelineStartTime) / 1000; + const pixelsPerSecond = pixelsPerMinute / 60; + return diffSeconds * pixelsPerSecond; +} + +function audioImageToUrl({ + bucket, + objectPath, +}: Pick) { + return `https://${bucket}.s3.amazonaws.com${objectPath}`; +} + +type SpectrogramFeedSegment = Pick< + FeedSegment, + "id" | "startTime" | "endTime" | "duration" | "audioImages" +>; + export default function SpectrogramTimeline({ playerTime, + timelineStartTime, + timelineEndTime, + feedSegments, }: { playerTime?: Date; + timelineStartTime: Date; + timelineEndTime: Date; + feedSegments: SpectrogramFeedSegment[]; }) { - const spectrogramContainer = useRef(null); + // Full spectrogram container + const spectrogramWindow = useRef(null); const [spectrogramTime, setSpectrogramTime] = useState(); const [isDragging, setIsDragging] = useState(false); const [zoomLevel, setZoomLevel] = useState(10); + const minZoom = 5; + const maxZoom = 100; - const visibleContainerStartX = useRef(0); - const containerScrollX = useRef(0); + // X position of visible window relative to browser + const windowStartX = useRef(0); + + // X position of how far it's scrolled from the beginning + const windowScrollX = useRef(0); const pixelsPerMinute = 50 * zoomLevel; useEffect(() => { if (spectrogramTime === undefined && playerTime !== undefined) { + // Set initial spectrogram time setSpectrogramTime(playerTime); } }, [playerTime, spectrogramTime]); const handleTouchStart = (e: React.TouchEvent) => { setIsDragging(true); - visibleContainerStartX.current = - e.touches[0].pageX - (spectrogramContainer.current?.offsetLeft ?? 0); - containerScrollX.current = spectrogramContainer.current?.scrollLeft ?? 0; + windowStartX.current = + e.touches[0].pageX - (spectrogramWindow.current?.offsetLeft ?? 0); + windowScrollX.current = spectrogramWindow.current?.scrollLeft ?? 0; }; const handleTouchMove = (e: React.TouchEvent) => { - if (!isDragging || !spectrogramContainer.current) return; + if (!isDragging || !spectrogramWindow.current) return; e.preventDefault(); const containerCursorX = - e.touches[0].pageX - spectrogramContainer.current.offsetLeft; - const move = containerCursorX - visibleContainerStartX.current; - spectrogramContainer.current.scrollLeft = containerScrollX.current - move; + e.touches[0].pageX - spectrogramWindow.current.offsetLeft; + const move = containerCursorX - windowStartX.current; + spectrogramWindow.current.scrollLeft = windowScrollX.current - move; }; const handleMouseDown = (e: React.MouseEvent) => { setIsDragging(true); - visibleContainerStartX.current = - e.pageX - (spectrogramContainer.current?.offsetLeft ?? 0); - containerScrollX.current = spectrogramContainer.current?.scrollLeft ?? 0; + windowStartX.current = + e.pageX - (spectrogramWindow.current?.offsetLeft ?? 0); + windowScrollX.current = spectrogramWindow.current?.scrollLeft ?? 0; }; const handleMouseLeave = () => { @@ -54,22 +100,24 @@ export default function SpectrogramTimeline({ }; const handleMouseMove = (e: React.MouseEvent) => { - if (!isDragging || !spectrogramContainer.current) return; + if (!isDragging || !spectrogramWindow.current) return; e.preventDefault(); - const containerCursorX = e.pageX - spectrogramContainer.current.offsetLeft; - const move = containerCursorX - visibleContainerStartX.current; - spectrogramContainer.current.scrollLeft = containerScrollX.current - move; + const containerCursorX = e.pageX - spectrogramWindow.current.offsetLeft; + const move = containerCursorX - windowStartX.current; + spectrogramWindow.current.scrollLeft = windowScrollX.current - move; }; const handleWheel = (e: React.WheelEvent) => { e.preventDefault(); - setZoomLevel((zoom) => Math.min(Math.max(5, zoom + e.deltaY * -0.01), 15)); - // if (!spectrogramContainer.current) return; - // spectrogramContainer.current.scrollLeft -= e.deltaY; + setZoomLevel((zoom) => + Math.min(Math.max(minZoom, zoom + e.deltaY * -0.01), maxZoom), + ); + // if (!spectrogramWindow.current) return; + // spectrogramWindow.current.scrollLeft -= e.deltaY; }; useEffect(() => { - const container = spectrogramContainer.current; + const container = spectrogramWindow.current; if (container) { container.addEventListener("mouseleave", handleMouseLeave); container.addEventListener("mouseup", handleMouseUp); @@ -82,10 +130,12 @@ export default function SpectrogramTimeline({ return ( <> - {zoomLevel} + Start: {JSON.stringify(timelineStartTime)} + End: {JSON.stringify(timelineEndTime)} - {Array(10) - .fill(0) - .map((_, idx) => ( + + + + + ({JSON.stringify(spectrogramTime)} / {JSON.stringify(playerTime)}) + + ); +} + +function BaseAudioWidthLayer({ + startTime, + endTime, + pixelsPerMinute, + zIndex, +}: { + startTime: Date; + endTime: Date; + pixelsPerMinute: number; + zIndex: number; +}) { + const minutes = differenceInMinutes(endTime, startTime); + const tiles = minutes * 6; // 10 second tiles + const pixelsPerTile = pixelsPerMinute / 6; + return ( + <> + {Array(tiles) + .fill(0) + .map((_, idx) => ( + + ))} + + ); +} + +function FeedSegmentsLayer({ + feedSegments, + timelineStartTime, + pixelsPerMinute, + zIndex, +}: { + feedSegments: SpectrogramFeedSegment[]; + timelineStartTime: Date; + pixelsPerMinute: number; + zIndex: number; +}) { + return ( + <> + {feedSegments.flatMap((feedSegment) => { + if ( + feedSegment.startTime !== undefined && + feedSegment.startTime !== null && + typeof feedSegment.duration === "string" + ) { + const startTime = new Date(feedSegment.startTime); + const offset = timeToOffset( + startTime, + timelineStartTime, + pixelsPerMinute, + ); + const width = (pixelsPerMinute * Number(feedSegment.duration)) / 60; + const audioImage = feedSegment.audioImages[0]; + const audioImageUrl = + audioImage !== undefined && audioImageToUrl(audioImage); + return [ theme.palette.accent2.main, + ...(audioImageUrl && { + backgroundImage: `url('${audioImageUrl}')`, + backgroundSize: "cover", + }), + }} display="flex" alignItems="center" justifyContent="center" - borderRight="1px solid #eee" > - spectrogram {idx} + + {!audioImageUrl && startTime?.toLocaleTimeString()} + + , + ]; + } + })} + + ); +} + +function TimelineTickerLayer({ + startTime, + endTime, + pixelsPerMinute, + zIndex, +}: { + startTime: Date; + endTime: Date; + pixelsPerMinute: number; + zIndex: number; +}) { + const minutes = differenceInMinutes(endTime, startTime); + const tiles = minutes; // 1 minute increments + const pixelsPerTile = pixelsPerMinute / 6; + return ( + <> + {Array(tiles) + .fill(0) + .map((_, idx) => ( + + + + + + + + + {format(addMinutes(startTime, idx), "hh:mm:ss")} + + - ))} - - ({JSON.stringify(playerTime)}) + + ))} ); } diff --git a/ui/src/components/Player/BoutPlayer.tsx b/ui/src/components/Player/BoutPlayer.tsx index f32d03e8..bc4b372d 100644 --- a/ui/src/components/Player/BoutPlayer.tsx +++ b/ui/src/components/Player/BoutPlayer.tsx @@ -76,7 +76,7 @@ export function BoutPlayer({ }, ], }), - [hlsURI], //, feed?.nodeName], + [hlsURI], ); const handleReady = useCallback( @@ -114,11 +114,9 @@ export function BoutPlayer({ player.on("loadedmetadata", () => { // On initial load, set target time - console.log("offset", targetOffset); player.currentTime(targetOffset); }); }, - // [startOffset, endOffset], [onPlayerTimeUpdate, playlistDatetime, targetOffset], ); const handlePlayPauseClick = () => { diff --git a/ui/src/graphql/fragments/AudioImageParts.graphql b/ui/src/graphql/fragments/AudioImageParts.graphql new file mode 100644 index 00000000..94adcb97 --- /dev/null +++ b/ui/src/graphql/fragments/AudioImageParts.graphql @@ -0,0 +1,12 @@ +fragment AudioImageParts on AudioImage { + id + startTime + endTime + status + objectPath + bucket + bucketRegion + feedId + imageSize + imageType +} diff --git a/ui/src/graphql/fragments/FeedSegmentParts.graphql b/ui/src/graphql/fragments/FeedSegmentParts.graphql new file mode 100644 index 00000000..a310ab23 --- /dev/null +++ b/ui/src/graphql/fragments/FeedSegmentParts.graphql @@ -0,0 +1,14 @@ +fragment FeedSegmentParts on FeedSegment { + id + startTime + endTime + duration + bucket + bucketRegion + cloudfrontUrl + fileName + playlistM3u8Path + playlistPath + playlistTimestamp + segmentPath +} diff --git a/ui/src/graphql/fragments/FeedStreamParts.graphql b/ui/src/graphql/fragments/FeedStreamParts.graphql new file mode 100644 index 00000000..a6378251 --- /dev/null +++ b/ui/src/graphql/fragments/FeedStreamParts.graphql @@ -0,0 +1,12 @@ +fragment FeedStreamParts on FeedStream { + id + startTime + endTime + duration + bucket + bucketRegion + cloudfrontUrl + playlistTimestamp + playlistPath + playlistM3u8Path +} diff --git a/ui/src/graphql/generated/index.ts b/ui/src/graphql/generated/index.ts index 0238c81b..a5423349 100644 --- a/ui/src/graphql/generated/index.ts +++ b/ui/src/graphql/generated/index.ts @@ -2063,6 +2063,50 @@ export type UserFilterUsername = { notEq?: InputMaybe; }; +export type AudioImagePartsFragment = { + __typename?: "AudioImage"; + id: string; + startTime: Date; + endTime: Date; + status: string; + objectPath?: string | null; + bucket?: string | null; + bucketRegion?: string | null; + feedId: string; + imageSize?: number | null; + imageType?: ImageType | null; +}; + +export type FeedSegmentPartsFragment = { + __typename?: "FeedSegment"; + id: string; + startTime?: Date | null; + endTime?: Date | null; + duration?: number | null; + bucket?: string | null; + bucketRegion?: string | null; + cloudfrontUrl?: string | null; + fileName: string; + playlistM3u8Path?: string | null; + playlistPath?: string | null; + playlistTimestamp?: string | null; + segmentPath?: string | null; +}; + +export type FeedStreamPartsFragment = { + __typename?: "FeedStream"; + id: string; + startTime?: Date | null; + endTime?: Date | null; + duration?: number | null; + bucket?: string | null; + bucketRegion?: string | null; + cloudfrontUrl?: string | null; + playlistTimestamp?: string | null; + playlistPath?: string | null; + playlistM3u8Path?: string | null; +}; + export type CancelCandidateNotificationsMutationVariables = Exact<{ candidateId: Scalars["ID"]["input"]; }>; @@ -2497,6 +2541,7 @@ export type ListFeedStreamsQuery = { playlistM3u8Path?: string | null; feedSegments: Array<{ __typename?: "FeedSegment"; + id: string; startTime?: Date | null; endTime?: Date | null; duration?: number | null; @@ -2508,6 +2553,19 @@ export type ListFeedStreamsQuery = { playlistPath?: string | null; playlistTimestamp?: string | null; segmentPath?: string | null; + audioImages: Array<{ + __typename?: "AudioImage"; + id: string; + startTime: Date; + endTime: Date; + status: string; + objectPath?: string | null; + bucket?: string | null; + bucketRegion?: string | null; + feedId: string; + imageSize?: number | null; + imageType?: ImageType | null; + }>; }>; }> | null; } | null; @@ -2536,6 +2594,50 @@ export type FeedsQuery = { }>; }; +export const AudioImagePartsFragmentDoc = ` + fragment AudioImageParts on AudioImage { + id + startTime + endTime + status + objectPath + bucket + bucketRegion + feedId + imageSize + imageType +} + `; +export const FeedSegmentPartsFragmentDoc = ` + fragment FeedSegmentParts on FeedSegment { + id + startTime + endTime + duration + bucket + bucketRegion + cloudfrontUrl + fileName + playlistM3u8Path + playlistPath + playlistTimestamp + segmentPath +} + `; +export const FeedStreamPartsFragmentDoc = ` + fragment FeedStreamParts on FeedStream { + id + startTime + endTime + duration + bucket + bucketRegion + cloudfrontUrl + playlistTimestamp + playlistPath + playlistM3u8Path +} + `; export const CancelCandidateNotificationsDocument = ` mutation cancelCandidateNotifications($candidateId: ID!) { cancelCandidateNotifications(id: $candidateId) { @@ -3460,36 +3562,22 @@ export const ListFeedStreamsDocument = ` ) { count results { - id - startTime - endTime - duration - bucket - bucketRegion - cloudfrontUrl - playlistTimestamp - playlistPath - playlistM3u8Path + ...FeedStreamParts feedSegments( filter: {and: [{startTime: {lessThanOrEqual: $toDateTime}}, {startTime: {greaterThanOrEqual: $dayBeforeFromDateTime}}], endTime: {greaterThanOrEqual: $fromDateTime}} sort: {field: START_TIME, order: DESC} ) { - startTime - endTime - duration - bucket - bucketRegion - cloudfrontUrl - fileName - playlistM3u8Path - playlistPath - playlistTimestamp - segmentPath + ...FeedSegmentParts + audioImages(filter: {status: {eq: "complete"}}) { + ...AudioImageParts + } } } } } - `; + ${FeedStreamPartsFragmentDoc} +${FeedSegmentPartsFragmentDoc} +${AudioImagePartsFragmentDoc}`; export const useListFeedStreamsQuery = < TData = ListFeedStreamsQuery, diff --git a/ui/src/graphql/queries/listFeedStreams.graphql b/ui/src/graphql/queries/listFeedStreams.graphql index bbee310e..6f4d26ae 100644 --- a/ui/src/graphql/queries/listFeedStreams.graphql +++ b/ui/src/graphql/queries/listFeedStreams.graphql @@ -21,16 +21,7 @@ query listFeedStreams( ) { count results { - id - startTime - endTime - duration - bucket - bucketRegion - cloudfrontUrl - playlistTimestamp - playlistPath - playlistM3u8Path + ...FeedStreamParts feedSegments( filter: { @@ -42,17 +33,10 @@ query listFeedStreams( } sort: { field: START_TIME, order: DESC } ) { - startTime - endTime - duration - bucket - bucketRegion - cloudfrontUrl - fileName - playlistM3u8Path - playlistPath - playlistTimestamp - segmentPath + ...FeedSegmentParts + audioImages(filter: { status: { eq: "complete" } }) { + ...AudioImageParts + } } } } diff --git a/ui/src/hooks/useTimestampFetcher.ts b/ui/src/hooks/useTimestampFetcher.ts index f66be190..384545da 100644 --- a/ui/src/hooks/useTimestampFetcher.ts +++ b/ui/src/hooks/useTimestampFetcher.ts @@ -4,7 +4,8 @@ if (!process.env.NEXT_PUBLIC_S3_BUCKET) { throw new Error("NEXT_PUBLIC_S3_BUCKET is not set"); } -const getBucketBase = (bucket: string) => `https://${bucket}.s3.amazonaws.com`; +export const getBucketBase = (bucket: string) => + `https://${bucket}.s3.amazonaws.com`; const getTimestampURI = (bucket: string, nodeName: string) => `${getBucketBase(bucket)}/${nodeName}/latest.txt`; diff --git a/ui/src/pages/bouts/[feedSlug]/new.tsx b/ui/src/pages/bouts/[feedSlug]/new.tsx index 8fd993d6..cbfd228a 100644 --- a/ui/src/pages/bouts/[feedSlug]/new.tsx +++ b/ui/src/pages/bouts/[feedSlug]/new.tsx @@ -1,6 +1,12 @@ import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; -import { addMinutes, subDays, subMinutes } from "date-fns"; +import { + addMinutes, + min, + roundToNearestMinutes, + subDays, + subMinutes, +} from "date-fns"; import Head from "next/head"; import { useParams, useSearchParams } from "next/navigation"; import { useMemo, useState } from "react"; @@ -18,9 +24,18 @@ import { import type { NextPageWithLayout } from "@/pages/_app"; const NewBoutPage: NextPageWithLayout = () => { + const now = useMemo(() => new Date(), []); + const targetTime = new Date("2024-12-11 19:55:44.013Z"); - const targetTimePlus10Minutes = addMinutes(targetTime, 10); - const targetTimeMinus10Minutes = subMinutes(targetTime, 10); + const timeBuffer = 5; // minutes + const targetTimePlusBuffer = roundToNearestMinutes( + min([now, addMinutes(targetTime, timeBuffer)]), + { roundingMethod: "ceil" }, + ); + const targetTimeMinusBuffer = roundToNearestMinutes( + subMinutes(targetTime, timeBuffer), + { roundingMethod: "floor" }, + ); const targetTimeMinusADay = subDays(targetTime, 1); const params = useParams<{ feedSlug?: string }>(); @@ -34,16 +49,14 @@ const NewBoutPage: NextPageWithLayout = () => { ); const feed = feedQueryResult.data?.feed; - const now = useMemo(() => new Date(), []); - // If feed is present, and there's no pre-set time, - // get latest stream and last 10 minutes of segments. + // get latest stream and last minutes of segments. // Set time to end of last segment const feedStreamQueryResult = useListFeedStreamsQuery( { feedId: feed?.id, - fromDateTime: targetTime, - toDateTime: targetTimeMinus10Minutes, + fromDateTime: targetTimeMinusBuffer, + toDateTime: targetTimePlusBuffer, dayBeforeFromDateTime: targetTimeMinusADay, }, { enabled: !!feed?.id }, @@ -54,12 +67,19 @@ const NewBoutPage: NextPageWithLayout = () => { { enabled: !!feed?.id }, ); + const feedStreams = useMemo( + () => feedStreamQueryResult.data?.feedStreams?.results ?? [], + [feedStreamQueryResult], + ); + const feedStream = feedStreams[0]; + const feedSegments = useMemo( + () => feedStreams.flatMap(({ feedSegments }) => feedSegments), + [feedStreams], + ); + if (!feedSlug || feedQueryResult.isLoading) return ; if (!feed) return

Feed not found

; - const feedStreams = feedStreamQueryResult.data?.feedStreams?.results ?? []; - const feedStream = feedStreams[0]; - return (
@@ -84,7 +104,13 @@ const NewBoutPage: NextPageWithLayout = () => { onPlayerTimeUpdate={setPlayerTime} /> )} - +
From 60b6f07be58e3c8955e818029d80a00a27e0fd60 Mon Sep 17 00:00:00 2001 From: Skander Mzali Date: Tue, 17 Dec 2024 12:50:56 -0800 Subject: [PATCH 10/40] Only show spectrograms for the visible window --- server/lib/orcasite/accounts/user.ex | 1 - .../components/Bouts/SpectrogramTimeline.tsx | 214 +++++++++++++++--- 2 files changed, 181 insertions(+), 34 deletions(-) diff --git a/server/lib/orcasite/accounts/user.ex b/server/lib/orcasite/accounts/user.ex index 3bb9b9db..660454da 100644 --- a/server/lib/orcasite/accounts/user.ex +++ b/server/lib/orcasite/accounts/user.ex @@ -110,7 +110,6 @@ defmodule Orcasite.Accounts.User do code_interface do domain Orcasite.Accounts - define :register_with_password define :sign_in_with_password define :by_email, args: [:email] define :request_password_reset_with_password diff --git a/ui/src/components/Bouts/SpectrogramTimeline.tsx b/ui/src/components/Bouts/SpectrogramTimeline.tsx index 48de457a..ac93b695 100644 --- a/ui/src/components/Bouts/SpectrogramTimeline.tsx +++ b/ui/src/components/Bouts/SpectrogramTimeline.tsx @@ -4,6 +4,7 @@ import { differenceInMilliseconds, differenceInMinutes, format, + subMinutes, } from "date-fns"; import { useEffect, useRef, useState } from "react"; @@ -22,6 +23,26 @@ function timeToOffset( return diffSeconds * pixelsPerSecond; } +function offsetToTime( + offset: number, + timelineStartTime: Date, + pixelsPerMinute: number, +) { + return addMinutes(timelineStartTime, offset / pixelsPerMinute); +} + +function rangesOverlap( + startTime1?: Date, + endTime1?: Date, + startTime2?: Date, + endTime2?: Date, +) { + if (startTime1 && endTime1 && startTime2 && endTime2) { + return startTime1 <= endTime2 && endTime1 >= startTime2; + } + return false; +} + function audioImageToUrl({ bucket, objectPath, @@ -50,6 +71,8 @@ export default function SpectrogramTimeline({ const [spectrogramTime, setSpectrogramTime] = useState(); const [isDragging, setIsDragging] = useState(false); const [zoomLevel, setZoomLevel] = useState(10); + const [windowStartTime, setWindowStartTime] = useState(); + const [windowEndTime, setWindowEndTime] = useState(); const minZoom = 5; const maxZoom = 100; @@ -66,7 +89,52 @@ export default function SpectrogramTimeline({ // Set initial spectrogram time setSpectrogramTime(playerTime); } - }, [playerTime, spectrogramTime]); + if ( + windowStartTime === undefined && + windowScrollX.current !== undefined && + timelineStartTime !== undefined + ) { + setWindowStartTime( + offsetToTime(windowScrollX.current, timelineStartTime, pixelsPerMinute), + ); + } + if ( + windowEndTime === undefined && + spectrogramWindow.current !== undefined && + spectrogramWindow.current !== null && + windowScrollX.current !== undefined && + timelineStartTime !== undefined + ) { + setWindowEndTime( + offsetToTime( + windowScrollX.current + spectrogramWindow.current.offsetWidth, + timelineStartTime, + pixelsPerMinute, + ), + ); + } + }, [ + playerTime, + spectrogramTime, + spectrogramWindow, + pixelsPerMinute, + timelineStartTime, + windowStartTime, + windowEndTime, + ]); + + useEffect(() => { + setWindowStartTime( + offsetToTime(windowScrollX.current, timelineStartTime, pixelsPerMinute), + ); + setWindowEndTime( + offsetToTime( + windowScrollX.current + (spectrogramWindow.current?.offsetWidth ?? 0), + timelineStartTime, + pixelsPerMinute, + ), + ); + }, [windowScrollX, spectrogramWindow, timelineStartTime, pixelsPerMinute]); const handleTouchStart = (e: React.TouchEvent) => { setIsDragging(true); @@ -82,6 +150,16 @@ export default function SpectrogramTimeline({ e.touches[0].pageX - spectrogramWindow.current.offsetLeft; const move = containerCursorX - windowStartX.current; spectrogramWindow.current.scrollLeft = windowScrollX.current - move; + setWindowStartTime( + offsetToTime(windowScrollX.current, timelineStartTime, pixelsPerMinute), + ); + setWindowEndTime( + offsetToTime( + windowScrollX.current + spectrogramWindow.current.offsetWidth, + timelineStartTime, + pixelsPerMinute, + ), + ); }; const handleMouseDown = (e: React.MouseEvent) => { @@ -105,13 +183,30 @@ export default function SpectrogramTimeline({ const containerCursorX = e.pageX - spectrogramWindow.current.offsetLeft; const move = containerCursorX - windowStartX.current; spectrogramWindow.current.scrollLeft = windowScrollX.current - move; + setWindowStartTime( + offsetToTime(windowScrollX.current, timelineStartTime, pixelsPerMinute), + ); + setWindowEndTime( + offsetToTime( + windowScrollX.current + spectrogramWindow.current.offsetWidth, + timelineStartTime, + pixelsPerMinute, + ), + ); }; const handleWheel = (e: React.WheelEvent) => { e.preventDefault(); - setZoomLevel((zoom) => - Math.min(Math.max(minZoom, zoom + e.deltaY * -0.01), maxZoom), - ); + setZoomLevel((zoom) => { + const zoomIncrement = zoom * 0.2; + return Math.min( + Math.max( + minZoom, + zoom + (e.deltaY > 0 ? -zoomIncrement : zoomIncrement), + ), + maxZoom, + ); + }); // if (!spectrogramWindow.current) return; // spectrogramWindow.current.scrollLeft -= e.deltaY; }; @@ -130,8 +225,11 @@ export default function SpectrogramTimeline({ return ( <> - Start: {JSON.stringify(timelineStartTime)} - End: {JSON.stringify(timelineEndTime)} +
Start: {JSON.stringify(timelineStartTime)}
+
End: {JSON.stringify(timelineEndTime)}
+
Window start: {JSON.stringify(windowStartTime)}
+
Window end: {JSON.stringify(windowEndTime)}
+
Zoom {zoomLevel}
+ {/* */} - + {windowStartTime && windowEndTime && ( + + )} ({JSON.stringify(spectrogramTime)} / {JSON.stringify(playerTime)}) @@ -222,11 +331,15 @@ function FeedSegmentsLayer({ timelineStartTime, pixelsPerMinute, zIndex, + windowStartTime, + windowEndTime, }: { feedSegments: SpectrogramFeedSegment[]; timelineStartTime: Date; pixelsPerMinute: number; zIndex: number; + windowStartTime: Date; + windowEndTime: Date; }) { return ( <> @@ -234,9 +347,12 @@ function FeedSegmentsLayer({ if ( feedSegment.startTime !== undefined && feedSegment.startTime !== null && + feedSegment.endTime !== undefined && + feedSegment.endTime !== null && typeof feedSegment.duration === "string" ) { const startTime = new Date(feedSegment.startTime); + const endTime = new Date(feedSegment.endTime); const offset = timeToOffset( startTime, timelineStartTime, @@ -257,14 +373,23 @@ function FeedSegmentsLayer({ top: TICKER_HEIGHT, width: width, backgroundColor: (theme) => theme.palette.accent2.main, - ...(audioImageUrl && { - backgroundImage: `url('${audioImageUrl}')`, - backgroundSize: "cover", - }), + ...(audioImageUrl && + rangesOverlap( + subMinutes(startTime, 500 / pixelsPerMinute), + addMinutes(endTime, 500 / pixelsPerMinute), + windowStartTime, + windowEndTime, + ) && { + backgroundImage: `url('${audioImageUrl}')`, + backgroundSize: "cover", + }), }} display="flex" alignItems="center" justifyContent="center" + data-starttime={feedSegment.startTime} + data-endtime={feedSegment.endTime} + data-duration={feedSegment.duration} > {!audioImageUrl && startTime?.toLocaleTimeString()} @@ -290,7 +415,6 @@ function TimelineTickerLayer({ }) { const minutes = differenceInMinutes(endTime, startTime); const tiles = minutes; // 1 minute increments - const pixelsPerTile = pixelsPerMinute / 6; return ( <> {Array(tiles) @@ -326,22 +450,17 @@ function TimelineTickerLayer({ borderLeft="1px solid #666" borderRight="1px solid #aaa" /> - - + {[1, 2, 4, 5].map((tensIndex) => ( + + ))} ); } + +function PlayHeadLayer({ + playerTime, + timelineStartTime, + pixelsPerMinute, + zIndex, +}: { + playerTime: Date; + // spectrogramWindow: MutableRefObject; + timelineStartTime: Date; + pixelsPerMinute: number; + zIndex: number; +}) { + const offset = timeToOffset(playerTime, timelineStartTime, pixelsPerMinute); + + return ( + <> + + + ); +} From 31169c3cf546b5d4aa709626f9636532bfc9c3c0 Mon Sep 17 00:00:00 2001 From: Skander Mzali Date: Wed, 18 Dec 2024 12:06:50 -0800 Subject: [PATCH 11/40] Add more buffer for loading spectrogram images, update background and timeline styling --- ui/src/components/Bouts/SpectrogramTimeline.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/ui/src/components/Bouts/SpectrogramTimeline.tsx b/ui/src/components/Bouts/SpectrogramTimeline.tsx index ac93b695..bd57e42e 100644 --- a/ui/src/components/Bouts/SpectrogramTimeline.tsx +++ b/ui/src/components/Bouts/SpectrogramTimeline.tsx @@ -244,6 +244,9 @@ export default function SpectrogramTimeline({ scrollbarWidth: "none", cursor: isDragging ? "grabbing" : "grab", userSelect: "none", + border: "1px solid #ccc", + borderRadius: 2, + boxShadow: 1, }} onMouseDown={handleMouseDown} onMouseMove={handleMouseMove} @@ -308,7 +311,7 @@ function BaseAudioWidthLayer({ theme.palette.accent2.main, ...(audioImageUrl && rangesOverlap( - subMinutes(startTime, 500 / pixelsPerMinute), - addMinutes(endTime, 500 / pixelsPerMinute), + subMinutes(startTime, 1500 / pixelsPerMinute), + addMinutes(endTime, 1500 / pixelsPerMinute), windowStartTime, windowEndTime, ) && { From 60a4b41c73dc1b73ef35b2dc7774ce8e7e043dfb Mon Sep 17 00:00:00 2001 From: Skander Mzali Date: Fri, 20 Dec 2024 18:35:16 -0800 Subject: [PATCH 12/40] Attempt to forward playerTime ref --- .../components/Bouts/SpectrogramTimeline.tsx | 130 +++++++++++------- ui/src/pages/bouts/[feedSlug]/new.tsx | 12 +- 2 files changed, 91 insertions(+), 51 deletions(-) diff --git a/ui/src/components/Bouts/SpectrogramTimeline.tsx b/ui/src/components/Bouts/SpectrogramTimeline.tsx index bd57e42e..22215bc2 100644 --- a/ui/src/components/Bouts/SpectrogramTimeline.tsx +++ b/ui/src/components/Bouts/SpectrogramTimeline.tsx @@ -6,7 +6,15 @@ import { format, subMinutes, } from "date-fns"; -import { useEffect, useRef, useState } from "react"; +import { + ForwardedRef, + forwardRef, + MutableRefObject, + useEffect, + useImperativeHandle, + useRef, + useState, +} from "react"; import { AudioImage, FeedSegment } from "@/graphql/generated"; @@ -55,17 +63,19 @@ type SpectrogramFeedSegment = Pick< "id" | "startTime" | "endTime" | "duration" | "audioImages" >; -export default function SpectrogramTimeline({ - playerTime, - timelineStartTime, - timelineEndTime, - feedSegments, -}: { - playerTime?: Date; - timelineStartTime: Date; - timelineEndTime: Date; - feedSegments: SpectrogramFeedSegment[]; -}) { +// TODO: Remove forwardRef with React 19+ +export default forwardRef(function SpectrogramTimeline( + { + timelineStartTime, + timelineEndTime, + feedSegments, + }: { + timelineStartTime: Date; + timelineEndTime: Date; + feedSegments: SpectrogramFeedSegment[]; + }, + playerTimeRef: ForwardedRef, +) { // Full spectrogram container const spectrogramWindow = useRef(null); const [spectrogramTime, setSpectrogramTime] = useState(); @@ -76,6 +86,9 @@ export default function SpectrogramTimeline({ const minZoom = 5; const maxZoom = 100; + const playerTime = useRef(); + useImperativeHandle(playerTimeRef, () => playerTime.current!, []); + // X position of visible window relative to browser const windowStartX = useRef(0); @@ -85,9 +98,13 @@ export default function SpectrogramTimeline({ const pixelsPerMinute = 50 * zoomLevel; useEffect(() => { - if (spectrogramTime === undefined && playerTime !== undefined) { + if ( + spectrogramTime === undefined && + playerTime !== undefined && + playerTime.current !== undefined + ) { // Set initial spectrogram time - setSpectrogramTime(playerTime); + setSpectrogramTime(playerTime.current); } if ( windowStartTime === undefined && @@ -114,7 +131,7 @@ export default function SpectrogramTimeline({ ); } }, [ - playerTime, + playerTimeRef, spectrogramTime, spectrogramWindow, pixelsPerMinute, @@ -254,13 +271,15 @@ export default function SpectrogramTimeline({ onTouchMove={handleTouchMove} onWheel={handleWheel} > - {/* */} + {spectrogramWindow.current && ( + + )} )} - ({JSON.stringify(spectrogramTime)} / {JSON.stringify(playerTime)}) + ({JSON.stringify(spectrogramTime)} / {JSON.stringify(playerTime.current)}) ); -} +}); function BaseAudioWidthLayer({ startTime, @@ -494,31 +513,48 @@ function TimelineTickerLayer({ ); } -function PlayHeadLayer({ - playerTime, - timelineStartTime, - pixelsPerMinute, - zIndex, -}: { - playerTime: Date; - // spectrogramWindow: MutableRefObject; - timelineStartTime: Date; - pixelsPerMinute: number; - zIndex: number; -}) { - const offset = timeToOffset(playerTime, timelineStartTime, pixelsPerMinute); +const PlayHeadLayer = forwardRef(function PlayHeadLayer( + { + timelineStartTime, + pixelsPerMinute, + spectrogramWindow, + zIndex, + }: { + timelineStartTime: Date; + spectrogramWindow: MutableRefObject; + pixelsPerMinute: number; + zIndex: number; + }, + playerTimeRef: ForwardedRef, +) { + const playerTime = useRef(); + useImperativeHandle(playerTimeRef, () => playerTime.current!, []); + + // const offset = timeToOffset( + // playerTime.current, + // timelineStartTime, + // pixelsPerMinute, + // ); return ( <> - + {playerTime.current && ( + + )} ); -} +}); diff --git a/ui/src/pages/bouts/[feedSlug]/new.tsx b/ui/src/pages/bouts/[feedSlug]/new.tsx index cbfd228a..a72bc14a 100644 --- a/ui/src/pages/bouts/[feedSlug]/new.tsx +++ b/ui/src/pages/bouts/[feedSlug]/new.tsx @@ -9,7 +9,7 @@ import { } from "date-fns"; import Head from "next/head"; import { useParams, useSearchParams } from "next/navigation"; -import { useMemo, useState } from "react"; +import { useCallback, useMemo, useRef } from "react"; import SpectrogramTimeline from "@/components/Bouts/SpectrogramTimeline"; import { getSimpleLayout } from "@/components/layouts/SimpleLayout"; @@ -24,9 +24,14 @@ import { import type { NextPageWithLayout } from "@/pages/_app"; const NewBoutPage: NextPageWithLayout = () => { + const targetTime = new Date("2024-12-11 19:55:44.013Z"); + const playerTime = useRef(targetTime); + const setPlayerTime = useCallback( + (time: Date) => (playerTime.current = time), + [], + ); const now = useMemo(() => new Date(), []); - const targetTime = new Date("2024-12-11 19:55:44.013Z"); const timeBuffer = 5; // minutes const targetTimePlusBuffer = roundToNearestMinutes( min([now, addMinutes(targetTime, timeBuffer)]), @@ -41,7 +46,6 @@ const NewBoutPage: NextPageWithLayout = () => { const params = useParams<{ feedSlug?: string }>(); const feedSlug = params?.feedSlug; const searchParams = useSearchParams(); - const [playerTime, setPlayerTime] = useState(); const audioCategory = searchParams.get("category") as AudioCategory; const feedQueryResult = useFeedQuery( { slug: feedSlug || "" }, @@ -105,7 +109,7 @@ const NewBoutPage: NextPageWithLayout = () => { /> )} Date: Tue, 31 Dec 2024 10:12:09 -0800 Subject: [PATCH 13/40] Add working PlayHeadLayer with time updates using a ref --- .../components/Bouts/SpectrogramTimeline.tsx | 153 ++++++++---------- ui/src/components/Player/BoutPlayer.tsx | 42 ++--- ui/src/pages/bouts/[feedSlug]/new.tsx | 1 - 3 files changed, 89 insertions(+), 107 deletions(-) diff --git a/ui/src/components/Bouts/SpectrogramTimeline.tsx b/ui/src/components/Bouts/SpectrogramTimeline.tsx index 22215bc2..0cd467fc 100644 --- a/ui/src/components/Bouts/SpectrogramTimeline.tsx +++ b/ui/src/components/Bouts/SpectrogramTimeline.tsx @@ -6,15 +6,7 @@ import { format, subMinutes, } from "date-fns"; -import { - ForwardedRef, - forwardRef, - MutableRefObject, - useEffect, - useImperativeHandle, - useRef, - useState, -} from "react"; +import { MutableRefObject, useEffect, useRef, useState } from "react"; import { AudioImage, FeedSegment } from "@/graphql/generated"; @@ -63,22 +55,19 @@ type SpectrogramFeedSegment = Pick< "id" | "startTime" | "endTime" | "duration" | "audioImages" >; -// TODO: Remove forwardRef with React 19+ -export default forwardRef(function SpectrogramTimeline( - { - timelineStartTime, - timelineEndTime, - feedSegments, - }: { - timelineStartTime: Date; - timelineEndTime: Date; - feedSegments: SpectrogramFeedSegment[]; - }, - playerTimeRef: ForwardedRef, -) { +export default function SpectrogramTimeline({ + timelineStartTime, + timelineEndTime, + feedSegments, + playerTimeRef, +}: { + timelineStartTime: Date; + timelineEndTime: Date; + feedSegments: SpectrogramFeedSegment[]; + playerTimeRef: MutableRefObject; +}) { // Full spectrogram container const spectrogramWindow = useRef(null); - const [spectrogramTime, setSpectrogramTime] = useState(); const [isDragging, setIsDragging] = useState(false); const [zoomLevel, setZoomLevel] = useState(10); const [windowStartTime, setWindowStartTime] = useState(); @@ -86,9 +75,6 @@ export default forwardRef(function SpectrogramTimeline( const minZoom = 5; const maxZoom = 100; - const playerTime = useRef(); - useImperativeHandle(playerTimeRef, () => playerTime.current!, []); - // X position of visible window relative to browser const windowStartX = useRef(0); @@ -98,14 +84,14 @@ export default forwardRef(function SpectrogramTimeline( const pixelsPerMinute = 50 * zoomLevel; useEffect(() => { - if ( - spectrogramTime === undefined && - playerTime !== undefined && - playerTime.current !== undefined - ) { - // Set initial spectrogram time - setSpectrogramTime(playerTime.current); - } + // if ( + // spectrogramTime === undefined && + // playerTime !== undefined && + // playerTime.current !== undefined + // ) { + // // Set initial spectrogram time + // setSpectrogramTime(playerTime.current); + // } if ( windowStartTime === undefined && windowScrollX.current !== undefined && @@ -131,8 +117,6 @@ export default forwardRef(function SpectrogramTimeline( ); } }, [ - playerTimeRef, - spectrogramTime, spectrogramWindow, pixelsPerMinute, timelineStartTime, @@ -271,15 +255,14 @@ export default forwardRef(function SpectrogramTimeline( onTouchMove={handleTouchMove} onWheel={handleWheel} > - {spectrogramWindow.current && ( - - )} + + )} - ({JSON.stringify(spectrogramTime)} / {JSON.stringify(playerTime.current)}) ); -}); +} function BaseAudioWidthLayer({ startTime, @@ -513,48 +495,47 @@ function TimelineTickerLayer({ ); } -const PlayHeadLayer = forwardRef(function PlayHeadLayer( - { - timelineStartTime, - pixelsPerMinute, - spectrogramWindow, - zIndex, - }: { - timelineStartTime: Date; - spectrogramWindow: MutableRefObject; - pixelsPerMinute: number; - zIndex: number; - }, - playerTimeRef: ForwardedRef, -) { - const playerTime = useRef(); - useImperativeHandle(playerTimeRef, () => playerTime.current!, []); +function PlayHeadLayer({ + playerTimeRef, + timelineStartTime, + pixelsPerMinute, + spectrogramWindow, + zIndex, +}: { + playerTimeRef: MutableRefObject; + timelineStartTime: Date; + spectrogramWindow: MutableRefObject; + pixelsPerMinute: number; + zIndex: number; +}) { + const playHead = useRef(); + const intervalRef = useRef(); + useEffect(() => { + clearInterval(intervalRef.current); + intervalRef.current = setInterval(() => { + if (playHead.current && spectrogramWindow.current) { + const offset = timeToOffset( + playerTimeRef.current, + timelineStartTime, + pixelsPerMinute, + ); - // const offset = timeToOffset( - // playerTime.current, - // timelineStartTime, - // pixelsPerMinute, - // ); + playHead.current.style.width = `${offset}px`; + } + }, 10); + }, [pixelsPerMinute, spectrogramWindow, timelineStartTime, playerTimeRef]); return ( <> - {playerTime.current && ( - - )} + ); -}); +} diff --git a/ui/src/components/Player/BoutPlayer.tsx b/ui/src/components/Player/BoutPlayer.tsx index bc4b372d..b9175200 100644 --- a/ui/src/components/Player/BoutPlayer.tsx +++ b/ui/src/components/Player/BoutPlayer.tsx @@ -52,6 +52,8 @@ export function BoutPlayer({ [playlistDatetime, playerOffset], ); + const intervalRef = useRef(); + const playerOptions = useMemo( () => ({ autoplay: false, @@ -85,39 +87,39 @@ export function BoutPlayer({ player.on("playing", () => { setPlayerStatus("playing"); - // const currentTime = player.currentTime() ?? 0; - // if (currentTime < startOffset || currentTime > endOffset) { - // player.currentTime(startOffset); - // setPlayerOffset(endOffset); - // } + // 'ontimeupdate' is slow, so we update a ref with a short + // interval for faster updates + // https://github.com/videojs/video.js/issues/4322 + clearInterval(intervalRef.current); + intervalRef.current = setInterval(() => { + const currentTime = player.currentTime() ?? targetOffset ?? 0; + const playerDateTime = playerOffsetToDateTime( + playlistDatetime, + currentTime, + ); + if (onPlayerTimeUpdate !== undefined) { + onPlayerTimeUpdate(playerDateTime); + } + }, 10); + }); + player.on("pause", () => { + setPlayerStatus("paused"); + clearInterval(intervalRef.current); }); - player.on("pause", () => setPlayerStatus("paused")); player.on("waiting", () => setPlayerStatus("loading")); player.on("error", () => setPlayerStatus("error")); - // player.currentTime(startOffset); player.on("timeupdate", () => { const currentTime = player.currentTime() ?? targetOffset ?? 0; - // if (currentTime > endOffset) { - // player.currentTime(startOffset); - // setPlayerOffset(startOffset); - // } else { - // setPlayerTime(currentTime); - // } setPlayerOffset(currentTime); - if (onPlayerTimeUpdate !== undefined) { - onPlayerTimeUpdate( - playerOffsetToDateTime(playlistDatetime, currentTime), - ); - } }); player.on("loadedmetadata", () => { - // On initial load, set target time + // On initial load, set player time to target time player.currentTime(targetOffset); }); }, - [onPlayerTimeUpdate, playlistDatetime, targetOffset], + [onPlayerTimeUpdate, playlistDatetime, targetOffset, intervalRef], ); const handlePlayPauseClick = () => { const player = playerRef.current; diff --git a/ui/src/pages/bouts/[feedSlug]/new.tsx b/ui/src/pages/bouts/[feedSlug]/new.tsx index a72bc14a..e764eee8 100644 --- a/ui/src/pages/bouts/[feedSlug]/new.tsx +++ b/ui/src/pages/bouts/[feedSlug]/new.tsx @@ -113,7 +113,6 @@ const NewBoutPage: NextPageWithLayout = () => { timelineStartTime={targetTimeMinusBuffer} timelineEndTime={targetTimePlusBuffer} feedSegments={feedSegments} - // feedSegments={[]} /> From 60e1aae0f04a810d68d39c71032ab57e78afbd28 Mon Sep 17 00:00:00 2001 From: Skander Mzali Date: Tue, 31 Dec 2024 12:10:42 -0800 Subject: [PATCH 14/40] Set window scroll based on player time --- ui/package-lock.json | 11 +++- ui/package.json | 2 + .../components/Bouts/SpectrogramTimeline.tsx | 51 ++++++++++++++++--- 3 files changed, 57 insertions(+), 7 deletions(-) diff --git a/ui/package-lock.json b/ui/package-lock.json index c9dbbf0b..567e13f5 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -24,6 +24,7 @@ "graphql": "^16.9.0", "leaflet": "^1.9.4", "leaflet-defaulticon-compatibility": "^0.1.2", + "lodash": "^4.17.21", "next": "14.2.15", "phoenix": "^1.7.14", "react": "18.3.1", @@ -46,6 +47,7 @@ "@next/bundle-analyzer": "^14.2.15", "@tanstack/react-query-devtools": "^5.59.15", "@types/leaflet": "^1.9.14", + "@types/lodash": "^4.17.13", "@types/node": "22.7.5", "@types/phoenix": "^1.6.5", "@types/react": "18.3.11", @@ -3440,6 +3442,13 @@ "@types/geojson": "*" } }, + "node_modules/@types/lodash": { + "version": "4.17.13", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.13.tgz", + "integrity": "sha512-lfx+dftrEZcdBPczf9d0Qv0x+j/rfNCMuC6OcfXmO8gkfeNAY88PgKUbvG56whcN23gc27yenwF6oJZXGFpYxg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.7.5", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz", @@ -8390,7 +8399,7 @@ "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true + "license": "MIT" }, "node_modules/lodash.merge": { "version": "4.6.2", diff --git a/ui/package.json b/ui/package.json index d744797f..ed1d170a 100644 --- a/ui/package.json +++ b/ui/package.json @@ -32,6 +32,7 @@ "graphql": "^16.9.0", "leaflet": "^1.9.4", "leaflet-defaulticon-compatibility": "^0.1.2", + "lodash": "^4.17.21", "next": "14.2.15", "phoenix": "^1.7.14", "react": "18.3.1", @@ -54,6 +55,7 @@ "@next/bundle-analyzer": "^14.2.15", "@tanstack/react-query-devtools": "^5.59.15", "@types/leaflet": "^1.9.14", + "@types/lodash": "^4.17.13", "@types/node": "22.7.5", "@types/phoenix": "^1.6.5", "@types/react": "18.3.11", diff --git a/ui/src/components/Bouts/SpectrogramTimeline.tsx b/ui/src/components/Bouts/SpectrogramTimeline.tsx index 0cd467fc..bb1eea0c 100644 --- a/ui/src/components/Bouts/SpectrogramTimeline.tsx +++ b/ui/src/components/Bouts/SpectrogramTimeline.tsx @@ -6,6 +6,7 @@ import { format, subMinutes, } from "date-fns"; +import { throttle } from "lodash"; import { MutableRefObject, useEffect, useRef, useState } from "react"; import { AudioImage, FeedSegment } from "@/graphql/generated"; @@ -77,9 +78,9 @@ export default function SpectrogramTimeline({ // X position of visible window relative to browser const windowStartX = useRef(0); - // X position of how far it's scrolled from the beginning const windowScrollX = useRef(0); + const windowLockInterval = useRef(); const pixelsPerMinute = 50 * zoomLevel; @@ -137,6 +138,47 @@ export default function SpectrogramTimeline({ ); }, [windowScrollX, spectrogramWindow, timelineStartTime, pixelsPerMinute]); + // Center window on current time + useEffect(() => { + if (!isDragging) { + clearInterval(windowLockInterval.current); + windowLockInterval.current = setInterval(() => { + if (spectrogramWindow.current) { + const offset = timeToOffset( + playerTimeRef.current, + timelineStartTime, + pixelsPerMinute, + ); + const windowStartOffset = + offset - spectrogramWindow.current.clientWidth / 2; + const windowEndOffset = + offset + spectrogramWindow.current.clientWidth / 2; + + spectrogramWindow.current.scrollLeft = windowStartOffset; + + throttle(() => { + setWindowStartTime( + offsetToTime( + windowStartOffset, + timelineStartTime, + pixelsPerMinute, + ), + ); + setWindowEndTime( + offsetToTime(windowEndOffset, timelineStartTime, pixelsPerMinute), + ); + }, 500)(); + } + }, 50); + } + }, [ + isDragging, + playerTimeRef, + spectrogramWindow, + pixelsPerMinute, + timelineStartTime, + ]); + const handleTouchStart = (e: React.TouchEvent) => { setIsDragging(true); windowStartX.current = @@ -259,7 +301,6 @@ export default function SpectrogramTimeline({ playerTimeRef={playerTimeRef} timelineStartTime={timelineStartTime} pixelsPerMinute={pixelsPerMinute} - spectrogramWindow={spectrogramWindow} zIndex={5} /> @@ -499,12 +540,10 @@ function PlayHeadLayer({ playerTimeRef, timelineStartTime, pixelsPerMinute, - spectrogramWindow, zIndex, }: { playerTimeRef: MutableRefObject; timelineStartTime: Date; - spectrogramWindow: MutableRefObject; pixelsPerMinute: number; zIndex: number; }) { @@ -513,7 +552,7 @@ function PlayHeadLayer({ useEffect(() => { clearInterval(intervalRef.current); intervalRef.current = setInterval(() => { - if (playHead.current && spectrogramWindow.current) { + if (playHead.current) { const offset = timeToOffset( playerTimeRef.current, timelineStartTime, @@ -523,7 +562,7 @@ function PlayHeadLayer({ playHead.current.style.width = `${offset}px`; } }, 10); - }, [pixelsPerMinute, spectrogramWindow, timelineStartTime, playerTimeRef]); + }, [pixelsPerMinute, timelineStartTime, playerTimeRef]); return ( <> From 61eb2a5ebae5fa7c6568639b77b775bef00a7bb6 Mon Sep 17 00:00:00 2001 From: Skander Mzali Date: Mon, 6 Jan 2025 10:49:42 -0800 Subject: [PATCH 15/40] Add new separate ticker layer --- server/mix.exs | 2 +- server/mix.lock | 2 +- .../components/Bouts/SpectrogramTimeline.tsx | 339 ++++++++---------- .../components/Bouts/TimelineTickerLayer.tsx | 190 ++++++++++ ui/src/components/Player/BoutPlayer.tsx | 59 ++- ui/src/pages/bouts/[feedSlug]/new.tsx | 7 +- 6 files changed, 411 insertions(+), 188 deletions(-) create mode 100644 ui/src/components/Bouts/TimelineTickerLayer.tsx diff --git a/server/mix.exs b/server/mix.exs index 920ba021..bffc7c56 100644 --- a/server/mix.exs +++ b/server/mix.exs @@ -89,7 +89,7 @@ defmodule Orcasite.Mixfile do {:mjml, "~> 4.0"}, {:zappa, github: "skanderm/zappa", branch: "master"}, {:ash_uuid, "~> 1.1.2"}, - {:ash_graphql, github: "ash-project/ash_graphql", branch: "main"}, + {:ash_graphql, "~> 1.4.7"}, {:ash_json_api, "~> 1.2"}, {:open_api_spex, "~> 3.16"}, {:redoc_ui_plug, "~> 0.2.1"}, diff --git a/server/mix.lock b/server/mix.lock index a14cf6d7..ee36dd5a 100644 --- a/server/mix.lock +++ b/server/mix.lock @@ -5,7 +5,7 @@ "ash_admin": {:hex, :ash_admin, "0.11.11", "664d0ce8a147c40fd4e1fc30079fe89e26d39e6be23c8b059aab19359d80f8d6", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.1.8 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26", [hex: :gettext, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}], "hexpm", "457a01ee2dfc29a3708297fa6956156ba1f51cd19022904c80b141feec3e28ee"}, "ash_authentication": {:hex, :ash_authentication, "4.3.3", "8de80c175a512cb5dedcc8835604ab251aab69a6416f45592ccb53bd2ba4a6b8", [:mix], [{:ash, ">= 3.4.29 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_postgres, "~> 2.0", [hex: :ash_postgres, repo: "hexpm", optional: true]}, {:assent, ">= 0.2.8 and < 1.0.0-0", [hex: :assent, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.5", [hex: :joken, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}], "hexpm", "0207650b4c3c4ace438b7fb8199451b0d7f41b57fb32a58bfa0512790af0769e"}, "ash_authentication_phoenix": {:hex, :ash_authentication_phoenix, "2.2.1", "9dcbc9baa09055c05bd2118cee583d9859c5358f9c7e906a12d8b0206b7247f4", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_authentication, "~> 4.1", [hex: :ash_authentication, repo: "hexpm", optional: false]}, {:ash_phoenix, "~> 2.0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:igniter, ">= 0.3.62 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.18", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:slugify, "~> 1.3", [hex: :slugify, repo: "hexpm", optional: false]}], "hexpm", "57a2149b551b132e5fcc89a49ae2ef652aa60c0d330ec28649b6a9ceb109be34"}, - "ash_graphql": {:git, "https://github.com/ash-project/ash_graphql.git", "d601a7300e12ef0d850ed4e19e88307d7825428d", [branch: "main"]}, + "ash_graphql": {:hex, :ash_graphql, "1.4.7", "60fc9e756e3b8ef6b377a32d81deb53c9bbb67f3732f0b8683c9fb2f0d0827cb", [:mix], [{:absinthe, "~> 1.7", [hex: :absinthe, repo: "hexpm", optional: false]}, {:absinthe_phoenix, "~> 2.0.0", [hex: :absinthe_phoenix, repo: "hexpm", optional: true]}, {:absinthe_plug, "~> 1.4", [hex: :absinthe_plug, repo: "hexpm", optional: false]}, {:ash, ">= 3.2.3 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, ">= 0.3.34 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.10 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "4b9f9eaab909eabf826217f72feaf392ce955a5d8215259ebe744f3c5d0b986e"}, "ash_json_api": {:hex, :ash_json_api, "1.4.13", "4667094c107a306e0dcf6149f96da4dc33cbec145db5fdf0d20d29b3e31be8e2", [:mix], [{:ash, "~> 3.3", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, ">= 0.3.58 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:json_xema, "~> 0.4", [hex: :json_xema, repo: "hexpm", optional: false]}, {:open_api_spex, "~> 3.16", [hex: :open_api_spex, repo: "hexpm", optional: true]}, {:plug, "~> 1.11", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.10 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "237fd78dd346d3efdc2fcd811d0c4b9a55d07e43572011196b8639db22b19763"}, "ash_phoenix": {:hex, :ash_phoenix, "2.1.8", "0cb387338305a6f1cc3a76800aa49b3c914a24dbb3eff645344551c14082a52e", [:mix], [{:ash, ">= 3.4.31 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.5.6 or ~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.3 or ~> 1.0-rc.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "912829a8bf6f8cafc1f3eab35c9eadfd6e5d23dffb5db042d4bdcce0931f7bf7"}, "ash_postgres": {:hex, :ash_postgres, "2.4.12", "97f1b2bdd6b19a056d0ddaf71ba1070580519af2a4278f22eea319a6788a77d1", [:mix], [{:ash, ">= 3.4.37 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, ">= 0.2.37 and < 1.0.0-0", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, ">= 3.12.1 and < 4.0.0-0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.12", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, ">= 0.3.42 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: false]}, {:inflex, "~> 2.1", [hex: :inflex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "98ac149a23f2a43dab5ad03217d65977f42f50125a7c1b41a167145c6ceeddf5"}, diff --git a/ui/src/components/Bouts/SpectrogramTimeline.tsx b/ui/src/components/Bouts/SpectrogramTimeline.tsx index bb1eea0c..36c1b171 100644 --- a/ui/src/components/Bouts/SpectrogramTimeline.tsx +++ b/ui/src/components/Bouts/SpectrogramTimeline.tsx @@ -1,20 +1,70 @@ -import { Box, Typography } from "@mui/material"; +import { Box, Button, Typography } from "@mui/material"; import { addMinutes, differenceInMilliseconds, differenceInMinutes, - format, subMinutes, } from "date-fns"; import { throttle } from "lodash"; -import { MutableRefObject, useEffect, useRef, useState } from "react"; +import _ from "lodash"; +import { + MutableRefObject, + SetStateAction, + useCallback, + useEffect, + useRef, + useState, +} from "react"; import { AudioImage, FeedSegment } from "@/graphql/generated"; -const TICKER_HEIGHT = 30; +import { PlayerControls } from "../Player/BoutPlayer"; +import { TimelineTickerLayer } from "./TimelineTickerLayer"; + +export const TICKER_HEIGHT = 30; const SPECTROGRAM_HEIGHT = 300; +const PIXEL_ZOOM_FACTOR = 50; + +function centerWindow( + spectrogramWindow: MutableRefObject, + targetTime: Date, + timelineStartTime: Date, + pixelsPerMinute: number, + setWindowStartTime: (value: SetStateAction) => void, + setWindowEndTime: (value: SetStateAction) => void, +) { + if (spectrogramWindow.current) { + const offset = timeToOffset(targetTime, timelineStartTime, pixelsPerMinute); + spectrogramWindow.current.scrollLeft = + offset - spectrogramWindow.current.clientWidth / 2; -function timeToOffset( + throttle( + () => { + if (spectrogramWindow.current) { + setWindowStartTime( + offsetToTime( + spectrogramWindow.current.scrollLeft, + timelineStartTime, + pixelsPerMinute, + ), + ); + setWindowEndTime( + offsetToTime( + spectrogramWindow.current.scrollLeft + + spectrogramWindow.current.clientWidth, + timelineStartTime, + pixelsPerMinute, + ), + ); + } + }, + 500, + { leading: true, trailing: true }, + )(); + } +} + +export function timeToOffset( time: Date, timelineStartTime: Date, pixelsPerMinute: number, @@ -53,23 +103,26 @@ function audioImageToUrl({ type SpectrogramFeedSegment = Pick< FeedSegment, - "id" | "startTime" | "endTime" | "duration" | "audioImages" ->; + "id" | "startTime" | "endTime" | "duration" +> & { audioImages: Pick[] }; export default function SpectrogramTimeline({ timelineStartTime, timelineEndTime, feedSegments, playerTimeRef, + playerControls, }: { timelineStartTime: Date; timelineEndTime: Date; feedSegments: SpectrogramFeedSegment[]; playerTimeRef: MutableRefObject; + playerControls?: PlayerControls; }) { // Full spectrogram container const spectrogramWindow = useRef(null); const [isDragging, setIsDragging] = useState(false); + const [wasPlaying, setWasPlaying] = useState(); const [zoomLevel, setZoomLevel] = useState(10); const [windowStartTime, setWindowStartTime] = useState(); const [windowEndTime, setWindowEndTime] = useState(); @@ -81,62 +134,29 @@ export default function SpectrogramTimeline({ // X position of how far it's scrolled from the beginning const windowScrollX = useRef(0); const windowLockInterval = useRef(); + const playHead = useRef(); - const pixelsPerMinute = 50 * zoomLevel; + const pixelsPerMinute = PIXEL_ZOOM_FACTOR * zoomLevel; useEffect(() => { - // if ( - // spectrogramTime === undefined && - // playerTime !== undefined && - // playerTime.current !== undefined - // ) { - // // Set initial spectrogram time - // setSpectrogramTime(playerTime.current); - // } - if ( - windowStartTime === undefined && - windowScrollX.current !== undefined && - timelineStartTime !== undefined - ) { + if (spectrogramWindow.current) { setWindowStartTime( - offsetToTime(windowScrollX.current, timelineStartTime, pixelsPerMinute), + offsetToTime( + spectrogramWindow.current.scrollLeft, + timelineStartTime, + pixelsPerMinute, + ), ); - } - if ( - windowEndTime === undefined && - spectrogramWindow.current !== undefined && - spectrogramWindow.current !== null && - windowScrollX.current !== undefined && - timelineStartTime !== undefined - ) { setWindowEndTime( offsetToTime( - windowScrollX.current + spectrogramWindow.current.offsetWidth, + spectrogramWindow.current.scrollLeft + + spectrogramWindow.current.clientWidth, timelineStartTime, pixelsPerMinute, ), ); } - }, [ - spectrogramWindow, - pixelsPerMinute, - timelineStartTime, - windowStartTime, - windowEndTime, - ]); - - useEffect(() => { - setWindowStartTime( - offsetToTime(windowScrollX.current, timelineStartTime, pixelsPerMinute), - ); - setWindowEndTime( - offsetToTime( - windowScrollX.current + (spectrogramWindow.current?.offsetWidth ?? 0), - timelineStartTime, - pixelsPerMinute, - ), - ); - }, [windowScrollX, spectrogramWindow, timelineStartTime, pixelsPerMinute]); + }, [spectrogramWindow, timelineStartTime, pixelsPerMinute]); // Center window on current time useEffect(() => { @@ -149,27 +169,21 @@ export default function SpectrogramTimeline({ timelineStartTime, pixelsPerMinute, ); - const windowStartOffset = - offset - spectrogramWindow.current.clientWidth / 2; - const windowEndOffset = - offset + spectrogramWindow.current.clientWidth / 2; - - spectrogramWindow.current.scrollLeft = windowStartOffset; - - throttle(() => { - setWindowStartTime( - offsetToTime( - windowStartOffset, - timelineStartTime, - pixelsPerMinute, - ), - ); - setWindowEndTime( - offsetToTime(windowEndOffset, timelineStartTime, pixelsPerMinute), - ); - }, 500)(); + + if (playHead.current) { + playHead.current.style.width = `${offset}px`; + } + + centerWindow( + spectrogramWindow, + playerTimeRef.current, + timelineStartTime, + pixelsPerMinute, + setWindowStartTime, + setWindowEndTime, + ); } - }, 50); + }, 20); } }, [ isDragging, @@ -181,9 +195,11 @@ export default function SpectrogramTimeline({ const handleTouchStart = (e: React.TouchEvent) => { setIsDragging(true); + // setWasPlaying(!playerControls || !playerControls?.paused()); windowStartX.current = e.touches[0].pageX - (spectrogramWindow.current?.offsetLeft ?? 0); windowScrollX.current = spectrogramWindow.current?.scrollLeft ?? 0; + playerControls?.pause(); }; const handleTouchMove = (e: React.TouchEvent) => { @@ -192,7 +208,15 @@ export default function SpectrogramTimeline({ const containerCursorX = e.touches[0].pageX - spectrogramWindow.current.offsetLeft; const move = containerCursorX - windowStartX.current; - spectrogramWindow.current.scrollLeft = windowScrollX.current - move; + const offset = windowScrollX.current - move; + spectrogramWindow.current.scrollLeft = offset; + const windowWidth = spectrogramWindow.current.offsetWidth; + const targetTime = offsetToTime( + offset + windowWidth / 2, + timelineStartTime, + pixelsPerMinute, + ); + playerControls?.setPlayerTime(targetTime); setWindowStartTime( offsetToTime(windowScrollX.current, timelineStartTime, pixelsPerMinute), ); @@ -207,25 +231,44 @@ export default function SpectrogramTimeline({ const handleMouseDown = (e: React.MouseEvent) => { setIsDragging(true); + setWasPlaying(!playerControls && !playerControls?.paused()); windowStartX.current = e.pageX - (spectrogramWindow.current?.offsetLeft ?? 0); windowScrollX.current = spectrogramWindow.current?.scrollLeft ?? 0; + playerControls?.pause(); }; - const handleMouseLeave = () => { + const handleMouseLeave = useCallback(() => { setIsDragging(false); - }; + if (wasPlaying) { + playerControls?.play(); + } + }, [wasPlaying, playerControls]); - const handleMouseUp = () => { + const handleMouseUp = useCallback(() => { setIsDragging(false); - }; + if (wasPlaying) { + playerControls?.play(); + } + }, [wasPlaying, playerControls]); const handleMouseMove = (e: React.MouseEvent) => { if (!isDragging || !spectrogramWindow.current) return; e.preventDefault(); const containerCursorX = e.pageX - spectrogramWindow.current.offsetLeft; const move = containerCursorX - windowStartX.current; - spectrogramWindow.current.scrollLeft = windowScrollX.current - move; + const offset = windowScrollX.current - move; + spectrogramWindow.current.scrollLeft = offset; + const windowWidth = spectrogramWindow.current.offsetWidth; + const targetTime = offsetToTime( + offset + windowWidth / 2, + timelineStartTime, + pixelsPerMinute, + ); + playerTimeRef.current = targetTime; + + playerControls?.setPlayerTime(targetTime); + setWindowStartTime( offsetToTime(windowScrollX.current, timelineStartTime, pixelsPerMinute), ); @@ -240,15 +283,16 @@ export default function SpectrogramTimeline({ const handleWheel = (e: React.WheelEvent) => { e.preventDefault(); - setZoomLevel((zoom) => { - const zoomIncrement = zoom * 0.2; - return Math.min( + setZoomLevel((zoomLevel) => { + const zoomIncrement = zoomLevel * 0.2; + const newZoom = Math.min( Math.max( minZoom, - zoom + (e.deltaY > 0 ? -zoomIncrement : zoomIncrement), + zoomLevel + (e.deltaY > 0 ? -zoomIncrement : zoomIncrement), ), maxZoom, ); + return newZoom; }); // if (!spectrogramWindow.current) return; // spectrogramWindow.current.scrollLeft -= e.deltaY; @@ -264,7 +308,7 @@ export default function SpectrogramTimeline({ container.removeEventListener("mouseup", handleMouseUp); }; } - }, []); + }, [handleMouseUp, handleMouseLeave]); return ( <> @@ -273,6 +317,18 @@ export default function SpectrogramTimeline({
Window start: {JSON.stringify(windowStartTime)}
Window end: {JSON.stringify(windowEndTime)}
Zoom {zoomLevel}
+ + + + - + > - + {windowStartTime && windowEndTime && ( + + )} - {Array(tiles) - .fill(0) - .map((_, idx) => ( - - - - - {[1, 2, 4, 5].map((tensIndex) => ( - - ))} - - - {format(addMinutes(startTime, idx), "hh:mm:ss")} - - - - - ))} - - ); -} - function PlayHeadLayer({ playerTimeRef, timelineStartTime, @@ -573,7 +547,6 @@ function PlayHeadLayer({ top={TICKER_HEIGHT} height={`calc(100% - ${TICKER_HEIGHT}px)`} zIndex={zIndex} - sx={{ transition: "width 0.01s" }} > ); diff --git a/ui/src/components/Bouts/TimelineTickerLayer.tsx b/ui/src/components/Bouts/TimelineTickerLayer.tsx new file mode 100644 index 00000000..e30a4ab7 --- /dev/null +++ b/ui/src/components/Bouts/TimelineTickerLayer.tsx @@ -0,0 +1,190 @@ +import { Box, Typography } from "@mui/material"; +import { addMinutes, addSeconds, differenceInMinutes, format } from "date-fns"; +import _ from "lodash"; + +import { TICKER_HEIGHT, timeToOffset } from "./SpectrogramTimeline"; + +export function TimelineTickerLayer({ + startTime, + endTime, + pixelsPerMinute, + timelineStartTime, + windowStartTime, + windowEndTime, + zIndex, +}: { + startTime: Date; + endTime: Date; + timelineStartTime: Date; + windowStartTime: Date; + windowEndTime: Date; + pixelsPerMinute: number; + zIndex: number; +}) { + const minutes = differenceInMinutes(endTime, startTime); + const tiles = minutes; // 1 minute increments + + const seconds = _.range(1, 59); + const tens = seconds.filter((second) => second % 10 === 0); + const tensTicks = _.without(tens, 30); + const fives = seconds.filter((second) => second % 5 === 0); + const fivesTicks = _.without(fives, ...tens); + const onesTicks = _.without(seconds, ...tens, ...fives); + + const tensLabelThreshold = 600; // pixels per minute + const fivesTicksThreshold = 600; // pixels per minute + const fivesLabelThreshold = 1750; // pixels per minute + const onesTicksThreshold = 750; // pixels per minute + + const windowStartOffset = timeToOffset( + windowStartTime, + timelineStartTime, + pixelsPerMinute, + ); + const windowEndOffset = timeToOffset( + windowEndTime, + timelineStartTime, + pixelsPerMinute, + ); + + return ( + <> + {Array(tiles) + .fill(0) + .map((_minute, idx) => { + const inRange = _.inRange( + idx * pixelsPerMinute, + windowStartOffset - 1000, + windowEndOffset + 1000, + ); + // if (!inRange) return <>; + return ( + + + + + {tensTicks.map((number, tensTickerIndex) => ( + + ))} + {pixelsPerMinute >= tensLabelThreshold && + tens.map((number, tensLabelIndex) => ( + + + ); + })} + + ); +} + +function Tick({ + left, + height, +}: { + left: number | string; + height: number | string; +}) { + return ( + + ); +} + +function Label({ + time, + width, + left, +}: { + time: Date; + width: number | string; + left: number | string; +}) { + return ( + + + {format(time, "hh:mm:ss")} + + + ); +} diff --git a/ui/src/components/Player/BoutPlayer.tsx b/ui/src/components/Player/BoutPlayer.tsx index b9175200..33c1a29a 100644 --- a/ui/src/components/Player/BoutPlayer.tsx +++ b/ui/src/components/Player/BoutPlayer.tsx @@ -15,15 +15,25 @@ const VideoJS = dynamic(() => import("./VideoJS")); const playerOffsetToDateTime = (playlistDatetime: Date, playerOffset: number) => new Date(playlistDatetime.valueOf() + playerOffset * 1000); +export type PlayerControls = { + setPlayerTime: (time: Date) => void; + play: () => void; + pause: () => void; + paused: () => boolean; + player: VideoJSPlayer; +}; + export function BoutPlayer({ feed, feedStream, targetTime, + onPlayerInit, onPlayerTimeUpdate, }: { feed: Pick; feedStream: Pick; targetTime: Date; + onPlayerInit?: (playerControls: PlayerControls) => void; onPlayerTimeUpdate?: (time: Date) => void; }) { const hlsURI = getHlsURI( @@ -84,6 +94,38 @@ export function BoutPlayer({ const handleReady = useCallback( (player: VideoJSPlayer) => { playerRef.current = player; + if (onPlayerInit) { + onPlayerInit({ + player: player, + play: () => { + try { + player.play(); + } catch (e) { + console.error(e); + } + }, + pause: () => { + try { + player.pause(); + } catch (e) { + console.error(e); + } + }, + paused: () => { + try { + return player.paused(); + } catch (e) { + console.error(e); + return true; + } + }, + setPlayerTime: (time: Date) => { + const offset = differenceInSeconds(time, playlistDatetime); + player.currentTime(offset); + setPlayerOffset(offset); + }, + }); + } player.on("playing", () => { setPlayerStatus("playing"); @@ -112,6 +154,15 @@ export function BoutPlayer({ player.on("timeupdate", () => { const currentTime = player.currentTime() ?? targetOffset ?? 0; setPlayerOffset(currentTime); + // if (!intervalRef.current) { + // const playerDateTime = playerOffsetToDateTime( + // playlistDatetime, + // currentTime, + // ); + // if (onPlayerTimeUpdate !== undefined) { + // onPlayerTimeUpdate(playerDateTime); + // } + // } }); player.on("loadedmetadata", () => { @@ -119,7 +170,13 @@ export function BoutPlayer({ player.currentTime(targetOffset); }); }, - [onPlayerTimeUpdate, playlistDatetime, targetOffset, intervalRef], + [ + onPlayerTimeUpdate, + onPlayerInit, + playlistDatetime, + targetOffset, + intervalRef, + ], ); const handlePlayPauseClick = () => { const player = playerRef.current; diff --git a/ui/src/pages/bouts/[feedSlug]/new.tsx b/ui/src/pages/bouts/[feedSlug]/new.tsx index e764eee8..d49a5ca9 100644 --- a/ui/src/pages/bouts/[feedSlug]/new.tsx +++ b/ui/src/pages/bouts/[feedSlug]/new.tsx @@ -9,12 +9,12 @@ import { } from "date-fns"; import Head from "next/head"; import { useParams, useSearchParams } from "next/navigation"; -import { useCallback, useMemo, useRef } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import SpectrogramTimeline from "@/components/Bouts/SpectrogramTimeline"; import { getSimpleLayout } from "@/components/layouts/SimpleLayout"; import LoadingSpinner from "@/components/LoadingSpinner"; -import { BoutPlayer } from "@/components/Player/BoutPlayer"; +import { BoutPlayer, PlayerControls } from "@/components/Player/BoutPlayer"; import { AudioCategory, useDetectionsQuery, @@ -31,6 +31,7 @@ const NewBoutPage: NextPageWithLayout = () => { [], ); const now = useMemo(() => new Date(), []); + const [playerControls, setPlayerControls] = useState(); const timeBuffer = 5; // minutes const targetTimePlusBuffer = roundToNearestMinutes( @@ -106,12 +107,14 @@ const NewBoutPage: NextPageWithLayout = () => { targetTime={targetTime} feedStream={feedStream} onPlayerTimeUpdate={setPlayerTime} + onPlayerInit={setPlayerControls} /> )} From 0ba36c388d40793d8c472832c0865ba51c77e95c Mon Sep 17 00:00:00 2001 From: Skander Mzali Date: Mon, 6 Jan 2025 13:16:01 -0800 Subject: [PATCH 16/40] Remove mouse wheel zoom. Replace playhead with sticky element in the center --- .../components/Bouts/SpectrogramTimeline.tsx | 88 ++++++------------- .../components/Bouts/TimelineTickerLayer.tsx | 51 ++++++----- 2 files changed, 58 insertions(+), 81 deletions(-) diff --git a/ui/src/components/Bouts/SpectrogramTimeline.tsx b/ui/src/components/Bouts/SpectrogramTimeline.tsx index 36c1b171..44b6f1bc 100644 --- a/ui/src/components/Bouts/SpectrogramTimeline.tsx +++ b/ui/src/components/Bouts/SpectrogramTimeline.tsx @@ -64,7 +64,7 @@ function centerWindow( } } -export function timeToOffset( +function timeToOffset( time: Date, timelineStartTime: Date, pixelsPerMinute: number, @@ -82,7 +82,7 @@ function offsetToTime( return addMinutes(timelineStartTime, offset / pixelsPerMinute); } -function rangesOverlap( +export function rangesOverlap( startTime1?: Date, endTime1?: Date, startTime2?: Date, @@ -122,19 +122,18 @@ export default function SpectrogramTimeline({ // Full spectrogram container const spectrogramWindow = useRef(null); const [isDragging, setIsDragging] = useState(false); - const [wasPlaying, setWasPlaying] = useState(); - const [zoomLevel, setZoomLevel] = useState(10); + const [zoomLevel, setZoomLevel] = useState(8); const [windowStartTime, setWindowStartTime] = useState(); const [windowEndTime, setWindowEndTime] = useState(); - const minZoom = 5; - const maxZoom = 100; + + const minZoom = 2; + const maxZoom = 400; // X position of visible window relative to browser const windowStartX = useRef(0); // X position of how far it's scrolled from the beginning const windowScrollX = useRef(0); const windowLockInterval = useRef(); - const playHead = useRef(); const pixelsPerMinute = PIXEL_ZOOM_FACTOR * zoomLevel; @@ -164,16 +163,6 @@ export default function SpectrogramTimeline({ clearInterval(windowLockInterval.current); windowLockInterval.current = setInterval(() => { if (spectrogramWindow.current) { - const offset = timeToOffset( - playerTimeRef.current, - timelineStartTime, - pixelsPerMinute, - ); - - if (playHead.current) { - playHead.current.style.width = `${offset}px`; - } - centerWindow( spectrogramWindow, playerTimeRef.current, @@ -195,7 +184,6 @@ export default function SpectrogramTimeline({ const handleTouchStart = (e: React.TouchEvent) => { setIsDragging(true); - // setWasPlaying(!playerControls || !playerControls?.paused()); windowStartX.current = e.touches[0].pageX - (spectrogramWindow.current?.offsetLeft ?? 0); windowScrollX.current = spectrogramWindow.current?.scrollLeft ?? 0; @@ -229,9 +217,12 @@ export default function SpectrogramTimeline({ ); }; + const handleTouchEnd = useCallback(() => { + setIsDragging(false); + }, []); + const handleMouseDown = (e: React.MouseEvent) => { setIsDragging(true); - setWasPlaying(!playerControls && !playerControls?.paused()); windowStartX.current = e.pageX - (spectrogramWindow.current?.offsetLeft ?? 0); windowScrollX.current = spectrogramWindow.current?.scrollLeft ?? 0; @@ -240,17 +231,11 @@ export default function SpectrogramTimeline({ const handleMouseLeave = useCallback(() => { setIsDragging(false); - if (wasPlaying) { - playerControls?.play(); - } - }, [wasPlaying, playerControls]); + }, []); const handleMouseUp = useCallback(() => { setIsDragging(false); - if (wasPlaying) { - playerControls?.play(); - } - }, [wasPlaying, playerControls]); + }, []); const handleMouseMove = (e: React.MouseEvent) => { if (!isDragging || !spectrogramWindow.current) return; @@ -281,23 +266,6 @@ export default function SpectrogramTimeline({ ); }; - const handleWheel = (e: React.WheelEvent) => { - e.preventDefault(); - setZoomLevel((zoomLevel) => { - const zoomIncrement = zoomLevel * 0.2; - const newZoom = Math.min( - Math.max( - minZoom, - zoomLevel + (e.deltaY > 0 ? -zoomIncrement : zoomIncrement), - ), - maxZoom, - ); - return newZoom; - }); - // if (!spectrogramWindow.current) return; - // spectrogramWindow.current.scrollLeft -= e.deltaY; - }; - useEffect(() => { const container = spectrogramWindow.current; if (container) { @@ -312,19 +280,18 @@ export default function SpectrogramTimeline({ return ( <> -
Start: {JSON.stringify(timelineStartTime)}
-
End: {JSON.stringify(timelineEndTime)}
-
Window start: {JSON.stringify(windowStartTime)}
-
Window end: {JSON.stringify(windowEndTime)}
-
Zoom {zoomLevel}
@@ -351,15 +318,16 @@ export default function SpectrogramTimeline({ onMouseMove={handleMouseMove} onTouchStart={handleTouchStart} onTouchMove={handleTouchMove} - onWheel={handleWheel} + onTouchEnd={handleTouchEnd} + onTouchCancel={handleTouchEnd} > {windowStartTime && windowEndTime && ( @@ -370,7 +338,7 @@ export default function SpectrogramTimeline({ startTime={timelineStartTime} endTime={timelineEndTime} pixelsPerMinute={pixelsPerMinute} - zIndex={1} + zIndex={5} /> )} {Array(tiles) .fill(0) .map((_minute, idx) => { - const inRange = _.inRange( - idx * pixelsPerMinute, - windowStartOffset - 1000, - windowEndOffset + 1000, + const inRange = rangesOverlap( + addMinutes(timelineStartTime, idx), + addMinutes(timelineStartTime, idx + 1), + subMinutes(windowStartTime, 1), + addMinutes(windowEndTime, 1), ); - // if (!inRange) return <>; + + if (!inRange) return ; return ( + ))} + + {pixelsPerMinute >= onesLabelThreshold && + onesTicks.map((number, onesLabel) => ( + + {boutStartTime && ( + { + goToTime(boutStartTime); + }} + /> + )} + {boutEndTime && ( + { + goToTime(boutEndTime); + }} + /> + )} + {windowStartTime && windowEndTime && ( )} void; +}) { + const offset = timeToOffset(time, timelineStartTime, pixelsPerMinute); + return ( + + theme.palette.accent4.light, + ...iconProps, + }} + /> + `2px solid ${theme.palette.accent4.light}`, + top: TICKER_HEIGHT, + position: "absolute", + height: `calc(100% - ${TICKER_HEIGHT}px)`, + }} + > + + ); +} diff --git a/ui/src/components/Player/BoutPlayer.tsx b/ui/src/components/Player/BoutPlayer.tsx index 33c1a29a..375fc39c 100644 --- a/ui/src/components/Player/BoutPlayer.tsx +++ b/ui/src/components/Player/BoutPlayer.tsx @@ -29,12 +29,14 @@ export function BoutPlayer({ targetTime, onPlayerInit, onPlayerTimeUpdate, + setPlayerTimeRef, }: { feed: Pick; feedStream: Pick; targetTime: Date; onPlayerInit?: (playerControls: PlayerControls) => void; onPlayerTimeUpdate?: (time: Date) => void; + setPlayerTimeRef?: (time: Date) => void; }) { const hlsURI = getHlsURI( feed.bucket, @@ -123,6 +125,7 @@ export function BoutPlayer({ const offset = differenceInSeconds(time, playlistDatetime); player.currentTime(offset); setPlayerOffset(offset); + setPlayerTimeRef && setPlayerTimeRef(time); }, }); } diff --git a/ui/src/pages/bouts/[feedSlug]/new.tsx b/ui/src/pages/bouts/[feedSlug]/new.tsx index d49a5ca9..5b5ef851 100644 --- a/ui/src/pages/bouts/[feedSlug]/new.tsx +++ b/ui/src/pages/bouts/[feedSlug]/new.tsx @@ -30,30 +30,33 @@ const NewBoutPage: NextPageWithLayout = () => { (time: Date) => (playerTime.current = time), [], ); - const now = useMemo(() => new Date(), []); const [playerControls, setPlayerControls] = useState(); - const timeBuffer = 5; // minutes - const targetTimePlusBuffer = roundToNearestMinutes( - min([now, addMinutes(targetTime, timeBuffer)]), - { roundingMethod: "ceil" }, - ); - const targetTimeMinusBuffer = roundToNearestMinutes( - subMinutes(targetTime, timeBuffer), - { roundingMethod: "floor" }, - ); - const targetTimeMinusADay = subDays(targetTime, 1); - const params = useParams<{ feedSlug?: string }>(); const feedSlug = params?.feedSlug; const searchParams = useSearchParams(); const audioCategory = searchParams.get("category") as AudioCategory; + const [boutStartTime, setBoutStartTime] = useState(); + const [boutEndTime, setBoutEndTime] = useState(); + const feedQueryResult = useFeedQuery( { slug: feedSlug || "" }, { enabled: !!feedSlug }, ); const feed = feedQueryResult.data?.feed; + // Get feed segments for current time +/- 5 minute buffer + const now = useMemo(() => new Date(), []); + const timeBuffer = 5; // minutes + const targetTimePlusBuffer = roundToNearestMinutes( + min([now, addMinutes(targetTime, timeBuffer)]), + { roundingMethod: "ceil" }, + ); + const targetTimeMinusBuffer = roundToNearestMinutes( + subMinutes(targetTime, timeBuffer), + { roundingMethod: "floor" }, + ); + const targetTimeMinusADay = subDays(targetTime, 1); // If feed is present, and there's no pre-set time, // get latest stream and last minutes of segments. // Set time to end of last segment @@ -107,6 +110,7 @@ const NewBoutPage: NextPageWithLayout = () => { targetTime={targetTime} feedStream={feedStream} onPlayerTimeUpdate={setPlayerTime} + setPlayerTimeRef={setPlayerTime} onPlayerInit={setPlayerControls} /> )} @@ -116,6 +120,10 @@ const NewBoutPage: NextPageWithLayout = () => { timelineEndTime={targetTimePlusBuffer} playerControls={playerControls} feedSegments={feedSegments} + boutStartTime={boutStartTime} + boutEndTime={boutEndTime} + setBoutStartTime={setBoutStartTime} + setBoutEndTime={setBoutEndTime} /> From eee9b5a4fe8b4bb597561c88b57773a03f9b8b5e Mon Sep 17 00:00:00 2001 From: Skander Mzali Date: Thu, 23 Jan 2025 13:07:33 -0800 Subject: [PATCH 19/40] Add frequency axis, detections table, UI for setting bout start and end --- .../components/Bouts/FrequencyAxisLayer.tsx | 133 +++++++++ .../components/Bouts/SpectrogramTimeline.tsx | 264 ++++++++++++------ ui/src/components/Bouts/TimelineMarker.tsx | 1 + .../components/Bouts/TimelineTickerLayer.tsx | 231 ++++++++------- ui/src/components/Player/BoutPlayer.tsx | 3 +- ui/src/pages/bouts/[feedSlug]/new.tsx | 192 ++++++++++++- 6 files changed, 619 insertions(+), 205 deletions(-) create mode 100644 ui/src/components/Bouts/FrequencyAxisLayer.tsx diff --git a/ui/src/components/Bouts/FrequencyAxisLayer.tsx b/ui/src/components/Bouts/FrequencyAxisLayer.tsx new file mode 100644 index 00000000..6175b6d1 --- /dev/null +++ b/ui/src/components/Bouts/FrequencyAxisLayer.tsx @@ -0,0 +1,133 @@ +import { Box, Typography } from "@mui/material"; +import _ from "lodash"; +import { Fragment } from "react"; + +import { TICKER_HEIGHT } from "./SpectrogramTimeline"; + +type Scaling = "linear" | "logarithmic"; + +const MAX_TICK_WIDTH = 0.3; + +export function FrequencyAxisLayer({ + scaling, + minFrequency, + maxFrequency, + zIndex, +}: { + scaling: Scaling; + minFrequency: number; + maxFrequency: number; + zIndex: number; +}) { + const adjustedMinFrequency = Math.max(1, minFrequency); + const maxScale = Math.ceil(Math.log10(maxFrequency)); + const minScale = Math.floor(Math.log10(adjustedMinFrequency)); + const ticks = _.range(minScale, maxScale).flatMap((scale) => { + return _.range( + Math.pow(10, scale), + Math.pow(10, scale + 1), + Math.pow(10, scale), + ) + .map((frequency) => { + const bottom = `${(100 * (Math.log10(frequency) - Math.log10(adjustedMinFrequency))) / (Math.log10(maxFrequency) - Math.log10(adjustedMinFrequency))}%`; + const minWidth = 0.2; + const isMajor = frequency === Math.pow(10, scale); + const widthFactor = isMajor + ? 1 + : (1 - minWidth) * (frequency / Math.pow(10, scale + 1)) + minWidth; + + return { frequency, bottom, widthFactor, isMajor }; + }) + .filter( + ({ frequency }) => + frequency >= adjustedMinFrequency && frequency <= maxFrequency, + ); + }); + + return ( + + + + Hz + + {ticks.map((tick, idx) => ( + + {tick.isMajor && ( + + ))} + + + ); +} + +function Label({ frequency, bottom }: { frequency: number; bottom: string }) { + const value = frequency >= 1000 ? frequency / 1000 : frequency; + const label = frequency >= 1000 ? "k" : ""; + return ( + + + + {value} + {label} + + + + ); +} + +function Tick({ + frequency, + bottom, + widthFactor, +}: { + frequency: number; + bottom: string; + widthFactor: number; +}) { + return ( + + ); +} diff --git a/ui/src/components/Bouts/SpectrogramTimeline.tsx b/ui/src/components/Bouts/SpectrogramTimeline.tsx index 58776ad7..c0b0734c 100644 --- a/ui/src/components/Bouts/SpectrogramTimeline.tsx +++ b/ui/src/components/Bouts/SpectrogramTimeline.tsx @@ -1,11 +1,18 @@ -import { PlayCircleFilled } from "@mui/icons-material"; -import { Box, Button } from "@mui/material"; -import { addMinutes, differenceInMilliseconds } from "date-fns"; -import { throttle } from "lodash"; +import { + ArrowRight, + Clear, + PlayCircleFilled, + Start, + ZoomIn, + ZoomOut, +} from "@mui/icons-material"; +import { Box, Button, IconButton, Typography } from "@mui/material"; +import { addMinutes, differenceInMilliseconds, format } from "date-fns"; import _ from "lodash"; import { Dispatch, MutableRefObject, + PropsWithChildren, SetStateAction, useCallback, useEffect, @@ -18,6 +25,7 @@ import { AudioImage, FeedSegment } from "@/graphql/generated"; import { PlayerControls } from "../Player/BoutPlayer"; import { BaseAudioWidthLayer } from "./BaseAudioWidthLayer"; import { FeedSegmentsLayer } from "./FeedSegmentsLayer"; +import { FrequencyAxisLayer } from "./FrequencyAxisLayer"; import { TimelineMarker } from "./TimelineMarker"; import { TimelineTickerLayer } from "./TimelineTickerLayer"; @@ -25,6 +33,11 @@ export const TICKER_HEIGHT = 30; const SPECTROGRAM_HEIGHT = 300; const PIXEL_ZOOM_FACTOR = 50; +type SpectrogramFeedSegment = Pick< + FeedSegment, + "id" | "startTime" | "endTime" | "duration" +> & { audioImages: Pick[] }; + function centerWindow( spectrogramWindow: MutableRefObject, targetTime: Date, @@ -39,30 +52,23 @@ function centerWindow( spectrogramWindow.current.scrollLeft = offset - spectrogramWindow.current.clientWidth / 2; - throttle( - () => { - if (spectrogramWindow.current) { - setWindowStartTime( - offsetToTime( - spectrogramWindow.current.scrollLeft, - timelineStartTime, - pixelsPerMinute, - ), - ); - setWindowEndTime( - offsetToTime( - spectrogramWindow.current.scrollLeft + - spectrogramWindow.current.clientWidth, - timelineStartTime, - pixelsPerMinute, - ), - ); - } - }, - 500, - { leading: true, trailing: true }, - )(); - + if (spectrogramWindow.current) { + setWindowStartTime( + offsetToTime( + spectrogramWindow.current.scrollLeft, + timelineStartTime, + pixelsPerMinute, + ), + ); + setWindowEndTime( + offsetToTime( + spectrogramWindow.current.scrollLeft + + spectrogramWindow.current.clientWidth, + timelineStartTime, + pixelsPerMinute, + ), + ); + } if (playerControls?.setPlayerTime) { playerControls.setPlayerTime(targetTime); } @@ -98,12 +104,6 @@ export function rangesOverlap( } return false; } - -export type SpectrogramFeedSegment = Pick< - FeedSegment, - "id" | "startTime" | "endTime" | "duration" -> & { audioImages: Pick[] }; - export default function SpectrogramTimeline({ timelineStartTime, timelineEndTime, @@ -114,7 +114,8 @@ export default function SpectrogramTimeline({ boutEndTime, setBoutStartTime, setBoutEndTime, -}: { + children, +}: PropsWithChildren<{ timelineStartTime: Date; timelineEndTime: Date; feedSegments: SpectrogramFeedSegment[]; @@ -124,16 +125,29 @@ export default function SpectrogramTimeline({ boutEndTime?: Date; setBoutStartTime: Dispatch>; setBoutEndTime: Dispatch>; -}) { +}>) { // Full spectrogram container const spectrogramWindow = useRef(null); const [isDragging, setIsDragging] = useState(false); const [zoomLevel, setZoomLevel] = useState(8); - const [windowStartTime, setWindowStartTime] = useState(); - const [windowEndTime, setWindowEndTime] = useState(); + const [windowStartTime, setWindowStartTimeUnthrottled] = useState(); + const [windowEndTime, setWindowEndTimeUnthrottled] = useState(); + const setWindowStartTime = useCallback( + _.throttle(setWindowStartTimeUnthrottled, 500, { trailing: false }), + [], + ); + const setWindowEndTime = useCallback( + _.throttle(setWindowEndTimeUnthrottled, 500, { trailing: false }), + [], + ); + const setPlayerTime = _.throttle( + (time: Date) => playerControls?.setPlayerTime(time), + 200, + { trailing: false }, + ); const minZoom = 2; - const maxZoom = 400; + const maxZoom = 1600; // X position of visible window relative to browser const windowStartX = useRef(0); @@ -142,7 +156,6 @@ export default function SpectrogramTimeline({ const windowLockInterval = useRef(); const pixelsPerMinute = PIXEL_ZOOM_FACTOR * zoomLevel; - const goToTime = (time: Date) => { centerWindow( spectrogramWindow, @@ -173,7 +186,13 @@ export default function SpectrogramTimeline({ ), ); } - }, [spectrogramWindow, timelineStartTime, pixelsPerMinute]); + }, [ + spectrogramWindow, + setWindowStartTime, + setWindowEndTime, + timelineStartTime, + pixelsPerMinute, + ]); // Center window on current time useEffect(() => { @@ -190,14 +209,17 @@ export default function SpectrogramTimeline({ setWindowEndTime, ); } - }, 20); + }, 100); } + return () => clearInterval(windowLockInterval.current); }, [ isDragging, playerTimeRef, spectrogramWindow, pixelsPerMinute, timelineStartTime, + setWindowStartTime, + setWindowEndTime, ]); const handleTouchStart = (e: React.TouchEvent) => { @@ -222,7 +244,7 @@ export default function SpectrogramTimeline({ timelineStartTime, pixelsPerMinute, ); - playerControls?.setPlayerTime(targetTime); + setPlayerTime(targetTime); setWindowStartTime( offsetToTime(windowScrollX.current, timelineStartTime, pixelsPerMinute), ); @@ -270,7 +292,7 @@ export default function SpectrogramTimeline({ ); playerTimeRef.current = targetTime; - playerControls?.setPlayerTime(targetTime); + setPlayerTime(targetTime); setWindowStartTime( offsetToTime(windowScrollX.current, timelineStartTime, pixelsPerMinute), @@ -298,46 +320,6 @@ export default function SpectrogramTimeline({ return ( <> - - - - - {boutStartTime && ( - - )} - - {boutEndTime && ( - - )} - )} + + {windowStartTime && windowEndTime && ( )} + + {children} + + + Zoom + + + + setZoomLevel((zoom) => _.clamp(zoom * 2, minZoom, maxZoom)) + } + > + + + + setZoomLevel((zoom) => _.clamp(zoom / 2, minZoom, maxZoom)) + } + > + + + + + + + + Bout start + + + + (!boutEndTime || playerTimeRef.current < boutEndTime) && + setBoutStartTime(playerTimeRef.current) + } + title="Set bout start" + > + + + + {boutStartTime && ( + + + setBoutStartTime(undefined)} + title="Clear bout start" + size="small" + > + + + + )} + + + + Bout end + + + + (!boutStartTime || playerTimeRef.current > boutStartTime) && + setBoutEndTime(playerTimeRef.current) + } + title="Set bout end" + > + + + + {boutEndTime && ( + + + setBoutEndTime(undefined)} + title="Clear bout end" + size="small" + > + + + + )} + + ); } diff --git a/ui/src/components/Bouts/TimelineMarker.tsx b/ui/src/components/Bouts/TimelineMarker.tsx index 27e6c55e..52560e0c 100644 --- a/ui/src/components/Bouts/TimelineMarker.tsx +++ b/ui/src/components/Bouts/TimelineMarker.tsx @@ -48,6 +48,7 @@ export function TimelineMarker({ top: TICKER_HEIGHT, position: "absolute", height: `calc(100% - ${TICKER_HEIGHT}px)`, + zIndex: zIndex - 2, }} >
diff --git a/ui/src/components/Bouts/TimelineTickerLayer.tsx b/ui/src/components/Bouts/TimelineTickerLayer.tsx index 7a53ffe6..49551fb4 100644 --- a/ui/src/components/Bouts/TimelineTickerLayer.tsx +++ b/ui/src/components/Bouts/TimelineTickerLayer.tsx @@ -1,15 +1,19 @@ import { Box, Typography } from "@mui/material"; import { - addMinutes, - addSeconds, - differenceInMinutes, + addMilliseconds, + differenceInMilliseconds, format, - subMinutes, + hoursToMilliseconds, + minutesToMilliseconds, + secondsToMilliseconds, + subMilliseconds, } from "date-fns"; -import _ from "lodash"; +import { throttle } from "lodash"; import { Fragment } from "react"; -import { rangesOverlap, TICKER_HEIGHT } from "./SpectrogramTimeline"; +import { TICKER_HEIGHT, timeToOffset } from "./SpectrogramTimeline"; + +const log = throttle(console.log, 5000); export function TimelineTickerLayer({ timelineStartTime, @@ -19,42 +23,111 @@ export function TimelineTickerLayer({ windowEndTime, zIndex, }: { - timelineEndTime: Date; timelineStartTime: Date; + timelineEndTime: Date; windowStartTime: Date; windowEndTime: Date; pixelsPerMinute: number; zIndex: number; }) { - const minutes = differenceInMinutes(timelineEndTime, timelineStartTime); - const tiles = minutes; // 1 minute increments + const windowRange = differenceInMilliseconds(windowEndTime, windowStartTime); + const ticksPerWindow = 20; + const labelsPerWindow = 8; + + const tickSize = windowRange / ticksPerWindow; + const labelSize = windowRange / labelsPerWindow; + const tickScales = [ + hoursToMilliseconds(24), + hoursToMilliseconds(6), + hoursToMilliseconds(3), + hoursToMilliseconds(1), + minutesToMilliseconds(10), + minutesToMilliseconds(5), + minutesToMilliseconds(1), + secondsToMilliseconds(30), + secondsToMilliseconds(10), + secondsToMilliseconds(5), + secondsToMilliseconds(1), + secondsToMilliseconds(1 / 10), + secondsToMilliseconds(1 / 100), + 1, + ]; + const labelScales = [ + hoursToMilliseconds(24), + hoursToMilliseconds(6), + hoursToMilliseconds(3), + hoursToMilliseconds(1), + minutesToMilliseconds(10), + minutesToMilliseconds(5), + minutesToMilliseconds(1), + secondsToMilliseconds(30), + secondsToMilliseconds(10), + secondsToMilliseconds(5), + secondsToMilliseconds(1), + secondsToMilliseconds(1 / 10), + secondsToMilliseconds(1 / 100), + 1, + ]; - const seconds = _.range(1, 59); - const tens = seconds.filter((second) => second % 10 === 0); - const tensTicks = _.without(tens, 30); - const fives = seconds.filter((second) => second % 5 === 0); - const fivesTicks = _.without(fives, ...tens); - const onesTicks = _.without(seconds, ...tens, ...fives); + // console.log("range", windowRange, "ts", tickSize, "ls", labelSize); + + const minScaleIndex = tickScales.findIndex( + (num) => Math.floor(tickSize / num) > 0, + ); + const scale = tickScales[Math.max(0, minScaleIndex - 1)]; + const pixelsPerTick = pixelsPerMinute * (scale / minutesToMilliseconds(1)); + + const minLabelScaleIndex = labelScales.findIndex( + (num) => Math.floor(labelSize / num) > 0, + ); + const labelScale = labelScales[Math.max(0, minLabelScaleIndex - 1)]; + const pixelsPerLabel = + pixelsPerMinute * (labelScale / minutesToMilliseconds(1)); + + const tickStartTime = new Date( + Math.floor( + subMilliseconds(windowStartTime, windowRange * 0.2).getTime() / scale, + ) * scale, + ); + const tickEndTime = new Date( + Math.ceil( + addMilliseconds(windowEndTime, windowRange * 0.2).getTime() / scale, + ) * scale, + ); + const tickStartOffset = timeToOffset( + tickStartTime, + timelineStartTime, + pixelsPerMinute, + ); + + const ticks = differenceInMilliseconds(tickEndTime, tickStartTime) / scale; + + const labelStartTime = new Date( + Math.floor( + subMilliseconds(windowStartTime, windowRange * 0.2).getTime() / + labelScale, + ) * labelScale, + ); + const labelEndTime = new Date( + Math.ceil( + addMilliseconds(windowEndTime, windowRange * 0.2).getTime() / labelScale, + ) * labelScale, + ); + const labelStartOffset = timeToOffset( + labelStartTime, + timelineStartTime, + pixelsPerMinute, + ); - const tensLabelThreshold = 600; // pixels per minute - const fivesTicksThreshold = 600; // pixels per minute - const fivesLabelThreshold = 1600; // pixels per minute - const onesTicksThreshold = 750; // pixels per minute - const onesLabelThreshold = 6400; // pixels per minute + const labels = + differenceInMilliseconds(labelEndTime, labelStartTime) / labelScale; + // log("scale", scale, "ppt", pixelsPerTick, "ppm", pixelsPerMinute); return ( <> - {Array(tiles) + {Array(ticks) .fill(0) - .map((_minute, idx) => { - const inRange = rangesOverlap( - addMinutes(timelineStartTime, idx), - addMinutes(timelineStartTime, idx + 1), - subMinutes(windowStartTime, 1), - addMinutes(windowEndTime, 1), - ); - - if (!inRange) return ; + .map((_tick, idx) => { return ( - - - {tensTicks.map((number, tensTickerIndex) => ( - - ))} - {pixelsPerMinute >= tensLabelThreshold && - tens.map((number, tensLabelIndex) => ( - ); })} + {Array(labels) + .fill(0) + .map((_label, idx) => { + return ( +
{feed.name}
- - - - Audio category - - - + + {currentUser?.moderator && ( + + )} + + {Object.entries(boutForm.errors).map(([key, msg], idx) => ( + + setBoutForm((form) => ({ + ...form, + errors: Object.fromEntries( + Object.entries(form.errors).filter( + ([errorKey, _msg]) => key !== errorKey, + ), + ), + })) + } + > + {msg} + + ))} + { boutEndTime={boutEndTime} setBoutStartTime={setBoutStartTime} setBoutEndTime={setBoutEndTime} - onSpectrogramInit={setSpectrogramControls} + spectrogramControls={spectrogramControls} > @@ -223,10 +277,10 @@ const NewBoutPage: NextPageWithLayout = () => { Zoom - + - + @@ -237,6 +291,15 @@ const NewBoutPage: NextPageWithLayout = () => { flexDirection="column" alignItems="center" minWidth={120} + sx={ + boutForm.errors.startTime + ? { + border: (theme) => + `1px solid ${theme.palette.error.main}`, + borderRadius: 1, + } + : {} + } > Bout start @@ -256,7 +319,9 @@ const NewBoutPage: NextPageWithLayout = () => { + )} + + + + {Object.entries(boutForm.errors).map(([key, msg], idx) => ( + + setBoutForm((form) => ({ + ...form, + errors: Object.fromEntries( + Object.entries(form.errors).filter( + ([errorKey, _msg]) => key !== errorKey, + ), + ), + })) + } + > + {msg} + + ))} + + + + + + + {feedStream && ( + + )} + + + + Zoom + + + + + + + + + + + + `1px solid ${theme.palette.error.main}`, + borderRadius: 1, + } + : {} + } + > + + Bout start + + + + (!boutEndTime || playerTime.current < boutEndTime) && + setBoutStartTime(playerTime.current) + } + title="Set bout start" + > + + + + {boutStartTime && ( + + + setBoutStartTime(undefined)} + title="Clear bout start" + size="small" + > + + + + )} + + + + Bout end + + + + (!boutStartTime || playerTime.current > boutStartTime) && + setBoutEndTime(playerTime.current) + } + title="Set bout end" + > + + + + {boutEndTime && ( + + + setBoutEndTime(undefined)} + title="Clear bout end" + size="small" + > + + + + )} + + + + + + Audio category + + + {boutForm.errors.audioCategory && ( + Required + )} + + + + + + setCurrentTab(value)} + > + } label="Detections" /> + } label="Notifications" /> + + + + + + + ID + Category + Description + Timestamp + Candidate + + + + {detections.map((det, index) => ( + + + {det.id} + + + + + {det.description} + + {formatTimestamp(det.timestamp)} + + + {det?.candidate?.id && ( + + + + )} + + + ))} + + {detections.length < 1 && ( + + + + No detections submitted for{" "} + {format(minDetectionsTime, "hh:mm")} to{" "} + {format(maxDetectionsTime, "hh:mm")} + + + + )} + +
+
+
+
+
+ + ); +} + +function TabPanel(props: { + children?: React.ReactNode; + index: number; + value: number; +}) { + const { children, value, index, ...other } = props; + + return ( + + ); +} diff --git a/ui/src/pages/bouts/[boutId].tsx b/ui/src/pages/bouts/[boutId].tsx new file mode 100644 index 00000000..e69de29b diff --git a/ui/src/pages/bouts/[feedSlug]/new.tsx b/ui/src/pages/bouts/[feedSlug]/new.tsx deleted file mode 100644 index 08b387c3..00000000 --- a/ui/src/pages/bouts/[feedSlug]/new.tsx +++ /dev/null @@ -1,508 +0,0 @@ -import { - ArrowRight, - Clear, - GraphicEq, - Launch, - Notifications, - Start, - ZoomIn, - ZoomOut, -} from "@mui/icons-material"; -import { - Alert, - Button, - Chip, - FormControl, - FormHelperText, - IconButton, - InputLabel, - MenuItem, - Select, - Tab, - Table, - TableBody, - TableCell, - TableHead, - TableRow, - Tabs, -} from "@mui/material"; -import Box from "@mui/material/Box"; -import Typography from "@mui/material/Typography"; -import { - addMinutes, - format, - min, - roundToNearestMinutes, - subDays, - subMinutes, -} from "date-fns"; -import _ from "lodash"; -import Head from "next/head"; -import { useParams, useSearchParams } from "next/navigation"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; - -import SpectrogramTimeline, { - SpectrogramControls, -} from "@/components/Bouts/SpectrogramTimeline"; -import { getSimpleLayout } from "@/components/layouts/SimpleLayout"; -import LoadingSpinner from "@/components/LoadingSpinner"; -import { BoutPlayer, PlayerControls } from "@/components/Player/BoutPlayer"; -import { - AudioCategory, - useCreateBoutMutation, - useDetectionsQuery, - useFeedQuery, - useGetCurrentUserQuery, - useListFeedStreamsQuery, -} from "@/graphql/generated"; -import type { NextPageWithLayout } from "@/pages/_app"; -import { formatTimestamp } from "@/utils/time"; - -const NewBoutPage: NextPageWithLayout = () => { - const { currentUser } = useGetCurrentUserQuery().data ?? {}; - - const targetTime = new Date("2024-12-11 19:55:44.013Z"); - const playerTime = useRef(targetTime); - const setPlayerTime = useCallback( - (time: Date) => (playerTime.current = time), - [], - ); - const [playerControls, setPlayerControls] = useState(); - const spectrogramControls = useRef(); - - const params = useParams<{ feedSlug?: string }>(); - const feedSlug = params?.feedSlug; - const searchParams = useSearchParams(); - const targetAudioCategory = searchParams.get("category"); - const [boutStartTime, setBoutStartTime] = useState(); - const [boutEndTime, setBoutEndTime] = useState(); - const [currentTab, setCurrentTab] = useState(0); - const audioCategories: AudioCategory[] = useMemo( - () => ["ANTHROPHONY", "BIOPHONY", "GEOPHONY"], - [], - ); - const [audioCategory, setAudioCategory] = useState(); - - useEffect(() => { - if ( - targetAudioCategory && - !audioCategory && - audioCategories.includes(_.toUpper(targetAudioCategory) as AudioCategory) - ) { - setAudioCategory(targetAudioCategory.toUpperCase() as AudioCategory); - } - }, [audioCategory, targetAudioCategory, audioCategories]); - - const feedQueryResult = useFeedQuery( - { slug: feedSlug || "" }, - { enabled: !!feedSlug }, - ); - const feed = feedQueryResult.data?.feed; - - // Get feed segments for current time +/- 5 minute buffer - const now = useMemo(() => new Date(), []); - const timeBuffer = 5; // minutes - const targetTimePlusBuffer = roundToNearestMinutes( - min([now, addMinutes(targetTime, timeBuffer)]), - { roundingMethod: "ceil" }, - ); - const targetTimeMinusBuffer = roundToNearestMinutes( - subMinutes(targetTime, timeBuffer), - { roundingMethod: "floor" }, - ); - const targetTimeMinusADay = subDays(targetTime, 1); - // If feed is present, and there's no pre-set time, - // get latest stream and last minutes of segments. - // Set time to end of last segment - const feedStreamQueryResult = useListFeedStreamsQuery( - { - feedId: feed?.id, - fromDateTime: targetTimeMinusBuffer, - toDateTime: targetTimePlusBuffer, - dayBeforeFromDateTime: targetTimeMinusADay, - }, - { enabled: !!feed?.id }, - ); - - // Get detections at current time plus or minus 1 hour - const minDetectionsTime = subMinutes(targetTime, 60); - const maxDetectionsTime = addMinutes(targetTime, 60); - const detectionQueryResult = useDetectionsQuery( - { - feedId: feed?.id, - filter: { - timestamp: { - greaterThanOrEqual: minDetectionsTime, - lessThanOrEqual: maxDetectionsTime, - }, - }, - }, - { enabled: !!feed?.id }, - ); - - const feedStreams = useMemo( - () => feedStreamQueryResult.data?.feedStreams?.results ?? [], - [feedStreamQueryResult], - ); - const feedStream = feedStreams[0]; - const feedSegments = useMemo( - () => feedStreams.flatMap(({ feedSegments }) => feedSegments), - [feedStreams], - ); - - const [boutForm, setBoutForm] = useState<{ - errors: Record; - }>({ - errors: {}, - }); - const createBoutMutation = useCreateBoutMutation({ - onSuccess: ({ createBout: { errors } }) => { - if (errors && errors.length > 0) { - console.error(errors); - setBoutForm((form) => ({ - ...form, - errors: { - ...form.errors, - ...Object.fromEntries( - errors.map(({ code, message }) => [code, message] as const), - ), - }, - })); - } - }, - }); - - if (!feedSlug || feedQueryResult.isLoading) return ; - if (!feed) return

Feed not found

; - - const createBout = () => { - setBoutForm((form) => ({ ...form, errors: {} })); - if (audioCategory && boutStartTime) { - createBoutMutation.mutate({ - feedId: feed.id, - startTime: boutStartTime, - endTime: boutEndTime, - category: audioCategory, - }); - } else { - const errors: Record = {}; - if (!audioCategory) { - errors["audioCategory"] = "Audio category required"; - } - if (!boutStartTime) { - errors["startTime"] = "Bout start time required"; - } - setBoutForm((form) => ({ ...form, errors })); - } - }; - const detections = detectionQueryResult.data?.detections?.results ?? []; - - return ( -
- - New Bout | Orcasound - - -
- - - - New Bout - - {feed.name} - - - {currentUser?.moderator && ( - - )} - - - - {Object.entries(boutForm.errors).map(([key, msg], idx) => ( - - setBoutForm((form) => ({ - ...form, - errors: Object.fromEntries( - Object.entries(form.errors).filter( - ([errorKey, _msg]) => key !== errorKey, - ), - ), - })) - } - > - {msg} - - ))} - - - - - - - {feedStream && ( - - )} - - - - Zoom - - - - - - - - - - - - - `1px solid ${theme.palette.error.main}`, - borderRadius: 1, - } - : {} - } - > - - Bout start - - - - (!boutEndTime || playerTime.current < boutEndTime) && - setBoutStartTime(playerTime.current) - } - title="Set bout start" - > - - - - {boutStartTime && ( - - - setBoutStartTime(undefined)} - title="Clear bout start" - size="small" - > - - - - )} - - - - Bout end - - - - (!boutStartTime || playerTime.current > boutStartTime) && - setBoutEndTime(playerTime.current) - } - title="Set bout end" - > - - - - {boutEndTime && ( - - - setBoutEndTime(undefined)} - title="Clear bout end" - size="small" - > - - - - )} - - - - - - Audio category - - - {boutForm.errors.audioCategory && ( - Required - )} - - - - - - setCurrentTab(value)} - > - } label="Detections" /> - } label="Notifications" /> - - - - - - - ID - Category - Description - Timestamp - Candidate - - - - {detections.map((det, index) => ( - - - {det.id} - - - - - {det.description} - - {formatTimestamp(det.timestamp)} - - - {det?.candidate?.id && ( - - - - )} - - - ))} - - {detections.length < 1 && ( - - - - No detections submitted for{" "} - {format(minDetectionsTime, "hh:mm")} to{" "} - {format(maxDetectionsTime, "hh:mm")} - - - - )} - -
-
-
-
-
-
-
- ); -}; - -function TabPanel(props: { - children?: React.ReactNode; - index: number; - value: number; -}) { - const { children, value, index, ...other } = props; - - return ( - - ); -} - -NewBoutPage.getLayout = getSimpleLayout; - -export default NewBoutPage; diff --git a/ui/src/pages/bouts/new/[feedSlug].tsx b/ui/src/pages/bouts/new/[feedSlug].tsx new file mode 100644 index 00000000..85abd5be --- /dev/null +++ b/ui/src/pages/bouts/new/[feedSlug].tsx @@ -0,0 +1,62 @@ +import _ from "lodash"; +import Head from "next/head"; +import { useParams, useSearchParams } from "next/navigation"; +import { useMemo } from "react"; + +import BoutPage from "@/components/Bouts/BoutPage"; +import { getSimpleLayout } from "@/components/layouts/SimpleLayout"; +import LoadingSpinner from "@/components/LoadingSpinner"; +import { AudioCategory, useFeedQuery } from "@/graphql/generated"; +import type { NextPageWithLayout } from "@/pages/_app"; + +const NewBoutPage: NextPageWithLayout = () => { + const targetTime = new Date("2024-12-11 19:55:44.013Z"); + + const params = useParams<{ feedSlug?: string }>(); + const feedSlug = params?.feedSlug; + const searchParams = useSearchParams(); + const audioCategories: AudioCategory[] = useMemo( + () => ["ANTHROPHONY", "BIOPHONY", "GEOPHONY"], + [], + ); + const categoryParam = searchParams.get("category"); + let targetAudioCategory; + if ( + categoryParam && + audioCategories.includes(_.toUpper(categoryParam) as AudioCategory) + ) { + targetAudioCategory = categoryParam.toUpperCase() as AudioCategory; + } else { + targetAudioCategory = undefined; + } + + const feedQueryResult = useFeedQuery( + { slug: feedSlug || "" }, + { enabled: !!feedSlug }, + ); + const feed = feedQueryResult.data?.feed; + + if (!feedSlug || feedQueryResult.isLoading) return ; + if (!feed) return

Feed not found

; + + return ( +
+ + New Bout | Orcasound + + +
+ +
+
+ ); +}; + +NewBoutPage.getLayout = getSimpleLayout; + +export default NewBoutPage; From 80ccd712e59fa16bdc65639108bc2c976629c1eb Mon Sep 17 00:00:00 2001 From: Skander Mzali Date: Mon, 27 Jan 2025 15:09:32 -0800 Subject: [PATCH 23/40] Create bout show page, allow updating existing bout --- server/lib/orcasite/radio/bout.ex | 28 ++- ui/src/components/Bouts/BoutPage.tsx | 58 ++++- ui/src/graphql/fragments/FeedParts.graphql | 15 ++ ui/src/graphql/generated/index.ts | 248 ++++++++++++++++++-- ui/src/graphql/mutations/updateBout.graphql | 27 +++ ui/src/graphql/queries/getBout.graphql | 12 + ui/src/graphql/queries/getFeed.graphql | 14 +- ui/src/pages/bouts/[boutId].tsx | 44 ++++ 8 files changed, 404 insertions(+), 42 deletions(-) create mode 100644 ui/src/graphql/fragments/FeedParts.graphql create mode 100644 ui/src/graphql/mutations/updateBout.graphql create mode 100644 ui/src/graphql/queries/getBout.graphql diff --git a/server/lib/orcasite/radio/bout.ex b/server/lib/orcasite/radio/bout.ex index 60e379c4..29452642 100644 --- a/server/lib/orcasite/radio/bout.ex +++ b/server/lib/orcasite/radio/bout.ex @@ -36,11 +36,15 @@ defmodule Orcasite.Radio.Bout do relationships do belongs_to :created_by_user, Orcasite.Accounts.User - belongs_to :feed, Orcasite.Radio.Feed + belongs_to :feed, Orcasite.Radio.Feed do + public? true + end + has_many :bout_feed_streams, Orcasite.Radio.BoutFeedStream many_to_many :feed_streams, Orcasite.Radio.FeedStream do through Orcasite.Radio.BoutFeedStream + public? true end end @@ -63,7 +67,7 @@ defmodule Orcasite.Radio.Bout do end actions do - defaults [:read, :update, :destroy] + defaults [:read, :destroy] read :index do pagination do @@ -113,17 +117,37 @@ defmodule Orcasite.Radio.Bout do changeset end end + + update :update do + primary? true + accept [:category, :start_time, :end_time] + + change fn changeset, _ -> + end_time = Ash.Changeset.get_argument_or_attribute(changeset, :end_time) + start_time = Ash.Changeset.get_argument_or_attribute(changeset, :start_time) + + if start_time && end_time do + changeset + |> Ash.Changeset.change_attribute(:duration, DateTime.diff(end_time, start_time, :millisecond) / 1000) + else + changeset + end + end + end end graphql do type :bout + attribute_types [feed_id: :id, feed_stream_id: :id] queries do list :bouts, :index + get :bout, :read end mutations do create :create_bout, :create + update :update_bout, :update end end end diff --git a/ui/src/components/Bouts/BoutPage.tsx b/ui/src/components/Bouts/BoutPage.tsx index b2c2301c..a26661fc 100644 --- a/ui/src/components/Bouts/BoutPage.tsx +++ b/ui/src/components/Bouts/BoutPage.tsx @@ -45,11 +45,13 @@ import SpectrogramTimeline, { import { BoutPlayer, PlayerControls } from "@/components/Player/BoutPlayer"; import { AudioCategory, + BoutQuery, FeedQuery, useCreateBoutMutation, useDetectionsQuery, useGetCurrentUserQuery, useListFeedStreamsQuery, + useUpdateBoutMutation, } from "@/graphql/generated"; import { formatTimestamp } from "@/utils/time"; @@ -64,10 +66,11 @@ export default function BoutPage({ feed: FeedQuery["feed"]; targetAudioCategory?: AudioCategory; targetTime?: Date; - // bout?: BoutQuery["bout"]; + bout?: BoutQuery["bout"]; }) { const now = useMemo(() => new Date(), []); - targetTime = targetTime ?? now; + targetTime = + targetTime ?? (bout?.startTime && new Date(bout.startTime)) ?? now; const { currentUser } = useGetCurrentUserQuery().data ?? {}; const playerTime = useRef(targetTime); @@ -78,15 +81,19 @@ export default function BoutPage({ const [playerControls, setPlayerControls] = useState(); const spectrogramControls = useRef(); - const [boutStartTime, setBoutStartTime] = useState(); - const [boutEndTime, setBoutEndTime] = useState(); + const [boutStartTime, setBoutStartTime] = useState( + bout?.startTime && new Date(bout.startTime), + ); + const [boutEndTime, setBoutEndTime] = useState( + (bout?.endTime && new Date(bout.endTime)) ?? undefined, + ); const [currentTab, setCurrentTab] = useState(0); const audioCategories: AudioCategory[] = useMemo( () => ["ANTHROPHONY", "BIOPHONY", "GEOPHONY"], [], ); const [audioCategory, setAudioCategory] = useState( - targetAudioCategory, + targetAudioCategory ?? bout?.category, ); const timeBuffer = 5; // minutes @@ -159,15 +166,40 @@ export default function BoutPage({ } }, }); + const updateBoutMutation = useUpdateBoutMutation({ + onSuccess: ({ updateBout: { errors } }) => { + if (errors && errors.length > 0) { + console.error(errors); + setBoutForm((form) => ({ + ...form, + errors: { + ...form.errors, + ...Object.fromEntries( + errors.map(({ code, message }) => [code, message] as const), + ), + }, + })); + } + }, + }); const saveBout = () => { setBoutForm((form) => ({ ...form, errors: {} })); if (audioCategory && boutStartTime) { - createBoutMutation.mutate({ - feedId: feed.id, - startTime: boutStartTime, - endTime: boutEndTime, - category: audioCategory, - }); + if (isNew) { + createBoutMutation.mutate({ + feedId: feed.id, + startTime: boutStartTime, + endTime: boutEndTime, + category: audioCategory, + }); + } else if (bout) { + updateBoutMutation.mutate({ + id: bout.id, + startTime: boutStartTime, + endTime: boutEndTime, + category: audioCategory, + }); + } } else { const errors: Record = {}; if (!audioCategory) { @@ -190,14 +222,14 @@ export default function BoutPage({ > - New Bout + Bout {feed.name} {currentUser?.moderator && ( )} diff --git a/ui/src/graphql/fragments/FeedParts.graphql b/ui/src/graphql/fragments/FeedParts.graphql new file mode 100644 index 00000000..db7765b4 --- /dev/null +++ b/ui/src/graphql/fragments/FeedParts.graphql @@ -0,0 +1,15 @@ +fragment FeedParts on Feed { + id + name + slug + nodeName + latLng { + lat + lng + } + introHtml + thumbUrl + imageUrl + mapUrl + bucket +} diff --git a/ui/src/graphql/generated/index.ts b/ui/src/graphql/generated/index.ts index c71eac7e..a2ef8206 100644 --- a/ui/src/graphql/generated/index.ts +++ b/ui/src/graphql/generated/index.ts @@ -292,10 +292,20 @@ export type Bout = { category: AudioCategory; duration?: Maybe; endTime?: Maybe; + feed?: Maybe; + feedId?: Maybe; + feedStreams: Array; id: Scalars["ID"]["output"]; startTime: Scalars["DateTime"]["output"]; }; +export type BoutFeedStreamsArgs = { + filter?: InputMaybe; + limit?: InputMaybe; + offset?: InputMaybe; + sort?: InputMaybe>>; +}; + /** Join table between Bout and FeedStream */ export type BoutFeedStream = { __typename?: "BoutFeedStream"; @@ -360,6 +370,10 @@ export type BoutFilterEndTime = { notEq?: InputMaybe; }; +export type BoutFilterFeedId = { + isNil?: InputMaybe; +}; + export type BoutFilterId = { isNil?: InputMaybe; }; @@ -369,6 +383,9 @@ export type BoutFilterInput = { category?: InputMaybe; duration?: InputMaybe; endTime?: InputMaybe; + feed?: InputMaybe; + feedId?: InputMaybe; + feedStreams?: InputMaybe; id?: InputMaybe; not?: InputMaybe>; or?: InputMaybe>; @@ -390,6 +407,7 @@ export type BoutSortField = | "CATEGORY" | "DURATION" | "END_TIME" + | "FEED_ID" | "ID" | "START_TIME"; @@ -1759,6 +1777,7 @@ export type RootMutationType = { signInWithPassword?: Maybe; signOut?: Maybe; submitDetection: SubmitDetectionResult; + updateBout: UpdateBoutResult; }; export type RootMutationTypeCancelCandidateNotificationsArgs = { @@ -1808,9 +1827,15 @@ export type RootMutationTypeSubmitDetectionArgs = { input: SubmitDetectionInput; }; +export type RootMutationTypeUpdateBoutArgs = { + id: Scalars["ID"]["input"]; + input?: InputMaybe; +}; + export type RootQueryType = { __typename?: "RootQueryType"; audioImages?: Maybe; + bout?: Maybe; bouts?: Maybe; candidate?: Maybe; candidates?: Maybe; @@ -1832,6 +1857,10 @@ export type RootQueryTypeAudioImagesArgs = { sort?: InputMaybe>>; }; +export type RootQueryTypeBoutArgs = { + id: Scalars["ID"]["input"]; +}; + export type RootQueryTypeBoutsArgs = { feedId?: InputMaybe; filter?: InputMaybe; @@ -1953,6 +1982,21 @@ export type SubmitDetectionResult = { result?: Maybe; }; +export type UpdateBoutInput = { + category?: InputMaybe; + endTime?: InputMaybe; + startTime?: InputMaybe; +}; + +/** The result of the :update_bout mutation */ +export type UpdateBoutResult = { + __typename?: "UpdateBoutResult"; + /** Any errors generated, if the mutation failed */ + errors: Array; + /** The successful result of the mutation */ + result?: Maybe; +}; + export type User = { __typename?: "User"; admin: Scalars["Boolean"]["output"]; @@ -2074,6 +2118,20 @@ export type AudioImagePartsFragment = { imageType?: ImageType | null; }; +export type FeedPartsFragment = { + __typename?: "Feed"; + id: string; + name: string; + slug: string; + nodeName: string; + introHtml?: string | null; + thumbUrl?: string | null; + imageUrl?: string | null; + mapUrl?: string | null; + bucket: string; + latLng: { __typename?: "LatLng"; lat: number; lng: number }; +}; + export type FeedSegmentPartsFragment = { __typename?: "FeedSegment"; id: string; @@ -2371,6 +2429,65 @@ export type SubmitDetectionMutation = { }; }; +export type UpdateBoutMutationVariables = Exact<{ + id: Scalars["ID"]["input"]; + startTime: Scalars["DateTime"]["input"]; + endTime?: InputMaybe; + category: AudioCategory; +}>; + +export type UpdateBoutMutation = { + __typename?: "RootMutationType"; + updateBout: { + __typename?: "UpdateBoutResult"; + result?: { + __typename?: "Bout"; + id: string; + category: AudioCategory; + duration?: number | null; + endTime?: Date | null; + startTime: Date; + } | null; + errors: Array<{ + __typename?: "MutationError"; + code?: string | null; + fields?: Array | null; + message?: string | null; + shortMessage?: string | null; + vars?: { [key: string]: any } | null; + }>; + }; +}; + +export type BoutQueryVariables = Exact<{ + id: Scalars["ID"]["input"]; +}>; + +export type BoutQuery = { + __typename?: "RootQueryType"; + bout?: { + __typename?: "Bout"; + id: string; + category: AudioCategory; + duration?: number | null; + startTime: Date; + endTime?: Date | null; + feed?: { + __typename?: "Feed"; + id: string; + name: string; + slug: string; + nodeName: string; + introHtml?: string | null; + thumbUrl?: string | null; + imageUrl?: string | null; + mapUrl?: string | null; + bucket: string; + latLng: { __typename?: "LatLng"; lat: number; lng: number }; + } | null; + } | null; +}; + export type CandidateQueryVariables = Exact<{ id: Scalars["ID"]["input"]; }>; @@ -2635,6 +2752,23 @@ export const AudioImagePartsFragmentDoc = ` imageType } `; +export const FeedPartsFragmentDoc = ` + fragment FeedParts on Feed { + id + name + slug + nodeName + latLng { + lat + lng + } + introHtml + thumbUrl + imageUrl + mapUrl + bucket +} + `; export const FeedSegmentPartsFragmentDoc = ` fragment FeedSegmentParts on FeedSegment { id @@ -3291,6 +3425,104 @@ useSubmitDetectionMutation.fetcher = ( options, ); +export const UpdateBoutDocument = ` + mutation updateBout($id: ID!, $startTime: DateTime!, $endTime: DateTime, $category: AudioCategory!) { + updateBout( + id: $id + input: {category: $category, startTime: $startTime, endTime: $endTime} + ) { + result { + id + category + duration + endTime + startTime + endTime + } + errors { + code + fields + message + shortMessage + vars + } + } +} + `; + +export const useUpdateBoutMutation = ( + options?: UseMutationOptions< + UpdateBoutMutation, + TError, + UpdateBoutMutationVariables, + TContext + >, +) => { + return useMutation< + UpdateBoutMutation, + TError, + UpdateBoutMutationVariables, + TContext + >({ + mutationKey: ["updateBout"], + mutationFn: (variables?: UpdateBoutMutationVariables) => + fetcher( + UpdateBoutDocument, + variables, + )(), + ...options, + }); +}; + +useUpdateBoutMutation.getKey = () => ["updateBout"]; + +useUpdateBoutMutation.fetcher = ( + variables: UpdateBoutMutationVariables, + options?: RequestInit["headers"], +) => + fetcher( + UpdateBoutDocument, + variables, + options, + ); + +export const BoutDocument = ` + query bout($id: ID!) { + bout(id: $id) { + id + category + duration + startTime + endTime + feed { + ...FeedParts + } + } +} + ${FeedPartsFragmentDoc}`; + +export const useBoutQuery = ( + variables: BoutQueryVariables, + options?: Omit, "queryKey"> & { + queryKey?: UseQueryOptions["queryKey"]; + }, +) => { + return useQuery({ + queryKey: ["bout", variables], + queryFn: fetcher(BoutDocument, variables), + ...options, + }); +}; + +useBoutQuery.document = BoutDocument; + +useBoutQuery.getKey = (variables: BoutQueryVariables) => ["bout", variables]; + +useBoutQuery.fetcher = ( + variables: BoutQueryVariables, + options?: RequestInit["headers"], +) => fetcher(BoutDocument, variables, options); + export const CandidateDocument = ` query candidate($id: ID!) { candidate(id: $id) { @@ -3411,22 +3643,10 @@ useGetCurrentUserQuery.fetcher = ( export const FeedDocument = ` query feed($slug: String!) { feed(slug: $slug) { - id - name - slug - nodeName - latLng { - lat - lng - } - introHtml - thumbUrl - imageUrl - mapUrl - bucket + ...FeedParts } } - `; + ${FeedPartsFragmentDoc}`; export const useFeedQuery = ( variables: FeedQueryVariables, diff --git a/ui/src/graphql/mutations/updateBout.graphql b/ui/src/graphql/mutations/updateBout.graphql new file mode 100644 index 00000000..d2a06f1c --- /dev/null +++ b/ui/src/graphql/mutations/updateBout.graphql @@ -0,0 +1,27 @@ +mutation updateBout( + $id: ID! + $startTime: DateTime! + $endTime: DateTime + $category: AudioCategory! +) { + updateBout( + id: $id + input: { category: $category, startTime: $startTime, endTime: $endTime } + ) { + result { + id + category + duration + endTime + startTime + endTime + } + errors { + code + fields + message + shortMessage + vars + } + } +} diff --git a/ui/src/graphql/queries/getBout.graphql b/ui/src/graphql/queries/getBout.graphql new file mode 100644 index 00000000..3765ba05 --- /dev/null +++ b/ui/src/graphql/queries/getBout.graphql @@ -0,0 +1,12 @@ +query bout($id: ID!) { + bout(id: $id) { + id + category + duration + startTime + endTime + feed { + ...FeedParts + } + } +} diff --git a/ui/src/graphql/queries/getFeed.graphql b/ui/src/graphql/queries/getFeed.graphql index 93a9d36a..e90ef0cb 100644 --- a/ui/src/graphql/queries/getFeed.graphql +++ b/ui/src/graphql/queries/getFeed.graphql @@ -1,17 +1,5 @@ query feed($slug: String!) { feed(slug: $slug) { - id - name - slug - nodeName - latLng { - lat - lng - } - introHtml - thumbUrl - imageUrl - mapUrl - bucket + ...FeedParts } } diff --git a/ui/src/pages/bouts/[boutId].tsx b/ui/src/pages/bouts/[boutId].tsx index e69de29b..c1362109 100644 --- a/ui/src/pages/bouts/[boutId].tsx +++ b/ui/src/pages/bouts/[boutId].tsx @@ -0,0 +1,44 @@ +import Head from "next/head"; +import { useParams } from "next/navigation"; + +import BoutPage from "@/components/Bouts/BoutPage"; +import { getSimpleLayout } from "@/components/layouts/SimpleLayout"; +import LoadingSpinner from "@/components/LoadingSpinner"; +import { useBoutQuery } from "@/graphql/generated"; +import type { NextPageWithLayout } from "@/pages/_app"; + +const BoutShowPage: NextPageWithLayout = () => { + const targetTime = new Date("2024-12-11 19:55:44.013Z"); + + const params = useParams<{ boutId?: string }>(); + const boutId = params?.boutId; + + const boutQueryResult = useBoutQuery( + { id: boutId || "" }, + { enabled: !!boutId }, + ); + + const bout = boutQueryResult.data?.bout; + + if (!boutId || boutQueryResult.isLoading) return ; + if (!bout) return

Bout not found

; + + const feed = bout.feed; + if (!feed) return

Feed not found

; + + return ( +
+ + Bout | Orcasound + + +
+ +
+
+ ); +}; + +BoutShowPage.getLayout = getSimpleLayout; + +export default BoutShowPage; From ea57e95d7ebc9b3c5dd7109a85170e1c6c2eee70 Mon Sep 17 00:00:00 2001 From: Skander Mzali Date: Mon, 27 Jan 2025 15:17:41 -0800 Subject: [PATCH 24/40] Add success alert on bout save --- ui/src/components/Bouts/BoutPage.tsx | 31 +++++++++++++++++++++------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/ui/src/components/Bouts/BoutPage.tsx b/ui/src/components/Bouts/BoutPage.tsx index a26661fc..a306c1ec 100644 --- a/ui/src/components/Bouts/BoutPage.tsx +++ b/ui/src/components/Bouts/BoutPage.tsx @@ -12,6 +12,7 @@ import { Alert, Button, Chip, + Fade, FormControl, FormHelperText, IconButton, @@ -72,6 +73,8 @@ export default function BoutPage({ targetTime = targetTime ?? (bout?.startTime && new Date(bout.startTime)) ?? now; + const [boutSaved, setBoutSaved] = useState(false); + const { currentUser } = useGetCurrentUserQuery().data ?? {}; const playerTime = useRef(targetTime); const setPlayerTime = useCallback( @@ -179,6 +182,11 @@ export default function BoutPage({ ), }, })); + } else { + setBoutSaved(true); + setTimeout(() => { + setBoutSaved(false); + }, 5000); } }, }); @@ -226,7 +234,12 @@ export default function BoutPage({ {feed.name}
- + + + + Bout saved + + {currentUser?.moderator && ( - setBoutStartTime(undefined)} - title="Clear bout start" - size="small" - > - - + {isNew && ( + setBoutStartTime(undefined)} + title="Clear bout start" + size="small" + > + + + )} )} From 4fb4babd036ea525774d332445d85e7f6c92dd80 Mon Sep 17 00:00:00 2001 From: Skander Mzali Date: Mon, 27 Jan 2025 15:47:48 -0800 Subject: [PATCH 25/40] Add icons for audio category selection and read-only category for non-mods --- ui/src/components/Bouts/BoutPage.tsx | 124 ++++++++++++++++++++------- 1 file changed, 95 insertions(+), 29 deletions(-) diff --git a/ui/src/components/Bouts/BoutPage.tsx b/ui/src/components/Bouts/BoutPage.tsx index a306c1ec..9081f8c7 100644 --- a/ui/src/components/Bouts/BoutPage.tsx +++ b/ui/src/components/Bouts/BoutPage.tsx @@ -17,6 +17,7 @@ import { FormHelperText, IconButton, InputLabel, + ListItemIcon, MenuItem, Select, Tab, @@ -38,6 +39,7 @@ import { subMinutes, } from "date-fns"; import _ from "lodash"; +import Image from "next/legacy/image"; import { useCallback, useMemo, useRef, useState } from "react"; import SpectrogramTimeline, { @@ -54,6 +56,9 @@ import { useListFeedStreamsQuery, useUpdateBoutMutation, } from "@/graphql/generated"; +import vesselIconImage from "@/public/icons/vessel-purple.svg"; +import wavesIconImage from "@/public/icons/water-waves-blue.svg"; +import whaleFlukeIconImage from "@/public/icons/whale-fluke-gray.svg"; import { formatTimestamp } from "@/utils/time"; export default function BoutPage({ @@ -404,35 +409,57 @@ export default function BoutPage({ )}
- - - - Audio category - - - {boutForm.errors.audioCategory && ( - Required - )} - - - + + Category + + + {boutForm.errors.audioCategory && ( + Required + )} + +
+ )} + {!currentUser?.moderator && bout?.category && ( + + + Category + + + + {_.startCase(_.toLower(bout.category))} + + + )} setCurrentTab(value)} > } label="Detections" /> - } label="Notifications" /> + {currentUser?.moderator && ( + } label="Notifications" /> + )} @@ -503,6 +532,43 @@ export default function BoutPage({ ); } +function CategoryIcon({ + audioCategory, + size, +}: { + audioCategory: AudioCategory; + size?: number; +}) { + size = size ?? 15; + if (audioCategory === "BIOPHONY") + return ( + Whale fluke icon + ); + if (audioCategory === "ANTHROPHONY") + return ( + Vessel icon + ); + if (audioCategory === "GEOPHONY") + return ( + Waves icon + ); +} + function TabPanel(props: { children?: React.ReactNode; index: number; From 3c3bc51b80279f3e977468f53aae17e1f1e9e7e4 Mon Sep 17 00:00:00 2001 From: Skander Mzali Date: Tue, 28 Jan 2025 13:13:02 -0800 Subject: [PATCH 26/40] Add BoutParts gql fragment --- ui/src/graphql/fragments/BoutParts.graphql | 8 ++++ ui/src/graphql/generated/index.ts | 48 ++++++++++++--------- ui/src/graphql/mutations/createBout.graphql | 7 +-- ui/src/graphql/mutations/updateBout.graphql | 7 +-- ui/src/graphql/queries/getBout.graphql | 6 +-- 5 files changed, 38 insertions(+), 38 deletions(-) create mode 100644 ui/src/graphql/fragments/BoutParts.graphql diff --git a/ui/src/graphql/fragments/BoutParts.graphql b/ui/src/graphql/fragments/BoutParts.graphql new file mode 100644 index 00000000..8fae373a --- /dev/null +++ b/ui/src/graphql/fragments/BoutParts.graphql @@ -0,0 +1,8 @@ +fragment BoutParts on Bout { + id + category + duration + endTime + startTime + endTime +} diff --git a/ui/src/graphql/generated/index.ts b/ui/src/graphql/generated/index.ts index a2ef8206..5312cdf9 100644 --- a/ui/src/graphql/generated/index.ts +++ b/ui/src/graphql/generated/index.ts @@ -2118,6 +2118,15 @@ export type AudioImagePartsFragment = { imageType?: ImageType | null; }; +export type BoutPartsFragment = { + __typename?: "Bout"; + id: string; + category: AudioCategory; + duration?: number | null; + endTime?: Date | null; + startTime: Date; +}; + export type FeedPartsFragment = { __typename?: "Feed"; id: string; @@ -2470,8 +2479,8 @@ export type BoutQuery = { id: string; category: AudioCategory; duration?: number | null; - startTime: Date; endTime?: Date | null; + startTime: Date; feed?: { __typename?: "Feed"; id: string; @@ -2752,6 +2761,16 @@ export const AudioImagePartsFragmentDoc = ` imageType } `; +export const BoutPartsFragmentDoc = ` + fragment BoutParts on Bout { + id + category + duration + endTime + startTime + endTime +} + `; export const FeedPartsFragmentDoc = ` fragment FeedParts on Feed { id @@ -2925,12 +2944,7 @@ export const CreateBoutDocument = ` input: {feedId: $feedId, category: $category, startTime: $startTime, endTime: $endTime} ) { result { - id - category - duration - endTime - startTime - endTime + ...BoutParts } errors { code @@ -2941,7 +2955,7 @@ export const CreateBoutDocument = ` } } } - `; + ${BoutPartsFragmentDoc}`; export const useCreateBoutMutation = ( options?: UseMutationOptions< @@ -3432,12 +3446,7 @@ export const UpdateBoutDocument = ` input: {category: $category, startTime: $startTime, endTime: $endTime} ) { result { - id - category - duration - endTime - startTime - endTime + ...BoutParts } errors { code @@ -3448,7 +3457,7 @@ export const UpdateBoutDocument = ` } } } - `; + ${BoutPartsFragmentDoc}`; export const useUpdateBoutMutation = ( options?: UseMutationOptions< @@ -3489,17 +3498,14 @@ useUpdateBoutMutation.fetcher = ( export const BoutDocument = ` query bout($id: ID!) { bout(id: $id) { - id - category - duration - startTime - endTime + ...BoutParts feed { ...FeedParts } } } - ${FeedPartsFragmentDoc}`; + ${BoutPartsFragmentDoc} +${FeedPartsFragmentDoc}`; export const useBoutQuery = ( variables: BoutQueryVariables, diff --git a/ui/src/graphql/mutations/createBout.graphql b/ui/src/graphql/mutations/createBout.graphql index b84c8c40..18b6d7ca 100644 --- a/ui/src/graphql/mutations/createBout.graphql +++ b/ui/src/graphql/mutations/createBout.graphql @@ -13,12 +13,7 @@ mutation createBout( } ) { result { - id - category - duration - endTime - startTime - endTime + ...BoutParts } errors { code diff --git a/ui/src/graphql/mutations/updateBout.graphql b/ui/src/graphql/mutations/updateBout.graphql index d2a06f1c..92f638fe 100644 --- a/ui/src/graphql/mutations/updateBout.graphql +++ b/ui/src/graphql/mutations/updateBout.graphql @@ -9,12 +9,7 @@ mutation updateBout( input: { category: $category, startTime: $startTime, endTime: $endTime } ) { result { - id - category - duration - endTime - startTime - endTime + ...BoutParts } errors { code diff --git a/ui/src/graphql/queries/getBout.graphql b/ui/src/graphql/queries/getBout.graphql index 3765ba05..c2ee2211 100644 --- a/ui/src/graphql/queries/getBout.graphql +++ b/ui/src/graphql/queries/getBout.graphql @@ -1,10 +1,6 @@ query bout($id: ID!) { bout(id: $id) { - id - category - duration - startTime - endTime + ...BoutParts feed { ...FeedParts } From f158ea3ccd1e9b06c1b3fbce7f0f2c3ddbd66e86 Mon Sep 17 00:00:00 2001 From: Skander Mzali Date: Tue, 28 Jan 2025 13:20:49 -0800 Subject: [PATCH 27/40] Fix unexported type --- ui/src/components/Bouts/BoutPage.tsx | 10 ++++++++-- ui/src/components/Bouts/SpectrogramTimeline.tsx | 2 +- ui/src/pages/bouts/new/[feedSlug].tsx | 4 ++-- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/ui/src/components/Bouts/BoutPage.tsx b/ui/src/components/Bouts/BoutPage.tsx index 9081f8c7..e878abe5 100644 --- a/ui/src/components/Bouts/BoutPage.tsx +++ b/ui/src/components/Bouts/BoutPage.tsx @@ -104,7 +104,7 @@ export default function BoutPage({ targetAudioCategory ?? bout?.category, ); - const timeBuffer = 5; // minutes + const timeBuffer = 15; // minutes const targetTimePlusBuffer = roundToNearestMinutes( min([targetTime, addMinutes(targetTime, timeBuffer)]), { roundingMethod: "ceil" }, @@ -415,7 +415,13 @@ export default function BoutPage({ sx={{ width: "100%" }} {...(boutForm.errors.audioCategory ? { error: true } : {})} > - + Category + {list?.map((el) => ( + + {el.label} + + ))} + + + + ); +} diff --git a/ui/src/components/FeedList.tsx b/ui/src/components/FeedList.tsx new file mode 100644 index 00000000..8b747b71 --- /dev/null +++ b/ui/src/components/FeedList.tsx @@ -0,0 +1,52 @@ +import { Container, Stack, Typography } from "@mui/material"; +import { dehydrate, QueryClient } from "@tanstack/react-query"; +import { useMemo } from "react"; + +import FeedCard from "@/components/FeedCard"; +import { useFeedsQuery } from "@/graphql/generated"; + +const FeedList = () => { + const feedsQueryResult = useFeedsQuery(); + + // Sort feeds by high latitude to low (to match the order on the map) + const sortedFeeds = useMemo( + () => + feedsQueryResult.data?.feeds.sort((a, b) => b.latLng.lat - a.latLng.lat), + [feedsQueryResult.data], + ); + + if (!sortedFeeds) return null; + + return ( + + + Listen live + + + Select a location to start listening live + + + {sortedFeeds.map((feed) => ( + + ))} + + + ); +}; + +export async function getStaticProps() { + const queryClient = new QueryClient(); + + await queryClient.prefetchQuery({ + queryKey: useFeedsQuery.getKey(), + queryFn: useFeedsQuery.fetcher(), + }); + + return { + props: { + dehydratedState: dehydrate(queryClient), + }, + }; +} + +export default FeedList; diff --git a/ui/src/components/Header.tsx b/ui/src/components/Header.tsx index 569558bb..5931384d 100644 --- a/ui/src/components/Header.tsx +++ b/ui/src/components/Header.tsx @@ -35,7 +35,8 @@ export default function Header({ }) { return ( theme.zIndex.drawer + 1, diff --git a/ui/src/components/PlayBar.tsx b/ui/src/components/PlayBar.tsx new file mode 100644 index 00000000..967bd06a --- /dev/null +++ b/ui/src/components/PlayBar.tsx @@ -0,0 +1,365 @@ +import { + Close, + Feedback, + Home, + Menu, + Notifications, +} from "@mui/icons-material"; +import { + AppBar, + Box, + Button, + Divider, + Drawer, + IconButton, + List, + ListItem, + ListItemButton, + ListItemIcon, + ListItemText, + Toolbar, + Typography, +} from "@mui/material"; +import Image from "next/image"; +import { useEffect, useState } from "react"; + +import Link from "@/components/Link"; +import { useFeedsQuery } from "@/graphql/generated"; +import { Candidate } from "@/pages/moderator/candidates"; +import wordmark from "@/public/wordmark/wordmark-white.svg"; +import { displayDesktopOnly, displayMobileOnly } from "@/styles/responsive"; +import { analytics } from "@/utils/analytics"; + +import { CandidateCardAIPlayer } from "./Player/CandidateCardAIPlayer"; +import { CandidateCardPlayer } from "./Player/CandidateCardPlayer"; + +export default function PlayBar({ candidate }: { candidate: Candidate }) { + // get hydrophone feed list + const feedsQueryResult = useFeedsQuery(); + const feedsData = feedsQueryResult.data?.feeds ?? []; + + const [playerProps, setPlayerProps] = useState({ + feed: feedsData[0], + timestamp: 0, + startOffset: 0, + endOffset: 0, + audioUri: "", + }); + + useEffect(() => { + const candidateArray = candidate.array; + if (candidateArray) { + const firstDetection = candidateArray[candidateArray.length - 1]; + const lastDetection = candidateArray[0]; + const feed = feedsData.find((feed) => feed.id === firstDetection.feedId); + + const startTimestamp = Math.min( + ...candidateArray.map((d) => +d.playlistTimestamp), + ); + + const offsetPadding = 15; + const minOffset = Math.min(...candidateArray.map((d) => +d.playerOffset)); + + // const maxOffset = Math.max(...candidateArray.map((d) => +d.playerOffset)); + // instead, ensure that the last offset is still in the same playlist -- future iteration may pull a second playlist if needed + const firstPlaylist = candidateArray.filter( + (d) => +d.playlistTimestamp === startTimestamp, + ); + + const maxOffset = Math.max(...firstPlaylist.map((d) => +d.playerOffset)); + const startOffset = Math.max(0, minOffset - offsetPadding); + const endOffset = maxOffset + offsetPadding; + + feed && + setPlayerProps({ + feed: feed ? feed : feedsData[0], + timestamp: startTimestamp, + startOffset: startOffset, + endOffset: endOffset, + audioUri: "", + }); + + lastDetection.audioUri && + setPlayerProps({ + ...playerProps, + timestamp: startTimestamp, + audioUri: lastDetection.audioUri, + }); + } + }, [candidate]); + + return ( + theme.zIndex.drawer + 1, + bottom: 0, + top: "auto", + height: "100px", + }} + > + + {/*
{JSON.stringify(candidate)}
*/} + {candidate.array && playerProps.feed ? ( + <> + {`${playerProps.timestamp}`} + + + ) : candidate.array && playerProps.audioUri.length ? ( + + ) : ( + "No recordings loaded" + )} +
+
+ ); +} + +function Mobile({ + window, + onBrandClick, +}: { + window?: () => Window; + onBrandClick?: () => void; +}) { + const drawerWidth = "100%"; + const [menuIsOpen, setMenuOpen] = useState(false); + + const handleMenuToggle = () => { + setMenuOpen(!menuIsOpen); + }; + + const container = + window !== undefined ? () => window().document.body : undefined; + + const navItems = [ + { + label: "About us", + url: "https://www.orcasound.net/", + ItemIcon: Home, + onClick: () => analytics.nav.aboutTabClicked(), + }, + { + label: "Get notified", + url: "https://docs.google.com/forms/d/1oYSTa3QeAAG-G_eTxjabrXd264zVARId9tp2iBRWpFs/edit", + ItemIcon: Notifications, + onClick: () => analytics.nav.notificationsClicked(), + }, + { + label: "Send feedback", + url: "https://forms.gle/wKpAnxzUh9a5LMfd7", + ItemIcon: Feedback, + onClick: () => analytics.nav.feedbackTabClicked(), + }, + ]; + + return ( + + + + {menuIsOpen ? : } + + + + + + ); +} + +function Desktop() { + const pages = [ + { + label: "About us", + url: "https://www.orcasound.net/", + onClick: () => analytics.nav.aboutTabClicked(), + }, + { + label: "Send feedback", + url: "https://forms.gle/wKpAnxzUh9a5LMfd7", + onClick: () => analytics.nav.feedbackTabClicked(), + }, + ]; + return ( + + + + + {pages.map((page) => ( + + ))} + analytics.nav.notificationsClicked()} + > + + + + + + ); +} + +function Brand({ onClick }: { onClick?: () => void }) { + return ( + + { + if (onClick) onClick(); + analytics.nav.logoClicked(); + }} + > + Orcasound + + + ); +} diff --git a/ui/src/components/Player/CandidateCardAIPlayer.tsx b/ui/src/components/Player/CandidateCardAIPlayer.tsx new file mode 100644 index 00000000..32579f41 --- /dev/null +++ b/ui/src/components/Player/CandidateCardAIPlayer.tsx @@ -0,0 +1,263 @@ +import "videojs-offset"; + +import { Box, Slider, Typography } from "@mui/material"; +import dynamic from "next/dynamic"; +import { useCallback, useMemo, useRef, useState } from "react"; + +import { useData } from "@/context/DataContext"; +import { Candidate } from "@/pages/moderator/candidates"; +import { mobileOnly } from "@/styles/responsive"; + +import { type PlayerStatus } from "./Player"; +import PlayPauseButton from "./PlayPauseButton"; +import { type VideoJSPlayer } from "./VideoJS"; + +// dynamically import VideoJS to speed up initial page load + +const VideoJS = dynamic(() => import("./VideoJS")); + +export function CandidateCardAIPlayer({ + // feed, + marks, + // timestamp, + // startOffset, + // endOffset, + audioUri, + onAudioPlay, + changeListState, + index, + command, + onPlayerInit, + onPlay, + onPlayerEnd, + candidate, +}: { + // feed: Pick; + marks?: { label: string; value: number }[]; + // timestamp: number; + // startOffset: number; + // endOffset: number; + audioUri: string; + onAudioPlay?: () => void; + changeListState?: (value: number, status: string) => void; + index?: number; + command?: string; + onPlayerInit?: (player: VideoJSPlayer) => void; + onPlay?: () => void; + onPlayerEnd?: () => void; + candidate?: Candidate; +}) { + // special to the AI player + const startOffset = 0; + + const [playerStatus, setPlayerStatus] = useState("idle"); + const playerRef = useRef(null); + const [playerTime, setPlayerTime] = useState(startOffset); + const { setNowPlaying } = useData(); + + // special to the AI player + const [endOffset, setEndOffset] = useState(58); + + // const sliderMax = endOffset - startOffset; + // const sliderValue = playerTime - startOffset; + + // const hlsURI = getHlsURI(feed.bucket, feed.nodeName, timestamp); + + const playerOptions = useMemo( + () => ({ + autoplay: false, + // flash: { + // hls: { + // overrideNative: true, + // }, + // }, + // html5: { + // hls: { + // overrideNative: true, + // }, + // }, + sources: [ + { + // If hlsURI isn't set, use a dummy URI to trigger an error + // The dummy URI doesn't actually exist, it should return 404 + // This is the only way to get videojs to throw an error, otherwise + // it just won't initialize (if src is undefined/null/empty)) + src: audioUri, + type: "audio/wav", + // type: "application/x-mpegurl", + }, + ], + }), + [audioUri], + ); + + const handleReady = useCallback((player: VideoJSPlayer) => { + playerRef.current = player; + + onPlayerInit && onPlayerInit(player); + player.on("playing", () => { + setPlayerStatus("playing"); + // const currentTime = player.currentTime() ?? 0; + // if (currentTime < startOffset || currentTime > endOffset) { + // player.currentTime(startOffset); + // setPlayerTime(endOffset); + // } + // (changeListState && index) && changeListState(index, "playing"); + onPlay && onPlay(); + candidate && console.log("aiplayer"); + setNowPlaying && candidate && setNowPlaying(candidate); + }); + player.on("pause", () => { + setPlayerStatus("paused"); + // (changeListState && index) && changeListState(index, "paused"); + }); + player.on("waiting", () => setPlayerStatus("loading")); + player.on("error", () => setPlayerStatus("error")); + + player.on("timeupdate", () => { + const currentTime = player.currentTime() ?? 0; + if (currentTime >= endOffset) { + player.currentTime(startOffset); + setPlayerTime(startOffset); + player.pause(); + onPlayerEnd && onPlayerEnd(); + } else { + setPlayerTime(currentTime); + } + }); + player.on("loadedmetadata", () => { + // special to the AI player + const duration = player.duration() || 0; + setEndOffset(duration); + // On initial load, set player time to startOffset + player.currentTime(startOffset); + }); + }, []); + + const handlePlayPauseClick = () => { + const player = playerRef.current; + + if (playerStatus === "error") { + setPlayerStatus("idle"); + return; + } + + if (!player) { + setPlayerStatus("error"); + return; + } + + try { + if (playerStatus === "loading" || playerStatus === "playing") { + player.pause(); + } else { + player.play(); + onAudioPlay?.(); + } + } catch (e) { + console.error(e); + // AbortError is thrown if pause() is called while play() is still loading (e.g. if segments are 404ing) + // It's not important, so don't show this error to the user + if (e instanceof DOMException && e.name === "AbortError") return; + setPlayerStatus("error"); + } + }; + + // useEffect(() => { + // if (process.env.NODE_ENV === "development" && hlsURI) { + // console.log(`New stream instance: ${hlsURI}`); + // } + // return () => { + // setPlayerStatus("idle"); + // }; + // }, [hlsURI, feed.nodeName]); + + const handleSliderChange = ( + _e: Event, + v: number | number[], + _activeThumb: number, + ) => { + playerRef?.current?.pause(); + if (typeof v !== "number") return; + playerRef?.current?.currentTime(v + startOffset); + }; + + const handleSliderChangeCommitted = ( + _e: Event | React.SyntheticEvent, + v: number | number[], + ) => { + if (typeof v !== "number") return; + playerRef?.current?.currentTime(v + startOffset); + playerRef?.current?.play(); + }; + + return ( + ({ + minHeight: theme.spacing(10), + display: "flex", + alignItems: "center", + justifyContent: "space-between", + px: [0, 2], + position: "relative", + [mobileOnly(theme)]: { + position: "fixed", + bottom: 0, + left: 0, + right: 0, + }, + // Keep player above the sliding drawer + zIndex: theme.zIndex.drawer + 1, + })} + > + + + + + + + + + `${v + startOffset.toFixed(2)} s`} + step={0.1} + max={endOffset} + // max={sliderMax} + value={playerTime} + // value={sliderValue} + marks={marks} + onChange={handleSliderChange} + onChangeCommitted={handleSliderChangeCommitted} + size="small" + /> + + + + + {formattedSeconds(Number((playerTime - startOffset).toFixed(0)))} + + + {"-" + + formattedSeconds(Number((endOffset - playerTime).toFixed(0)))} + + + + + ); +} + +const formattedSeconds = (seconds: number) => { + const mm = Math.floor(seconds / 60); + const ss = seconds % 60; + return `${Number(mm).toString().padStart(2, "0")}:${ss + .toFixed(0) + .padStart(2, "0")}`; +}; diff --git a/ui/src/components/Player/CandidateCardPlayer.tsx b/ui/src/components/Player/CandidateCardPlayer.tsx new file mode 100644 index 00000000..3a9d5798 --- /dev/null +++ b/ui/src/components/Player/CandidateCardPlayer.tsx @@ -0,0 +1,249 @@ +import "videojs-offset"; + +import { Box, Slider, Typography } from "@mui/material"; +import dynamic from "next/dynamic"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { useData } from "@/context/DataContext"; +import { Feed } from "@/graphql/generated"; +import { getHlsURI } from "@/hooks/useTimestampFetcher"; +import { Candidate } from "@/pages/moderator/candidates"; +import { mobileOnly } from "@/styles/responsive"; + +import { type PlayerStatus } from "./Player"; +import PlayPauseButton from "./PlayPauseButton"; +import { type VideoJSPlayer } from "./VideoJS"; + +// dynamically import VideoJS to speed up initial page load + +const VideoJS = dynamic(() => import("./VideoJS")); + +export function CandidateCardPlayer({ + feed, + marks, + timestamp, + startOffset, + endOffset, + onAudioPlay, + changeListState, + index, + command, + onPlayerInit, + onPlay, + onPlayerEnd, + candidate, +}: { + feed: Pick; + marks?: { label: string; value: number }[]; + timestamp: number; + startOffset: number; + endOffset: number; + onAudioPlay?: () => void; + changeListState?: (value: number, status: string) => void; + index?: number; + command?: string; + onPlayerInit?: (player: VideoJSPlayer) => void; + onPlay?: () => void; + onPlayerEnd?: () => void; + candidate?: Candidate; +}) { + const [playerStatus, setPlayerStatus] = useState("idle"); + const playerRef = useRef(null); + const [playerTime, setPlayerTime] = useState(startOffset); + const { setNowPlaying } = useData(); + + const sliderMax = endOffset - startOffset; + const sliderValue = playerTime - startOffset; + + const hlsURI = getHlsURI(feed.bucket, feed.nodeName, timestamp); + + const playerOptions = useMemo( + () => ({ + autoplay: false, + flash: { + hls: { + overrideNative: true, + }, + }, + html5: { + hls: { + overrideNative: true, + }, + }, + sources: [ + { + // If hlsURI isn't set, use a dummy URI to trigger an error + // The dummy URI doesn't actually exist, it should return 404 + // This is the only way to get videojs to throw an error, otherwise + // it just won't initialize (if src is undefined/null/empty)) + src: hlsURI ?? `${feed.nodeName}/404`, + type: "application/x-mpegurl", + }, + ], + }), + [hlsURI, feed?.nodeName], + ); + + const handleReady = useCallback( + (player: VideoJSPlayer) => { + playerRef.current = player; + onPlayerInit && onPlayerInit(player); + player.on("playing", () => { + setPlayerStatus("playing"); + const currentTime = player.currentTime() ?? 0; + if (currentTime < startOffset || currentTime > endOffset) { + player.currentTime(startOffset); + } + onPlay && onPlay(); + setNowPlaying && candidate && setNowPlaying(candidate); + }); + player.on("pause", () => { + setPlayerStatus("paused"); + }); + player.on("waiting", () => setPlayerStatus("loading")); + player.on("error", () => setPlayerStatus("error")); + + player.on("timeupdate", () => { + const currentTime = player.currentTime() ?? 0; + if (currentTime > endOffset) { + player.currentTime(startOffset); + setPlayerTime(startOffset); + player.pause(); + onPlayerEnd && onPlayerEnd(); + } else { + setPlayerTime(currentTime); + } + }); + player.on("loadedmetadata", () => { + // On initial load, set player time to startOffset + player.currentTime(startOffset); + }); + }, + [startOffset, endOffset], + ); + + const handlePlayPauseClick = () => { + const player = playerRef.current; + + if (playerStatus === "error") { + setPlayerStatus("idle"); + return; + } + + if (!player) { + setPlayerStatus("error"); + return; + } + + try { + if (playerStatus === "loading" || playerStatus === "playing") { + player.pause(); + } else { + player.play(); + onAudioPlay?.(); + } + } catch (e) { + console.error(e); + // AbortError is thrown if pause() is called while play() is still loading (e.g. if segments are 404ing) + // It's not important, so don't show this error to the user + if (e instanceof DOMException && e.name === "AbortError") return; + setPlayerStatus("error"); + } + }; + + useEffect(() => { + if (process.env.NODE_ENV === "development" && hlsURI) { + console.log(`New stream instance: ${hlsURI}`); + } + return () => { + setPlayerStatus("idle"); + }; + }, [hlsURI, feed.nodeName]); + + const handleSliderChange = ( + _e: Event, + v: number | number[], + _activeThumb: number, + ) => { + playerRef?.current?.pause(); + if (typeof v !== "number") return; + playerRef?.current?.currentTime(v + startOffset); + }; + + const handleSliderChangeCommitted = ( + _e: Event | React.SyntheticEvent, + v: number | number[], + ) => { + if (typeof v !== "number") return; + playerRef?.current?.currentTime(v + startOffset); + playerRef?.current?.play(); + }; + + return ( + ({ + minHeight: theme.spacing(10), + display: "flex", + alignItems: "center", + justifyContent: "space-between", + px: [0, 2], + position: "relative", + [mobileOnly(theme)]: { + position: "fixed", + bottom: 0, + left: 0, + right: 0, + }, + // Keep player above the sliding drawer + zIndex: theme.zIndex.drawer + 1, + })} + > + + + + + + + + + `${(v + startOffset).toFixed(2)} s`} + step={0.1} + max={sliderMax} + value={sliderValue} + marks={marks} + onChange={handleSliderChange} + onChangeCommitted={handleSliderChangeCommitted} + size="small" + /> + + + + + {formattedSeconds(Number((playerTime - startOffset).toFixed(0)))} + + + {"-" + + formattedSeconds(Number((endOffset - playerTime).toFixed(0)))} + + + + + ); +} + +const formattedSeconds = (seconds: number) => { + const mm = Math.floor(seconds / 60); + const ss = seconds % 60; + return `${Number(mm).toString().padStart(2, "0")}:${ss + .toFixed(0) + .padStart(2, "0")}`; +}; diff --git a/ui/src/components/ReportsBarChart.tsx b/ui/src/components/ReportsBarChart.tsx new file mode 100644 index 00000000..69d738eb --- /dev/null +++ b/ui/src/components/ReportsBarChart.tsx @@ -0,0 +1,181 @@ +import { Box, Button } from "@mui/material"; +import { BarChart } from "@mui/x-charts/BarChart"; +import React from "react"; + +import { useFeedsQuery } from "@/graphql/generated"; +import { CombinedData } from "@/types/DataTypes"; + +const chartSetting = { + yAxis: [ + { + label: "Reports", + dataKey: "detections", + }, + ], + height: 300, +}; + +type ChartData = { + tick: number; + milliseconds: number; + label: string; + detections: number; + whale: number; + vessel: number; + other: number; + "whale (ai)": number; +}; + +export default function ReportsBarChart({ + dataset, + timeRange, +}: { + dataset: CombinedData[]; + timeRange: number; +}) { + // get hydrophone feed list + const feedsQueryResult = useFeedsQuery(); + const feeds = feedsQueryResult.data?.feeds ?? []; + + const [legend, setLegend] = React.useState(true); + + const max = Date.now(); + const min = max - timeRange; + const startHour = new Date(min).setMinutes(0, 0, 0); + const endHour = new Date(max).setMinutes(0, 0, 0); + const timeDifferenceHours = (endHour - startHour) / (1000 * 60 * 60); + + const chartData: ChartData[] = []; + + for (let i = 0; i < timeDifferenceHours; i++) { + chartData.push({ + tick: i, + milliseconds: i * 1000 * 60 * 60, + label: new Date(startHour + i * 1000 * 60 * 60).toLocaleString(), + detections: 0, + whale: 0, + vessel: 0, + other: 0, + "whale (ai)": 0, + }); + } + + const categorySeries = [ + { dataKey: "whale", label: "Whale" }, + { dataKey: "vessel", label: "Vessel" }, + { dataKey: "other", label: "Other" }, + { dataKey: "whale (ai)", label: "Whale (AI)" }, + ]; + + const hydrophoneSeries = feeds.map((el) => ({ + dataKey: el.name.toLowerCase(), + label: el.name, + })); + hydrophoneSeries.shift(); // remove the "all hydrophones" from legend + + const hydrophoneCounts = feeds.map((el) => ({ + [el.name.toLowerCase()]: 0, + })); + + chartData.forEach((el) => { + hydrophoneCounts.forEach((hydro) => { + Object.assign(el, hydro); + }); + }); + + const countData = () => { + for (let i = 0; i < dataset.length; i++) { + const timestamp = Date.parse(dataset[i].timestampString); + const tick = Math.round((timestamp - min) / (1000 * 60 * 60)); + for (let j = 0; j < chartData.length; j++) { + if (chartData[j].tick === tick) { + const chartItem = chartData[j]; + chartItem.detections += 1; + if (dataset[i].newCategory.toLowerCase() === "whale") { + chartItem.whale += 1; + } + switch (dataset[i].newCategory.toLowerCase()) { + case "whale": + chartItem.whale += 1; + break; + case "vessel": + chartItem.vessel += 1; + break; + case "other": + chartItem.other += 1; + break; + case "whale (ai)": + chartItem["whale (ai)"] += 1; + break; + default: + null; + } + } + } + } + }; + countData(); + + interface ChartButtonProps { + onClick: (e: React.MouseEvent) => void; + name: string; + label: string; + } + + const ChartButton: React.FC = ({ + onClick, + name, + label, + }) => { + return ( + + ); + }; + + const handleLegend = (e: React.MouseEvent) => { + const button = e.target as HTMLButtonElement; + button.name === "category" ? setLegend(true) : setLegend(false); + }; + + return ( + <> + + + + + + + ); +} diff --git a/ui/src/components/layouts/DrawerLayout.tsx b/ui/src/components/layouts/DrawerLayout.tsx new file mode 100644 index 00000000..482ddf3d --- /dev/null +++ b/ui/src/components/layouts/DrawerLayout.tsx @@ -0,0 +1,176 @@ +import { PlayLessonOutlined } from "@mui/icons-material"; +import BarChartIcon from "@mui/icons-material/BarChart"; +import DataObjectIcon from "@mui/icons-material/DataObject"; +import EarbudsIcon from "@mui/icons-material/Earbuds"; +import GraphicEqIcon from "@mui/icons-material/GraphicEq"; +import { Box, Container } from "@mui/material"; +import Divider from "@mui/material/Divider"; +import List from "@mui/material/List"; +import ListItem from "@mui/material/ListItem"; +import ListItemButton from "@mui/material/ListItemButton"; +import ListItemIcon from "@mui/material/ListItemIcon"; +import ListItemText from "@mui/material/ListItemText"; +import ListSubheader from "@mui/material/ListSubheader"; +import Toolbar from "@mui/material/Toolbar"; +import * as React from "react"; +import { ReactElement } from "react"; + +import Drawer from "@/components/Drawer"; +import Header from "@/components/Header"; +import Link from "@/components/Link"; + +const navigation = [ + { + kind: "subheader", + title: "New versions", + children: [ + { + title: "Reports", + path: "/moderator/", + icon: , + }, + { + title: "Hydrophones", + path: "/moderator/listen/", + icon: , + }, + { + title: "Archive", + path: "/moderator/learn/", + icon: , + }, + { + title: "JSON", + path: "/moderator/json", + icon: , + }, + ], + }, + { + kind: "divider", + }, + { + kind: "subheader", + title: "Existing versions", + children: [ + { + title: "Reports", + path: "/reports/", + icon: , + }, + { + title: "Bouts", + path: "/bouts/", + icon: , + }, + { + title: "Listen", + path: "/listen/", + icon: , + }, + { + title: "Learn", + path: "/moderator/learn/", + icon: , + }, + ], + }, +]; + +function ModeratorLayout({ children }: { children: React.ReactNode }) { + const listItem = (title: string, path: string, icon: ReactElement) => ( + + + + {icon} + + + + + ); + + const subheader = (content: string) => ( + + {content} + + ); + + interface NavDiv { + title?: string; + kind: string; + children?: NavItem[]; + } + + interface NavItem { + title: string; + path: string; + icon: ReactElement; + } + + const navDiv = (div: NavDiv) => { + let component; + switch (div.kind) { + case "divider": + component = ; + break; + case "subheader": + component = ( + + {div.children && + div.children.map((item) => + listItem(item.title, item.path, item.icon), + )} + + ); + } + return component; + }; + + const DrawerList = ( + + {navigation.map((item) => navDiv(item))} + + ); + + return ( + +
+ + +
+ {}}> + + {DrawerList} + +
+ + {children} +
+ + ); +} + +export function getDrawerLayout(page: ReactElement) { + return {page}; +} diff --git a/ui/src/components/layouts/ModeratorLayout.tsx b/ui/src/components/layouts/ModeratorLayout.tsx new file mode 100644 index 00000000..897c6ca1 --- /dev/null +++ b/ui/src/components/layouts/ModeratorLayout.tsx @@ -0,0 +1,325 @@ +import { PlayLessonOutlined } from "@mui/icons-material"; +import BarChartIcon from "@mui/icons-material/BarChart"; +import DataObjectIcon from "@mui/icons-material/DataObject"; +import EarbudsIcon from "@mui/icons-material/Earbuds"; +import MicIcon from "@mui/icons-material/Mic"; +import PlayCircleOutlineIcon from "@mui/icons-material/PlayCircleOutline"; +import { Box } from "@mui/material"; +import Divider from "@mui/material/Divider"; +import Drawer from "@mui/material/Drawer"; +import List from "@mui/material/List"; +import ListItem from "@mui/material/ListItem"; +import ListItemButton from "@mui/material/ListItemButton"; +import ListItemIcon from "@mui/material/ListItemIcon"; +import ListItemText from "@mui/material/ListItemText"; +import ListSubheader from "@mui/material/ListSubheader"; +import Toolbar from "@mui/material/Toolbar"; +import { useQuery } from "@tanstack/react-query"; +import * as React from "react"; +import { ReactElement, useMemo, useState } from "react"; + +import Header from "@/components/Header"; +import Link from "@/components/Link"; +import { DataProvider } from "@/context/DataContext"; +import { + DetectionCategory, + useDetectionsQuery, + useFeedsQuery, +} from "@/graphql/generated"; +import { Candidate } from "@/pages/moderator/candidates"; +import { AIData } from "@/types/DataTypes"; + +import PlayBar from "../PlayBar"; + +const drawerWidth = 240; + +const navigation = [ + { + kind: "subheader", + title: "", + children: [ + { + title: "Recordings", + path: "/moderator/candidates", + icon: , + }, + { + title: "Hydrophones", + path: "/moderator/hydrophones/", + icon: , + }, + { + title: "Bouts", + path: "/moderator/bouts/", + icon: , + }, + { + title: "Learn", + path: "/moderator/learn/", + icon: , + }, + { + title: "Reports", + path: "/moderator/reports", + icon: , + }, + { + title: "JSON", + path: "/moderator/json", + icon: , + }, + ], + }, + // { kind: "divider", }, +]; + +const endpointOrcahello = + "https://aifororcasdetections.azurewebsites.net/api/detections?"; +const daysAgo = 7; +const paramsOrcahello = { + page: 1, + sortBy: "timestamp", + sortOrder: "desc", + timeframe: "all", + dateFrom: new Date(new Date().setDate(new Date().getDate() - daysAgo)) + .toLocaleDateString() + .replaceAll(/\//g, "%2F"), + dateTo: new Date().toLocaleDateString().replaceAll(/\//g, "%2F"), + location: "all", + recordsPerPage: 100, +}; +function constructUrl(endpoint: string, paramsObj: object) { + let params = ""; + const entries = Object.entries(paramsObj); + for (const [key, value] of entries) { + const str = [key, value].join("=") + "&"; + params += str; + } + return endpoint + params; +} +const standardizeFeedName = (name: string) => { + switch (name) { + case "Beach Camp at Sunset Bay": + return "Sunset Bay"; + break; + case "North SJC": + return "North San Juan Channel"; + break; + case "Haro Strait": + return "Orcasound Lab"; + break; + default: + return name; + break; + } +}; +const lookupFeedName = ( + id: string, + feedList: { id: string; name: string }[], +) => { + let name = "feed not found"; + feedList.forEach((feed) => { + if (id === feed.id) { + name = feed.name; + } + }); + return standardizeFeedName(name); +}; + +function ModeratorLayout({ children }: { children: React.ReactNode }) { + //// DATA + + const [nowPlaying, setNowPlaying] = useState({} as Candidate); + + // get data on hydrophones + const feedsQueryResult = useFeedsQuery(); + const feedsData = feedsQueryResult.data?.feeds ?? []; + + type CategoryOptions = "WHALE" | "WHALE (AI)" | "VESSEL" | "OTHER" | "ALL"; + const [category, setCategory] = useState("ALL"); + + // get data on human detections + const detectionQueryResult = useDetectionsQuery( + ["WHALE", "VESSEL", "OTHER"].includes(category) + ? { filter: { category: { eq: category as DetectionCategory } } } + : {}, + { enabled: ["WHALE", "VESSEL", "OTHER", "ALL"].includes(category || "") }, + ); + const detectionsData = detectionQueryResult.data?.detections?.results ?? []; + + // get data on AI detections + const fetchOrcahelloData = async () => { + const response = await fetch( + constructUrl(endpointOrcahello, paramsOrcahello), + ); + if (!response.ok) { + throw new Error("Network response from Orcahello was not ok"); + } + return response.json(); + }; + + const { data, isSuccess } = useQuery({ + queryKey: ["ai-detections"], + queryFn: fetchOrcahelloData, + }); + const aiDetections = data; + + // deduplicate data on human detections + const dedupeHuman = detectionsData.filter( + (obj, index, arr) => + arr.findIndex( + (el) => + el.timestamp === obj.timestamp && el.description === obj.description, + ) === index, + ); + + // standardize data from Orcasound and OrcaHello + const datasetHuman = dedupeHuman.map((el) => ({ + ...el, + type: "human", + hydrophone: lookupFeedName(el.feedId!, feedsData), + comments: el.description, + newCategory: el!.category!, + timestampString: el.timestamp.toString(), + })); + + // combine global data into one object, to be passed into Data Provider for all child pages + const dataset = useMemo(() => { + const datasetAI = + aiDetections?.map((el: AIData) => ({ + ...el, + type: "ai", + hydrophone: standardizeFeedName(el.location.name), + newCategory: "WHALE (AI)", + timestampString: el.timestamp.toString(), + })) ?? []; + return { + human: datasetHuman, + ai: datasetAI, + combined: [...datasetHuman, ...datasetAI], + feeds: feedsData, + isSuccess: isSuccess, + nowPlaying: nowPlaying, + setNowPlaying: setNowPlaying, + }; + }, [datasetHuman, aiDetections, feedsData, isSuccess]); + + //// COMPONENTS + + const listItem = (title: string, path: string, icon: ReactElement) => ( + + + + {icon} + + + + + ); + + const subheader = (content: string) => ( + + {content} + + ); + + interface NavDiv { + title?: string; + kind: string; + children?: NavItem[]; + } + + interface NavItem { + title: string; + path: string; + icon: ReactElement; + } + + const navDiv = (div: NavDiv, index: number) => { + let component; + switch (div.kind) { + case "divider": + component = ; + break; + case "subheader": + component = ( + + {div.children && + div.children.map((item) => + listItem(item.title, item.path, item.icon), + )} + + ); + } + return component; + }; + + const DrawerList = ( + + {navigation.map((item, index) => navDiv(item, index))} + + ); + + //// RENDER + + return ( + +
+ + +
+ + + {DrawerList} + +
+ + + {children} + + + +
+ + ); +} + +export function getModeratorLayout(page: ReactElement) { + return {page}; +} diff --git a/ui/src/components/layouts/ToolpadLayout.tsx b/ui/src/components/layouts/ToolpadLayout.tsx new file mode 100644 index 00000000..4a171123 --- /dev/null +++ b/ui/src/components/layouts/ToolpadLayout.tsx @@ -0,0 +1,38 @@ +"use client"; +import DashboardIcon from "@mui/icons-material/Dashboard"; +import type { Navigation } from "@toolpad/core/AppProvider"; +import { DashboardLayout as ToolpadDashboardLayout } from "@toolpad/core/DashboardLayout"; +import { NextAppProvider } from "@toolpad/core/nextjs"; +import { PageContainer } from "@toolpad/core/PageContainer"; +import * as React from "react"; +import { ReactElement } from "react"; + +const NAVIGATION: Navigation = [ + { + kind: "header", + title: "Main items", + }, + { + segment: "", + title: "Dashboard", + icon: , + }, +]; + +const BRANDING = { + title: "My Toolpad Core App", +}; + +const ToolpadLayout = ({ children }: { children: React.ReactNode }) => { + return ( + + + {children} + + + ); +}; + +export function getToolpadLayout(page: ReactElement) { + return {page}; +} diff --git a/ui/src/context/DataContext.tsx b/ui/src/context/DataContext.tsx new file mode 100644 index 00000000..48030280 --- /dev/null +++ b/ui/src/context/DataContext.tsx @@ -0,0 +1,23 @@ +import React, { createContext, useContext } from "react"; + +import { Dataset } from "@/types/DataTypes"; + +const DataContext = createContext({ + human: [], + ai: [], + combined: [], + isSuccess: false, + setNowPlaying: undefined, +}); + +export const useData = () => useContext(DataContext); + +export const DataProvider = ({ + children, + data, +}: { + children: React.ReactNode; + data: Dataset; +}) => { + return {children}; +}; diff --git a/ui/src/graphql/generated/index.ts b/ui/src/graphql/generated/index.ts index 4fea992e..c85513ee 100644 --- a/ui/src/graphql/generated/index.ts +++ b/ui/src/graphql/generated/index.ts @@ -2641,6 +2641,29 @@ export type NotificationsForCandidateQuery = { }>; }; +export type Feeds2QueryVariables = Exact<{ + sort?: InputMaybe< + Array> | InputMaybe + >; +}>; + +export type Feeds2Query = { + __typename?: "RootQueryType"; + feeds: Array<{ + __typename?: "Feed"; + id: string; + name: string; + slug: string; + nodeName: string; + imageUrl?: string | null; + mapUrl?: string | null; + thumbUrl?: string | null; + bucket: string; + online?: boolean | null; + latLng: { __typename?: "LatLng"; lat: number; lng: number }; + }>; +}; + export type CandidatesQueryVariables = Exact<{ filter?: InputMaybe; limit?: InputMaybe; @@ -3783,6 +3806,57 @@ useNotificationsForCandidateQuery.fetcher = ( NotificationsForCandidateQueryVariables >(NotificationsForCandidateDocument, variables, options); +export const Feeds2Document = ` + query feeds2($sort: [FeedSortInput]) { + feeds(sort: $sort) { + id + name + slug + nodeName + latLng { + lat + lng + } + imageUrl + mapUrl + thumbUrl + bucket + online + } +} + `; + +export const useFeeds2Query = ( + variables?: Feeds2QueryVariables, + options?: Omit, "queryKey"> & { + queryKey?: UseQueryOptions["queryKey"]; + }, +) => { + return useQuery({ + queryKey: variables === undefined ? ["feeds2"] : ["feeds2", variables], + queryFn: fetcher( + Feeds2Document, + variables, + ), + ...options, + }); +}; + +useFeeds2Query.document = Feeds2Document; + +useFeeds2Query.getKey = (variables?: Feeds2QueryVariables) => + variables === undefined ? ["feeds2"] : ["feeds2", variables]; + +useFeeds2Query.fetcher = ( + variables?: Feeds2QueryVariables, + options?: RequestInit["headers"], +) => + fetcher( + Feeds2Document, + variables, + options, + ); + export const CandidatesDocument = ` query candidates($filter: CandidateFilterInput, $limit: Int, $offset: Int, $sort: [CandidateSortInput]) { candidates(filter: $filter, limit: $limit, offset: $offset, sort: $sort) { diff --git a/ui/src/graphql/queries/list2Feeds.graphql b/ui/src/graphql/queries/list2Feeds.graphql new file mode 100644 index 00000000..5fdd399d --- /dev/null +++ b/ui/src/graphql/queries/list2Feeds.graphql @@ -0,0 +1,17 @@ +query feeds2($sort: [FeedSortInput]) { + feeds(sort: $sort) { + id + name + slug + nodeName + latLng { + lat + lng + } + imageUrl + mapUrl + thumbUrl + bucket + online + } +} diff --git a/ui/src/graphql/queries/listDetections2.graphql b/ui/src/graphql/queries/listDetections2.graphql new file mode 100644 index 00000000..7b8232fb --- /dev/null +++ b/ui/src/graphql/queries/listDetections2.graphql @@ -0,0 +1,24 @@ +query detections2( + $filter: DetectionFilterInput + $limit: Int + $offset: Int + $sort: [DetectionSortInput] +) { + detections(filter: $filter, limit: $limit, offset: $offset, sort: $sort) { + count + hasNextPage + results { + id + timestamp + category + description + listenerCount + feed { + name + } + candidate { + id + } + } + } +} diff --git a/ui/src/pages/_app.tsx b/ui/src/pages/_app.tsx index bc8dfc6c..a40f4700 100644 --- a/ui/src/pages/_app.tsx +++ b/ui/src/pages/_app.tsx @@ -1,3 +1,4 @@ +"use client"; import CssBaseline from "@mui/material/CssBaseline"; import { ThemeProvider } from "@mui/material/styles"; import { AppCacheProvider } from "@mui/material-nextjs/v14-pagesRouter"; diff --git a/ui/src/pages/moderator/[candidateId].tsx b/ui/src/pages/moderator/[candidateId].tsx new file mode 100644 index 00000000..69c66c9e --- /dev/null +++ b/ui/src/pages/moderator/[candidateId].tsx @@ -0,0 +1,251 @@ +import { + AccountCircle, + Edit, + ThumbDownOffAlt, + ThumbUpOffAlt, +} from "@mui/icons-material"; +import { + Box, + List, + ListItemAvatar, + ListItemButton, + ListItemText, + Typography, +} from "@mui/material"; +import Grid from "@mui/material/Grid2"; +import Head from "next/head"; +import { useRouter } from "next/router"; +import { useEffect, useState } from "react"; + +import { getModeratorLayout } from "@/components/layouts/ModeratorLayout"; +import { CandidateCardAIPlayer } from "@/components/Player/CandidateCardAIPlayer"; +import { CandidateCardPlayer } from "@/components/Player/CandidateCardPlayer"; +import { useData } from "@/context/DataContext"; +import { useFeedsQuery } from "@/graphql/generated"; +import type { NextPageWithLayout } from "@/pages/_app"; +import { CombinedData } from "@/types/DataTypes"; + +const CandidatePage: NextPageWithLayout = () => { + const router = useRouter(); + const { candidateId } = router.query; + const startEnd = + typeof candidateId === "string" ? candidateId?.split("_") : []; + const startTime = new Date(startEnd[0]).getTime(); + const endTime = new Date(startEnd[startEnd.length - 1]).getTime(); + console.log("startTime: " + startTime + ", endTime: " + endTime); + + // replace this with a direct react-query... + const { + combined, + isSuccess, + }: { combined: CombinedData[] | undefined; isSuccess: boolean } = useData(); // this uses a context provider to call data once and make it available to all children -- this may not be better than just using the query hooks, kind of does the same thing + + // get hydrophone feed list + const feedsQueryResult = useFeedsQuery(); + const feeds = feedsQueryResult.data?.feeds ?? []; + + type DetectionStats = { + combined: CombinedData[]; + human: CombinedData[]; + ai: CombinedData[]; + }; + + const [detections, setDetections] = useState({ + combined: [], + human: [], + ai: [], + }); + + useEffect(() => { + const arr: CombinedData[] = []; + combined?.forEach((d) => { + const time = new Date(d.timestamp).getTime(); + if (time >= startTime && time <= endTime) { + console.log("both true"); + } + if (time >= startTime && time <= endTime) { + arr.push(d); + } + }); + setDetections({ + combined: arr, + human: arr.filter((d) => d.newCategory !== "WHALE (AI)"), + ai: arr.filter((d) => d.newCategory === "WHALE (AI)"), + }); + console.log("combined length is " + combined.length); + }, [combined]); + + const userName = "UserProfile123"; + const aiName = "Orcahello AI"; + const communityName = "Community"; + + const feed = feeds.filter((f) => f.id === detections?.human[0]?.feedId)[0]; + const startTimestamp = detections.human.length + ? detections.human[0].playlistTimestamp + : 0; + + const offsetPadding = 15; + const minOffset = Math.min(...detections.human.map((d) => +d.playerOffset)); + // const maxOffset = Math.max(...candidateArray.map((d) => +d.playerOffset)); + + // ensures that the last offset is still in the same playlist -- future iteration may pull a second playlist if needed + const firstPlaylist = detections.human.filter( + (d) => +d.playlistTimestamp === startTimestamp, + ); + + const maxOffset = Math.max(...firstPlaylist.map((d) => +d.playerOffset)); + + const startOffset = Math.max(0, minOffset - offsetPadding); + const endOffset = maxOffset + offsetPadding; + + // const [breadcrumb, setBreadcrumb] = useState("") + // useEffect(() => { + // const breadcrumbName: string[] = []; + // detections.human.length && breadcrumbName.push(communityName); + // detections.ai.length && breadcrumbName.push(aiName); + // setBreadcrumb(breadcrumbName.join(" + ")); + // }, [detections]) + + return ( +
+ Report {candidateId} | Orcasound + + + + {/* + + Recordings + + + {breadcrumb} + + */} + {/* +  {!isSuccess && "Waiting for Orcahello request..."} + */} + + + + + + {detections.combined[0]?.hydrophone} + + + {new Date(startEnd[0]).toLocaleString()} + + + + +
+ {detections?.ai?.map((d) => ( + + ))} + +  {!isSuccess && "Waiting for Orcahello request..."} + +
+ + + {detections.human.length ? ( + + ) : detections.ai.length ? ( + <> + + + ) : ( + "no player found" + )} + + + + {detections.combined?.map((el, index) => ( + + + + + + + + + + + + + + ))} + + + + More things to go here include: +
    +
  • + Tags - initially populated by regex, can be edited by moderator +
  • +
  • Valentina noise analysis
  • +
  • Dave T signal state
  • +
  • Share / save an audio clip
  • +
+
+ + Things to go here could include: +
    +
  • Acartia map of detections in time range
  • +
  • + Marine Exchange of Puget Sound map of ship traffic in time range +
  • +
  • Local weather conditions in time range
  • +
+
+
+
+ ); +}; + +CandidatePage.getLayout = getModeratorLayout; + +export default CandidatePage; diff --git a/ui/src/pages/moderator/bouts.tsx b/ui/src/pages/moderator/bouts.tsx new file mode 100644 index 00000000..2f8999a9 --- /dev/null +++ b/ui/src/pages/moderator/bouts.tsx @@ -0,0 +1,11 @@ +import { getModeratorLayout } from "@/components/layouts/ModeratorLayout"; +import type { NextPageWithLayout } from "@/pages/_app"; +import BoutsPage from "@/pages/bouts"; + +const ModeratorLearnPage: NextPageWithLayout = () => { + return ; +}; + +ModeratorLearnPage.getLayout = getModeratorLayout; + +export default ModeratorLearnPage; diff --git a/ui/src/pages/moderator/candidates.tsx b/ui/src/pages/moderator/candidates.tsx new file mode 100644 index 00000000..5b6b34c3 --- /dev/null +++ b/ui/src/pages/moderator/candidates.tsx @@ -0,0 +1,370 @@ +import { Box, Button, Container, Stack, Typography } from "@mui/material"; +import { SelectChangeEvent } from "@mui/material/Select"; +import { useEffect, useRef, useState } from "react"; + +import CandidateCard from "@/components/CandidateCard"; +import ChartSelect from "@/components/ChartSelect"; +import { getModeratorLayout } from "@/components/layouts/ModeratorLayout"; +import ReportsBarChart from "@/components/ReportsBarChart"; +import { useData } from "@/context/DataContext"; +import { useFeedsQuery } from "@/graphql/generated"; +import { CombinedData } from "@/types/DataTypes"; + +const sevenDays = 7 * 24 * 60 * 60 * 1000; +const threeDays = 3 * 24 * 60 * 60 * 1000; +const oneDay = 24 * 60 * 60 * 1000; + +const timeRangeSelect = [ + { + label: "Last 7 days", + value: sevenDays, + }, + { + label: "Last 3 days", + value: threeDays, + }, + { + label: "Last 24 hours", + value: oneDay, + }, +]; + +const timeIncrementSelect = [ + { + label: "Group reports within 15 min", + value: 15, + }, + { + label: "Group reports within 30 min", + value: 30, + }, + { + label: "Group reports within 60 min", + value: 60, + }, + { + label: "Do not group reports", + value: 0, + }, +]; + +const categorySelect = [ + { + label: "All categories", + value: "All categories", + }, + { + label: "Whale", + value: "whale", + }, + { + label: "Vessel", + value: "vessel", + }, + { + label: "Other", + value: "other", + }, + { + label: "Whale (AI)", + value: "whale (ai)", + }, +]; + +export interface Candidate { + array: CombinedData[]; + whale: number; + vessel: number; + other: number; + "whale (AI)": number; + hydrophone: string; + descriptions: string; +} + +const createCandidates = ( + dataset: CombinedData[], + interval: number, +): Candidate[] => { + const candidates: Array> = []; + const sort = dataset.sort( + (a, b) => Date.parse(b.timestampString) - Date.parse(a.timestampString), + ); + sort.forEach((el: CombinedData) => { + if (!candidates.length) { + const firstArray = []; + firstArray.push(el); + candidates.push(firstArray); + } else { + const hydrophone = el.hydrophone; + const findLastMatchingArray = () => { + for (let i = candidates.length - 1; i >= 0; i--) { + if (candidates[i][0].hydrophone === hydrophone) { + return candidates[i]; + } + } + }; + const lastMatchingArray = findLastMatchingArray(); + const lastTimestamp = + lastMatchingArray && + lastMatchingArray[lastMatchingArray.length - 1].timestampString; + if ( + lastTimestamp && + Math.abs(Date.parse(lastTimestamp) - Date.parse(el.timestampString)) / + (1000 * 60) <= + interval + ) { + lastMatchingArray.push(el); + } else { + const newArray = []; + newArray.push(el); + candidates.push(newArray); + } + } + }); + const countCategories = (arr: { newCategory: string }[], cat: string) => { + return arr.filter((d) => d.newCategory.toLowerCase() === cat).length; + }; + + const candidatesMap = candidates.map((candidate) => ({ + array: candidate, + whale: countCategories(candidate, "whale"), + vessel: countCategories(candidate, "vessel"), + other: countCategories(candidate, "other"), + "whale (AI)": countCategories(candidate, "whale (ai)"), + hydrophone: candidate[0].hydrophone, + descriptions: candidate + .map((el: CombinedData) => el.comments) + .filter((el: string | null | undefined) => el !== null) + .join(" • "), + })); + + return candidatesMap; +}; + +export default function Candidates() { + // replace this with a direct react-query... + const { + combined, + isSuccess, + }: { combined: CombinedData[] | undefined; isSuccess: boolean } = useData(); // this uses a context provider to call data once and make it available to all children -- this may not be better than just using the query hooks, kind of does the same thing + + // get hydrophone feed list + const feedsQueryResult = useFeedsQuery(); + const feeds = feedsQueryResult.data?.feeds ?? []; + + const [filters, setFilters] = useState({ + timeRange: threeDays, + timeIncrement: 15, + hydrophone: "All hydrophones", + category: "All categories", + }); + + const [timeRange, setTimeRange] = useState(threeDays); + const [timeIncrement, setTimeIncrement] = useState(15); + const [hydrophone, setHydrophone] = useState("All hydrophones"); + const [category, setCategory] = useState("All categories"); + + const handleChange = (event: SelectChangeEvent) => { + const { name, value } = event.target; + setFilters((prevFilters) => ({ + ...prevFilters, + [name]: value, + })); + }; + + const initChartSelect = (name: string, value: string | number) => { + setFilters((prevFilters) => ({ + ...prevFilters, + [name]: value, + })); + }; + + // const [playing, setPlaying] = useState({ + // index: -1, + // status: "ready", + // }); + + // const changeListState = (index: number, status: string) => { + // setPlaying((prevState) => ({ + // ...prevState, + // index: index, + // status: status, + // })); + // }; + + const [playNext, setPlayNext] = useState(true); + + const players = useRef({}); + + const feedList = feeds.map((el) => ({ + label: el.name, + value: el.name, + })); + feedList.unshift({ label: "All hydrophones", value: "All hydrophones" }); + + const filteredData = combined.filter((el: CombinedData) => { + return ( + // uncomment this to block Orcahello data + // el.type === "human" && + + // Disabling timerange filter for now because seed data is all from 2023 + // (Date.parse(el.timestamp) >= min) && + + (filters.hydrophone === "All hydrophones" || + el.hydrophone === filters.hydrophone) && + (filters.category === "All categories" || + el.newCategory.toLowerCase() === filters.category) + ); + }); + + const handledGetTime = (date?: Date) => { + return date != null ? new Date(date).getTime() : 0; + }; + + const sortDescending = (array: Candidate[]) => { + const sort = array.sort( + (a, b) => + handledGetTime(b.array[0].timestamp) - + handledGetTime(a.array[0].timestamp), + ); + return sort; + }; + + const sortAscending = (array: Candidate[]) => { + const sort = array.sort( + (a, b) => + handledGetTime(a.array[0].timestamp) - + handledGetTime(b.array[0].timestamp), + ); + return sort; + }; + + const candidates = sortDescending( + createCandidates(filteredData, filters.timeIncrement), + ); + const [sortedCandidates, setSortedCandidates] = useState([...candidates]); + + const handleSortAscending = (array: Candidate[]) => { + setSortedCandidates((v) => [...sortAscending(array)]); + }; + + const handleSortDescending = (array: Candidate[]) => { + setSortedCandidates((v) => [...sortDescending(array)]); + }; + + useEffect(() => { + setSortedCandidates((v) => [...candidates]); + if (isSuccess) { + setSortedCandidates((v) => [...candidates]); + } + }, [isSuccess]); + + // render these first because it seems to take a while for candidates to populate from state, could just be the dev environment + const candidateCards = candidates.map( + (candidate: Candidate, index: number) => ( + + ), + ); + + // these render from state after delay, then re-render after another delay when AI candidates come through + const sortedCandidateCards = sortedCandidates.map( + (candidate: Candidate, index: number) => ( + + ), + ); + + return ( + + + + + + + + + + + + Showing{" "} + {sortedCandidates.length + ? sortedCandidates.length + : candidates.length}{" "} + {!isSuccess + ? "results from Orcasound, loading Orcahello..." + : "results"} + + + + + + + + {sortedCandidates.length ? sortedCandidateCards : candidateCards} + + + + ); +} + +Candidates.getLayout = getModeratorLayout; diff --git a/ui/src/pages/moderator/hydrophones.tsx b/ui/src/pages/moderator/hydrophones.tsx new file mode 100644 index 00000000..4ccbfa8a --- /dev/null +++ b/ui/src/pages/moderator/hydrophones.tsx @@ -0,0 +1,11 @@ +import { getModeratorLayout } from "@/components/layouts/ModeratorLayout"; +import type { NextPageWithLayout } from "@/pages/_app"; +import FeedsPage from "@/pages/listen"; + +const ModeratorFeedsPage: NextPageWithLayout = () => { + return ; +}; + +ModeratorFeedsPage.getLayout = getModeratorLayout; + +export default ModeratorFeedsPage; diff --git a/ui/src/pages/moderator/index.tsx b/ui/src/pages/moderator/index.tsx new file mode 100644 index 00000000..7333ca4a --- /dev/null +++ b/ui/src/pages/moderator/index.tsx @@ -0,0 +1,16 @@ +import { getModeratorLayout } from "@/components/layouts/ModeratorLayout"; +import type { NextPageWithLayout } from "@/pages/_app"; + +import Candidates from "./candidates"; + +const NewFeedsPage: NextPageWithLayout = () => { + return ( + <> + + + ); +}; + +NewFeedsPage.getLayout = getModeratorLayout; + +export default NewFeedsPage; diff --git a/ui/src/pages/moderator/json.tsx b/ui/src/pages/moderator/json.tsx new file mode 100644 index 00000000..fd1a05c1 --- /dev/null +++ b/ui/src/pages/moderator/json.tsx @@ -0,0 +1,22 @@ +import { getModeratorLayout } from "@/components/layouts/ModeratorLayout"; +import { useData } from "@/context/DataContext"; +import { useFeedsQuery } from "@/graphql/generated"; +import type { NextPageWithLayout } from "@/pages/_app"; + +const JSONPage: NextPageWithLayout = () => { + const { combined } = useData(); + // get hydrophone feed list + const feedsQueryResult = useFeedsQuery(); + const feeds = feedsQueryResult.data?.feeds ?? []; + + return ( + <> +
{JSON.stringify(combined, null, 2)}
+
{JSON.stringify(feeds, null, 2)}
+ + ); +}; + +JSONPage.getLayout = getModeratorLayout; + +export default JSONPage; diff --git a/ui/src/pages/moderator/learn.tsx b/ui/src/pages/moderator/learn.tsx new file mode 100644 index 00000000..1eb4d664 --- /dev/null +++ b/ui/src/pages/moderator/learn.tsx @@ -0,0 +1,11 @@ +import { getModeratorLayout } from "@/components/layouts/ModeratorLayout"; +import type { NextPageWithLayout } from "@/pages/_app"; +import LearnPage from "@/pages/learn"; + +const ModeratorLearnPage: NextPageWithLayout = () => { + return ; +}; + +ModeratorLearnPage.getLayout = getModeratorLayout; + +export default ModeratorLearnPage; diff --git a/ui/src/pages/moderator/playertest.tsx b/ui/src/pages/moderator/playertest.tsx new file mode 100644 index 00000000..cd82d319 --- /dev/null +++ b/ui/src/pages/moderator/playertest.tsx @@ -0,0 +1,60 @@ +import { getModeratorLayout } from "@/components/layouts/ModeratorLayout"; +import { DetectionsPlayer } from "@/components/Player/DetectionsPlayer"; +import { useCandidateQuery } from "@/graphql/generated"; +import { useFeedsQuery } from "@/graphql/generated"; +import { CombinedData } from "@/types/DataTypes"; + +const ModeratorReportsPlayer = ({ + firstDetection, + lastDetection, +}: { + firstDetection?: CombinedData; + lastDetection?: CombinedData; +}) => { + // const router = useRouter(); + // const { candidateId } = router.query; + const candidateId = "cand_02z0tyRhTNcgmch7FIHrXJ"; + + const candidateQuery = useCandidateQuery({ + id: (candidateId || "") as string, + }); + const candidate = candidateQuery.data?.candidate; + const detections = candidate && candidate.detections; + + // get hydrophone feed list + const feedsQueryResult = useFeedsQuery(); + const feedsData = feedsQueryResult.data?.feeds ?? []; + + // get feed object from feedId + // const feedObj = feedsData.find((feed) => feed.id === firstDetection.feedId); + // const firstTimestamp = firstDetection.playlistTimestamp; + // const lastTimestamp = lastDetection.playlistTimestamp; + + const offsetPadding = 15; + const minOffset = detections + ? Math.min(...detections.map((d) => +d.playerOffset)) + : 0; + const maxOffset = detections + ? Math.max(...detections.map((d) => +d.playerOffset)) + : 0; + const startOffset = Math.max(0, minOffset - offsetPadding); + const endOffset = maxOffset + offsetPadding; + + return ( + <> + {candidate && ( + + )} + + ); +}; + +ModeratorReportsPlayer.getLayout = getModeratorLayout; + +export default ModeratorReportsPlayer; diff --git a/ui/src/pages/moderator/reports.tsx b/ui/src/pages/moderator/reports.tsx new file mode 100644 index 00000000..d2693be6 --- /dev/null +++ b/ui/src/pages/moderator/reports.tsx @@ -0,0 +1,11 @@ +import { getModeratorLayout } from "@/components/layouts/ModeratorLayout"; +import type { NextPageWithLayout } from "@/pages/_app"; +import DetectionsPage from "@/pages/reports"; + +const ModeratorReportsPage: NextPageWithLayout = () => { + return ; +}; + +ModeratorReportsPage.getLayout = getModeratorLayout; + +export default ModeratorReportsPage; diff --git a/ui/src/types/DataTypes.ts b/ui/src/types/DataTypes.ts new file mode 100644 index 00000000..44e132ee --- /dev/null +++ b/ui/src/types/DataTypes.ts @@ -0,0 +1,54 @@ +import { Dispatch, SetStateAction } from "react"; + +import { Detection, Scalars } from "@/graphql/generated"; +import { Candidate } from "@/pages/moderator/candidates"; + +export interface HumanData extends Omit { + type: string; + hydrophone: string; + comments: string | null | undefined; + newCategory: string; + timestampString: string; +} + +export interface AIDetection { + id: string; + audioUri: string; + spectrogramUri: string; + location: Location; + timestamp: Scalars["DateTime"]["output"]; + annotations: Annotation[]; + reviewed: boolean; + found: string; + comments: string | null | undefined; + confidence: number; + moderator: string; + moderated: string; + tags: string; +} +export interface AIData extends AIDetection { + type: string; + hydrophone: string; + newCategory: string; + timestampString: string; +} +export interface CombinedData extends HumanData, AIData {} + +export interface Dataset { + human: HumanData[]; + ai: AIData[]; + combined: CombinedData[]; + // feeds: Feed[]; + isSuccess: boolean; + setNowPlaying?: Dispatch>; +} + +export interface Location { + name: string; +} +export interface Annotation { + id: number; + startTime: number; + endTime: number; + confidence: number; +} From e67238c73553e3b14b1384b29b8556c8977c2d83 Mon Sep 17 00:00:00 2001 From: Adrian <26727606+adrmac@users.noreply.github.com> Date: Thu, 6 Mar 2025 13:01:40 -0800 Subject: [PATCH 40/40] Revert "Adrian playground (#787)" (#794) This reverts commit 1b56c1f83d0b2b2d807eee2ff7202dd247715bc4. --- .DS_Store | Bin 6148 -> 0 bytes package-lock.json | 6 - package.json | 1 - server/priv/repo/seeds.exs | 26 +- ui/package-lock.json | 2508 ++++------------- ui/package.json | 12 +- ui/src/components/CandidateCard.tsx | 175 -- ui/src/components/ChartSelect.tsx | 45 - ui/src/components/FeedList.tsx | 52 - ui/src/components/Header.tsx | 3 +- ui/src/components/PlayBar.tsx | 365 --- .../Player/CandidateCardAIPlayer.tsx | 263 -- .../components/Player/CandidateCardPlayer.tsx | 249 -- ui/src/components/ReportsBarChart.tsx | 181 -- ui/src/components/layouts/DrawerLayout.tsx | 176 -- ui/src/components/layouts/ModeratorLayout.tsx | 325 --- ui/src/components/layouts/ToolpadLayout.tsx | 38 - ui/src/context/DataContext.tsx | 23 - ui/src/graphql/generated/index.ts | 74 - ui/src/graphql/queries/list2Feeds.graphql | 17 - .../graphql/queries/listDetections2.graphql | 24 - ui/src/pages/_app.tsx | 1 - ui/src/pages/moderator/[candidateId].tsx | 251 -- ui/src/pages/moderator/bouts.tsx | 11 - ui/src/pages/moderator/candidates.tsx | 370 --- ui/src/pages/moderator/hydrophones.tsx | 11 - ui/src/pages/moderator/index.tsx | 16 - ui/src/pages/moderator/json.tsx | 22 - ui/src/pages/moderator/learn.tsx | 11 - ui/src/pages/moderator/playertest.tsx | 60 - ui/src/pages/moderator/reports.tsx | 11 - ui/src/types/DataTypes.ts | 54 - 32 files changed, 585 insertions(+), 4796 deletions(-) delete mode 100644 .DS_Store delete mode 100644 package-lock.json delete mode 100644 package.json delete mode 100644 ui/src/components/CandidateCard.tsx delete mode 100644 ui/src/components/ChartSelect.tsx delete mode 100644 ui/src/components/FeedList.tsx delete mode 100644 ui/src/components/PlayBar.tsx delete mode 100644 ui/src/components/Player/CandidateCardAIPlayer.tsx delete mode 100644 ui/src/components/Player/CandidateCardPlayer.tsx delete mode 100644 ui/src/components/ReportsBarChart.tsx delete mode 100644 ui/src/components/layouts/DrawerLayout.tsx delete mode 100644 ui/src/components/layouts/ModeratorLayout.tsx delete mode 100644 ui/src/components/layouts/ToolpadLayout.tsx delete mode 100644 ui/src/context/DataContext.tsx delete mode 100644 ui/src/graphql/queries/list2Feeds.graphql delete mode 100644 ui/src/graphql/queries/listDetections2.graphql delete mode 100644 ui/src/pages/moderator/[candidateId].tsx delete mode 100644 ui/src/pages/moderator/bouts.tsx delete mode 100644 ui/src/pages/moderator/candidates.tsx delete mode 100644 ui/src/pages/moderator/hydrophones.tsx delete mode 100644 ui/src/pages/moderator/index.tsx delete mode 100644 ui/src/pages/moderator/json.tsx delete mode 100644 ui/src/pages/moderator/learn.tsx delete mode 100644 ui/src/pages/moderator/playertest.tsx delete mode 100644 ui/src/pages/moderator/reports.tsx delete mode 100644 ui/src/types/DataTypes.ts diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 5008ddfcf53c02e82d7eee2e57c38e5672ef89f6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeH~Jr2S!425mzP>H1@V-^m;4Wg<&0T*E43hX&L&p$$qDprKhvt+--jT7}7np#A3 zem<@ulZcFPQ@L2!n>{z**++&mCkOWA81W14cNZlEfg7;MkzE(HCqgga^y>{tEnwC%0;vJ&^%eQ zLs35+`xjp>T0=6.9.0" } }, "node_modules/@babel/core": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.9.tgz", - "integrity": "sha512-lWBYIrF7qK5+GjY5Uy+/hEgp8OJWOD/rpy74GplYRhEauvbHDeFB8t5hPOZxCZ0Oxf4Cc36tK51/l3ymJysrKw==", + "version": "7.22.9", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.22.9.tgz", + "integrity": "sha512-G2EgeufBcYw27U4hhoIwFcgc1XU7TlXJ3mv04oOv1WCuo900U/anZSPzEqNjwdjgffkk2Gs0AN0dW1CKVLcG7w==", + "dev": true, "dependencies": { "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.26.2", - "@babel/generator": "^7.26.9", - "@babel/helper-compilation-targets": "^7.26.5", - "@babel/helper-module-transforms": "^7.26.0", - "@babel/helpers": "^7.26.9", - "@babel/parser": "^7.26.9", - "@babel/template": "^7.26.9", - "@babel/traverse": "^7.26.9", - "@babel/types": "^7.26.9", - "convert-source-map": "^2.0.0", + "@babel/code-frame": "^7.22.5", + "@babel/generator": "^7.22.9", + "@babel/helper-compilation-targets": "^7.22.9", + "@babel/helper-module-transforms": "^7.22.9", + "@babel/helpers": "^7.22.6", + "@babel/parser": "^7.22.7", + "@babel/template": "^7.22.5", + "@babel/traverse": "^7.22.8", + "@babel/types": "^7.22.5", + "convert-source-map": "^1.7.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", + "json5": "^2.2.2", "semver": "^6.3.1" }, "engines": { @@ -299,21 +299,16 @@ "url": "https://opencollective.com/babel" } }, - "node_modules/@babel/core/node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==" - }, "node_modules/@babel/generator": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.9.tgz", - "integrity": "sha512-kEWdzjOAUMW4hAyrzJ0ZaTOu9OmpyDIQicIh0zg0EEcEkYXZb2TjtBhnHi2ViX7PKwZqF4xwqfAm299/QMP3lg==", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.24.1.tgz", + "integrity": "sha512-DfCRfZsBcrPEHUfuBMgbJ1Ut01Y/itOs+hY2nFLgqsqXd52/iSiVq5TITtUasIUgm+IIKdY2/1I7auiQOEeC9A==", + "dev": true, "dependencies": { - "@babel/parser": "^7.26.9", - "@babel/types": "^7.26.9", + "@babel/types": "^7.24.0", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", - "jsesc": "^3.0.2" + "jsesc": "^2.5.1" }, "engines": { "node": ">=6.9.0" @@ -332,18 +327,22 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.26.5", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.26.5.tgz", - "integrity": "sha512-IXuyn5EkouFJscIDuFF5EsiSolseme1s0CZB+QxVugqJLYmKdxI1VfIBOst0SUu4rnk2Z7kqTwmoO1lp3HIfnA==", + "version": "7.22.9", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.22.9.tgz", + "integrity": "sha512-7qYrNM6HjpnPHJbopxmb8hSPoZ0gsX8IvUS32JGVoy+pU9e5N0nLr1VjJoR6kA4d9dmGLxNYOjeB8sUDal2WMw==", + "dev": true, "dependencies": { - "@babel/compat-data": "^7.26.5", - "@babel/helper-validator-option": "^7.25.9", - "browserslist": "^4.24.0", + "@babel/compat-data": "^7.22.9", + "@babel/helper-validator-option": "^7.22.5", + "browserslist": "^4.21.9", "lru-cache": "^5.1.1", "semver": "^6.3.1" }, "engines": { "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" } }, "node_modules/@babel/helper-create-class-features-plugin": { @@ -391,6 +390,18 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-hoist-variables": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz", + "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-member-expression-to-functions": { "version": "7.22.5", "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.22.5.tgz", @@ -404,25 +415,27 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz", - "integrity": "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==", + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.22.5.tgz", + "integrity": "sha512-8Dl6+HD/cKifutF5qGd/8ZJi84QeAKh+CEe1sBzz8UayBBGg1dAIJrdHOcOM5b2MpzWL2yuotJTtGjETq0qjXg==", "dependencies": { - "@babel/traverse": "^7.25.9", - "@babel/types": "^7.25.9" + "@babel/types": "^7.22.5" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.26.0.tgz", - "integrity": "sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==", + "version": "7.22.9", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.22.9.tgz", + "integrity": "sha512-t+WA2Xn5K+rTeGtC8jCsdAH52bjggG5TKRuRrAGNM/mjIbO4GxvlLMFOEz9wXY5I2XQ60PMFsAG2WIcG82dQMQ==", + "dev": true, "dependencies": { - "@babel/helper-module-imports": "^7.25.9", - "@babel/helper-validator-identifier": "^7.25.9", - "@babel/traverse": "^7.25.9" + "@babel/helper-environment-visitor": "^7.22.5", + "@babel/helper-module-imports": "^7.22.5", + "@babel/helper-simple-access": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/helper-validator-identifier": "^7.22.5" }, "engines": { "node": ">=6.9.0" @@ -444,9 +457,10 @@ } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.26.5", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.26.5.tgz", - "integrity": "sha512-RS+jZcRdZdRFzMyr+wcsaqOmld1/EqTghfaBGQQd/WnRdzdlvSZ//kF7U8VQTxf1ynZ4cjUcYgjVGx13ewNPMg==", + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.22.5.tgz", + "integrity": "sha512-uLls06UVKgFG9QD4OeFYLEGteMIAa5kpTPcFL28yuCIIzsf6ZyKZMllKVOCZFhiZ5ptnwX4mtKdWCBE/uT4amg==", + "dev": true, "engines": { "node": ">=6.9.0" } @@ -505,48 +519,127 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", - "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.1.tgz", + "integrity": "sha512-2ofRCjnnA9y+wk8b9IAREroeUP02KHp431N2mhKniy2yKIDKpbrHv9eXwm8cBeWQYcJmzv5qKCu65P47eCF7CQ==", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", - "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", + "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-option": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.25.9.tgz", - "integrity": "sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==", + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.22.5.tgz", + "integrity": "sha512-R3oB6xlIVKUnxNUxbmgq7pKjxpru24zlimpE8WK47fACIlM0II/Hm1RS8IaOI7NgCr6LNS+jl5l75m20npAziw==", + "dev": true, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helpers": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.9.tgz", - "integrity": "sha512-Mz/4+y8udxBKdmzt/UjPACs4G3j5SshJJEFFKxlCGPydG4JAHXxjWjAwjd09tf6oINvl1VfMJo+nB7H2YKQ0dA==", + "version": "7.22.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.22.6.tgz", + "integrity": "sha512-YjDs6y/fVOYFV8hAf1rxd1QvR9wJe1pDBZ2AREKq/SDayfPzgk0PBnVuTCE5X1acEpMMNOVUqoe+OwiZGJ+OaA==", + "dev": true, "dependencies": { - "@babel/template": "^7.26.9", - "@babel/types": "^7.26.9" + "@babel/template": "^7.22.5", + "@babel/traverse": "^7.22.6", + "@babel/types": "^7.22.5" }, "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/parser": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.9.tgz", - "integrity": "sha512-81NWa1njQblgZbQHxWHpxxCzNsa3ZwvFqpUg7P+NNUU6f3UU2jBEg4OlF/J6rl8+PQGh1q6/zWScd001YwcA5A==", + "node_modules/@babel/highlight": { + "version": "7.24.2", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.24.2.tgz", + "integrity": "sha512-Yac1ao4flkTxTteCDZLEvdxg2fZfz1v8M4QpaGypq/WPDqg3ijHYbDfs+LG5hvzSoqaSZ9/Z9lKSP3CjZjv+pA==", + "dependencies": { + "@babel/helper-validator-identifier": "^7.22.20", + "chalk": "^2.4.2", + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/@babel/highlight/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" + }, + "node_modules/@babel/highlight/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@babel/highlight/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", "dependencies": { - "@babel/types": "^7.26.9" + "has-flag": "^3.0.0" }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/parser": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.1.tgz", + "integrity": "sha512-Zo9c7N3xdOIQrNip7Lc9wvRPzlRtovHVE4lkz8WEDr7uYh/GMQhSiIgFxGIArRHYdJE5kxtZjAf8rT0xhdLCzg==", + "dev": true, "bin": { "parser": "bin/babel-parser.js" }, @@ -932,34 +1025,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.25.9.tgz", - "integrity": "sha512-y8quW6p0WHkEhmErnfe58r7x0A70uKphQm8Sp8cV7tjNQwK56sNVK0M73LK3WuYmsuyrftut4xAkjjgU0twaMg==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.25.9.tgz", - "integrity": "sha512-+iqjT8xmXhhYv4/uiYd8FNQsraMFZIfxVSqxxVSZP0WbbSAWvBXAul0m/zu+7Vv4O/3WtApy9pmaTMiumEZgfg==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/plugin-transform-shorthand-properties": { "version": "7.22.5", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.22.5.tgz", @@ -1007,9 +1072,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.9.tgz", - "integrity": "sha512-aA63XwOkcl4xxQa3HjPMqOP6LiK0ZDv3mUPYEFXkpHbaFjtGggE1A61FjFzJnB+p7/oy2gA8E+rcBNl/zC1tMg==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.25.7.tgz", + "integrity": "sha512-FjoyLe754PMiYsFaN5C94ttGiOmBNYTf6pLr4xXHAT5uctHb092PBszndLDR5XA/jghQvn4n7JMHl7dmTgbm9w==", "dependencies": { "regenerator-runtime": "^0.14.0" }, @@ -1018,28 +1083,33 @@ } }, "node_modules/@babel/template": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.9.tgz", - "integrity": "sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==", + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.24.0.tgz", + "integrity": "sha512-Bkf2q8lMB0AFpX0NFEqSbx1OkTHf0f+0j82mkw+ZpzBnkk7e9Ql0891vlfgi+kHwOk8tQjiQHpqh4LaSa0fKEA==", + "dev": true, "dependencies": { - "@babel/code-frame": "^7.26.2", - "@babel/parser": "^7.26.9", - "@babel/types": "^7.26.9" + "@babel/code-frame": "^7.23.5", + "@babel/parser": "^7.24.0", + "@babel/types": "^7.24.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.9.tgz", - "integrity": "sha512-ZYW7L+pL8ahU5fXmNbPF+iZFHCv5scFak7MZ9bwaRPLUhHh7QQEMjZUg0HevihoqCM5iSYHN61EyCoZvqC+bxg==", - "dependencies": { - "@babel/code-frame": "^7.26.2", - "@babel/generator": "^7.26.9", - "@babel/parser": "^7.26.9", - "@babel/template": "^7.26.9", - "@babel/types": "^7.26.9", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.24.1.tgz", + "integrity": "sha512-xuU6o9m68KeqZbQuDt2TcKSxUw/mrsvavlEqQ1leZ/B+C9tk6E4sRWy97WaXgvq5E+nU3cXMxv3WKOCanVMCmQ==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.24.1", + "@babel/generator": "^7.24.1", + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-function-name": "^7.23.0", + "@babel/helper-hoist-variables": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/parser": "^7.24.1", + "@babel/types": "^7.24.0", "debug": "^4.3.1", "globals": "^11.1.0" }, @@ -1048,12 +1118,13 @@ } }, "node_modules/@babel/types": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.9.tgz", - "integrity": "sha512-Y3IR1cRnOxOCDvMmNiym7XpXQ93iGDDPHx+Zj+NM+rg0fBaShfQLkg+hKPaZCEvg5N/LeCo4+Rj/i3FuJsIQaw==", + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.0.tgz", + "integrity": "sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w==", "dependencies": { - "@babel/helper-string-parser": "^7.25.9", - "@babel/helper-validator-identifier": "^7.25.9" + "@babel/helper-string-parser": "^7.23.4", + "@babel/helper-validator-identifier": "^7.22.20", + "to-fast-properties": "^2.0.0" }, "engines": { "node": ">=6.9.0" @@ -1096,13 +1167,13 @@ } }, "node_modules/@emotion/cache": { - "version": "11.14.0", - "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", - "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "version": "11.13.1", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.13.1.tgz", + "integrity": "sha512-iqouYkuEblRcXmylXIwwOodiEK5Ifl7JcX7o6V4jI3iW4mLXX3dmt5xwBtIkJiQEXFAI+pC8X0i67yiPkH9Ucw==", "dependencies": { "@emotion/memoize": "^0.9.0", "@emotion/sheet": "^1.4.0", - "@emotion/utils": "^1.4.2", + "@emotion/utils": "^1.4.0", "@emotion/weak-memoize": "^0.4.0", "stylis": "4.2.0" } @@ -1149,14 +1220,14 @@ } }, "node_modules/@emotion/serialize": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", - "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.2.tgz", + "integrity": "sha512-grVnMvVPK9yUVE6rkKfAJlYZgo0cu3l9iMC77V7DW6E1DUIrU68pSEXRmFZFOFB1QFo57TncmOcvcbMDWsL4yA==", "dependencies": { "@emotion/hash": "^0.9.2", "@emotion/memoize": "^0.9.0", "@emotion/unitless": "^0.10.0", - "@emotion/utils": "^1.4.2", + "@emotion/utils": "^1.4.1", "csstype": "^3.0.2" } }, @@ -1220,534 +1291,112 @@ } }, "node_modules/@emotion/utils": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", - "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==" + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.1.tgz", + "integrity": "sha512-BymCXzCG3r72VKJxaYVwOXATqXIZ85cuvg0YOUDxMGNrKc1DJRZk8MgV5wyXRyEayIMd4FuXJIUgTBXvDNW5cA==" }, "node_modules/@emotion/weak-memoize": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==" }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz", - "integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==", - "cpu": [ - "ppc64" - ], - "optional": true, - "os": [ - "aix" - ], - "peer": true, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", + "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^3.3.0" + }, "engines": { - "node": ">=18" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, - "node_modules/@esbuild/android-arm": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz", - "integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==", - "cpu": [ - "arm" - ], - "optional": true, - "os": [ - "android" - ], - "peer": true, + "node_modules/@eslint-community/regexpp": { + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.11.0.tgz", + "integrity": "sha512-G/M/tIiMrTAxEWRfLfQJMmGNX28IxBg4PBz8XqQhqUHLFI6TL2htpIB1iQCj144V5ee/JaKyT9/WZ0MGZWfA7A==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=18" + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, - "node_modules/@esbuild/android-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz", - "integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "android" - ], - "peer": true, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, "engines": { - "node": ">=18" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/@esbuild/android-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz", - "integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "android" - ], - "peer": true, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, "engines": { - "node": ">=18" + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz", - "integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "darwin" - ], - "peer": true, + "node_modules/@eslint/eslintrc/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", "engines": { - "node": ">=18" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz", - "integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "darwin" - ], - "peer": true, + "node_modules/@eslint/js": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz", + "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=18" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz", - "integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } + "node_modules/@fontsource/montserrat": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fontsource/montserrat/-/montserrat-5.1.0.tgz", + "integrity": "sha512-HB4+rWP9Y8g6T9RGRVJk2SvAJtx2eBAXuivvPOqQdD806/9WESUfucfH9LqFj3bGgdhNCfh0Rv0NGuwEmBLRiw==" }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz", - "integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz", - "integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==", - "cpu": [ - "arm" - ], - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz", - "integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz", - "integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==", - "cpu": [ - "ia32" - ], - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz", - "integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==", - "cpu": [ - "loong64" - ], - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz", - "integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==", - "cpu": [ - "mips64el" - ], - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz", - "integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==", - "cpu": [ - "ppc64" - ], - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz", - "integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==", - "cpu": [ - "riscv64" - ], - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz", - "integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==", - "cpu": [ - "s390x" - ], - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz", - "integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz", - "integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "netbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz", - "integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "netbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz", - "integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "openbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz", - "integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "openbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz", - "integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "sunos" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz", - "integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz", - "integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==", - "cpu": [ - "ia32" - ], - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz", - "integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", - "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", - "dev": true, - "dependencies": { - "eslint-visitor-keys": "^3.3.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.11.0", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.11.0.tgz", - "integrity": "sha512-G/M/tIiMrTAxEWRfLfQJMmGNX28IxBg4PBz8XqQhqUHLFI6TL2htpIB1iQCj144V5ee/JaKyT9/WZ0MGZWfA7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", - "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^9.6.0", - "globals": "^13.19.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "13.24.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "type-fest": "^0.20.2" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@eslint/eslintrc/node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@eslint/js": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz", - "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, - "node_modules/@floating-ui/core": { - "version": "1.6.9", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.9.tgz", - "integrity": "sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==", - "dependencies": { - "@floating-ui/utils": "^0.2.9" - } - }, - "node_modules/@floating-ui/dom": { - "version": "1.6.13", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.13.tgz", - "integrity": "sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w==", - "dependencies": { - "@floating-ui/core": "^1.6.0", - "@floating-ui/utils": "^0.2.9" - } - }, - "node_modules/@floating-ui/utils": { - "version": "0.2.9", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz", - "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==" - }, - "node_modules/@fontsource/montserrat": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@fontsource/montserrat/-/montserrat-5.1.0.tgz", - "integrity": "sha512-HB4+rWP9Y8g6T9RGRVJk2SvAJtx2eBAXuivvPOqQdD806/9WESUfucfH9LqFj3bGgdhNCfh0Rv0NGuwEmBLRiw==" - }, - "node_modules/@fontsource/mukta": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@fontsource/mukta/-/mukta-5.1.0.tgz", - "integrity": "sha512-VkxZ8t5nMkIDtlPiTn9vYshZ0IfJ9khoLNtLrJj7xGsFLQnSJQYE7muE5vJk32H78y3OU47udNc3HJ2et2SBYw==" + "node_modules/@fontsource/mukta": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fontsource/mukta/-/mukta-5.1.0.tgz", + "integrity": "sha512-VkxZ8t5nMkIDtlPiTn9vYshZ0IfJ9khoLNtLrJj7xGsFLQnSJQYE7muE5vJk32H78y3OU47udNc3HJ2et2SBYw==" }, "node_modules/@graphql-codegen/add": { "version": "5.0.3", @@ -3096,6 +2745,7 @@ "version": "0.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", + "dev": true, "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", @@ -3109,6 +2759,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz", "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==", + "dev": true, "engines": { "node": ">=6.0.0" } @@ -3117,6 +2768,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "dev": true, "engines": { "node": ">=6.0.0" } @@ -3124,12 +2776,14 @@ "node_modules/@jridgewell/sourcemap-codec": { "version": "1.4.15", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", - "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==" + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", + "dev": true }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.25", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "dev": true, "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" @@ -3142,9 +2796,9 @@ "dev": true }, "node_modules/@mui/core-downloads-tracker": { - "version": "6.4.4", - "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-6.4.4.tgz", - "integrity": "sha512-r+J0EditrekkTtO2CnCBCOGpNaDYwJqz8lH4rj6o/anDcskZFJodBlG8aCJkS8DL/CF/9EHS+Gz53EbmYEnQbw==", + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-6.1.4.tgz", + "integrity": "sha512-jCRsB9NDJJatVCHvwWSTfYUzuTQ7E0Km6tAQWz2Md1SLHIbVj5visC9yHbf/Cv2IDcG6XdHRv3e7Bt1rIburNw==", "funding": { "type": "opencollective", "url": "https://opencollective.com/mui-org" @@ -3176,21 +2830,21 @@ } }, "node_modules/@mui/material": { - "version": "6.4.4", - "resolved": "https://registry.npmjs.org/@mui/material/-/material-6.4.4.tgz", - "integrity": "sha512-ISVPrIsPQsxnwvS40C4u03AuNSPigFeS2+n1qpuEZ94hDsdMi19dQM2JcC9CHEhXecSIQjP1RTyY0mPiSpSrFQ==", - "dependencies": { - "@babel/runtime": "^7.26.0", - "@mui/core-downloads-tracker": "^6.4.4", - "@mui/system": "^6.4.3", - "@mui/types": "^7.2.21", - "@mui/utils": "^6.4.3", + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-6.1.4.tgz", + "integrity": "sha512-mIVdjzDYU4U/XYzf8pPEz3zDZFS4Wbyr0cjfgeGiT/s60EvtEresXXQy8XUA0bpJDJjgic1Hl5AIRcqWDyi2eg==", + "dependencies": { + "@babel/runtime": "^7.25.7", + "@mui/core-downloads-tracker": "^6.1.4", + "@mui/system": "^6.1.4", + "@mui/types": "^7.2.18", + "@mui/utils": "^6.1.4", "@popperjs/core": "^2.11.8", - "@types/react-transition-group": "^4.4.12", + "@types/react-transition-group": "^4.4.11", "clsx": "^2.1.1", "csstype": "^3.1.3", "prop-types": "^15.8.1", - "react-is": "^19.0.0", + "react-is": "^18.3.1", "react-transition-group": "^4.4.5" }, "engines": { @@ -3203,7 +2857,7 @@ "peerDependencies": { "@emotion/react": "^11.5.0", "@emotion/styled": "^11.3.0", - "@mui/material-pigment-css": "^6.4.3", + "@mui/material-pigment-css": "^6.1.4", "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" @@ -3224,11 +2878,11 @@ } }, "node_modules/@mui/material-nextjs": { - "version": "6.4.3", - "resolved": "https://registry.npmjs.org/@mui/material-nextjs/-/material-nextjs-6.4.3.tgz", - "integrity": "sha512-4ZRLrcD1HeWpvY8c7MrKYKuaUSobtvqcLYeEfGh/x5ezzPgKizhl7C0jpVVEgf6g+C9OgOGbhLTVfks7Y2IBAQ==", + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/@mui/material-nextjs/-/material-nextjs-6.1.4.tgz", + "integrity": "sha512-dCXnoxky+Ts04xkU8lafkdJ3OVh+n/mXORq50B0YAVUl2f06Uw2dYImHkqeSJwJrBhj+j/mMlJrfhf5RnWDy5A==", "dependencies": { - "@babel/runtime": "^7.26.0" + "@babel/runtime": "^7.25.7" }, "engines": { "node": ">=14.0.0" @@ -3242,7 +2896,7 @@ "@emotion/react": "^11.11.4", "@emotion/server": "^11.11.0", "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "next": "^13.0.0 || ^14.0.0 || ^15.0.0", + "next": "^13.0.0 || ^14.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "peerDependenciesMeta": { @@ -3257,135 +2911,13 @@ } } }, - "node_modules/@mui/material/node_modules/@mui/utils": { - "version": "6.4.3", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.4.3.tgz", - "integrity": "sha512-jxHRHh3BqVXE9ABxDm+Tc3wlBooYz/4XPa0+4AI+iF38rV1/+btJmSUgG4shDtSWVs/I97aDn5jBCt6SF2Uq2A==", - "dependencies": { - "@babel/runtime": "^7.26.0", - "@mui/types": "^7.2.21", - "@types/prop-types": "^15.7.14", - "clsx": "^2.1.1", - "prop-types": "^15.8.1", - "react-is": "^19.0.0" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@mui/private-theming": { - "version": "6.4.3", - "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-6.4.3.tgz", - "integrity": "sha512-7x9HaNwDCeoERc4BoEWLieuzKzXu5ZrhRnEM6AUcRXUScQLvF1NFkTlP59+IJfTbEMgcGg1wWHApyoqcksrBpQ==", - "dependencies": { - "@babel/runtime": "^7.26.0", - "@mui/utils": "^6.4.3", - "prop-types": "^15.8.1" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@mui/private-theming/node_modules/@mui/utils": { - "version": "6.4.3", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.4.3.tgz", - "integrity": "sha512-jxHRHh3BqVXE9ABxDm+Tc3wlBooYz/4XPa0+4AI+iF38rV1/+btJmSUgG4shDtSWVs/I97aDn5jBCt6SF2Uq2A==", - "dependencies": { - "@babel/runtime": "^7.26.0", - "@mui/types": "^7.2.21", - "@types/prop-types": "^15.7.14", - "clsx": "^2.1.1", - "prop-types": "^15.8.1", - "react-is": "^19.0.0" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@mui/styled-engine": { - "version": "6.4.3", - "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-6.4.3.tgz", - "integrity": "sha512-OC402VfK+ra2+f12Gef8maY7Y9n7B6CZcoQ9u7mIkh/7PKwW/xH81xwX+yW+Ak1zBT3HYcVjh2X82k5cKMFGoQ==", + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-6.1.4.tgz", + "integrity": "sha512-FPa+W5BSrRM/1QI5Gf/GwJinJ2WsrKPpJB6xMmmXMXSUIp31YioIVT04i28DQUXFFB3yZY12ukcZi51iLvPljw==", "dependencies": { - "@babel/runtime": "^7.26.0", - "@emotion/cache": "^11.13.5", - "@emotion/serialize": "^1.3.3", - "@emotion/sheet": "^1.4.0", - "csstype": "^3.1.3", - "prop-types": "^15.8.1" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@emotion/react": "^11.4.1", - "@emotion/styled": "^11.3.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@emotion/react": { - "optional": true - }, - "@emotion/styled": { - "optional": true - } - } - }, - "node_modules/@mui/system": { - "version": "6.4.3", - "resolved": "https://registry.npmjs.org/@mui/system/-/system-6.4.3.tgz", - "integrity": "sha512-Q0iDwnH3+xoxQ0pqVbt8hFdzhq1g2XzzR4Y5pVcICTNtoCLJmpJS3vI4y/OIM1FHFmpfmiEC2IRIq7YcZ8nsmg==", - "dependencies": { - "@babel/runtime": "^7.26.0", - "@mui/private-theming": "^6.4.3", - "@mui/styled-engine": "^6.4.3", - "@mui/types": "^7.2.21", - "@mui/utils": "^6.4.3", - "clsx": "^2.1.1", - "csstype": "^3.1.3", + "@babel/runtime": "^7.25.7", + "@mui/utils": "^6.1.4", "prop-types": "^15.8.1" }, "engines": { @@ -3396,58 +2928,8 @@ "url": "https://opencollective.com/mui-org" }, "peerDependencies": { - "@emotion/react": "^11.5.0", - "@emotion/styled": "^11.3.0", - "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@emotion/react": { - "optional": true - }, - "@emotion/styled": { - "optional": true - }, - "@types/react": { - "optional": true - } - } - }, - "node_modules/@mui/system/node_modules/@mui/utils": { - "version": "6.4.3", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.4.3.tgz", - "integrity": "sha512-jxHRHh3BqVXE9ABxDm+Tc3wlBooYz/4XPa0+4AI+iF38rV1/+btJmSUgG4shDtSWVs/I97aDn5jBCt6SF2Uq2A==", - "dependencies": { - "@babel/runtime": "^7.26.0", - "@mui/types": "^7.2.21", - "@types/prop-types": "^15.7.14", - "clsx": "^2.1.1", - "prop-types": "^15.8.1", - "react-is": "^19.0.0" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@mui/types": { - "version": "7.2.21", - "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.21.tgz", - "integrity": "sha512-6HstngiUxNqLU+/DPqlUJDIPbzUBxIVHb1MmXP0eTWDIROiCR2viugXpEif0PPe2mLqqakPzzRClWAnK+8UJww==", - "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "peerDependenciesMeta": { "@types/react": { @@ -3455,17 +2937,17 @@ } } }, - "node_modules/@mui/utils": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.3.1.tgz", - "integrity": "sha512-sjGjXAngoio6lniQZKJ5zGfjm+LD2wvLwco7FbKe1fu8A7VIFmz2SwkLb+MDPLNX1lE7IscvNNyh1pobtZg2tw==", + "node_modules/@mui/styled-engine": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-6.1.4.tgz", + "integrity": "sha512-D+aiIDtJsU9OVJ7dgayhCDABJHT7jTlnz1FKyxa5mNVHsxjjeG1M4OpLsRQvx4dcvJfDywnU2cE+nFm4Ln2aFQ==", "dependencies": { - "@babel/runtime": "^7.26.0", - "@mui/types": "^7.2.21", - "@types/prop-types": "^15.7.14", - "clsx": "^2.1.1", - "prop-types": "^15.8.1", - "react-is": "^19.0.0" + "@babel/runtime": "^7.25.7", + "@emotion/cache": "^11.13.1", + "@emotion/serialize": "^1.3.2", + "@emotion/sheet": "^1.4.0", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" }, "engines": { "node": ">=14.0.0" @@ -3475,39 +2957,45 @@ "url": "https://opencollective.com/mui-org" }, "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "@emotion/react": "^11.4.1", + "@emotion/styled": "^11.3.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "peerDependenciesMeta": { - "@types/react": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { "optional": true } } }, - "node_modules/@mui/x-charts": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@mui/x-charts/-/x-charts-7.27.0.tgz", - "integrity": "sha512-EIT5zbClc8n14qBvCD7jYSVI4jWAWajY7g8gznf5rggCJuv08IHfmi23q6afax73q6yTAi30qeUmcqttqXV4DQ==", + "node_modules/@mui/system": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-6.1.4.tgz", + "integrity": "sha512-lCveY/UtDhYwMg1WnLc3wEEuGymLi6YI79VOwFV9zfZT5Et+XEw/e1It26fiKwUZ+mB1+v1iTYMpJnwnsrn2aQ==", "dependencies": { "@babel/runtime": "^7.25.7", - "@mui/utils": "^5.16.6 || ^6.0.0", - "@mui/x-charts-vendor": "7.20.0", - "@mui/x-internals": "7.26.0", - "@react-spring/rafz": "^9.7.5", - "@react-spring/web": "^9.7.5", + "@mui/private-theming": "^6.1.4", + "@mui/styled-engine": "^6.1.4", + "@mui/types": "^7.2.18", + "@mui/utils": "^6.1.4", "clsx": "^2.1.1", + "csstype": "^3.1.3", "prop-types": "^15.8.1" }, "engines": { "node": ">=14.0.0" }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, "peerDependencies": { - "@emotion/react": "^11.9.0", - "@emotion/styled": "^11.8.1", - "@mui/material": "^5.15.14 || ^6.0.0", - "@mui/system": "^5.15.14 || ^6.0.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "peerDependenciesMeta": { "@emotion/react": { @@ -3515,38 +3003,36 @@ }, "@emotion/styled": { "optional": true + }, + "@types/react": { + "optional": true } } }, - "node_modules/@mui/x-charts-vendor": { - "version": "7.20.0", - "resolved": "https://registry.npmjs.org/@mui/x-charts-vendor/-/x-charts-vendor-7.20.0.tgz", - "integrity": "sha512-pzlh7z/7KKs5o0Kk0oPcB+sY0+Dg7Q7RzqQowDQjpy5Slz6qqGsgOB5YUzn0L+2yRmvASc4Pe0914Ao3tMBogg==", - "dependencies": { - "@babel/runtime": "^7.25.7", - "@types/d3-color": "^3.1.3", - "@types/d3-delaunay": "^6.0.4", - "@types/d3-interpolate": "^3.0.4", - "@types/d3-scale": "^4.0.8", - "@types/d3-shape": "^3.1.6", - "@types/d3-time": "^3.0.3", - "d3-color": "^3.1.0", - "d3-delaunay": "^6.0.4", - "d3-interpolate": "^3.0.1", - "d3-scale": "^4.0.2", - "d3-shape": "^3.2.0", - "d3-time": "^3.1.0", - "delaunator": "^5.0.1", - "robust-predicates": "^3.0.2" + "node_modules/@mui/types": { + "version": "7.2.18", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.18.tgz", + "integrity": "sha512-uvK9dWeyCJl/3ocVnTOS6nlji/Knj8/tVqVX03UVTpdmTJYu/s4jtDd9Kvv0nRGE0CUSNW1UYAci7PYypjealg==", + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@mui/x-internals": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-7.26.0.tgz", - "integrity": "sha512-VxTCYQcZ02d3190pdvys2TDg9pgbvewAVakEopiOgReKAUhLdRlgGJHcOA/eAuGLyK1YIo26A6Ow6ZKlSRLwMg==", + "node_modules/@mui/utils": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.1.4.tgz", + "integrity": "sha512-v0wXkyh3/Hpw48ivlNvgs4ZT6M8BIEAMdLgvct59rQBggYFhoAVKyliKDzdj37CnIlYau3DYIn7x5bHlRYFBow==", "dependencies": { "@babel/runtime": "^7.25.7", - "@mui/utils": "^5.16.6 || ^6.0.0" + "@mui/types": "^7.2.18", + "@types/prop-types": "^15.7.13", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^18.3.1" }, "engines": { "node": ">=14.0.0" @@ -3556,7 +3042,13 @@ "url": "https://opencollective.com/mui-org" }, "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, "node_modules/@next/bundle-analyzer": { @@ -3833,324 +3325,11 @@ "react-dom": "^18.0.0" } }, - "node_modules/@react-spring/animated": { - "version": "9.7.5", - "resolved": "https://registry.npmjs.org/@react-spring/animated/-/animated-9.7.5.tgz", - "integrity": "sha512-Tqrwz7pIlsSDITzxoLS3n/v/YCUHQdOIKtOJf4yL6kYVSDTSmVK1LI1Q3M/uu2Sx4X3pIWF3xLUhlsA6SPNTNg==", - "dependencies": { - "@react-spring/shared": "~9.7.5", - "@react-spring/types": "~9.7.5" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - } - }, - "node_modules/@react-spring/core": { - "version": "9.7.5", - "resolved": "https://registry.npmjs.org/@react-spring/core/-/core-9.7.5.tgz", - "integrity": "sha512-rmEqcxRcu7dWh7MnCcMXLvrf6/SDlSokLaLTxiPlAYi11nN3B5oiCUAblO72o+9z/87j2uzxa2Inm8UbLjXA+w==", - "dependencies": { - "@react-spring/animated": "~9.7.5", - "@react-spring/shared": "~9.7.5", - "@react-spring/types": "~9.7.5" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/react-spring/donate" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - } - }, - "node_modules/@react-spring/rafz": { - "version": "9.7.5", - "resolved": "https://registry.npmjs.org/@react-spring/rafz/-/rafz-9.7.5.tgz", - "integrity": "sha512-5ZenDQMC48wjUzPAm1EtwQ5Ot3bLIAwwqP2w2owG5KoNdNHpEJV263nGhCeKKmuA3vG2zLLOdu3or6kuDjA6Aw==" - }, - "node_modules/@react-spring/shared": { - "version": "9.7.5", - "resolved": "https://registry.npmjs.org/@react-spring/shared/-/shared-9.7.5.tgz", - "integrity": "sha512-wdtoJrhUeeyD/PP/zo+np2s1Z820Ohr/BbuVYv+3dVLW7WctoiN7std8rISoYoHpUXtbkpesSKuPIw/6U1w1Pw==", - "dependencies": { - "@react-spring/rafz": "~9.7.5", - "@react-spring/types": "~9.7.5" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - } - }, - "node_modules/@react-spring/types": { - "version": "9.7.5", - "resolved": "https://registry.npmjs.org/@react-spring/types/-/types-9.7.5.tgz", - "integrity": "sha512-HVj7LrZ4ReHWBimBvu2SKND3cDVUPWKLqRTmWe/fNY6o1owGOX0cAHbdPDTMelgBlVbrTKrre6lFkhqGZErK/g==" - }, - "node_modules/@react-spring/web": { - "version": "9.7.5", - "resolved": "https://registry.npmjs.org/@react-spring/web/-/web-9.7.5.tgz", - "integrity": "sha512-lmvqGwpe+CSttsWNZVr+Dg62adtKhauGwLyGE/RRyZ8AAMLgb9x3NDMA5RMElXo+IMyTkPp7nxTB8ZQlmhb6JQ==", - "dependencies": { - "@react-spring/animated": "~9.7.5", - "@react-spring/core": "~9.7.5", - "@react-spring/shared": "~9.7.5", - "@react-spring/types": "~9.7.5" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" - } - }, "node_modules/@repeaterjs/repeater": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@repeaterjs/repeater/-/repeater-3.0.4.tgz", - "integrity": "sha512-AW8PKd6iX3vAZ0vA43nOUOnbq/X5ihgU+mSXXqunMkeQADGiqw/PY0JNeYtD5sr0PAy51YPgAPbDoeapv9r8WA==", - "dev": true - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.34.8.tgz", - "integrity": "sha512-q217OSE8DTp8AFHuNHXo0Y86e1wtlfVrXiAlwkIvGRQv9zbc6mE3sjIVfwI8sYUyNxwOg0j/Vm1RKM04JcWLJw==", - "cpu": [ - "arm" - ], - "optional": true, - "os": [ - "android" - ], - "peer": true - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.34.8.tgz", - "integrity": "sha512-Gigjz7mNWaOL9wCggvoK3jEIUUbGul656opstjaUSGC3eT0BM7PofdAJaBfPFWWkXNVAXbaQtC99OCg4sJv70Q==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "android" - ], - "peer": true - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.34.8.tgz", - "integrity": "sha512-02rVdZ5tgdUNRxIUrFdcMBZQoaPMrxtwSb+/hOfBdqkatYHR3lZ2A2EGyHq2sGOd0Owk80oV3snlDASC24He3Q==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "darwin" - ], - "peer": true - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.34.8.tgz", - "integrity": "sha512-qIP/elwR/tq/dYRx3lgwK31jkZvMiD6qUtOycLhTzCvrjbZ3LjQnEM9rNhSGpbLXVJYQ3rq39A6Re0h9tU2ynw==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "darwin" - ], - "peer": true - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.34.8.tgz", - "integrity": "sha512-IQNVXL9iY6NniYbTaOKdrlVP3XIqazBgJOVkddzJlqnCpRi/yAeSOa8PLcECFSQochzqApIOE1GHNu3pCz+BDA==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "freebsd" - ], - "peer": true - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.34.8.tgz", - "integrity": "sha512-TYXcHghgnCqYFiE3FT5QwXtOZqDj5GmaFNTNt3jNC+vh22dc/ukG2cG+pi75QO4kACohZzidsq7yKTKwq/Jq7Q==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "freebsd" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.34.8.tgz", - "integrity": "sha512-A4iphFGNkWRd+5m3VIGuqHnG3MVnqKe7Al57u9mwgbyZ2/xF9Jio72MaY7xxh+Y87VAHmGQr73qoKL9HPbXj1g==", - "cpu": [ - "arm" - ], - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.34.8.tgz", - "integrity": "sha512-S0lqKLfTm5u+QTxlFiAnb2J/2dgQqRy/XvziPtDd1rKZFXHTyYLoVL58M/XFwDI01AQCDIevGLbQrMAtdyanpA==", - "cpu": [ - "arm" - ], - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.34.8.tgz", - "integrity": "sha512-jpz9YOuPiSkL4G4pqKrus0pn9aYwpImGkosRKwNi+sJSkz+WU3anZe6hi73StLOQdfXYXC7hUfsQlTnjMd3s1A==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.34.8.tgz", - "integrity": "sha512-KdSfaROOUJXgTVxJNAZ3KwkRc5nggDk+06P6lgi1HLv1hskgvxHUKZ4xtwHkVYJ1Rep4GNo+uEfycCRRxht7+Q==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.34.8.tgz", - "integrity": "sha512-NyF4gcxwkMFRjgXBM6g2lkT58OWztZvw5KkV2K0qqSnUEqCVcqdh2jN4gQrTn/YUpAcNKyFHfoOZEer9nwo6uQ==", - "cpu": [ - "loong64" - ], - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.34.8.tgz", - "integrity": "sha512-LMJc999GkhGvktHU85zNTDImZVUCJ1z/MbAJTnviiWmmjyckP5aQsHtcujMjpNdMZPT2rQEDBlJfubhs3jsMfw==", - "cpu": [ - "ppc64" - ], - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.34.8.tgz", - "integrity": "sha512-xAQCAHPj8nJq1PI3z8CIZzXuXCstquz7cIOL73HHdXiRcKk8Ywwqtx2wrIy23EcTn4aZ2fLJNBB8d0tQENPCmw==", - "cpu": [ - "riscv64" - ], - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.34.8.tgz", - "integrity": "sha512-DdePVk1NDEuc3fOe3dPPTb+rjMtuFw89gw6gVWxQFAuEqqSdDKnrwzZHrUYdac7A7dXl9Q2Vflxpme15gUWQFA==", - "cpu": [ - "s390x" - ], - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.34.8.tgz", - "integrity": "sha512-8y7ED8gjxITUltTUEJLQdgpbPh1sUQ0kMTmufRF/Ns5tI9TNMNlhWtmPKKHCU0SilX+3MJkZ0zERYYGIVBYHIA==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.34.8.tgz", - "integrity": "sha512-SCXcP0ZpGFIe7Ge+McxY5zKxiEI5ra+GT3QRxL0pMMtxPfpyLAKleZODi1zdRHkz5/BhueUrYtYVgubqe9JBNQ==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.34.8.tgz", - "integrity": "sha512-YHYsgzZgFJzTRbth4h7Or0m5O74Yda+hLin0irAIobkLQFRQd1qWmnoVfwmKm9TXIZVAD0nZ+GEb2ICicLyCnQ==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "win32" - ], - "peer": true - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.34.8.tgz", - "integrity": "sha512-r3NRQrXkHr4uWy5TOjTpTYojR9XmF0j/RYgKCef+Ag46FWUTltm5ziticv8LdNsDMehjJ543x/+TJAek/xBA2w==", - "cpu": [ - "ia32" - ], - "optional": true, - "os": [ - "win32" - ], - "peer": true - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.34.8.tgz", - "integrity": "sha512-U0FaE5O1BCpZSeE6gBl3c5ObhePQSfk9vDRToMmTkbhCOgW4jqvtS5LGyQ76L1fH8sM0keRp4uDTsbjiUyjk0g==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "win32" - ], - "peer": true + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@repeaterjs/repeater/-/repeater-3.0.4.tgz", + "integrity": "sha512-AW8PKd6iX3vAZ0vA43nOUOnbq/X5ihgU+mSXXqunMkeQADGiqw/PY0JNeYtD5sr0PAy51YPgAPbDoeapv9r8WA==", + "dev": true }, "node_modules/@rushstack/eslint-patch": { "version": "1.8.0", @@ -4173,9 +3352,9 @@ } }, "node_modules/@tanstack/query-core": { - "version": "5.66.4", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.66.4.tgz", - "integrity": "sha512-skM/gzNX4shPkqmdTCSoHtJAPMTtmIJNS0hE+xwTTUVYwezArCT34NMermABmBVUg5Ls5aiUXEDXfqwR1oVkcA==", + "version": "5.59.13", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.59.13.tgz", + "integrity": "sha512-Oou0bBu/P8+oYjXsJQ11j+gcpLAMpqW42UlokQYEz4dE7+hOtVO9rVuolJKgEccqzvyFzqX4/zZWY+R/v1wVsQ==", "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" @@ -4192,11 +3371,11 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.66.9", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.66.9.tgz", - "integrity": "sha512-NRI02PHJsP5y2gAuWKP+awamTIBFBSKMnO6UVzi03GTclmHHHInH5UzVgzi5tpu4+FmGfsdT7Umqegobtsp23A==", + "version": "5.59.15", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.59.15.tgz", + "integrity": "sha512-QbVlAkTI78wB4Mqgf2RDmgC0AOiJqer2c5k9STOOSXGv1S6ZkY37r/6UpE8DbQ2Du0ohsdoXgFNEyv+4eDoPEw==", "dependencies": { - "@tanstack/query-core": "5.66.4" + "@tanstack/query-core": "5.59.13" }, "funding": { "type": "github", @@ -4223,234 +3402,6 @@ "react": "^18 || ^19" } }, - "node_modules/@toolpad/core": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@toolpad/core/-/core-0.12.0.tgz", - "integrity": "sha512-2nzy6Y16nIvZspfdeKJqp70ZKTL4l3DVGe4zpjKi60UoRDYhQHTtwcXlTPiKYw/sCXjT8oa7svNaVD2GAI8Hfg==", - "dependencies": { - "@babel/runtime": "^7.26.0", - "@mui/lab": "6.0.0-beta.22", - "@mui/utils": "6.3.1", - "@toolpad/utils": "0.12.0", - "@vitejs/plugin-react": "4.3.4", - "client-only": "^0.0.1", - "invariant": "2.2.4", - "path-to-regexp": "6.3.0", - "prop-types": "15.8.1" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "@mui/icons-material": "5 - 6", - "@mui/material": "5 - 6", - "next": "^14 || ^15", - "react": "^18 || ^19", - "react-router": "^7" - }, - "peerDependenciesMeta": { - "next": { - "optional": true - }, - "react-router": { - "optional": true - } - } - }, - "node_modules/@toolpad/core/node_modules/@mui/lab": { - "version": "6.0.0-beta.22", - "resolved": "https://registry.npmjs.org/@mui/lab/-/lab-6.0.0-beta.22.tgz", - "integrity": "sha512-9nwUfBj+UzoQJOCbqV+JcCSJ74T+gGWrM1FMlXzkahtYUcMN+5Zmh2ArlttW3zv2dZyCzp7K5askcnKF0WzFQg==", - "dependencies": { - "@babel/runtime": "^7.26.0", - "@mui/base": "5.0.0-beta.68", - "@mui/system": "^6.3.1", - "@mui/types": "^7.2.21", - "@mui/utils": "^6.3.1", - "clsx": "^2.1.1", - "prop-types": "^15.8.1" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@emotion/react": "^11.5.0", - "@emotion/styled": "^11.3.0", - "@mui/material": "^6.3.1", - "@mui/material-pigment-css": "^6.3.1", - "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@emotion/react": { - "optional": true - }, - "@emotion/styled": { - "optional": true - }, - "@mui/material-pigment-css": { - "optional": true - }, - "@types/react": { - "optional": true - } - } - }, - "node_modules/@toolpad/core/node_modules/@mui/lab/node_modules/@mui/base": { - "version": "5.0.0-beta.68", - "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.68.tgz", - "integrity": "sha512-F1JMNeLS9Qhjj3wN86JUQYBtJoXyQvknxlzwNl6eS0ZABo1MiohMONj3/WQzYPSXIKC2bS/ZbyBzdHhi2GnEpA==", - "deprecated": "This package has been replaced by @base-ui-components/react", - "dependencies": { - "@babel/runtime": "^7.26.0", - "@floating-ui/react-dom": "^2.1.1", - "@mui/types": "^7.2.20", - "@mui/utils": "^6.3.0", - "@popperjs/core": "^2.11.8", - "clsx": "^2.1.1", - "prop-types": "^15.8.1" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@toolpad/core/node_modules/@mui/lab/node_modules/@mui/base/node_modules/@floating-ui/react-dom": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz", - "integrity": "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==", - "dependencies": { - "@floating-ui/dom": "^1.0.0" - }, - "peerDependencies": { - "react": ">=16.8.0", - "react-dom": ">=16.8.0" - } - }, - "node_modules/@toolpad/utils": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@toolpad/utils/-/utils-0.12.0.tgz", - "integrity": "sha512-tZ5HjlGmHRMTNp0/3qab2IKD+G0AkooO0uH7Qpn3aRvAZB3mmbUje0IfTJc12slVvv2YU57s4sIgG65c0vZfrA==", - "dependencies": { - "invariant": "2.2.4", - "prettier": "3.3.3", - "react": "^19.0.0", - "react-is": "^19.0.0", - "title": "4.0.1", - "yaml": "2.5.1", - "yaml-diff-patch": "2.0.0" - }, - "peerDependencies": { - "react": "^18.0.0 || ^19.0.0" - } - }, - "node_modules/@toolpad/utils/node_modules/react": { - "version": "19.0.0", - "resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz", - "integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.6.8", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.8.tgz", - "integrity": "sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==", - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.20.6", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.6.tgz", - "integrity": "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==", - "dependencies": { - "@babel/types": "^7.20.7" - } - }, - "node_modules/@types/d3-color": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", - "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==" - }, - "node_modules/@types/d3-delaunay": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", - "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==" - }, - "node_modules/@types/d3-interpolate": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", - "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", - "dependencies": { - "@types/d3-color": "*" - } - }, - "node_modules/@types/d3-path": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", - "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==" - }, - "node_modules/@types/d3-scale": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", - "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", - "dependencies": { - "@types/d3-time": "*" - } - }, - "node_modules/@types/d3-shape": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", - "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", - "dependencies": { - "@types/d3-path": "*" - } - }, - "node_modules/@types/d3-time": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", - "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==" - }, "node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", @@ -4502,7 +3453,7 @@ "version": "22.7.5", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz", "integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.19.2" @@ -4520,9 +3471,9 @@ "dev": true }, "node_modules/@types/prop-types": { - "version": "15.7.14", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz", - "integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==" + "version": "15.7.13", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.13.tgz", + "integrity": "sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==" }, "node_modules/@types/react": { "version": "18.3.11", @@ -4543,10 +3494,10 @@ } }, "node_modules/@types/react-transition-group": { - "version": "4.4.12", - "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", - "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", - "peerDependencies": { + "version": "4.4.11", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.11.tgz", + "integrity": "sha512-RM05tAniPZ5DZPzzNFP+DmrcOdD0efDUxMy3145oljWSl3x9ZV5vhme98gTxFrj2lhXvmGNnUiuDyJgY9IKkNA==", + "dependencies": { "@types/react": "*" } }, @@ -4865,24 +3816,6 @@ "is-function": "^1.0.1" } }, - "node_modules/@vitejs/plugin-react": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.3.4.tgz", - "integrity": "sha512-SCCPBJtYLdE8PX/7ZQAs1QAZ8Jqwih+0VBLum1EGqmCCQal+MIUqLCzj3ZUy8ufbC0cAM4LRlSTm7IQJwWT4ug==", - "dependencies": { - "@babel/core": "^7.26.0", - "@babel/plugin-transform-react-jsx-self": "^7.25.9", - "@babel/plugin-transform-react-jsx-source": "^7.25.9", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.14.2" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0" - } - }, "node_modules/@whatwg-node/fetch": { "version": "0.9.21", "resolved": "https://registry.npmjs.org/@whatwg-node/fetch/-/fetch-0.9.21.tgz", @@ -5041,11 +3974,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/arg": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", - "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==" - }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -5457,9 +4385,10 @@ } }, "node_modules/browserslist": { - "version": "4.24.4", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz", - "integrity": "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==", + "version": "4.21.9", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.21.9.tgz", + "integrity": "sha512-M0MFoZzbUrRU4KNfCrDLnvyE7gub+peetoTid3TBIqtunaDJyXlwhakT+/VkvSXcfIzFfK/nkCs4nmyTmxdNSg==", + "dev": true, "funding": [ { "type": "opencollective", @@ -5475,10 +4404,10 @@ } ], "dependencies": { - "caniuse-lite": "^1.0.30001688", - "electron-to-chromium": "^1.5.73", - "node-releases": "^2.0.19", - "update-browserslist-db": "^1.1.1" + "caniuse-lite": "^1.0.30001503", + "electron-to-chromium": "^1.4.431", + "node-releases": "^2.0.12", + "update-browserslist-db": "^1.0.11" }, "bin": { "browserslist": "cli.js" @@ -5583,9 +4512,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001700", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001700.tgz", - "integrity": "sha512-2S6XIXwaE7K7erT8dY+kLQcpa5ms63XlRkMkReXjle+kf6c5g38vyMl+Z5y8dSxOFDhcFe+nxnn261PLxBSQsQ==", + "version": "1.0.30001597", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001597.tgz", + "integrity": "sha512-7LjJvmQU6Sj7bL0j5b5WY/3n7utXUJvAe1lxhsHDbLmwX9mdL86Yjtr+5SRCyf8qME4M7pU2hswj0FpyBVCv9w==", "funding": [ { "type": "opencollective", @@ -5616,6 +4545,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -5734,22 +4664,6 @@ "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==" }, - "node_modules/clipboardy": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/clipboardy/-/clipboardy-4.0.0.tgz", - "integrity": "sha512-5mOlNS0mhX0707P2I0aZ2V/cmHUEO/fL7VFLqszkhUsxt7RwnmrInf/eEQKlf5GzvYeHIjT+Ov1HRfNmymlG0w==", - "dependencies": { - "execa": "^8.0.1", - "is-wsl": "^3.1.0", - "is64bit": "^2.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -5939,6 +4853,7 @@ "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", "license": "ISC", + "peer": true, "dependencies": { "internmap": "1 - 2" }, @@ -5951,6 +4866,7 @@ "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -5960,6 +4876,7 @@ "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", "license": "ISC", + "peer": true, "dependencies": { "delaunator": "5" }, @@ -6036,6 +4953,7 @@ "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -6090,6 +5008,7 @@ "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", "license": "ISC", + "peer": true, "dependencies": { "d3-color": "1 - 3" }, @@ -6102,6 +5021,7 @@ "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -6121,6 +5041,7 @@ "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", "license": "ISC", + "peer": true, "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", @@ -6151,6 +5072,7 @@ "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", "license": "ISC", + "peer": true, "dependencies": { "d3-path": "^3.1.0" }, @@ -6163,6 +5085,7 @@ "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", "license": "ISC", + "peer": true, "dependencies": { "d3-array": "2 - 3" }, @@ -6175,6 +5098,7 @@ "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", "license": "ISC", + "peer": true, "dependencies": { "d3-time": "1 - 3" }, @@ -6276,6 +5200,7 @@ "version": "4.3.6", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", + "dev": true, "dependencies": { "ms": "2.1.2" }, @@ -6394,6 +5319,7 @@ "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz", "integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==", "license": "ISC", + "peer": true, "dependencies": { "robust-predicates": "^3.0.2" } @@ -6569,9 +5495,10 @@ "dev": true }, "node_modules/electron-to-chromium": { - "version": "1.5.102", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.102.tgz", - "integrity": "sha512-eHhqaja8tE/FNpIiBrvBjFV/SSKpyWHLvxuR9dPTdo+3V9ppdLmFB7ZZQ98qNovcngPLYIz0oOBF9P0FfZef5Q==" + "version": "1.4.475", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.475.tgz", + "integrity": "sha512-mTye5u5P98kSJO2n7zYALhpJDmoSQejIGya0iR01GpoRady8eK3bw7YHHnjA1Rfi4ZSLdpuzlAC7Zw+1Zu7Z6A==", + "dev": true }, "node_modules/emoji-regex": { "version": "9.2.2", @@ -6801,50 +5728,10 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/esbuild": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz", - "integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==", - "hasInstallScript": true, - "peer": true, - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.24.2", - "@esbuild/android-arm": "0.24.2", - "@esbuild/android-arm64": "0.24.2", - "@esbuild/android-x64": "0.24.2", - "@esbuild/darwin-arm64": "0.24.2", - "@esbuild/darwin-x64": "0.24.2", - "@esbuild/freebsd-arm64": "0.24.2", - "@esbuild/freebsd-x64": "0.24.2", - "@esbuild/linux-arm": "0.24.2", - "@esbuild/linux-arm64": "0.24.2", - "@esbuild/linux-ia32": "0.24.2", - "@esbuild/linux-loong64": "0.24.2", - "@esbuild/linux-mips64el": "0.24.2", - "@esbuild/linux-ppc64": "0.24.2", - "@esbuild/linux-riscv64": "0.24.2", - "@esbuild/linux-s390x": "0.24.2", - "@esbuild/linux-x64": "0.24.2", - "@esbuild/netbsd-arm64": "0.24.2", - "@esbuild/netbsd-x64": "0.24.2", - "@esbuild/openbsd-arm64": "0.24.2", - "@esbuild/openbsd-x64": "0.24.2", - "@esbuild/sunos-x64": "0.24.2", - "@esbuild/win32-arm64": "0.24.2", - "@esbuild/win32-ia32": "0.24.2", - "@esbuild/win32-x64": "0.24.2" - } - }, "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", + "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", "engines": { "node": ">=6" } @@ -7361,64 +6248,6 @@ "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", "dev": true }, - "node_modules/execa": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", - "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^8.0.1", - "human-signals": "^5.0.0", - "is-stream": "^3.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^5.1.0", - "onetime": "^6.0.0", - "signal-exit": "^4.1.0", - "strip-final-newline": "^3.0.0" - }, - "engines": { - "node": ">=16.17" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/execa/node_modules/mimic-fn": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", - "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/execa/node_modules/onetime": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", - "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", - "dependencies": { - "mimic-fn": "^4.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/execa/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/external-editor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", @@ -7687,20 +6516,6 @@ "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "dev": true }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -7740,6 +6555,7 @@ "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, "engines": { "node": ">=6.9.0" } @@ -7783,17 +6599,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/get-stream": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", - "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/get-symbol-description": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.2.tgz", @@ -7869,6 +6674,7 @@ "version": "11.12.0", "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true, "engines": { "node": ">=4" } @@ -8071,6 +6877,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, "engines": { "node": ">=8" } @@ -8207,14 +7014,6 @@ "node": ">= 14" } }, - "node_modules/human-signals": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", - "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", - "engines": { - "node": ">=16.17.0" - } - }, "node_modules/husky": { "version": "9.1.6", "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.6.tgz", @@ -8398,6 +7197,7 @@ "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -8406,6 +7206,7 @@ "version": "2.2.4", "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "dev": true, "dependencies": { "loose-envify": "^1.0.0" } @@ -8558,20 +7359,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-docker": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", - "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -8635,23 +7422,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-inside-container": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", - "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", - "dependencies": { - "is-docker": "^3.0.0" - }, - "bin": { - "is-inside-container": "cli.js" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-interactive": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", @@ -8798,6 +7568,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, @@ -8934,34 +7705,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-wsl": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", - "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", - "dependencies": { - "is-inside-container": "^1.0.0" - }, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is64bit": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is64bit/-/is64bit-2.0.0.tgz", - "integrity": "sha512-jv+8jaWCl0g2lSBkNSVXdzfBA0npK1HGC2KtWM9FumFRoGS94g3NbCCLVnCYHLjp4GrW2KZeeSTMo5ddtznmGw==", - "dependencies": { - "system-architecture": "^0.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/isarray": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", @@ -9017,7 +7760,7 @@ "version": "1.21.6", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.6.tgz", "integrity": "sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==", - "devOptional": true, + "dev": true, "bin": { "jiti": "bin/jiti.js" } @@ -9049,14 +7792,15 @@ } }, "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", + "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "dev": true, "bin": { "jsesc": "bin/jsesc" }, "engines": { - "node": ">=6" + "node": ">=4" } }, "node_modules/json-buffer": { @@ -9119,6 +7863,7 @@ "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, "bin": { "json5": "lib/cli.js" }, @@ -9350,6 +8095,50 @@ "integrity": "sha512-QpLs9D9v9kArv4lfDEgg1X/gN5XLnf/A6l9cs8SPZLRZR3ZkY9+kwIQTxm+fsSej5UMYGE8fdoaZVIBlqG0XTw==", "dev": true }, + "node_modules/lint-staged/node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/lint-staged/node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lint-staged/node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "engines": { + "node": ">=16.17.0" + } + }, "node_modules/lint-staged/node_modules/is-fullwidth-code-point": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", @@ -9429,6 +8218,33 @@ "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, + "node_modules/lint-staged/node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lint-staged/node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/lint-staged/node_modules/restore-cursor": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", @@ -9695,6 +8511,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, "dependencies": { "yallist": "^3.0.2" } @@ -9721,7 +8538,8 @@ "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==" + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true }, "node_modules/merge2": { "version": "1.4.1", @@ -9846,7 +8664,8 @@ "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true }, "node_modules/multipipe": { "version": "1.0.2", @@ -9988,9 +8807,10 @@ "dev": true }, "node_modules/node-releases": { - "version": "2.0.19", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", - "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==" + "version": "2.0.13", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.13.tgz", + "integrity": "sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==", + "dev": true }, "node_modules/normalize-path": { "version": "2.1.1", @@ -10008,6 +8828,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.1.0.tgz", "integrity": "sha512-sJOdmRGrY2sjNTRMbSvluQqg+8X7ZK61yvzBEIDhz4f8z1TZFYABsqjjCBd/0PUNE9M6QDgHJXQkGUEm7Q+l9Q==", + "dev": true, "dependencies": { "path-key": "^4.0.0" }, @@ -10022,6 +8843,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, "engines": { "node": ">=12" }, @@ -10193,17 +9015,6 @@ "opener": "bin/opener-bin.js" } }, - "node_modules/oppa": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/oppa/-/oppa-0.4.0.tgz", - "integrity": "sha512-DFvM3+F+rB/igo3FRnkDWitjZgBH9qZAn68IacYHsqbZBKwuTA+LdD4zSJiQtgQpWq7M08we5FlGAVHz0yW7PQ==", - "dependencies": { - "chalk": "^4.1.1" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/optionator": { "version": "0.9.3", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", @@ -10453,11 +9264,6 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "dev": true }, - "node_modules/path-to-regexp": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", - "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==" - }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -10472,9 +9278,9 @@ "integrity": "sha512-3tZ76PiH/2g+Kyzhz8+GIFYrnx3lRnwi/Qt3ZUH04xpMxXL7Guerd5aaxtpWal73X+H8iLAjo2c+AgRy2KYQcQ==" }, "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" }, "node_modules/picomatch": { "version": "2.3.1", @@ -10560,6 +9366,7 @@ "version": "3.3.3", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz", "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==", + "dev": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -10675,9 +9482,9 @@ "integrity": "sha512-ZKS7PGNFqqMd3PJ6+C2Jtz/o1iU9ggiy8Y8nUeksgVuvNISbmrQtJiZNvC/TjDsqD0QlU5Wkgs7i+w9+OjHhhQ==" }, "node_modules/react-is": { - "version": "19.0.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.0.0.tgz", - "integrity": "sha512-H91OHcwjZsbq3ClIDHMzBShc1rotbfACdWENsmEf0IFvZ3FgGPtdHMcsv45bQ1hAbgdfiA8SnxTKfDS+x/8m2g==" + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==" }, "node_modules/react-leaflet": { "version": "4.2.1", @@ -10692,14 +9499,6 @@ "react-dom": "^18.0.0" } }, - "node_modules/react-refresh": { - "version": "0.14.2", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", - "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/react-transition-group": { "version": "4.4.5", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", @@ -10919,45 +9718,8 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==", - "license": "Unlicense" - }, - "node_modules/rollup": { - "version": "4.34.8", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.34.8.tgz", - "integrity": "sha512-489gTVMzAYdiZHFVA/ig/iYFllCcWFHMvUHI1rpFmkoUtRlQxqh6/yiNqnYibjMZ2b/+FUQwldG+aLsEt6bglQ==", - "peer": true, - "dependencies": { - "@types/estree": "1.0.6" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.34.8", - "@rollup/rollup-android-arm64": "4.34.8", - "@rollup/rollup-darwin-arm64": "4.34.8", - "@rollup/rollup-darwin-x64": "4.34.8", - "@rollup/rollup-freebsd-arm64": "4.34.8", - "@rollup/rollup-freebsd-x64": "4.34.8", - "@rollup/rollup-linux-arm-gnueabihf": "4.34.8", - "@rollup/rollup-linux-arm-musleabihf": "4.34.8", - "@rollup/rollup-linux-arm64-gnu": "4.34.8", - "@rollup/rollup-linux-arm64-musl": "4.34.8", - "@rollup/rollup-linux-loongarch64-gnu": "4.34.8", - "@rollup/rollup-linux-powerpc64le-gnu": "4.34.8", - "@rollup/rollup-linux-riscv64-gnu": "4.34.8", - "@rollup/rollup-linux-s390x-gnu": "4.34.8", - "@rollup/rollup-linux-x64-gnu": "4.34.8", - "@rollup/rollup-linux-x64-musl": "4.34.8", - "@rollup/rollup-win32-arm64-msvc": "4.34.8", - "@rollup/rollup-win32-ia32-msvc": "4.34.8", - "@rollup/rollup-win32-x64-msvc": "4.34.8", - "fsevents": "~2.3.2" - } + "license": "Unlicense", + "peer": true }, "node_modules/run-async": { "version": "2.4.1", @@ -11107,6 +9869,7 @@ "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, "bin": { "semver": "bin/semver.js" } @@ -11342,9 +10105,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", + "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", "engines": { "node": ">=0.10.0" } @@ -11574,6 +10337,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, "engines": { "node": ">=12" }, @@ -11625,6 +10389,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -11652,17 +10417,6 @@ "tslib": "^2.0.3" } }, - "node_modules/system-architecture": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/system-architecture/-/system-architecture-0.1.0.tgz", - "integrity": "sha512-ulAk51I9UVUyJgxlv9M6lFot2WP3e7t8Kz9+IS6D4rVba1tR9kON+Ey69f+1R4Q8cd45Lod6a4IcJIxnzGc/zA==", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/tapable": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", @@ -11693,19 +10447,6 @@ "xtend": "~2.1.1" } }, - "node_modules/title": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/title/-/title-4.0.1.tgz", - "integrity": "sha512-xRnPkJx9nvE5MF6LkB5e8QJjE2FW8269wTu/LQdf7zZqBgPly0QJPf/CWAo7srj5so4yXfoLEdCFgurlpi47zg==", - "dependencies": { - "arg": "^5.0.0", - "chalk": "^5.0.0", - "clipboardy": "^4.0.0" - }, - "bin": { - "title": "dist/esm/bin.js" - } - }, "node_modules/title-case": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/title-case/-/title-case-3.0.3.tgz", @@ -11715,17 +10456,6 @@ "tslib": "^2.0.3" } }, - "node_modules/title/node_modules/chalk": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", - "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/tmp": { "version": "0.0.33", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", @@ -11738,6 +10468,14 @@ "node": ">=0.6.0" } }, + "node_modules/to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", + "engines": { + "node": ">=4" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -11993,7 +10731,7 @@ "version": "6.19.8", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/unixify": { @@ -12009,9 +10747,10 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.2.tgz", - "integrity": "sha512-PPypAm5qvlD7XMZC3BujecnaOxwhrtoFR+Dqkk5Aa/6DssiH0ibKoketaj9w8LP7Bont1rYeoV5plxD7RTEPRg==", + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.11.tgz", + "integrity": "sha512-dCwEFf0/oT85M1fHBg4F0jtLwJrutGoHSQXCh7u4o2t1drG+c0a9Flnqww6XUKSfQMPpJBRjU8d4RXB09qtvaA==", + "dev": true, "funding": [ { "type": "opencollective", @@ -12027,8 +10766,8 @@ } ], "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" + "escalade": "^3.1.1", + "picocolors": "^1.0.0" }, "bin": { "update-browserslist-db": "cli.js" @@ -12769,105 +11508,6 @@ "global": "^4.3.1" } }, - "node_modules/vite": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.1.0.tgz", - "integrity": "sha512-RjjMipCKVoR4hVfPY6GQTgveinjNuyLw+qruksLDvA5ktI1150VmcMBKmQaEWJhg/j6Uaf6dNCNA0AfdzUb/hQ==", - "peer": true, - "dependencies": { - "esbuild": "^0.24.2", - "postcss": "^8.5.1", - "rollup": "^4.30.1" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "jiti": ">=1.21.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vite/node_modules/postcss": { - "version": "8.5.2", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.2.tgz", - "integrity": "sha512-MjOadfU3Ys9KYoX0AdkBlFEF1Vx37uCCeN4ZHnmwm9FfpbsGWMZeBLMmmpY+6Ocqod7mkdZ0DT31OlbsFrLlkA==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "peer": true, - "dependencies": { - "nanoid": "^3.3.8", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, "node_modules/wcwidth": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", @@ -13137,12 +11777,14 @@ "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true }, "node_modules/yaml": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.1.tgz", - "integrity": "sha512-bLQOjaX/ADgQ20isPJRvF0iRUHIxVhYvr53Of7wGcWlO2jvtUlH5m87DsmulFVxRpNLOnI4tB6p/oh8D7kpn9Q==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.0.tgz", + "integrity": "sha512-2wWLbGbYDiSqqIKoPjar3MPgB94ErzCtrNE1FdqGuaO0pi2JGjmE8aW8TDZwzU7vuxcGRdL/4gPQwQ7hD5AMSw==", + "dev": true, "bin": { "yaml": "bin.mjs" }, @@ -13156,24 +11798,6 @@ "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==", "dev": true }, - "node_modules/yaml-diff-patch": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/yaml-diff-patch/-/yaml-diff-patch-2.0.0.tgz", - "integrity": "sha512-RhfIQPGcKSZhsUmsczXAeg5jNhWXk3tAmhl2kjfZthdyaL0XXXOpvRozUp22HvPStmZsHu8T30/UEfX9oIwGxw==", - "dependencies": { - "fast-json-patch": "^3.1.0", - "oppa": "^0.4.0", - "yaml": "^2.0.0-10" - }, - "bin": { - "yaml-diff-patch": "dist/bin/yaml-patch.js", - "yaml-overwrite": "dist/bin/yaml-patch.js", - "yaml-patch": "dist/bin/yaml-patch.js" - }, - "engines": { - "node": ">=14" - } - }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", diff --git a/ui/package.json b/ui/package.json index 93bd6bd8..ed1d170a 100644 --- a/ui/package.json +++ b/ui/package.json @@ -16,7 +16,7 @@ "prepare": "cd .. && husky ui/.husky" }, "dependencies": { - "@emotion/cache": "^11.14.0", + "@emotion/cache": "^11.13.1", "@emotion/react": "^11.13.3", "@emotion/server": "^11.11.0", "@emotion/styled": "^11.13.0", @@ -24,10 +24,8 @@ "@fontsource/mukta": "^5.1.0", "@mui/icons-material": "^6.1.4", "@mui/material": "^6.1.0", - "@mui/material-nextjs": "^6.4.3", - "@mui/x-charts": "^7.27.0", - "@tanstack/react-query": "^5.66.9", - "@toolpad/core": "^0.12.0", + "@mui/material-nextjs": "^6.1.4", + "@tanstack/react-query": "^5.59.15", "clsx": "^2.1.0", "date-fns": "^4.1.0", "dotenv-cli": "^7.4.2", @@ -37,8 +35,8 @@ "lodash": "^4.17.21", "next": "14.2.15", "phoenix": "^1.7.14", - "react": "^18.3.1", - "react-dom": "^18.3.1", + "react": "18.3.1", + "react-dom": "18.3.1", "react-fast-marquee": "^1.6.5", "react-ga4": "^2.1.0", "react-leaflet": "^4.2.1", diff --git a/ui/src/components/CandidateCard.tsx b/ui/src/components/CandidateCard.tsx deleted file mode 100644 index 5fee6aa2..00000000 --- a/ui/src/components/CandidateCard.tsx +++ /dev/null @@ -1,175 +0,0 @@ -import { - Box, - Card, - CardActionArea, - CardContent, - Link, - Typography, -} from "@mui/material"; -import { MutableRefObject } from "react"; - -import { useFeedsQuery } from "@/graphql/generated"; -import { type Candidate } from "@/pages/moderator/candidates"; - -import { CandidateCardAIPlayer } from "./Player/CandidateCardAIPlayer"; -import { CandidateCardPlayer } from "./Player/CandidateCardPlayer"; -import { VideoJSPlayer } from "./Player/VideoJS"; - -export default function CandidateCard(props: { - candidate: Candidate; - index: number; - // changeListState?: (value: number, status: string) => void; - // command?: string; - players: MutableRefObject<{ [index: number]: VideoJSPlayer }>; - playNext: boolean; -}) { - // get hydrophone feed list - const feedsQueryResult = useFeedsQuery(); - const feedsData = feedsQueryResult.data?.feeds ?? []; - - const candidate = props.candidate; - const candidateArray = candidate.array; - const firstCandidate = candidateArray[candidateArray.length - 1]; - const lastCandidate = candidateArray[0]; - const firstTimestamp = firstCandidate.timestamp; - const lastTimestamp = lastCandidate.timestamp; - const firstTimestampString = firstCandidate.timestampString; - const lastTimestampString = lastCandidate.timestampString; - const feed = feedsData.find((feed) => feed.id === firstCandidate.feedId); - - const startTimestamp = Math.min( - ...candidateArray.map((d) => +d.playlistTimestamp), - ); - - const offsetPadding = 15; - const minOffset = Math.min(...candidateArray.map((d) => +d.playerOffset)); - - // const maxOffset = Math.max(...candidateArray.map((d) => +d.playerOffset)); - // instead, ensure that the last offset is still in the same playlist -- future iteration may pull a second playlist if needed - const firstPlaylist = candidateArray.filter( - (d) => +d.playlistTimestamp === startTimestamp, - ); - - const maxOffset = Math.max(...firstPlaylist.map((d) => +d.playerOffset)); - const startOffset = Math.max(0, minOffset - offsetPadding); - const endOffset = maxOffset + offsetPadding; - - return ( - - - {feed && candidate.array[0].playlistTimestamp ? ( - { - props.players.current[props.index] = player; - }} - onPlay={() => { - Object.entries(props.players.current).forEach(([key, player]) => { - if (+key !== props.index) { - player.pause(); - } - }); - }} - onPlayerEnd={() => { - if (props.playNext) - props.players.current[props.index + 1]?.play(); - }} - /> - ) : candidate.array[0].audioUri ? ( - <> - { - props.players.current[props.index] = player; - }} - onPlay={() => { - Object.entries(props.players.current).forEach( - ([key, player]) => { - if (+key !== props.index) { - player.pause(); - } - }, - ); - }} - onPlayerEnd={() => { - if (props.playNext) - props.players.current[props.index + 1]?.play(); - }} - /> - - ) : ( - "no player found" - )} - - - - - - {new Date(lastTimestamp).toLocaleString()} - - - {candidate.hydrophone} - {" • "} - {candidate.array.length === 1 - ? candidate.array[0].type === "human" - ? "30 seconds" - : "1 minute" - : Math.round( - (Date.parse(lastTimestampString) - - Date.parse(firstTimestampString)) / - (1000 * 60), - ) >= 1 - ? Math.round( - (Date.parse(lastTimestampString) - - Date.parse(firstTimestampString)) / - (1000 * 60), - ) + " minutes" - : Math.round( - (Date.parse(lastTimestampString) - - Date.parse(firstTimestampString)) / - (1000 * 60 * 60), - ) + " seconds"} -
- {["whale", "vessel", "other", "whale (AI)"] - .map((item) => - candidate[item as keyof Candidate] - ? candidate[item as keyof Candidate] + " " + item - : null, - ) - .filter((candidate) => candidate !== null) - .join(" • ")} -
- {candidate.descriptions ? ( - {"Descriptions: " + candidate.descriptions} - ) : ( -
- )} -
-
-
- -
- ); -} diff --git a/ui/src/components/ChartSelect.tsx b/ui/src/components/ChartSelect.tsx deleted file mode 100644 index 37f790ec..00000000 --- a/ui/src/components/ChartSelect.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import Box from "@mui/material/Box"; -import FormControl from "@mui/material/FormControl"; -import MenuItem from "@mui/material/MenuItem"; -import Select, { SelectChangeEvent } from "@mui/material/Select"; -import * as React from "react"; - -type SelectProps = { - name: string; - value: string | number; - list: { label: string; value: string | number }[]; - size?: "small" | "medium"; - onChange: (event: SelectChangeEvent) => void; -}; - -export default function ChartSelect({ - name, - value, - list, - size, - onChange, -}: SelectProps) { - // const [selectedValue, setSelectedValue] = useState(value); - // const handleChange = (event: SelectChangeEvent) => { - // setSelectedValue(event.target.value as string | number) - // } - - return ( - - - - - - ); -} diff --git a/ui/src/components/FeedList.tsx b/ui/src/components/FeedList.tsx deleted file mode 100644 index 8b747b71..00000000 --- a/ui/src/components/FeedList.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { Container, Stack, Typography } from "@mui/material"; -import { dehydrate, QueryClient } from "@tanstack/react-query"; -import { useMemo } from "react"; - -import FeedCard from "@/components/FeedCard"; -import { useFeedsQuery } from "@/graphql/generated"; - -const FeedList = () => { - const feedsQueryResult = useFeedsQuery(); - - // Sort feeds by high latitude to low (to match the order on the map) - const sortedFeeds = useMemo( - () => - feedsQueryResult.data?.feeds.sort((a, b) => b.latLng.lat - a.latLng.lat), - [feedsQueryResult.data], - ); - - if (!sortedFeeds) return null; - - return ( - - - Listen live - - - Select a location to start listening live - - - {sortedFeeds.map((feed) => ( - - ))} - - - ); -}; - -export async function getStaticProps() { - const queryClient = new QueryClient(); - - await queryClient.prefetchQuery({ - queryKey: useFeedsQuery.getKey(), - queryFn: useFeedsQuery.fetcher(), - }); - - return { - props: { - dehydratedState: dehydrate(queryClient), - }, - }; -} - -export default FeedList; diff --git a/ui/src/components/Header.tsx b/ui/src/components/Header.tsx index 5931384d..569558bb 100644 --- a/ui/src/components/Header.tsx +++ b/ui/src/components/Header.tsx @@ -35,8 +35,7 @@ export default function Header({ }) { return ( theme.zIndex.drawer + 1, diff --git a/ui/src/components/PlayBar.tsx b/ui/src/components/PlayBar.tsx deleted file mode 100644 index 967bd06a..00000000 --- a/ui/src/components/PlayBar.tsx +++ /dev/null @@ -1,365 +0,0 @@ -import { - Close, - Feedback, - Home, - Menu, - Notifications, -} from "@mui/icons-material"; -import { - AppBar, - Box, - Button, - Divider, - Drawer, - IconButton, - List, - ListItem, - ListItemButton, - ListItemIcon, - ListItemText, - Toolbar, - Typography, -} from "@mui/material"; -import Image from "next/image"; -import { useEffect, useState } from "react"; - -import Link from "@/components/Link"; -import { useFeedsQuery } from "@/graphql/generated"; -import { Candidate } from "@/pages/moderator/candidates"; -import wordmark from "@/public/wordmark/wordmark-white.svg"; -import { displayDesktopOnly, displayMobileOnly } from "@/styles/responsive"; -import { analytics } from "@/utils/analytics"; - -import { CandidateCardAIPlayer } from "./Player/CandidateCardAIPlayer"; -import { CandidateCardPlayer } from "./Player/CandidateCardPlayer"; - -export default function PlayBar({ candidate }: { candidate: Candidate }) { - // get hydrophone feed list - const feedsQueryResult = useFeedsQuery(); - const feedsData = feedsQueryResult.data?.feeds ?? []; - - const [playerProps, setPlayerProps] = useState({ - feed: feedsData[0], - timestamp: 0, - startOffset: 0, - endOffset: 0, - audioUri: "", - }); - - useEffect(() => { - const candidateArray = candidate.array; - if (candidateArray) { - const firstDetection = candidateArray[candidateArray.length - 1]; - const lastDetection = candidateArray[0]; - const feed = feedsData.find((feed) => feed.id === firstDetection.feedId); - - const startTimestamp = Math.min( - ...candidateArray.map((d) => +d.playlistTimestamp), - ); - - const offsetPadding = 15; - const minOffset = Math.min(...candidateArray.map((d) => +d.playerOffset)); - - // const maxOffset = Math.max(...candidateArray.map((d) => +d.playerOffset)); - // instead, ensure that the last offset is still in the same playlist -- future iteration may pull a second playlist if needed - const firstPlaylist = candidateArray.filter( - (d) => +d.playlistTimestamp === startTimestamp, - ); - - const maxOffset = Math.max(...firstPlaylist.map((d) => +d.playerOffset)); - const startOffset = Math.max(0, minOffset - offsetPadding); - const endOffset = maxOffset + offsetPadding; - - feed && - setPlayerProps({ - feed: feed ? feed : feedsData[0], - timestamp: startTimestamp, - startOffset: startOffset, - endOffset: endOffset, - audioUri: "", - }); - - lastDetection.audioUri && - setPlayerProps({ - ...playerProps, - timestamp: startTimestamp, - audioUri: lastDetection.audioUri, - }); - } - }, [candidate]); - - return ( - theme.zIndex.drawer + 1, - bottom: 0, - top: "auto", - height: "100px", - }} - > - - {/*
{JSON.stringify(candidate)}
*/} - {candidate.array && playerProps.feed ? ( - <> - {`${playerProps.timestamp}`} - - - ) : candidate.array && playerProps.audioUri.length ? ( - - ) : ( - "No recordings loaded" - )} -
-
- ); -} - -function Mobile({ - window, - onBrandClick, -}: { - window?: () => Window; - onBrandClick?: () => void; -}) { - const drawerWidth = "100%"; - const [menuIsOpen, setMenuOpen] = useState(false); - - const handleMenuToggle = () => { - setMenuOpen(!menuIsOpen); - }; - - const container = - window !== undefined ? () => window().document.body : undefined; - - const navItems = [ - { - label: "About us", - url: "https://www.orcasound.net/", - ItemIcon: Home, - onClick: () => analytics.nav.aboutTabClicked(), - }, - { - label: "Get notified", - url: "https://docs.google.com/forms/d/1oYSTa3QeAAG-G_eTxjabrXd264zVARId9tp2iBRWpFs/edit", - ItemIcon: Notifications, - onClick: () => analytics.nav.notificationsClicked(), - }, - { - label: "Send feedback", - url: "https://forms.gle/wKpAnxzUh9a5LMfd7", - ItemIcon: Feedback, - onClick: () => analytics.nav.feedbackTabClicked(), - }, - ]; - - return ( - - - - {menuIsOpen ? : } - - - - - - ); -} - -function Desktop() { - const pages = [ - { - label: "About us", - url: "https://www.orcasound.net/", - onClick: () => analytics.nav.aboutTabClicked(), - }, - { - label: "Send feedback", - url: "https://forms.gle/wKpAnxzUh9a5LMfd7", - onClick: () => analytics.nav.feedbackTabClicked(), - }, - ]; - return ( - - - - - {pages.map((page) => ( - - ))} - analytics.nav.notificationsClicked()} - > - - - - - - ); -} - -function Brand({ onClick }: { onClick?: () => void }) { - return ( - - { - if (onClick) onClick(); - analytics.nav.logoClicked(); - }} - > - Orcasound - - - ); -} diff --git a/ui/src/components/Player/CandidateCardAIPlayer.tsx b/ui/src/components/Player/CandidateCardAIPlayer.tsx deleted file mode 100644 index 32579f41..00000000 --- a/ui/src/components/Player/CandidateCardAIPlayer.tsx +++ /dev/null @@ -1,263 +0,0 @@ -import "videojs-offset"; - -import { Box, Slider, Typography } from "@mui/material"; -import dynamic from "next/dynamic"; -import { useCallback, useMemo, useRef, useState } from "react"; - -import { useData } from "@/context/DataContext"; -import { Candidate } from "@/pages/moderator/candidates"; -import { mobileOnly } from "@/styles/responsive"; - -import { type PlayerStatus } from "./Player"; -import PlayPauseButton from "./PlayPauseButton"; -import { type VideoJSPlayer } from "./VideoJS"; - -// dynamically import VideoJS to speed up initial page load - -const VideoJS = dynamic(() => import("./VideoJS")); - -export function CandidateCardAIPlayer({ - // feed, - marks, - // timestamp, - // startOffset, - // endOffset, - audioUri, - onAudioPlay, - changeListState, - index, - command, - onPlayerInit, - onPlay, - onPlayerEnd, - candidate, -}: { - // feed: Pick; - marks?: { label: string; value: number }[]; - // timestamp: number; - // startOffset: number; - // endOffset: number; - audioUri: string; - onAudioPlay?: () => void; - changeListState?: (value: number, status: string) => void; - index?: number; - command?: string; - onPlayerInit?: (player: VideoJSPlayer) => void; - onPlay?: () => void; - onPlayerEnd?: () => void; - candidate?: Candidate; -}) { - // special to the AI player - const startOffset = 0; - - const [playerStatus, setPlayerStatus] = useState("idle"); - const playerRef = useRef(null); - const [playerTime, setPlayerTime] = useState(startOffset); - const { setNowPlaying } = useData(); - - // special to the AI player - const [endOffset, setEndOffset] = useState(58); - - // const sliderMax = endOffset - startOffset; - // const sliderValue = playerTime - startOffset; - - // const hlsURI = getHlsURI(feed.bucket, feed.nodeName, timestamp); - - const playerOptions = useMemo( - () => ({ - autoplay: false, - // flash: { - // hls: { - // overrideNative: true, - // }, - // }, - // html5: { - // hls: { - // overrideNative: true, - // }, - // }, - sources: [ - { - // If hlsURI isn't set, use a dummy URI to trigger an error - // The dummy URI doesn't actually exist, it should return 404 - // This is the only way to get videojs to throw an error, otherwise - // it just won't initialize (if src is undefined/null/empty)) - src: audioUri, - type: "audio/wav", - // type: "application/x-mpegurl", - }, - ], - }), - [audioUri], - ); - - const handleReady = useCallback((player: VideoJSPlayer) => { - playerRef.current = player; - - onPlayerInit && onPlayerInit(player); - player.on("playing", () => { - setPlayerStatus("playing"); - // const currentTime = player.currentTime() ?? 0; - // if (currentTime < startOffset || currentTime > endOffset) { - // player.currentTime(startOffset); - // setPlayerTime(endOffset); - // } - // (changeListState && index) && changeListState(index, "playing"); - onPlay && onPlay(); - candidate && console.log("aiplayer"); - setNowPlaying && candidate && setNowPlaying(candidate); - }); - player.on("pause", () => { - setPlayerStatus("paused"); - // (changeListState && index) && changeListState(index, "paused"); - }); - player.on("waiting", () => setPlayerStatus("loading")); - player.on("error", () => setPlayerStatus("error")); - - player.on("timeupdate", () => { - const currentTime = player.currentTime() ?? 0; - if (currentTime >= endOffset) { - player.currentTime(startOffset); - setPlayerTime(startOffset); - player.pause(); - onPlayerEnd && onPlayerEnd(); - } else { - setPlayerTime(currentTime); - } - }); - player.on("loadedmetadata", () => { - // special to the AI player - const duration = player.duration() || 0; - setEndOffset(duration); - // On initial load, set player time to startOffset - player.currentTime(startOffset); - }); - }, []); - - const handlePlayPauseClick = () => { - const player = playerRef.current; - - if (playerStatus === "error") { - setPlayerStatus("idle"); - return; - } - - if (!player) { - setPlayerStatus("error"); - return; - } - - try { - if (playerStatus === "loading" || playerStatus === "playing") { - player.pause(); - } else { - player.play(); - onAudioPlay?.(); - } - } catch (e) { - console.error(e); - // AbortError is thrown if pause() is called while play() is still loading (e.g. if segments are 404ing) - // It's not important, so don't show this error to the user - if (e instanceof DOMException && e.name === "AbortError") return; - setPlayerStatus("error"); - } - }; - - // useEffect(() => { - // if (process.env.NODE_ENV === "development" && hlsURI) { - // console.log(`New stream instance: ${hlsURI}`); - // } - // return () => { - // setPlayerStatus("idle"); - // }; - // }, [hlsURI, feed.nodeName]); - - const handleSliderChange = ( - _e: Event, - v: number | number[], - _activeThumb: number, - ) => { - playerRef?.current?.pause(); - if (typeof v !== "number") return; - playerRef?.current?.currentTime(v + startOffset); - }; - - const handleSliderChangeCommitted = ( - _e: Event | React.SyntheticEvent, - v: number | number[], - ) => { - if (typeof v !== "number") return; - playerRef?.current?.currentTime(v + startOffset); - playerRef?.current?.play(); - }; - - return ( - ({ - minHeight: theme.spacing(10), - display: "flex", - alignItems: "center", - justifyContent: "space-between", - px: [0, 2], - position: "relative", - [mobileOnly(theme)]: { - position: "fixed", - bottom: 0, - left: 0, - right: 0, - }, - // Keep player above the sliding drawer - zIndex: theme.zIndex.drawer + 1, - })} - > - - - - - - - - - `${v + startOffset.toFixed(2)} s`} - step={0.1} - max={endOffset} - // max={sliderMax} - value={playerTime} - // value={sliderValue} - marks={marks} - onChange={handleSliderChange} - onChangeCommitted={handleSliderChangeCommitted} - size="small" - /> - - - - - {formattedSeconds(Number((playerTime - startOffset).toFixed(0)))} - - - {"-" + - formattedSeconds(Number((endOffset - playerTime).toFixed(0)))} - - - - - ); -} - -const formattedSeconds = (seconds: number) => { - const mm = Math.floor(seconds / 60); - const ss = seconds % 60; - return `${Number(mm).toString().padStart(2, "0")}:${ss - .toFixed(0) - .padStart(2, "0")}`; -}; diff --git a/ui/src/components/Player/CandidateCardPlayer.tsx b/ui/src/components/Player/CandidateCardPlayer.tsx deleted file mode 100644 index 3a9d5798..00000000 --- a/ui/src/components/Player/CandidateCardPlayer.tsx +++ /dev/null @@ -1,249 +0,0 @@ -import "videojs-offset"; - -import { Box, Slider, Typography } from "@mui/material"; -import dynamic from "next/dynamic"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; - -import { useData } from "@/context/DataContext"; -import { Feed } from "@/graphql/generated"; -import { getHlsURI } from "@/hooks/useTimestampFetcher"; -import { Candidate } from "@/pages/moderator/candidates"; -import { mobileOnly } from "@/styles/responsive"; - -import { type PlayerStatus } from "./Player"; -import PlayPauseButton from "./PlayPauseButton"; -import { type VideoJSPlayer } from "./VideoJS"; - -// dynamically import VideoJS to speed up initial page load - -const VideoJS = dynamic(() => import("./VideoJS")); - -export function CandidateCardPlayer({ - feed, - marks, - timestamp, - startOffset, - endOffset, - onAudioPlay, - changeListState, - index, - command, - onPlayerInit, - onPlay, - onPlayerEnd, - candidate, -}: { - feed: Pick; - marks?: { label: string; value: number }[]; - timestamp: number; - startOffset: number; - endOffset: number; - onAudioPlay?: () => void; - changeListState?: (value: number, status: string) => void; - index?: number; - command?: string; - onPlayerInit?: (player: VideoJSPlayer) => void; - onPlay?: () => void; - onPlayerEnd?: () => void; - candidate?: Candidate; -}) { - const [playerStatus, setPlayerStatus] = useState("idle"); - const playerRef = useRef(null); - const [playerTime, setPlayerTime] = useState(startOffset); - const { setNowPlaying } = useData(); - - const sliderMax = endOffset - startOffset; - const sliderValue = playerTime - startOffset; - - const hlsURI = getHlsURI(feed.bucket, feed.nodeName, timestamp); - - const playerOptions = useMemo( - () => ({ - autoplay: false, - flash: { - hls: { - overrideNative: true, - }, - }, - html5: { - hls: { - overrideNative: true, - }, - }, - sources: [ - { - // If hlsURI isn't set, use a dummy URI to trigger an error - // The dummy URI doesn't actually exist, it should return 404 - // This is the only way to get videojs to throw an error, otherwise - // it just won't initialize (if src is undefined/null/empty)) - src: hlsURI ?? `${feed.nodeName}/404`, - type: "application/x-mpegurl", - }, - ], - }), - [hlsURI, feed?.nodeName], - ); - - const handleReady = useCallback( - (player: VideoJSPlayer) => { - playerRef.current = player; - onPlayerInit && onPlayerInit(player); - player.on("playing", () => { - setPlayerStatus("playing"); - const currentTime = player.currentTime() ?? 0; - if (currentTime < startOffset || currentTime > endOffset) { - player.currentTime(startOffset); - } - onPlay && onPlay(); - setNowPlaying && candidate && setNowPlaying(candidate); - }); - player.on("pause", () => { - setPlayerStatus("paused"); - }); - player.on("waiting", () => setPlayerStatus("loading")); - player.on("error", () => setPlayerStatus("error")); - - player.on("timeupdate", () => { - const currentTime = player.currentTime() ?? 0; - if (currentTime > endOffset) { - player.currentTime(startOffset); - setPlayerTime(startOffset); - player.pause(); - onPlayerEnd && onPlayerEnd(); - } else { - setPlayerTime(currentTime); - } - }); - player.on("loadedmetadata", () => { - // On initial load, set player time to startOffset - player.currentTime(startOffset); - }); - }, - [startOffset, endOffset], - ); - - const handlePlayPauseClick = () => { - const player = playerRef.current; - - if (playerStatus === "error") { - setPlayerStatus("idle"); - return; - } - - if (!player) { - setPlayerStatus("error"); - return; - } - - try { - if (playerStatus === "loading" || playerStatus === "playing") { - player.pause(); - } else { - player.play(); - onAudioPlay?.(); - } - } catch (e) { - console.error(e); - // AbortError is thrown if pause() is called while play() is still loading (e.g. if segments are 404ing) - // It's not important, so don't show this error to the user - if (e instanceof DOMException && e.name === "AbortError") return; - setPlayerStatus("error"); - } - }; - - useEffect(() => { - if (process.env.NODE_ENV === "development" && hlsURI) { - console.log(`New stream instance: ${hlsURI}`); - } - return () => { - setPlayerStatus("idle"); - }; - }, [hlsURI, feed.nodeName]); - - const handleSliderChange = ( - _e: Event, - v: number | number[], - _activeThumb: number, - ) => { - playerRef?.current?.pause(); - if (typeof v !== "number") return; - playerRef?.current?.currentTime(v + startOffset); - }; - - const handleSliderChangeCommitted = ( - _e: Event | React.SyntheticEvent, - v: number | number[], - ) => { - if (typeof v !== "number") return; - playerRef?.current?.currentTime(v + startOffset); - playerRef?.current?.play(); - }; - - return ( - ({ - minHeight: theme.spacing(10), - display: "flex", - alignItems: "center", - justifyContent: "space-between", - px: [0, 2], - position: "relative", - [mobileOnly(theme)]: { - position: "fixed", - bottom: 0, - left: 0, - right: 0, - }, - // Keep player above the sliding drawer - zIndex: theme.zIndex.drawer + 1, - })} - > - - - - - - - - - `${(v + startOffset).toFixed(2)} s`} - step={0.1} - max={sliderMax} - value={sliderValue} - marks={marks} - onChange={handleSliderChange} - onChangeCommitted={handleSliderChangeCommitted} - size="small" - /> - - - - - {formattedSeconds(Number((playerTime - startOffset).toFixed(0)))} - - - {"-" + - formattedSeconds(Number((endOffset - playerTime).toFixed(0)))} - - - - - ); -} - -const formattedSeconds = (seconds: number) => { - const mm = Math.floor(seconds / 60); - const ss = seconds % 60; - return `${Number(mm).toString().padStart(2, "0")}:${ss - .toFixed(0) - .padStart(2, "0")}`; -}; diff --git a/ui/src/components/ReportsBarChart.tsx b/ui/src/components/ReportsBarChart.tsx deleted file mode 100644 index 69d738eb..00000000 --- a/ui/src/components/ReportsBarChart.tsx +++ /dev/null @@ -1,181 +0,0 @@ -import { Box, Button } from "@mui/material"; -import { BarChart } from "@mui/x-charts/BarChart"; -import React from "react"; - -import { useFeedsQuery } from "@/graphql/generated"; -import { CombinedData } from "@/types/DataTypes"; - -const chartSetting = { - yAxis: [ - { - label: "Reports", - dataKey: "detections", - }, - ], - height: 300, -}; - -type ChartData = { - tick: number; - milliseconds: number; - label: string; - detections: number; - whale: number; - vessel: number; - other: number; - "whale (ai)": number; -}; - -export default function ReportsBarChart({ - dataset, - timeRange, -}: { - dataset: CombinedData[]; - timeRange: number; -}) { - // get hydrophone feed list - const feedsQueryResult = useFeedsQuery(); - const feeds = feedsQueryResult.data?.feeds ?? []; - - const [legend, setLegend] = React.useState(true); - - const max = Date.now(); - const min = max - timeRange; - const startHour = new Date(min).setMinutes(0, 0, 0); - const endHour = new Date(max).setMinutes(0, 0, 0); - const timeDifferenceHours = (endHour - startHour) / (1000 * 60 * 60); - - const chartData: ChartData[] = []; - - for (let i = 0; i < timeDifferenceHours; i++) { - chartData.push({ - tick: i, - milliseconds: i * 1000 * 60 * 60, - label: new Date(startHour + i * 1000 * 60 * 60).toLocaleString(), - detections: 0, - whale: 0, - vessel: 0, - other: 0, - "whale (ai)": 0, - }); - } - - const categorySeries = [ - { dataKey: "whale", label: "Whale" }, - { dataKey: "vessel", label: "Vessel" }, - { dataKey: "other", label: "Other" }, - { dataKey: "whale (ai)", label: "Whale (AI)" }, - ]; - - const hydrophoneSeries = feeds.map((el) => ({ - dataKey: el.name.toLowerCase(), - label: el.name, - })); - hydrophoneSeries.shift(); // remove the "all hydrophones" from legend - - const hydrophoneCounts = feeds.map((el) => ({ - [el.name.toLowerCase()]: 0, - })); - - chartData.forEach((el) => { - hydrophoneCounts.forEach((hydro) => { - Object.assign(el, hydro); - }); - }); - - const countData = () => { - for (let i = 0; i < dataset.length; i++) { - const timestamp = Date.parse(dataset[i].timestampString); - const tick = Math.round((timestamp - min) / (1000 * 60 * 60)); - for (let j = 0; j < chartData.length; j++) { - if (chartData[j].tick === tick) { - const chartItem = chartData[j]; - chartItem.detections += 1; - if (dataset[i].newCategory.toLowerCase() === "whale") { - chartItem.whale += 1; - } - switch (dataset[i].newCategory.toLowerCase()) { - case "whale": - chartItem.whale += 1; - break; - case "vessel": - chartItem.vessel += 1; - break; - case "other": - chartItem.other += 1; - break; - case "whale (ai)": - chartItem["whale (ai)"] += 1; - break; - default: - null; - } - } - } - } - }; - countData(); - - interface ChartButtonProps { - onClick: (e: React.MouseEvent) => void; - name: string; - label: string; - } - - const ChartButton: React.FC = ({ - onClick, - name, - label, - }) => { - return ( - - ); - }; - - const handleLegend = (e: React.MouseEvent) => { - const button = e.target as HTMLButtonElement; - button.name === "category" ? setLegend(true) : setLegend(false); - }; - - return ( - <> - - - - - - - ); -} diff --git a/ui/src/components/layouts/DrawerLayout.tsx b/ui/src/components/layouts/DrawerLayout.tsx deleted file mode 100644 index 482ddf3d..00000000 --- a/ui/src/components/layouts/DrawerLayout.tsx +++ /dev/null @@ -1,176 +0,0 @@ -import { PlayLessonOutlined } from "@mui/icons-material"; -import BarChartIcon from "@mui/icons-material/BarChart"; -import DataObjectIcon from "@mui/icons-material/DataObject"; -import EarbudsIcon from "@mui/icons-material/Earbuds"; -import GraphicEqIcon from "@mui/icons-material/GraphicEq"; -import { Box, Container } from "@mui/material"; -import Divider from "@mui/material/Divider"; -import List from "@mui/material/List"; -import ListItem from "@mui/material/ListItem"; -import ListItemButton from "@mui/material/ListItemButton"; -import ListItemIcon from "@mui/material/ListItemIcon"; -import ListItemText from "@mui/material/ListItemText"; -import ListSubheader from "@mui/material/ListSubheader"; -import Toolbar from "@mui/material/Toolbar"; -import * as React from "react"; -import { ReactElement } from "react"; - -import Drawer from "@/components/Drawer"; -import Header from "@/components/Header"; -import Link from "@/components/Link"; - -const navigation = [ - { - kind: "subheader", - title: "New versions", - children: [ - { - title: "Reports", - path: "/moderator/", - icon: , - }, - { - title: "Hydrophones", - path: "/moderator/listen/", - icon: , - }, - { - title: "Archive", - path: "/moderator/learn/", - icon: , - }, - { - title: "JSON", - path: "/moderator/json", - icon: , - }, - ], - }, - { - kind: "divider", - }, - { - kind: "subheader", - title: "Existing versions", - children: [ - { - title: "Reports", - path: "/reports/", - icon: , - }, - { - title: "Bouts", - path: "/bouts/", - icon: , - }, - { - title: "Listen", - path: "/listen/", - icon: , - }, - { - title: "Learn", - path: "/moderator/learn/", - icon: , - }, - ], - }, -]; - -function ModeratorLayout({ children }: { children: React.ReactNode }) { - const listItem = (title: string, path: string, icon: ReactElement) => ( - - - - {icon} - - - - - ); - - const subheader = (content: string) => ( - - {content} - - ); - - interface NavDiv { - title?: string; - kind: string; - children?: NavItem[]; - } - - interface NavItem { - title: string; - path: string; - icon: ReactElement; - } - - const navDiv = (div: NavDiv) => { - let component; - switch (div.kind) { - case "divider": - component = ; - break; - case "subheader": - component = ( - - {div.children && - div.children.map((item) => - listItem(item.title, item.path, item.icon), - )} - - ); - } - return component; - }; - - const DrawerList = ( - - {navigation.map((item) => navDiv(item))} - - ); - - return ( - -
- - -
- {}}> - - {DrawerList} - -
- - {children} -
- - ); -} - -export function getDrawerLayout(page: ReactElement) { - return {page}; -} diff --git a/ui/src/components/layouts/ModeratorLayout.tsx b/ui/src/components/layouts/ModeratorLayout.tsx deleted file mode 100644 index 897c6ca1..00000000 --- a/ui/src/components/layouts/ModeratorLayout.tsx +++ /dev/null @@ -1,325 +0,0 @@ -import { PlayLessonOutlined } from "@mui/icons-material"; -import BarChartIcon from "@mui/icons-material/BarChart"; -import DataObjectIcon from "@mui/icons-material/DataObject"; -import EarbudsIcon from "@mui/icons-material/Earbuds"; -import MicIcon from "@mui/icons-material/Mic"; -import PlayCircleOutlineIcon from "@mui/icons-material/PlayCircleOutline"; -import { Box } from "@mui/material"; -import Divider from "@mui/material/Divider"; -import Drawer from "@mui/material/Drawer"; -import List from "@mui/material/List"; -import ListItem from "@mui/material/ListItem"; -import ListItemButton from "@mui/material/ListItemButton"; -import ListItemIcon from "@mui/material/ListItemIcon"; -import ListItemText from "@mui/material/ListItemText"; -import ListSubheader from "@mui/material/ListSubheader"; -import Toolbar from "@mui/material/Toolbar"; -import { useQuery } from "@tanstack/react-query"; -import * as React from "react"; -import { ReactElement, useMemo, useState } from "react"; - -import Header from "@/components/Header"; -import Link from "@/components/Link"; -import { DataProvider } from "@/context/DataContext"; -import { - DetectionCategory, - useDetectionsQuery, - useFeedsQuery, -} from "@/graphql/generated"; -import { Candidate } from "@/pages/moderator/candidates"; -import { AIData } from "@/types/DataTypes"; - -import PlayBar from "../PlayBar"; - -const drawerWidth = 240; - -const navigation = [ - { - kind: "subheader", - title: "", - children: [ - { - title: "Recordings", - path: "/moderator/candidates", - icon: , - }, - { - title: "Hydrophones", - path: "/moderator/hydrophones/", - icon: , - }, - { - title: "Bouts", - path: "/moderator/bouts/", - icon: , - }, - { - title: "Learn", - path: "/moderator/learn/", - icon: , - }, - { - title: "Reports", - path: "/moderator/reports", - icon: , - }, - { - title: "JSON", - path: "/moderator/json", - icon: , - }, - ], - }, - // { kind: "divider", }, -]; - -const endpointOrcahello = - "https://aifororcasdetections.azurewebsites.net/api/detections?"; -const daysAgo = 7; -const paramsOrcahello = { - page: 1, - sortBy: "timestamp", - sortOrder: "desc", - timeframe: "all", - dateFrom: new Date(new Date().setDate(new Date().getDate() - daysAgo)) - .toLocaleDateString() - .replaceAll(/\//g, "%2F"), - dateTo: new Date().toLocaleDateString().replaceAll(/\//g, "%2F"), - location: "all", - recordsPerPage: 100, -}; -function constructUrl(endpoint: string, paramsObj: object) { - let params = ""; - const entries = Object.entries(paramsObj); - for (const [key, value] of entries) { - const str = [key, value].join("=") + "&"; - params += str; - } - return endpoint + params; -} -const standardizeFeedName = (name: string) => { - switch (name) { - case "Beach Camp at Sunset Bay": - return "Sunset Bay"; - break; - case "North SJC": - return "North San Juan Channel"; - break; - case "Haro Strait": - return "Orcasound Lab"; - break; - default: - return name; - break; - } -}; -const lookupFeedName = ( - id: string, - feedList: { id: string; name: string }[], -) => { - let name = "feed not found"; - feedList.forEach((feed) => { - if (id === feed.id) { - name = feed.name; - } - }); - return standardizeFeedName(name); -}; - -function ModeratorLayout({ children }: { children: React.ReactNode }) { - //// DATA - - const [nowPlaying, setNowPlaying] = useState({} as Candidate); - - // get data on hydrophones - const feedsQueryResult = useFeedsQuery(); - const feedsData = feedsQueryResult.data?.feeds ?? []; - - type CategoryOptions = "WHALE" | "WHALE (AI)" | "VESSEL" | "OTHER" | "ALL"; - const [category, setCategory] = useState("ALL"); - - // get data on human detections - const detectionQueryResult = useDetectionsQuery( - ["WHALE", "VESSEL", "OTHER"].includes(category) - ? { filter: { category: { eq: category as DetectionCategory } } } - : {}, - { enabled: ["WHALE", "VESSEL", "OTHER", "ALL"].includes(category || "") }, - ); - const detectionsData = detectionQueryResult.data?.detections?.results ?? []; - - // get data on AI detections - const fetchOrcahelloData = async () => { - const response = await fetch( - constructUrl(endpointOrcahello, paramsOrcahello), - ); - if (!response.ok) { - throw new Error("Network response from Orcahello was not ok"); - } - return response.json(); - }; - - const { data, isSuccess } = useQuery({ - queryKey: ["ai-detections"], - queryFn: fetchOrcahelloData, - }); - const aiDetections = data; - - // deduplicate data on human detections - const dedupeHuman = detectionsData.filter( - (obj, index, arr) => - arr.findIndex( - (el) => - el.timestamp === obj.timestamp && el.description === obj.description, - ) === index, - ); - - // standardize data from Orcasound and OrcaHello - const datasetHuman = dedupeHuman.map((el) => ({ - ...el, - type: "human", - hydrophone: lookupFeedName(el.feedId!, feedsData), - comments: el.description, - newCategory: el!.category!, - timestampString: el.timestamp.toString(), - })); - - // combine global data into one object, to be passed into Data Provider for all child pages - const dataset = useMemo(() => { - const datasetAI = - aiDetections?.map((el: AIData) => ({ - ...el, - type: "ai", - hydrophone: standardizeFeedName(el.location.name), - newCategory: "WHALE (AI)", - timestampString: el.timestamp.toString(), - })) ?? []; - return { - human: datasetHuman, - ai: datasetAI, - combined: [...datasetHuman, ...datasetAI], - feeds: feedsData, - isSuccess: isSuccess, - nowPlaying: nowPlaying, - setNowPlaying: setNowPlaying, - }; - }, [datasetHuman, aiDetections, feedsData, isSuccess]); - - //// COMPONENTS - - const listItem = (title: string, path: string, icon: ReactElement) => ( - - - - {icon} - - - - - ); - - const subheader = (content: string) => ( - - {content} - - ); - - interface NavDiv { - title?: string; - kind: string; - children?: NavItem[]; - } - - interface NavItem { - title: string; - path: string; - icon: ReactElement; - } - - const navDiv = (div: NavDiv, index: number) => { - let component; - switch (div.kind) { - case "divider": - component = ; - break; - case "subheader": - component = ( - - {div.children && - div.children.map((item) => - listItem(item.title, item.path, item.icon), - )} - - ); - } - return component; - }; - - const DrawerList = ( - - {navigation.map((item, index) => navDiv(item, index))} - - ); - - //// RENDER - - return ( - -
- - -
- - - {DrawerList} - -
- - - {children} - - - -
- - ); -} - -export function getModeratorLayout(page: ReactElement) { - return {page}; -} diff --git a/ui/src/components/layouts/ToolpadLayout.tsx b/ui/src/components/layouts/ToolpadLayout.tsx deleted file mode 100644 index 4a171123..00000000 --- a/ui/src/components/layouts/ToolpadLayout.tsx +++ /dev/null @@ -1,38 +0,0 @@ -"use client"; -import DashboardIcon from "@mui/icons-material/Dashboard"; -import type { Navigation } from "@toolpad/core/AppProvider"; -import { DashboardLayout as ToolpadDashboardLayout } from "@toolpad/core/DashboardLayout"; -import { NextAppProvider } from "@toolpad/core/nextjs"; -import { PageContainer } from "@toolpad/core/PageContainer"; -import * as React from "react"; -import { ReactElement } from "react"; - -const NAVIGATION: Navigation = [ - { - kind: "header", - title: "Main items", - }, - { - segment: "", - title: "Dashboard", - icon: , - }, -]; - -const BRANDING = { - title: "My Toolpad Core App", -}; - -const ToolpadLayout = ({ children }: { children: React.ReactNode }) => { - return ( - - - {children} - - - ); -}; - -export function getToolpadLayout(page: ReactElement) { - return {page}; -} diff --git a/ui/src/context/DataContext.tsx b/ui/src/context/DataContext.tsx deleted file mode 100644 index 48030280..00000000 --- a/ui/src/context/DataContext.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import React, { createContext, useContext } from "react"; - -import { Dataset } from "@/types/DataTypes"; - -const DataContext = createContext({ - human: [], - ai: [], - combined: [], - isSuccess: false, - setNowPlaying: undefined, -}); - -export const useData = () => useContext(DataContext); - -export const DataProvider = ({ - children, - data, -}: { - children: React.ReactNode; - data: Dataset; -}) => { - return {children}; -}; diff --git a/ui/src/graphql/generated/index.ts b/ui/src/graphql/generated/index.ts index c85513ee..4fea992e 100644 --- a/ui/src/graphql/generated/index.ts +++ b/ui/src/graphql/generated/index.ts @@ -2641,29 +2641,6 @@ export type NotificationsForCandidateQuery = { }>; }; -export type Feeds2QueryVariables = Exact<{ - sort?: InputMaybe< - Array> | InputMaybe - >; -}>; - -export type Feeds2Query = { - __typename?: "RootQueryType"; - feeds: Array<{ - __typename?: "Feed"; - id: string; - name: string; - slug: string; - nodeName: string; - imageUrl?: string | null; - mapUrl?: string | null; - thumbUrl?: string | null; - bucket: string; - online?: boolean | null; - latLng: { __typename?: "LatLng"; lat: number; lng: number }; - }>; -}; - export type CandidatesQueryVariables = Exact<{ filter?: InputMaybe; limit?: InputMaybe; @@ -3806,57 +3783,6 @@ useNotificationsForCandidateQuery.fetcher = ( NotificationsForCandidateQueryVariables >(NotificationsForCandidateDocument, variables, options); -export const Feeds2Document = ` - query feeds2($sort: [FeedSortInput]) { - feeds(sort: $sort) { - id - name - slug - nodeName - latLng { - lat - lng - } - imageUrl - mapUrl - thumbUrl - bucket - online - } -} - `; - -export const useFeeds2Query = ( - variables?: Feeds2QueryVariables, - options?: Omit, "queryKey"> & { - queryKey?: UseQueryOptions["queryKey"]; - }, -) => { - return useQuery({ - queryKey: variables === undefined ? ["feeds2"] : ["feeds2", variables], - queryFn: fetcher( - Feeds2Document, - variables, - ), - ...options, - }); -}; - -useFeeds2Query.document = Feeds2Document; - -useFeeds2Query.getKey = (variables?: Feeds2QueryVariables) => - variables === undefined ? ["feeds2"] : ["feeds2", variables]; - -useFeeds2Query.fetcher = ( - variables?: Feeds2QueryVariables, - options?: RequestInit["headers"], -) => - fetcher( - Feeds2Document, - variables, - options, - ); - export const CandidatesDocument = ` query candidates($filter: CandidateFilterInput, $limit: Int, $offset: Int, $sort: [CandidateSortInput]) { candidates(filter: $filter, limit: $limit, offset: $offset, sort: $sort) { diff --git a/ui/src/graphql/queries/list2Feeds.graphql b/ui/src/graphql/queries/list2Feeds.graphql deleted file mode 100644 index 5fdd399d..00000000 --- a/ui/src/graphql/queries/list2Feeds.graphql +++ /dev/null @@ -1,17 +0,0 @@ -query feeds2($sort: [FeedSortInput]) { - feeds(sort: $sort) { - id - name - slug - nodeName - latLng { - lat - lng - } - imageUrl - mapUrl - thumbUrl - bucket - online - } -} diff --git a/ui/src/graphql/queries/listDetections2.graphql b/ui/src/graphql/queries/listDetections2.graphql deleted file mode 100644 index 7b8232fb..00000000 --- a/ui/src/graphql/queries/listDetections2.graphql +++ /dev/null @@ -1,24 +0,0 @@ -query detections2( - $filter: DetectionFilterInput - $limit: Int - $offset: Int - $sort: [DetectionSortInput] -) { - detections(filter: $filter, limit: $limit, offset: $offset, sort: $sort) { - count - hasNextPage - results { - id - timestamp - category - description - listenerCount - feed { - name - } - candidate { - id - } - } - } -} diff --git a/ui/src/pages/_app.tsx b/ui/src/pages/_app.tsx index a40f4700..bc8dfc6c 100644 --- a/ui/src/pages/_app.tsx +++ b/ui/src/pages/_app.tsx @@ -1,4 +1,3 @@ -"use client"; import CssBaseline from "@mui/material/CssBaseline"; import { ThemeProvider } from "@mui/material/styles"; import { AppCacheProvider } from "@mui/material-nextjs/v14-pagesRouter"; diff --git a/ui/src/pages/moderator/[candidateId].tsx b/ui/src/pages/moderator/[candidateId].tsx deleted file mode 100644 index 69c66c9e..00000000 --- a/ui/src/pages/moderator/[candidateId].tsx +++ /dev/null @@ -1,251 +0,0 @@ -import { - AccountCircle, - Edit, - ThumbDownOffAlt, - ThumbUpOffAlt, -} from "@mui/icons-material"; -import { - Box, - List, - ListItemAvatar, - ListItemButton, - ListItemText, - Typography, -} from "@mui/material"; -import Grid from "@mui/material/Grid2"; -import Head from "next/head"; -import { useRouter } from "next/router"; -import { useEffect, useState } from "react"; - -import { getModeratorLayout } from "@/components/layouts/ModeratorLayout"; -import { CandidateCardAIPlayer } from "@/components/Player/CandidateCardAIPlayer"; -import { CandidateCardPlayer } from "@/components/Player/CandidateCardPlayer"; -import { useData } from "@/context/DataContext"; -import { useFeedsQuery } from "@/graphql/generated"; -import type { NextPageWithLayout } from "@/pages/_app"; -import { CombinedData } from "@/types/DataTypes"; - -const CandidatePage: NextPageWithLayout = () => { - const router = useRouter(); - const { candidateId } = router.query; - const startEnd = - typeof candidateId === "string" ? candidateId?.split("_") : []; - const startTime = new Date(startEnd[0]).getTime(); - const endTime = new Date(startEnd[startEnd.length - 1]).getTime(); - console.log("startTime: " + startTime + ", endTime: " + endTime); - - // replace this with a direct react-query... - const { - combined, - isSuccess, - }: { combined: CombinedData[] | undefined; isSuccess: boolean } = useData(); // this uses a context provider to call data once and make it available to all children -- this may not be better than just using the query hooks, kind of does the same thing - - // get hydrophone feed list - const feedsQueryResult = useFeedsQuery(); - const feeds = feedsQueryResult.data?.feeds ?? []; - - type DetectionStats = { - combined: CombinedData[]; - human: CombinedData[]; - ai: CombinedData[]; - }; - - const [detections, setDetections] = useState({ - combined: [], - human: [], - ai: [], - }); - - useEffect(() => { - const arr: CombinedData[] = []; - combined?.forEach((d) => { - const time = new Date(d.timestamp).getTime(); - if (time >= startTime && time <= endTime) { - console.log("both true"); - } - if (time >= startTime && time <= endTime) { - arr.push(d); - } - }); - setDetections({ - combined: arr, - human: arr.filter((d) => d.newCategory !== "WHALE (AI)"), - ai: arr.filter((d) => d.newCategory === "WHALE (AI)"), - }); - console.log("combined length is " + combined.length); - }, [combined]); - - const userName = "UserProfile123"; - const aiName = "Orcahello AI"; - const communityName = "Community"; - - const feed = feeds.filter((f) => f.id === detections?.human[0]?.feedId)[0]; - const startTimestamp = detections.human.length - ? detections.human[0].playlistTimestamp - : 0; - - const offsetPadding = 15; - const minOffset = Math.min(...detections.human.map((d) => +d.playerOffset)); - // const maxOffset = Math.max(...candidateArray.map((d) => +d.playerOffset)); - - // ensures that the last offset is still in the same playlist -- future iteration may pull a second playlist if needed - const firstPlaylist = detections.human.filter( - (d) => +d.playlistTimestamp === startTimestamp, - ); - - const maxOffset = Math.max(...firstPlaylist.map((d) => +d.playerOffset)); - - const startOffset = Math.max(0, minOffset - offsetPadding); - const endOffset = maxOffset + offsetPadding; - - // const [breadcrumb, setBreadcrumb] = useState("") - // useEffect(() => { - // const breadcrumbName: string[] = []; - // detections.human.length && breadcrumbName.push(communityName); - // detections.ai.length && breadcrumbName.push(aiName); - // setBreadcrumb(breadcrumbName.join(" + ")); - // }, [detections]) - - return ( -
- Report {candidateId} | Orcasound - - - - {/* - - Recordings - - - {breadcrumb} - - */} - {/* -  {!isSuccess && "Waiting for Orcahello request..."} - */} - - - - - - {detections.combined[0]?.hydrophone} - - - {new Date(startEnd[0]).toLocaleString()} - - - - -
- {detections?.ai?.map((d) => ( - - ))} - -  {!isSuccess && "Waiting for Orcahello request..."} - -
- - - {detections.human.length ? ( - - ) : detections.ai.length ? ( - <> - - - ) : ( - "no player found" - )} - - - - {detections.combined?.map((el, index) => ( - - - - - - - - - - - - - - ))} - - - - More things to go here include: -
    -
  • - Tags - initially populated by regex, can be edited by moderator -
  • -
  • Valentina noise analysis
  • -
  • Dave T signal state
  • -
  • Share / save an audio clip
  • -
-
- - Things to go here could include: -
    -
  • Acartia map of detections in time range
  • -
  • - Marine Exchange of Puget Sound map of ship traffic in time range -
  • -
  • Local weather conditions in time range
  • -
-
-
-
- ); -}; - -CandidatePage.getLayout = getModeratorLayout; - -export default CandidatePage; diff --git a/ui/src/pages/moderator/bouts.tsx b/ui/src/pages/moderator/bouts.tsx deleted file mode 100644 index 2f8999a9..00000000 --- a/ui/src/pages/moderator/bouts.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { getModeratorLayout } from "@/components/layouts/ModeratorLayout"; -import type { NextPageWithLayout } from "@/pages/_app"; -import BoutsPage from "@/pages/bouts"; - -const ModeratorLearnPage: NextPageWithLayout = () => { - return ; -}; - -ModeratorLearnPage.getLayout = getModeratorLayout; - -export default ModeratorLearnPage; diff --git a/ui/src/pages/moderator/candidates.tsx b/ui/src/pages/moderator/candidates.tsx deleted file mode 100644 index 5b6b34c3..00000000 --- a/ui/src/pages/moderator/candidates.tsx +++ /dev/null @@ -1,370 +0,0 @@ -import { Box, Button, Container, Stack, Typography } from "@mui/material"; -import { SelectChangeEvent } from "@mui/material/Select"; -import { useEffect, useRef, useState } from "react"; - -import CandidateCard from "@/components/CandidateCard"; -import ChartSelect from "@/components/ChartSelect"; -import { getModeratorLayout } from "@/components/layouts/ModeratorLayout"; -import ReportsBarChart from "@/components/ReportsBarChart"; -import { useData } from "@/context/DataContext"; -import { useFeedsQuery } from "@/graphql/generated"; -import { CombinedData } from "@/types/DataTypes"; - -const sevenDays = 7 * 24 * 60 * 60 * 1000; -const threeDays = 3 * 24 * 60 * 60 * 1000; -const oneDay = 24 * 60 * 60 * 1000; - -const timeRangeSelect = [ - { - label: "Last 7 days", - value: sevenDays, - }, - { - label: "Last 3 days", - value: threeDays, - }, - { - label: "Last 24 hours", - value: oneDay, - }, -]; - -const timeIncrementSelect = [ - { - label: "Group reports within 15 min", - value: 15, - }, - { - label: "Group reports within 30 min", - value: 30, - }, - { - label: "Group reports within 60 min", - value: 60, - }, - { - label: "Do not group reports", - value: 0, - }, -]; - -const categorySelect = [ - { - label: "All categories", - value: "All categories", - }, - { - label: "Whale", - value: "whale", - }, - { - label: "Vessel", - value: "vessel", - }, - { - label: "Other", - value: "other", - }, - { - label: "Whale (AI)", - value: "whale (ai)", - }, -]; - -export interface Candidate { - array: CombinedData[]; - whale: number; - vessel: number; - other: number; - "whale (AI)": number; - hydrophone: string; - descriptions: string; -} - -const createCandidates = ( - dataset: CombinedData[], - interval: number, -): Candidate[] => { - const candidates: Array> = []; - const sort = dataset.sort( - (a, b) => Date.parse(b.timestampString) - Date.parse(a.timestampString), - ); - sort.forEach((el: CombinedData) => { - if (!candidates.length) { - const firstArray = []; - firstArray.push(el); - candidates.push(firstArray); - } else { - const hydrophone = el.hydrophone; - const findLastMatchingArray = () => { - for (let i = candidates.length - 1; i >= 0; i--) { - if (candidates[i][0].hydrophone === hydrophone) { - return candidates[i]; - } - } - }; - const lastMatchingArray = findLastMatchingArray(); - const lastTimestamp = - lastMatchingArray && - lastMatchingArray[lastMatchingArray.length - 1].timestampString; - if ( - lastTimestamp && - Math.abs(Date.parse(lastTimestamp) - Date.parse(el.timestampString)) / - (1000 * 60) <= - interval - ) { - lastMatchingArray.push(el); - } else { - const newArray = []; - newArray.push(el); - candidates.push(newArray); - } - } - }); - const countCategories = (arr: { newCategory: string }[], cat: string) => { - return arr.filter((d) => d.newCategory.toLowerCase() === cat).length; - }; - - const candidatesMap = candidates.map((candidate) => ({ - array: candidate, - whale: countCategories(candidate, "whale"), - vessel: countCategories(candidate, "vessel"), - other: countCategories(candidate, "other"), - "whale (AI)": countCategories(candidate, "whale (ai)"), - hydrophone: candidate[0].hydrophone, - descriptions: candidate - .map((el: CombinedData) => el.comments) - .filter((el: string | null | undefined) => el !== null) - .join(" • "), - })); - - return candidatesMap; -}; - -export default function Candidates() { - // replace this with a direct react-query... - const { - combined, - isSuccess, - }: { combined: CombinedData[] | undefined; isSuccess: boolean } = useData(); // this uses a context provider to call data once and make it available to all children -- this may not be better than just using the query hooks, kind of does the same thing - - // get hydrophone feed list - const feedsQueryResult = useFeedsQuery(); - const feeds = feedsQueryResult.data?.feeds ?? []; - - const [filters, setFilters] = useState({ - timeRange: threeDays, - timeIncrement: 15, - hydrophone: "All hydrophones", - category: "All categories", - }); - - const [timeRange, setTimeRange] = useState(threeDays); - const [timeIncrement, setTimeIncrement] = useState(15); - const [hydrophone, setHydrophone] = useState("All hydrophones"); - const [category, setCategory] = useState("All categories"); - - const handleChange = (event: SelectChangeEvent) => { - const { name, value } = event.target; - setFilters((prevFilters) => ({ - ...prevFilters, - [name]: value, - })); - }; - - const initChartSelect = (name: string, value: string | number) => { - setFilters((prevFilters) => ({ - ...prevFilters, - [name]: value, - })); - }; - - // const [playing, setPlaying] = useState({ - // index: -1, - // status: "ready", - // }); - - // const changeListState = (index: number, status: string) => { - // setPlaying((prevState) => ({ - // ...prevState, - // index: index, - // status: status, - // })); - // }; - - const [playNext, setPlayNext] = useState(true); - - const players = useRef({}); - - const feedList = feeds.map((el) => ({ - label: el.name, - value: el.name, - })); - feedList.unshift({ label: "All hydrophones", value: "All hydrophones" }); - - const filteredData = combined.filter((el: CombinedData) => { - return ( - // uncomment this to block Orcahello data - // el.type === "human" && - - // Disabling timerange filter for now because seed data is all from 2023 - // (Date.parse(el.timestamp) >= min) && - - (filters.hydrophone === "All hydrophones" || - el.hydrophone === filters.hydrophone) && - (filters.category === "All categories" || - el.newCategory.toLowerCase() === filters.category) - ); - }); - - const handledGetTime = (date?: Date) => { - return date != null ? new Date(date).getTime() : 0; - }; - - const sortDescending = (array: Candidate[]) => { - const sort = array.sort( - (a, b) => - handledGetTime(b.array[0].timestamp) - - handledGetTime(a.array[0].timestamp), - ); - return sort; - }; - - const sortAscending = (array: Candidate[]) => { - const sort = array.sort( - (a, b) => - handledGetTime(a.array[0].timestamp) - - handledGetTime(b.array[0].timestamp), - ); - return sort; - }; - - const candidates = sortDescending( - createCandidates(filteredData, filters.timeIncrement), - ); - const [sortedCandidates, setSortedCandidates] = useState([...candidates]); - - const handleSortAscending = (array: Candidate[]) => { - setSortedCandidates((v) => [...sortAscending(array)]); - }; - - const handleSortDescending = (array: Candidate[]) => { - setSortedCandidates((v) => [...sortDescending(array)]); - }; - - useEffect(() => { - setSortedCandidates((v) => [...candidates]); - if (isSuccess) { - setSortedCandidates((v) => [...candidates]); - } - }, [isSuccess]); - - // render these first because it seems to take a while for candidates to populate from state, could just be the dev environment - const candidateCards = candidates.map( - (candidate: Candidate, index: number) => ( - - ), - ); - - // these render from state after delay, then re-render after another delay when AI candidates come through - const sortedCandidateCards = sortedCandidates.map( - (candidate: Candidate, index: number) => ( - - ), - ); - - return ( - - - - - - - - - - - - Showing{" "} - {sortedCandidates.length - ? sortedCandidates.length - : candidates.length}{" "} - {!isSuccess - ? "results from Orcasound, loading Orcahello..." - : "results"} - - - - - - - - {sortedCandidates.length ? sortedCandidateCards : candidateCards} - - - - ); -} - -Candidates.getLayout = getModeratorLayout; diff --git a/ui/src/pages/moderator/hydrophones.tsx b/ui/src/pages/moderator/hydrophones.tsx deleted file mode 100644 index 4ccbfa8a..00000000 --- a/ui/src/pages/moderator/hydrophones.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { getModeratorLayout } from "@/components/layouts/ModeratorLayout"; -import type { NextPageWithLayout } from "@/pages/_app"; -import FeedsPage from "@/pages/listen"; - -const ModeratorFeedsPage: NextPageWithLayout = () => { - return ; -}; - -ModeratorFeedsPage.getLayout = getModeratorLayout; - -export default ModeratorFeedsPage; diff --git a/ui/src/pages/moderator/index.tsx b/ui/src/pages/moderator/index.tsx deleted file mode 100644 index 7333ca4a..00000000 --- a/ui/src/pages/moderator/index.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { getModeratorLayout } from "@/components/layouts/ModeratorLayout"; -import type { NextPageWithLayout } from "@/pages/_app"; - -import Candidates from "./candidates"; - -const NewFeedsPage: NextPageWithLayout = () => { - return ( - <> - - - ); -}; - -NewFeedsPage.getLayout = getModeratorLayout; - -export default NewFeedsPage; diff --git a/ui/src/pages/moderator/json.tsx b/ui/src/pages/moderator/json.tsx deleted file mode 100644 index fd1a05c1..00000000 --- a/ui/src/pages/moderator/json.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { getModeratorLayout } from "@/components/layouts/ModeratorLayout"; -import { useData } from "@/context/DataContext"; -import { useFeedsQuery } from "@/graphql/generated"; -import type { NextPageWithLayout } from "@/pages/_app"; - -const JSONPage: NextPageWithLayout = () => { - const { combined } = useData(); - // get hydrophone feed list - const feedsQueryResult = useFeedsQuery(); - const feeds = feedsQueryResult.data?.feeds ?? []; - - return ( - <> -
{JSON.stringify(combined, null, 2)}
-
{JSON.stringify(feeds, null, 2)}
- - ); -}; - -JSONPage.getLayout = getModeratorLayout; - -export default JSONPage; diff --git a/ui/src/pages/moderator/learn.tsx b/ui/src/pages/moderator/learn.tsx deleted file mode 100644 index 1eb4d664..00000000 --- a/ui/src/pages/moderator/learn.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { getModeratorLayout } from "@/components/layouts/ModeratorLayout"; -import type { NextPageWithLayout } from "@/pages/_app"; -import LearnPage from "@/pages/learn"; - -const ModeratorLearnPage: NextPageWithLayout = () => { - return ; -}; - -ModeratorLearnPage.getLayout = getModeratorLayout; - -export default ModeratorLearnPage; diff --git a/ui/src/pages/moderator/playertest.tsx b/ui/src/pages/moderator/playertest.tsx deleted file mode 100644 index cd82d319..00000000 --- a/ui/src/pages/moderator/playertest.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { getModeratorLayout } from "@/components/layouts/ModeratorLayout"; -import { DetectionsPlayer } from "@/components/Player/DetectionsPlayer"; -import { useCandidateQuery } from "@/graphql/generated"; -import { useFeedsQuery } from "@/graphql/generated"; -import { CombinedData } from "@/types/DataTypes"; - -const ModeratorReportsPlayer = ({ - firstDetection, - lastDetection, -}: { - firstDetection?: CombinedData; - lastDetection?: CombinedData; -}) => { - // const router = useRouter(); - // const { candidateId } = router.query; - const candidateId = "cand_02z0tyRhTNcgmch7FIHrXJ"; - - const candidateQuery = useCandidateQuery({ - id: (candidateId || "") as string, - }); - const candidate = candidateQuery.data?.candidate; - const detections = candidate && candidate.detections; - - // get hydrophone feed list - const feedsQueryResult = useFeedsQuery(); - const feedsData = feedsQueryResult.data?.feeds ?? []; - - // get feed object from feedId - // const feedObj = feedsData.find((feed) => feed.id === firstDetection.feedId); - // const firstTimestamp = firstDetection.playlistTimestamp; - // const lastTimestamp = lastDetection.playlistTimestamp; - - const offsetPadding = 15; - const minOffset = detections - ? Math.min(...detections.map((d) => +d.playerOffset)) - : 0; - const maxOffset = detections - ? Math.max(...detections.map((d) => +d.playerOffset)) - : 0; - const startOffset = Math.max(0, minOffset - offsetPadding); - const endOffset = maxOffset + offsetPadding; - - return ( - <> - {candidate && ( - - )} - - ); -}; - -ModeratorReportsPlayer.getLayout = getModeratorLayout; - -export default ModeratorReportsPlayer; diff --git a/ui/src/pages/moderator/reports.tsx b/ui/src/pages/moderator/reports.tsx deleted file mode 100644 index d2693be6..00000000 --- a/ui/src/pages/moderator/reports.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { getModeratorLayout } from "@/components/layouts/ModeratorLayout"; -import type { NextPageWithLayout } from "@/pages/_app"; -import DetectionsPage from "@/pages/reports"; - -const ModeratorReportsPage: NextPageWithLayout = () => { - return ; -}; - -ModeratorReportsPage.getLayout = getModeratorLayout; - -export default ModeratorReportsPage; diff --git a/ui/src/types/DataTypes.ts b/ui/src/types/DataTypes.ts deleted file mode 100644 index 44e132ee..00000000 --- a/ui/src/types/DataTypes.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { Dispatch, SetStateAction } from "react"; - -import { Detection, Scalars } from "@/graphql/generated"; -import { Candidate } from "@/pages/moderator/candidates"; - -export interface HumanData extends Omit { - type: string; - hydrophone: string; - comments: string | null | undefined; - newCategory: string; - timestampString: string; -} - -export interface AIDetection { - id: string; - audioUri: string; - spectrogramUri: string; - location: Location; - timestamp: Scalars["DateTime"]["output"]; - annotations: Annotation[]; - reviewed: boolean; - found: string; - comments: string | null | undefined; - confidence: number; - moderator: string; - moderated: string; - tags: string; -} -export interface AIData extends AIDetection { - type: string; - hydrophone: string; - newCategory: string; - timestampString: string; -} -export interface CombinedData extends HumanData, AIData {} - -export interface Dataset { - human: HumanData[]; - ai: AIData[]; - combined: CombinedData[]; - // feeds: Feed[]; - isSuccess: boolean; - setNowPlaying?: Dispatch>; -} - -export interface Location { - name: string; -} -export interface Annotation { - id: number; - startTime: number; - endTime: number; - confidence: number; -}