From 7158e3110e84a549537742e7f6cb28b6711c3f83 Mon Sep 17 00:00:00 2001 From: Adrian Date: Tue, 17 Feb 2026 22:11:37 +0000 Subject: [PATCH 01/12] Fix dev seeding failures and surface seed errors --- server/lib/orcasite/radio/feed.ex | 2 + server/lib/orcasite/radio/seed.ex | 89 ++++++++++++++----------- server/lib/orcasite/radio/seed/utils.ex | 12 +++- server/priv/repo/seeds.exs | 4 +- ui/src/pages/seed.tsx | 25 ++++++- 5 files changed, 88 insertions(+), 44 deletions(-) diff --git a/server/lib/orcasite/radio/feed.ex b/server/lib/orcasite/radio/feed.ex index 0dc092462..35c85070d 100644 --- a/server/lib/orcasite/radio/feed.ex +++ b/server/lib/orcasite/radio/feed.ex @@ -225,6 +225,7 @@ defmodule Orcasite.Radio.Feed do :cloudfront_url, :dataplicity_id, :orcahello_id, + :maintainer_emails, :location_point, if(Orcasite.Config.seeding_enabled?(), do: :id) ] @@ -241,6 +242,7 @@ defmodule Orcasite.Radio.Feed do :cloudfront_url, :dataplicity_id, :orcahello_id, + :maintainer_emails, :location_point ] diff --git a/server/lib/orcasite/radio/seed.ex b/server/lib/orcasite/radio/seed.ex index 88a7d8a1c..375491596 100644 --- a/server/lib/orcasite/radio/seed.ex +++ b/server/lib/orcasite/radio/seed.ex @@ -61,26 +61,24 @@ defmodule Orcasite.Radio.Seed do default: fn -> DateTime.utc_now() |> DateTime.add(-2, :minute) end run fn %{arguments: %{start_time: start_time, end_time: end_time}}, _ -> - __MODULE__.feeds() - - feeds = Orcasite.Radio.Feed |> Ash.read!() - - seed_params = - for feed <- feeds, resource <- [:feed_segment, :detection, :audio_image, :bout] do - {feed, resource} - end - - seed_params - |> Stream.map(fn {feed, resource} -> - __MODULE__.resource!(%{ - resource: resource, - start_time: start_time, - end_time: end_time, - feed_id: feed.id - }) - end) - |> Enum.to_list() - |> then(&{:ok, &1}) + with {:ok, feeds} <- seed_and_load_feeds() do + seed_params = + for feed <- feeds, resource <- [:feed_segment, :detection, :audio_image, :bout] do + {feed, resource} + end + + seed_params + |> Stream.map(fn {feed, resource} -> + __MODULE__.resource!(%{ + resource: resource, + start_time: start_time, + end_time: end_time, + feed_id: feed.id + }) + end) + |> Enum.to_list() + |> then(&{:ok, &1}) + end end end @@ -90,25 +88,23 @@ defmodule Orcasite.Radio.Seed do argument :limit, :integer, allow_nil?: false, default: 100 run fn %{arguments: %{limit: limit}}, _ -> - __MODULE__.feeds() - - feeds = Orcasite.Radio.Feed |> Ash.read!() - - seed_params = - for feed <- feeds, resource <- [:detection, :audio_image, :bout] do - {feed, resource} - end - - seed_params - |> Stream.map(fn {feed, resource} -> - __MODULE__.latest_resource!(%{ - resource: resource, - limit: limit, - feed_id: feed.id - }) - end) - |> Enum.to_list() - |> then(&{:ok, &1}) + with {:ok, feeds} <- seed_and_load_feeds() do + seed_params = + for feed <- feeds, resource <- [:detection, :audio_image, :bout] do + {feed, resource} + end + + seed_params + |> Stream.map(fn {feed, resource} -> + __MODULE__.latest_resource!(%{ + resource: resource, + limit: limit, + feed_id: feed.id + }) + end) + |> Enum.to_list() + |> then(&{:ok, &1}) + end end end @@ -243,4 +239,19 @@ defmodule Orcasite.Radio.Seed do :feed_stream -> Orcasite.Radio.FeedStream end end + + defp seed_and_load_feeds do + with {:ok, _} <- __MODULE__.feeds(), + {:ok, feeds} <- Ash.read(Orcasite.Radio.Feed, authorize?: false) do + case feeds do + [] -> + {:error, + message: + "No feeds were seeded. Run `seedFeeds` first and inspect server logs for feed seeding errors."} + + _ -> + {:ok, feeds} + end + end + end end diff --git a/server/lib/orcasite/radio/seed/utils.ex b/server/lib/orcasite/radio/seed/utils.ex index ed13dd243..182c78961 100644 --- a/server/lib/orcasite/radio/seed/utils.ex +++ b/server/lib/orcasite/radio/seed/utils.ex @@ -12,7 +12,7 @@ defmodule Orcasite.Radio.Seed.Utils do attr = Absinthe.Adapter.Underscore.to_internal_name(key, []) cond do - writable_attr?(resource, attr) -> + writable_attr?(resource, attr) and include_attribute_input?(resource, attr, val) -> [{attr, val}] many_relationship?(resource, attr) -> @@ -30,6 +30,16 @@ defmodule Orcasite.Radio.Seed.Utils do |> Map.new() end + def include_attribute_input?(resource, key, value) do + case Ash.Resource.Info.attribute(resource, key) do + %{allow_nil?: false} when is_nil(value) -> + false + + _ -> + true + end + end + def writable_attr?(resource, key) do Ash.Resource.Info.attribute(resource, key) |> case do diff --git a/server/priv/repo/seeds.exs b/server/priv/repo/seeds.exs index 1fadfa585..0b70ada48 100644 --- a/server/priv/repo/seeds.exs +++ b/server/priv/repo/seeds.exs @@ -1,11 +1,11 @@ require Ash.Query -Orcasite.Radio.Seed.time_range(%{ +Orcasite.Radio.Seed.time_range!(%{ end_time: DateTime.utc_now(), start_time: DateTime.add(DateTime.utc_now(), -1, :hour) }) -Orcasite.Radio.Seed.latest() +Orcasite.Radio.Seed.latest!() # Create admin account strategy = AshAuthentication.Info.strategy!(Orcasite.Accounts.User, :password) diff --git a/ui/src/pages/seed.tsx b/ui/src/pages/seed.tsx index b7452c91b..f5331df09 100644 --- a/ui/src/pages/seed.tsx +++ b/ui/src/pages/seed.tsx @@ -55,6 +55,15 @@ const SeedPage: NextPageWithLayout = () => { message: "", }); + const onMutationError = (error: unknown) => { + setSeedForm((form) => ({ + ...form, + isSaving: false, + saved: true, + message: error instanceof Error ? error.message : "Seeding failed", + })); + }; + const onSuccess = (response: SeedFeedsResult | SeedResourceResult) => { const { result, errors } = response; if (errors && errors.length > 0) { @@ -83,11 +92,13 @@ const SeedPage: NextPageWithLayout = () => { onSuccess(seedFeeds); feedsQuery.refetch(); }, + onError: onMutationError, }); const seedResourceMutation = useSeedResourceMutation({ onSuccess: ({ seedResource }: { seedResource: SeedResourceResult }) => { onSuccess(seedResource); }, + onError: onMutationError, }); const seedAllMutation = useSeedAllMutation({ @@ -111,13 +122,18 @@ const SeedPage: NextPageWithLayout = () => { return `${count} ${lowerCaseResource(resource)}${count === 1 ? "" : "s"}`; }) .join(", "); + setSeedForm((form) => ({ ...form, isSaving: false, saved: true, - message: `Seeded ${countString}`, + message: + countString.length > 0 + ? `Seeded ${countString}` + : "No records were seeded. Check server logs for feed/seed errors.", })); }, + onError: onMutationError, }); const handleSubmit = () => { @@ -311,7 +327,12 @@ function toLocalISOString(date: Date) { SeedPage.getLayout = getSimpleLayout; export async function getStaticProps() { - const enableSeedFromProd = process.env.ENABLE_SEED_FROM_PROD === "true"; + const seedFlag = process.env.ENABLE_SEED_FROM_PROD; + const gqlEndpoint = process.env.NEXT_PUBLIC_GQL_ENDPOINT ?? ""; + // The local dev stack runs with a localhost GraphQL endpoint. + const isLocalDevStack = gqlEndpoint.includes("localhost"); + const enableSeedFromProd = seedFlag ? seedFlag === "true" : isLocalDevStack; + // Hide the seed page when `ENABLE_SEED_FROM_PROD` isn't enabled return !enableSeedFromProd ? { notFound: true } : { props: {} }; } From cb15df00bf8bc9c75044d90979ab244c0282740e Mon Sep 17 00:00:00 2001 From: Adrian Date: Thu, 19 Feb 2026 07:26:02 +0000 Subject: [PATCH 02/12] added useSightings to map, and useCombinedData (currently unused) --- ui/src/components/Map.tsx | 3 + ui/src/components/layouts/MapLayout.tsx | 4 + ui/src/hooks/useCombinedData.ts | 60 +++++++++++ ui/src/hooks/useSightings.ts | 35 +++++++ ui/src/types/DataTypes.ts | 76 ++++++++++++++ ui/src/utils/dataHelpers.ts | 129 ++++++++++++++++++++++++ ui/src/utils/dataTransforms.ts | 93 +++++++++++++++++ 7 files changed, 400 insertions(+) create mode 100644 ui/src/hooks/useCombinedData.ts create mode 100644 ui/src/hooks/useSightings.ts create mode 100644 ui/src/types/DataTypes.ts create mode 100644 ui/src/utils/dataHelpers.ts create mode 100644 ui/src/utils/dataTransforms.ts diff --git a/ui/src/components/Map.tsx b/ui/src/components/Map.tsx index 340f0100a..0ae62ba1d 100644 --- a/ui/src/components/Map.tsx +++ b/ui/src/components/Map.tsx @@ -10,15 +10,18 @@ import { MapContainer, Marker, TileLayer, ZoomControl } from "react-leaflet"; import { Feed, FeedsQuery } from "@/graphql/generated"; import hydrophoneActiveIconImage from "@/public/icons/hydrophone-active.svg"; import hydrophoneDefaultIconImage from "@/public/icons/hydrophone-default.svg"; +import { CascadiaSighting } from "@/types/DataTypes"; export default function Map({ setMap, currentFeed, feeds, + sightings, }: { setMap?: (map: LeafletMap) => void; currentFeed?: Pick; feeds: FeedsQuery["feeds"]; + sightings: CascadiaSighting[]; }) { const router = useRouter(); diff --git a/ui/src/components/layouts/MapLayout.tsx b/ui/src/components/layouts/MapLayout.tsx index b0a86af6d..8be090ca6 100644 --- a/ui/src/components/layouts/MapLayout.tsx +++ b/ui/src/components/layouts/MapLayout.tsx @@ -9,6 +9,7 @@ import { ReactElement, ReactNode, useEffect, useState } from "react"; import Drawer from "@/components/Drawer"; import Header from "@/components/Header"; import { useFeedQuery, useFeedsQuery } from "@/graphql/generated"; +import { useSightings } from "@/hooks/useSightings"; import { displayDesktopOnly, displayMobileOnly } from "@/styles/responsive"; import Player, { PlayerSpacer } from "../Player"; @@ -46,6 +47,8 @@ function MapLayout({ children }: { children: ReactNode }) { const feeds = useFeedsQuery().data?.feeds ?? []; const firstOnlineFeed = feeds.filter(({ online }) => online)[0]; + const sightings = useSightings().data?.results ?? []; + // update the currentFeed only if there's a new feed useEffect(() => { if (feed && feed.slug !== currentFeed?.slug) { @@ -110,6 +113,7 @@ function MapLayout({ children }: { children: ReactNode }) { setMap={setMap} currentFeed={currentFeed} feeds={feeds} + sightings={sightings} /> transformAudioDetections(audioDetections, feeds), + [audioDetections, feeds], + ); + + //// ACARTIA sightings + // get detections + const { data: sightingsData, isSuccess: isSuccessSightings } = useSightings(); + const dataSightings = useMemo( + () => sightingsData?.results ?? [], + [sightingsData], + ); + // standardize data + const datasetSightings = useMemo( + () => transformSightings(dataSightings, feeds), + [dataSightings, feeds], + ); + + const combined: CombinedData[] = useMemo(() => { + return [...datasetAudio, ...datasetSightings]; + }, [datasetAudio, datasetSightings]); + + const dataset = useMemo(() => { + return { + audio: datasetAudio, + sightings: datasetSightings, + combined: combined, + feeds: feeds, + isSuccessSightings: isSuccessSightings, + }; + }, [datasetAudio, datasetSightings, combined, feeds, isSuccessSightings]); + return dataset; +} diff --git a/ui/src/hooks/useSightings.ts b/ui/src/hooks/useSightings.ts new file mode 100644 index 000000000..bb1c18d59 --- /dev/null +++ b/ui/src/hooks/useSightings.ts @@ -0,0 +1,35 @@ +import { useQuery } from "@tanstack/react-query"; + +import { CascadiaSighting } from "@/types/DataTypes"; +import { apiTodayUTC, constructUrl } from "@/utils/dataHelpers"; + +const endpointCascadia = + "https://maplify.com/waseak/php/search-all-sightings.php"; + +const startDateCascadia = "2025-01-01"; +const paramsCascadia = { + BBOX: "-136,36,-120,54", + start: startDateCascadia, + end: apiTodayUTC, +}; + +// live data call for Detections +type SightingsDataResponse = { + results: CascadiaSighting[]; +}; + +const fetchCascadiaData = async (): Promise => { + const response = await fetch(constructUrl(endpointCascadia, paramsCascadia)); + if (!response.ok) { + throw new Error("Network response from URL was not ok"); + } + return response.json(); +}; + +export function useSightings() { + const { data, isSuccess, error } = useQuery({ + queryKey: ["sightings"], + queryFn: fetchCascadiaData, + }); + return { data, isSuccess, error }; +} diff --git a/ui/src/types/DataTypes.ts b/ui/src/types/DataTypes.ts new file mode 100644 index 000000000..685891ecc --- /dev/null +++ b/ui/src/types/DataTypes.ts @@ -0,0 +1,76 @@ +import { DetectionsQuery } from "@/graphql/generated"; + +// Data Transfer Objects (DTOs) come from specific query results and may omit fields from full schema models. +// DetectionsResultList is a DTO alias so adapters match query output (listDetections) rather than requiring every field on the GraphQL `Detection` model type. + +// NonNullable is a built-in TypeScript utility type that removes null and undefined from a type, ensuring that the resulting type is always defined. +type DetectionsResultList = NonNullable< + NonNullable["results"] +>; + +// [number] means 'type of one item in the DetectionsQuery["detections"]>["results"] array' +export type DetectionsResult = DetectionsResultList[number]; + +export interface AudioDetection extends Omit { + type: "audio"; + hydrophone: string; + comments: string | null | undefined; + newCategory: + | "WHALE (HUMAN)" + | "VESSEL" + | "OTHER" + | "WHALE (AI)" + | "uncategorized"; + timestampString: string; +} + +export interface CascadiaSighting { + id: string; + type: string; // e.g., "sighting" + project_id: number; + trip_id: number; + name: string; // e.g., "Killer Whale (Orca)" + scientific_name: string; // e.g., "Orcinus orca" + number_sighted: number; + latitude: number; + longitude: number; + created: string; // ISO date string, e.g., "2025-01-01 17:25:00" + source: string; // e.g., "whale_alert" + comments: string | null | undefined; // HTML string, null | undefined matches Orcahello + icon: string; // e.g., "dot-black" + photo_url: string; + usernm: string; // e.g., "cascadiaWebMap" + count_check: number; + in_ocean: number; // boolean-like (0 or 1) + is_test: number; // boolean-like (0 or 1) + moderated: number | string; // boolean-like (0 or 1) for Cascadia, string for Orcahello + trusted: number; // boolean-like (0 or 1) +} + +export interface Sighting extends CascadiaSighting { + type: "sightings"; + hydrophone: string; + feedId: string; + newCategory: "SIGHTING"; + timestamp: Date; + timestampString: string; +} + +export type CombinedData = AudioDetection | Sighting; + +// future type for transformed data object +// export interface Candidate { +// id: string; +// array: CombinedData[]; +// startTimestamp: string; +// endTimestamp: string; +// whale: number; +// vessel: number; +// other: number; +// "whale (AI)": number; +// sightings: number; +// hydrophone: string; +// feedId: string | undefined; +// clipCount: string; +// descriptions: string; +// } diff --git a/ui/src/utils/dataHelpers.ts b/ui/src/utils/dataHelpers.ts new file mode 100644 index 000000000..340d1be03 --- /dev/null +++ b/ui/src/utils/dataHelpers.ts @@ -0,0 +1,129 @@ +import { Feed } from "@/graphql/generated"; + +export 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; +} + +export default function formatDuration(startOffset: number, endOffset: number) { + const seconds = endOffset - startOffset; + const minutesRound = Math.round(seconds / 60); + const minutesDown = Math.floor(seconds / 60); + const hoursDown = Math.floor(seconds / 60 / 60); + const daysDown = Math.floor(seconds / 60 / 60 / 24); + const remainder = Math.round(seconds % 60); + + if (seconds === 0) { + return "audio unavailable"; + } else if (seconds < 60) { + return `${seconds} second${seconds === 1 ? "" : "s"}`; + } else if (seconds < 600) { + return `${minutesDown} minute${minutesDown === 1 ? "" : "s"} ${remainder} second${remainder === 1 ? "" : "s"}`; + } else if (seconds >= 600 && seconds < 3600) { + return `${minutesRound} minute${minutesRound === 1 ? "" : "s"}`; + } else if (seconds >= 3600 && seconds < 86400) { + return `${hoursDown} hour${hoursDown === 1 ? "" : "s"}`; + } else if (seconds >= 86400) { + return `${daysDown} day${daysDown === 1 ? "" : "s"}`; + } +} + +export const cleanSightingsDescription = ( + description: string | null | undefined, +) => { + if (!description) return; + const removeBracket = description.replace(/^\[[^\]]*\]\s*/, ""); + const removeBreak = removeBracket.replace(/
[^•]*/g, ""); + const removeLinks = removeBreak + .replace(/https?:\/\/\S+/g, "") + .replace(/\s+•/g, " •") + .trim(); + + return removeLinks.trim(); +}; + +export const standardizeFeedName = (name: string) => { + switch (name) { + case "Beach Camp at Sunset Bay": + return "Sunset Bay"; + case "North SJC": + return "North San Juan Channel"; + case "Haro Strait": + return "Orcasound Lab"; + // case "out of range": + // return "Out of audible range"; + default: + return name; + } +}; + +export const lookupFeedName = (id: string, feedList: Feed[]) => { + let name = "feed name not found"; + feedList.forEach((feed) => { + if (id === feed.id) { + name = feed.name; + } + }); + return standardizeFeedName(name); +}; + +export const lookupFeedId = (name: string, feedList: Feed[]) => { + let id = "feed id not found"; + const standardizedName = standardizeFeedName(name); + feedList.forEach((feed) => { + const feedName = standardizeFeedName(feed.name); + if (standardizedName === feedName) { + id = feed.id; + } + }); + return id; +}; + +const now = new Date(); +const todayUTC = { + yyyy: now.getUTCFullYear(), + mm: String(now.getUTCMonth() + 1).padStart(2, "0"), // e.g. month as "05" + dd: String(now.getUTCDate()).padStart(2, "0"), +}; +export const apiTodayUTC = `${todayUTC.yyyy}-${todayUTC.mm}-${todayUTC.dd}`; + +export const sevenDays = 7 * 24 * 60 * 60 * 1000; +export const threeDays = 3 * 24 * 60 * 60 * 1000; +export const oneDay = 24 * 60 * 60 * 1000; +export const allTime = -1; +export const customRange = -2; + +export const addMilliseconds = (dateString: string, secondsToAdd: number) => { + const originalDate = new Date(dateString); + originalDate.setMilliseconds(originalDate.getMilliseconds() + secondsToAdd); + return originalDate?.toISOString(); +}; + +export const subtractMilliseconds = ( + dateString: string, + secondsToAdd: number, +) => { + const originalDate = new Date(dateString); + originalDate.setMilliseconds(originalDate.getMilliseconds() - secondsToAdd); + return originalDate?.toISOString(); +}; + +export 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")}`; +}; + +const _getTimeElapsed = (dateString: string, startTime: string) => { + const detectionTime = new Date(dateString).getTime(); + const zeroTime = new Date(startTime).getTime(); + const seconds = detectionTime - zeroTime; + return formattedSeconds(seconds / 1000); +}; diff --git a/ui/src/utils/dataTransforms.ts b/ui/src/utils/dataTransforms.ts new file mode 100644 index 000000000..8b26effd4 --- /dev/null +++ b/ui/src/utils/dataTransforms.ts @@ -0,0 +1,93 @@ +import { Feed } from "@/graphql/generated"; +import { + AudioDetection, + CascadiaSighting, + DetectionsResult, + Sighting, +} from "@/types/DataTypes"; + +import { + lookupFeedId, + lookupFeedName, + standardizeFeedName, +} from "./dataHelpers"; + +const toAudioCategory = ( + detection: DetectionsResult, +): AudioDetection["newCategory"] => { + if (detection.source === "MACHINE") return "WHALE (AI)"; + + switch (detection.category) { + case "WHALE": + return "WHALE (HUMAN)"; + case "VESSEL": + case "OTHER": + return detection.category; + default: + return "uncategorized"; + } +}; + +export function transformAudioDetections( + detections: DetectionsResult[], + feeds: Feed[], +): AudioDetection[] { + if (!feeds.length) return []; + + return detections.map((el) => ({ + ...el, + type: "audio", + hydrophone: lookupFeedName(el.feedId!, feeds), + comments: el.description, + newCategory: toAudioCategory(el), + timestampString: el.timestamp.toString(), + })); +} + +export function transformSightings( + sightings: CascadiaSighting[], + feeds: Feed[], +): Sighting[] { + // standardize data + const radius = 3; + const addLat = radius / 69; + const addLong = (lat: number) => + radius / (69 * Math.cos((lat * Math.PI) / 180)); + + const feedCoordinates = feeds.map((feed) => ({ + name: feed.name, + lat: feed.latLng.lat, + lng: feed.latLng.lng, + minLat: feed.latLng.lat - addLat, + maxLat: feed.latLng.lat + addLat, + minLng: feed.latLng.lng - addLong(feed.latLng.lat), + maxLng: feed.latLng.lng + addLong(feed.latLng.lat), + })); + + const assignSightingHydrophone = (sighting: CascadiaSighting) => { + let hydrophone: string = "out of range"; + feedCoordinates.forEach((feed) => { + const inLatRange = + sighting.latitude >= feed.minLat && sighting.latitude <= feed.maxLat; + const inLngRange = + sighting.longitude >= feed.minLng && sighting.longitude <= feed.maxLng; + if (inLatRange && inLngRange) { + hydrophone = feed.name; + } + }); + hydrophone = standardizeFeedName(hydrophone); + return hydrophone; + }; + + if (!Array.isArray(sightings)) return []; + + return sightings.map((el) => ({ + ...el, + type: "sightings", + newCategory: "SIGHTING", + hydrophone: assignSightingHydrophone(el), + feedId: lookupFeedId(assignSightingHydrophone(el), feeds ?? []), + timestampString: el.created.replace(" ", "T") + "Z", + timestamp: new Date(el.created.replace(" ", "T") + "Z"), + })); +} From 48f857efa4c13ba9a7e9b0ac7619393eb93334ce Mon Sep 17 00:00:00 2001 From: Adrian Date: Thu, 19 Feb 2026 17:46:00 +0000 Subject: [PATCH 03/12] feat(data): add 7-day combined data window helpers and stabilize combined-data hook Add getDateMsAgo utility and use it in sightings query start date Fix useCombinedData memo dependency warning by memoizing detections results Refine data transform naming/defaults (toNewCategory, optional sightings radius) Add /json debug page to inspect combined transformed data --- ui/src/hooks/useCombinedData.ts | 8 ++++++-- ui/src/hooks/useSightings.ts | 3 ++- ui/src/pages/json/index.tsx | 19 +++++++++++++++++++ ui/src/utils/dataHelpers.ts | 10 ++++++++-- ui/src/utils/dataTransforms.ts | 7 ++++--- 5 files changed, 39 insertions(+), 8 deletions(-) create mode 100644 ui/src/pages/json/index.tsx diff --git a/ui/src/hooks/useCombinedData.ts b/ui/src/hooks/useCombinedData.ts index f5d84dd08..c0f5778ab 100644 --- a/ui/src/hooks/useCombinedData.ts +++ b/ui/src/hooks/useCombinedData.ts @@ -16,10 +16,14 @@ type CombinedDataObject = { feeds: Feed[]; }; -export function useCombinedData(useLiveData: boolean): CombinedDataObject { +export function useCombinedData(): CombinedDataObject { //// ORCASOUND // get feeds and detections based on live/seed toggle in development UI - const audioDetections = useDetectionsQuery().data?.detections?.results ?? []; + const detectionsResults = useDetectionsQuery().data?.detections?.results; + const audioDetections = useMemo( + () => detectionsResults ?? [], + [detectionsResults], + ); const seedFeeds = useFeedsQuery().data?.feeds ?? ([] as Feed[]); const feeds = seedFeeds as Feed[]; diff --git a/ui/src/hooks/useSightings.ts b/ui/src/hooks/useSightings.ts index bb1c18d59..49dd894ea 100644 --- a/ui/src/hooks/useSightings.ts +++ b/ui/src/hooks/useSightings.ts @@ -2,11 +2,12 @@ import { useQuery } from "@tanstack/react-query"; import { CascadiaSighting } from "@/types/DataTypes"; import { apiTodayUTC, constructUrl } from "@/utils/dataHelpers"; +import { getDateMsAgo, sevenDays } from "@/utils/dataHelpers"; const endpointCascadia = "https://maplify.com/waseak/php/search-all-sightings.php"; -const startDateCascadia = "2025-01-01"; +const startDateCascadia = getDateMsAgo(sevenDays).toISOString().split("T")[0]; // e.g. "2025-01-01" const paramsCascadia = { BBOX: "-136,36,-120,54", start: startDateCascadia, diff --git a/ui/src/pages/json/index.tsx b/ui/src/pages/json/index.tsx new file mode 100644 index 000000000..640712c18 --- /dev/null +++ b/ui/src/pages/json/index.tsx @@ -0,0 +1,19 @@ +import { getSimpleLayout } from "@/components/layouts/SimpleLayout"; +import { useDetectionsQuery } from "@/graphql/generated"; +import { useCombinedData } from "@/hooks/useCombinedData"; + +function JsonPage() { + const detections = useDetectionsQuery().data?.detections?.results ?? []; + const combinedData = useCombinedData().combined; + + return ( + <> + {/*
{JSON.stringify(detections, null, 2)}
*/} +
{JSON.stringify(combinedData, null, 2)}
+ + ); +} + +JsonPage.getLayout = getSimpleLayout; + +export default JsonPage; diff --git a/ui/src/utils/dataHelpers.ts b/ui/src/utils/dataHelpers.ts index 340d1be03..1e73dc629 100644 --- a/ui/src/utils/dataHelpers.ts +++ b/ui/src/utils/dataHelpers.ts @@ -98,6 +98,10 @@ export const oneDay = 24 * 60 * 60 * 1000; export const allTime = -1; export const customRange = -2; +export const getDateMsAgo = (durationMs: number, nowMs = Date.now()) => { + return new Date(nowMs - durationMs); +}; + export const addMilliseconds = (dateString: string, secondsToAdd: number) => { const originalDate = new Date(dateString); originalDate.setMilliseconds(originalDate.getMilliseconds() + secondsToAdd); @@ -106,10 +110,12 @@ export const addMilliseconds = (dateString: string, secondsToAdd: number) => { export const subtractMilliseconds = ( dateString: string, - secondsToAdd: number, + secondsToSubtract: number, ) => { const originalDate = new Date(dateString); - originalDate.setMilliseconds(originalDate.getMilliseconds() - secondsToAdd); + originalDate.setMilliseconds( + originalDate.getMilliseconds() - secondsToSubtract, + ); return originalDate?.toISOString(); }; diff --git a/ui/src/utils/dataTransforms.ts b/ui/src/utils/dataTransforms.ts index 8b26effd4..d8575501b 100644 --- a/ui/src/utils/dataTransforms.ts +++ b/ui/src/utils/dataTransforms.ts @@ -12,7 +12,7 @@ import { standardizeFeedName, } from "./dataHelpers"; -const toAudioCategory = ( +const toNewCategory = ( detection: DetectionsResult, ): AudioDetection["newCategory"] => { if (detection.source === "MACHINE") return "WHALE (AI)"; @@ -39,7 +39,7 @@ export function transformAudioDetections( type: "audio", hydrophone: lookupFeedName(el.feedId!, feeds), comments: el.description, - newCategory: toAudioCategory(el), + newCategory: toNewCategory(el), timestampString: el.timestamp.toString(), })); } @@ -47,9 +47,10 @@ export function transformAudioDetections( export function transformSightings( sightings: CascadiaSighting[], feeds: Feed[], + radius?: number, ): Sighting[] { // standardize data - const radius = 3; + if (radius === undefined) radius = 3; // default radius in miles for assigning sightings to hydrophones const addLat = radius / 69; const addLong = (lat: number) => radius / (69 * Math.cos((lat * Math.PI) / 180)); From 03dce7485d3f7603647c30eefdb50c053d298cfd Mon Sep 17 00:00:00 2001 From: Adrian Date: Fri, 20 Feb 2026 05:49:48 +0000 Subject: [PATCH 04/12] add sightings markers, detection counts, and audible radii to map --- ui/src/components/Map.tsx | 147 ++++++++++++++++++------ ui/src/components/layouts/MapLayout.tsx | 21 +++- ui/src/hooks/useSightings.ts | 41 +++---- ui/src/utils/mapHelpers.tsx | 142 +++++++++++++++++++++++ 4 files changed, 293 insertions(+), 58 deletions(-) create mode 100644 ui/src/utils/mapHelpers.tsx diff --git a/ui/src/components/Map.tsx b/ui/src/components/Map.tsx index 0ae62ba1d..d02d46ab4 100644 --- a/ui/src/components/Map.tsx +++ b/ui/src/components/Map.tsx @@ -5,23 +5,40 @@ import "leaflet-defaulticon-compatibility"; import { Map as LeafletMap } from "leaflet"; import L from "leaflet"; import { useRouter } from "next/router"; -import { MapContainer, Marker, TileLayer, ZoomControl } from "react-leaflet"; +import { Fragment } from "react"; +import { + MapContainer, + Marker, + TileLayer, + Tooltip, + ZoomControl, +} from "react-leaflet"; import { Feed, FeedsQuery } from "@/graphql/generated"; import hydrophoneActiveIconImage from "@/public/icons/hydrophone-active.svg"; import hydrophoneDefaultIconImage from "@/public/icons/hydrophone-default.svg"; -import { CascadiaSighting } from "@/types/DataTypes"; +import { CascadiaSighting, DetectionsResult } from "@/types/DataTypes"; +import formatDuration from "@/utils/dataHelpers"; +// Added: new map helpers +import { + AudibleRadiusCircles, + LeafletTooltipGlobalStyles, + ReportCount, + sightingMarker, +} from "@/utils/mapHelpers"; export default function Map({ setMap, currentFeed, feeds, sightings, + detections, }: { setMap?: (map: LeafletMap) => void; currentFeed?: Pick; feeds: FeedsQuery["feeds"]; sightings: CascadiaSighting[]; + detections: DetectionsResult[]; }) { const router = useRouter(); @@ -35,39 +52,99 @@ export default function Map({ }); return ( - - - - - - {feeds.map((feed) => ( - { - router.push(`/listen/${feed.slug}`); - }, - }} + <> + + + + - ))} - + + + {/* Feed icons with red circles for detection count and audible radius */} + {feeds.map((feed) => { + const audioDetectionsThisFeed = detections.filter( + (d) => d.feedId === feed?.id, + ).length; + + return ( + + {feeds?.length && ( + f.latLng)} /> + )} + + + + { + router.push(`/listen/${feed.slug}`); + }} + /> + + ); + })} + + {/* Blue sighting markers with tooltips */} + {sightings?.map((sighting) => { + const sightingTimeSeconds = + new Date(sighting.created).getTime() / 1000; + const currentTimeSeconds = new Date().getTime() / 1000; + + const timeAgo = formatDuration( + sightingTimeSeconds, + currentTimeSeconds, + ); + + return ( + + +
${sighting.name}
+ ${timeAgo} ago
+ ${sighting.created}
+ ${sighting.comments}
+ `, + }} + /> + + + ); + })} + + ); } diff --git a/ui/src/components/layouts/MapLayout.tsx b/ui/src/components/layouts/MapLayout.tsx index 8be090ca6..2dc968e4f 100644 --- a/ui/src/components/layouts/MapLayout.tsx +++ b/ui/src/components/layouts/MapLayout.tsx @@ -4,11 +4,15 @@ import { QueryClient } from "@tanstack/react-query"; import type { Map as LeafletMap } from "leaflet"; import dynamic from "next/dynamic"; import { useRouter } from "next/router"; -import { ReactElement, ReactNode, useEffect, useState } from "react"; +import { ReactElement, ReactNode, useEffect, useMemo, useState } from "react"; import Drawer from "@/components/Drawer"; import Header from "@/components/Header"; -import { useFeedQuery, useFeedsQuery } from "@/graphql/generated"; +import { + useDetectionsQuery, + useFeedQuery, + useFeedsQuery, +} from "@/graphql/generated"; import { useSightings } from "@/hooks/useSightings"; import { displayDesktopOnly, displayMobileOnly } from "@/styles/responsive"; @@ -47,7 +51,17 @@ function MapLayout({ children }: { children: ReactNode }) { const feeds = useFeedsQuery().data?.feeds ?? []; const firstOnlineFeed = feeds.filter(({ online }) => online)[0]; - const sightings = useSightings().data?.results ?? []; + // Added: data call + const { data } = useSightings(); + const sightings = useMemo(() => data?.results ?? [], [data]); + + const detectionsResults = useDetectionsQuery().data?.detections?.results; + const detections = useMemo( + () => detectionsResults ?? [], + [detectionsResults], + ); + + // End: sightings data call // update the currentFeed only if there's a new feed useEffect(() => { @@ -114,6 +128,7 @@ function MapLayout({ children }: { children: ReactNode }) { currentFeed={currentFeed} feeds={feeds} sightings={sightings} + detections={detections} /> => { - const response = await fetch(constructUrl(endpointCascadia, paramsCascadia)); - if (!response.ok) { - throw new Error("Network response from URL was not ok"); - } - return response.json(); -}; +export function useSightings(startDate?: string, endDate?: string) { + const endpoint = "https://maplify.com/waseak/php/search-all-sightings.php"; + + if (startDate === undefined) + startDate = getDateMsAgo(sevenDays).toISOString().split("T")[0]; // e.g. "2025-01-01" + if (endDate === undefined) endDate = apiTodayUTC; + + const params = { + BBOX: "-136,36,-120,54", + start: startDate, + end: endDate, + }; + + const fetchSightings = async (): Promise => { + const response = await fetch(constructUrl(endpoint, params)); + if (!response.ok) { + throw new Error("Network response from URL was not ok"); + } + return response.json(); + }; -export function useSightings() { const { data, isSuccess, error } = useQuery({ queryKey: ["sightings"], - queryFn: fetchCascadiaData, + queryFn: fetchSightings, }); return { data, isSuccess, error }; } diff --git a/ui/src/utils/mapHelpers.tsx b/ui/src/utils/mapHelpers.tsx new file mode 100644 index 000000000..62d7bdd73 --- /dev/null +++ b/ui/src/utils/mapHelpers.tsx @@ -0,0 +1,142 @@ +import { GlobalStyles } from "@mui/material"; +import L, { LatLngExpression } from "leaflet"; +import { useEffect } from "react"; +import { useMap } from "react-leaflet"; + +export function LeafletTooltipGlobalStyles() { + return ( + + ); +} + +const materialLocationSvg = ` + +`; + +export const sightingMarker = L.divIcon({ + html: materialLocationSvg, + className: "", + iconSize: [20, 20], +}); + +export function AudibleRadiusCircles({ + centers, +}: { + centers: LatLngExpression[]; +}) { + const map = useMap(); + + useEffect(() => { + const circles: L.Circle[] = []; + + centers.forEach((center) => { + const circle = L.circle(center, { + radius: 4828.03, // 3 miles in meters (1 mile = 1609.34 meters) + color: "transparent", + fillColor: "#ff0000", + fillOpacity: 0.033, + }); + circle.addTo(map); + circles.push(circle); + }); + + return () => { + circles.forEach((circle) => map.removeLayer(circle)); + }; + }, [centers, map]); + + return null; +} + +export function ReportCount({ + center, + count, + onClick, +}: { + center: LatLngExpression; + count: number; + onClick?: () => void; +}) { + const map = useMap(); + + useEffect(() => { + if (!center) return; + + const countMarker = L.divIcon({ + html: `
+ ${count}
`, + className: "", + iconSize: [30, 30], + iconAnchor: [15, 15], + }); + + const marker = L.marker(center, { + icon: countMarker, + zIndexOffset: 1001, + }); + + if (onClick) { + marker.on("click", onClick); + } + + marker.addTo(map); + + return () => { + marker.removeFrom(map); + }; + }, [center, count, map, onClick]); + + return null; +} + +export function MapUpdater({ + center, + zoom, +}: { + center: LatLngExpression; + zoom: number; +}) { + const map = useMap(); + + useEffect(() => { + if (center && zoom) { + map.setView(center, zoom); // or map.panTo(center); map.setZoom(zoom); + } + }, [center, zoom, map]); + + return null; +} From adfef04098f1ef0a44964528859232f7305ab908 Mon Sep 17 00:00:00 2001 From: Adrian Date: Fri, 20 Feb 2026 19:38:18 +0000 Subject: [PATCH 05/12] added ReportCount component with useCombinedData --- ui/src/components/Map.tsx | 6 +- ui/src/components/ReportCount.tsx | 78 +++++++++++++++++++++++++ ui/src/components/layouts/MapLayout.tsx | 14 ++--- ui/src/hooks/useCombinedData.ts | 22 +++---- ui/src/pages/listen/[feed].tsx | 9 +++ ui/src/types/DataTypes.ts | 6 +- ui/src/utils/dataHelpers.ts | 24 +++++--- ui/src/utils/dataTransforms.ts | 44 +++++++++----- ui/src/utils/mapHelpers.tsx | 4 +- 9 files changed, 156 insertions(+), 51 deletions(-) create mode 100644 ui/src/components/ReportCount.tsx diff --git a/ui/src/components/Map.tsx b/ui/src/components/Map.tsx index d02d46ab4..e3a6e47c9 100644 --- a/ui/src/components/Map.tsx +++ b/ui/src/components/Map.tsx @@ -37,8 +37,8 @@ export default function Map({ setMap?: (map: LeafletMap) => void; currentFeed?: Pick; feeds: FeedsQuery["feeds"]; - sightings: CascadiaSighting[]; - detections: DetectionsResult[]; + sightings: CascadiaSighting[] | undefined; + detections: DetectionsResult[] | undefined | null; }) { const router = useRouter(); @@ -73,7 +73,7 @@ export default function Map({ {/* Feed icons with red circles for detection count and audible radius */} {feeds.map((feed) => { - const audioDetectionsThisFeed = detections.filter( + const audioDetectionsThisFeed = detections?.filter( (d) => d.feedId === feed?.id, ).length; diff --git a/ui/src/components/ReportCount.tsx b/ui/src/components/ReportCount.tsx new file mode 100644 index 000000000..17ca344e0 --- /dev/null +++ b/ui/src/components/ReportCount.tsx @@ -0,0 +1,78 @@ +import { Breadcrumbs, Button } from "@mui/material"; + +import { CombinedData } from "@/types/DataTypes"; + +const countCategories = ( + arr: { newCategory?: string | null }[], + cat: string, +) => { + if (!arr.length) { + return 0; + } + return arr.filter( + (d) => (d.newCategory ?? "").toLowerCase() === cat.toLowerCase(), + ).length; +}; + +export default function ReportCount({ + detectionArray, +}: { + detectionArray: CombinedData[]; +}) { + const categories = [ + "whale (human)", + "whale (AI)", + "vessel", + "other", + "sighting", + ]; + + const items = categories + .map((category) => { + const count = countCategories(detectionArray, category); + + let label = category; + if (category === "sighting" && count !== 1) { + label += "s in audible range"; + } else if (category === "sighting") { + label += " in audible range"; + } + + return ( +
+ {count} {label} +
+ ); + }) + .filter((c) => c); // filters out the null items + + items.unshift(Last 7 days); + + // Interleave with separators + const interleaved = items.flatMap((item, index) => + index < items.length - 1 + ? [item, ] + : [item], + ); + + return ( + <> +
+ {interleaved} +
+ + + + + ); +} diff --git a/ui/src/components/layouts/MapLayout.tsx b/ui/src/components/layouts/MapLayout.tsx index 2dc968e4f..fff7a93ef 100644 --- a/ui/src/components/layouts/MapLayout.tsx +++ b/ui/src/components/layouts/MapLayout.tsx @@ -4,7 +4,7 @@ import { QueryClient } from "@tanstack/react-query"; import type { Map as LeafletMap } from "leaflet"; import dynamic from "next/dynamic"; import { useRouter } from "next/router"; -import { ReactElement, ReactNode, useEffect, useMemo, useState } from "react"; +import { ReactElement, ReactNode, useEffect, useState } from "react"; import Drawer from "@/components/Drawer"; import Header from "@/components/Header"; @@ -13,6 +13,7 @@ import { useFeedQuery, useFeedsQuery, } from "@/graphql/generated"; +import { useCombinedData } from "@/hooks/useCombinedData"; import { useSightings } from "@/hooks/useSightings"; import { displayDesktopOnly, displayMobileOnly } from "@/styles/responsive"; @@ -52,14 +53,9 @@ function MapLayout({ children }: { children: ReactNode }) { const firstOnlineFeed = feeds.filter(({ online }) => online)[0]; // Added: data call - const { data } = useSightings(); - const sightings = useMemo(() => data?.results ?? [], [data]); - - const detectionsResults = useDetectionsQuery().data?.detections?.results; - const detections = useMemo( - () => detectionsResults ?? [], - [detectionsResults], - ); + const sightings = useSightings().data?.results; + const detections = useDetectionsQuery().data?.detections?.results; + const combined = useCombinedData().combined; // End: sightings data call diff --git a/ui/src/hooks/useCombinedData.ts b/ui/src/hooks/useCombinedData.ts index c0f5778ab..9e06547fc 100644 --- a/ui/src/hooks/useCombinedData.ts +++ b/ui/src/hooks/useCombinedData.ts @@ -36,29 +36,25 @@ export function useCombinedData(): CombinedDataObject { //// ACARTIA sightings // get detections - const { data: sightingsData, isSuccess: isSuccessSightings } = useSightings(); - const dataSightings = useMemo( - () => sightingsData?.results ?? [], - [sightingsData], - ); + const sightingResults = useSightings().data?.results; // standardize data - const datasetSightings = useMemo( - () => transformSightings(dataSightings, feeds), - [dataSightings, feeds], + const sightings = useMemo( + () => transformSightings(sightingResults, feeds), + [sightingResults, feeds], ); const combined: CombinedData[] = useMemo(() => { - return [...datasetAudio, ...datasetSightings]; - }, [datasetAudio, datasetSightings]); + return [...datasetAudio, ...sightings]; + }, [datasetAudio, sightings]); const dataset = useMemo(() => { return { audio: datasetAudio, - sightings: datasetSightings, + sightings: sightings, combined: combined, feeds: feeds, - isSuccessSightings: isSuccessSightings, + isSuccessSightings: !!sightingResults, }; - }, [datasetAudio, datasetSightings, combined, feeds, isSuccessSightings]); + }, [datasetAudio, sightings, combined, feeds, sightingResults]); return dataset; } diff --git a/ui/src/pages/listen/[feed].tsx b/ui/src/pages/listen/[feed].tsx index d6a0f34a3..b408ff53d 100644 --- a/ui/src/pages/listen/[feed].tsx +++ b/ui/src/pages/listen/[feed].tsx @@ -10,7 +10,9 @@ import { getMapStaticProps, } from "@/components/layouts/MapLayout"; import Link from "@/components/Link"; +import ReportCount from "@/components/ReportCount"; import { useFeedQuery, useFeedsQuery } from "@/graphql/generated"; +import { useCombinedData } from "@/hooks/useCombinedData"; import type { NextPageWithLayout } from "@/pages/_app"; const FeedPage: NextPageWithLayout = () => { @@ -18,6 +20,10 @@ const FeedPage: NextPageWithLayout = () => { const slug = router.query.feed as string; const feed = useFeedQuery({ slug: slug }).data?.feed; + const combinedData = useCombinedData().combined; + + const detectionsThisFeed = combinedData?.filter((d) => d.feedSlug === slug); + if (!feed) return null; return ( @@ -43,6 +49,9 @@ const FeedPage: NextPageWithLayout = () => { {feed.name}

{feed.name}

+ + +
diff --git a/ui/src/types/DataTypes.ts b/ui/src/types/DataTypes.ts index 685891ecc..73cda1daa 100644 --- a/ui/src/types/DataTypes.ts +++ b/ui/src/types/DataTypes.ts @@ -13,7 +13,8 @@ export type DetectionsResult = DetectionsResultList[number]; export interface AudioDetection extends Omit { type: "audio"; - hydrophone: string; + standardizedFeedName: string; + feedSlug: string; comments: string | null | undefined; newCategory: | "WHALE (HUMAN)" @@ -49,7 +50,8 @@ export interface CascadiaSighting { export interface Sighting extends CascadiaSighting { type: "sightings"; - hydrophone: string; + standardizedFeedName: string; + feedSlug: string; feedId: string; newCategory: "SIGHTING"; timestamp: Date; diff --git a/ui/src/utils/dataHelpers.ts b/ui/src/utils/dataHelpers.ts index 1e73dc629..63054266f 100644 --- a/ui/src/utils/dataHelpers.ts +++ b/ui/src/utils/dataHelpers.ts @@ -62,6 +62,18 @@ export const standardizeFeedName = (name: string) => { } }; +export const lookupFeedId = (name: string, feedList: Feed[]) => { + let id = "feed id not found"; + const standardizedName = standardizeFeedName(name); + feedList.forEach((feed) => { + const feedName = standardizeFeedName(feed.name); + if (standardizedName === feedName) { + id = feed.id; + } + }); + return id; +}; + export const lookupFeedName = (id: string, feedList: Feed[]) => { let name = "feed name not found"; feedList.forEach((feed) => { @@ -72,16 +84,14 @@ export const lookupFeedName = (id: string, feedList: Feed[]) => { return standardizeFeedName(name); }; -export const lookupFeedId = (name: string, feedList: Feed[]) => { - let id = "feed id not found"; - const standardizedName = standardizeFeedName(name); +export const lookupFeedSlug = (id: string, feedList: Feed[]) => { + let slug = "feed slug not found"; feedList.forEach((feed) => { - const feedName = standardizeFeedName(feed.name); - if (standardizedName === feedName) { - id = feed.id; + if (id === feed.id) { + slug = feed.slug; } }); - return id; + return slug; }; const now = new Date(); diff --git a/ui/src/utils/dataTransforms.ts b/ui/src/utils/dataTransforms.ts index d8575501b..a16e5569c 100644 --- a/ui/src/utils/dataTransforms.ts +++ b/ui/src/utils/dataTransforms.ts @@ -9,6 +9,7 @@ import { import { lookupFeedId, lookupFeedName, + lookupFeedSlug, standardizeFeedName, } from "./dataHelpers"; @@ -32,12 +33,13 @@ export function transformAudioDetections( detections: DetectionsResult[], feeds: Feed[], ): AudioDetection[] { - if (!feeds.length) return []; + if (!feeds.length || !detections.length) return []; return detections.map((el) => ({ ...el, type: "audio", - hydrophone: lookupFeedName(el.feedId!, feeds), + standardizedFeedName: lookupFeedName(el.feedId!, feeds), + feedSlug: lookupFeedSlug(el.feedId!, feeds), comments: el.description, newCategory: toNewCategory(el), timestampString: el.timestamp.toString(), @@ -45,17 +47,18 @@ export function transformAudioDetections( } export function transformSightings( - sightings: CascadiaSighting[], + sightings: CascadiaSighting[] | undefined, feeds: Feed[], radius?: number, ): Sighting[] { + if (!sightings || !sightings.length || !feeds.length) return []; // standardize data if (radius === undefined) radius = 3; // default radius in miles for assigning sightings to hydrophones const addLat = radius / 69; const addLong = (lat: number) => radius / (69 * Math.cos((lat * Math.PI) / 180)); - const feedCoordinates = feeds.map((feed) => ({ + const feedBoundingBoxes = feeds.map((feed) => ({ name: feed.name, lat: feed.latLng.lat, lng: feed.latLng.lng, @@ -65,9 +68,9 @@ export function transformSightings( maxLng: feed.latLng.lng + addLong(feed.latLng.lat), })); - const assignSightingHydrophone = (sighting: CascadiaSighting) => { - let hydrophone: string = "out of range"; - feedCoordinates.forEach((feed) => { + const assignSightingsToHydrophones = (sighting: CascadiaSighting) => { + let hydrophone = "out of range"; + feedBoundingBoxes.forEach((feed) => { const inLatRange = sighting.latitude >= feed.minLat && sighting.latitude <= feed.maxLat; const inLngRange = @@ -82,13 +85,22 @@ export function transformSightings( if (!Array.isArray(sightings)) return []; - return sightings.map((el) => ({ - ...el, - type: "sightings", - newCategory: "SIGHTING", - hydrophone: assignSightingHydrophone(el), - feedId: lookupFeedId(assignSightingHydrophone(el), feeds ?? []), - timestampString: el.created.replace(" ", "T") + "Z", - timestamp: new Date(el.created.replace(" ", "T") + "Z"), - })); + const transformedSightings: Sighting[] = sightings.map((el): Sighting => { + const feedName = assignSightingsToHydrophones(el); + const feedId = lookupFeedId(feedName, feeds); + const feedSlug = lookupFeedSlug(feedId, feeds); + + return { + ...el, + type: "sightings", + newCategory: "SIGHTING", + standardizedFeedName: feedName, + feedSlug: feedSlug, + feedId: feedId, + timestampString: el.created.replace(" ", "T") + "Z", + timestamp: new Date(el.created.replace(" ", "T") + "Z"), + }; + }); + + return transformedSightings; } diff --git a/ui/src/utils/mapHelpers.tsx b/ui/src/utils/mapHelpers.tsx index 62d7bdd73..d2b8f494b 100644 --- a/ui/src/utils/mapHelpers.tsx +++ b/ui/src/utils/mapHelpers.tsx @@ -74,11 +74,13 @@ export function ReportCount({ onClick, }: { center: LatLngExpression; - count: number; + count?: number; onClick?: () => void; }) { const map = useMap(); + if (!count) count = 0; + useEffect(() => { if (!center) return; From 4e66dc159f3f67b4986976871773193e9a075624 Mon Sep 17 00:00:00 2001 From: Adrian Date: Fri, 20 Feb 2026 19:56:28 +0000 Subject: [PATCH 06/12] clean sightings description and remove unused dataHelpers exports --- ui/src/components/Map.tsx | 4 +- ui/src/components/layouts/MapLayout.tsx | 2 - ui/src/hooks/useSightings.ts | 6 ++- ui/src/utils/dataHelpers.ts | 50 +++++-------------------- 4 files changed, 16 insertions(+), 46 deletions(-) diff --git a/ui/src/components/Map.tsx b/ui/src/components/Map.tsx index e3a6e47c9..aaefd1274 100644 --- a/ui/src/components/Map.tsx +++ b/ui/src/components/Map.tsx @@ -18,7 +18,7 @@ import { Feed, FeedsQuery } from "@/graphql/generated"; import hydrophoneActiveIconImage from "@/public/icons/hydrophone-active.svg"; import hydrophoneDefaultIconImage from "@/public/icons/hydrophone-default.svg"; import { CascadiaSighting, DetectionsResult } from "@/types/DataTypes"; -import formatDuration from "@/utils/dataHelpers"; +import formatDuration, { cleanSightingsDescription } from "@/utils/dataHelpers"; // Added: new map helpers import { AudibleRadiusCircles, @@ -136,7 +136,7 @@ export default function Map({ ${sighting.name}
${timeAgo} ago
${sighting.created}
- ${sighting.comments}
+ ${cleanSightingsDescription(sighting.comments)} `, }} /> diff --git a/ui/src/components/layouts/MapLayout.tsx b/ui/src/components/layouts/MapLayout.tsx index fff7a93ef..d7f132cae 100644 --- a/ui/src/components/layouts/MapLayout.tsx +++ b/ui/src/components/layouts/MapLayout.tsx @@ -13,7 +13,6 @@ import { useFeedQuery, useFeedsQuery, } from "@/graphql/generated"; -import { useCombinedData } from "@/hooks/useCombinedData"; import { useSightings } from "@/hooks/useSightings"; import { displayDesktopOnly, displayMobileOnly } from "@/styles/responsive"; @@ -55,7 +54,6 @@ function MapLayout({ children }: { children: ReactNode }) { // Added: data call const sightings = useSightings().data?.results; const detections = useDetectionsQuery().data?.detections?.results; - const combined = useCombinedData().combined; // End: sightings data call diff --git a/ui/src/hooks/useSightings.ts b/ui/src/hooks/useSightings.ts index 15c0f242d..ef7cc4f40 100644 --- a/ui/src/hooks/useSightings.ts +++ b/ui/src/hooks/useSightings.ts @@ -2,7 +2,7 @@ import { useQuery } from "@tanstack/react-query"; import { CascadiaSighting } from "@/types/DataTypes"; import { apiTodayUTC, constructUrl } from "@/utils/dataHelpers"; -import { getDateMsAgo, sevenDays } from "@/utils/dataHelpers"; +import { getDateMsAgo, rangeOptions } from "@/utils/dataHelpers"; type SightingsDataResponse = { results: CascadiaSighting[]; @@ -12,7 +12,9 @@ export function useSightings(startDate?: string, endDate?: string) { const endpoint = "https://maplify.com/waseak/php/search-all-sightings.php"; if (startDate === undefined) - startDate = getDateMsAgo(sevenDays).toISOString().split("T")[0]; // e.g. "2025-01-01" + startDate = getDateMsAgo(rangeOptions.sevenDays) + .toISOString() + .split("T")[0]; // e.g. "2025-01-01" if (endDate === undefined) endDate = apiTodayUTC; const params = { diff --git a/ui/src/utils/dataHelpers.ts b/ui/src/utils/dataHelpers.ts index 63054266f..944d7dc91 100644 --- a/ui/src/utils/dataHelpers.ts +++ b/ui/src/utils/dataHelpers.ts @@ -36,9 +36,9 @@ export default function formatDuration(startOffset: number, endOffset: number) { export const cleanSightingsDescription = ( description: string | null | undefined, ) => { - if (!description) return; - const removeBracket = description.replace(/^\[[^\]]*\]\s*/, ""); - const removeBreak = removeBracket.replace(/
[^•]*/g, ""); + if (!description) return "No description provided"; + // const removeBracket = description.replace(/^\[[^\]]*\]\s*/, ""); + const removeBreak = description.replace(/
[^•]*/g, ""); const removeLinks = removeBreak .replace(/https?:\/\/\S+/g, "") .replace(/\s+•/g, " •") @@ -102,44 +102,14 @@ const todayUTC = { }; export const apiTodayUTC = `${todayUTC.yyyy}-${todayUTC.mm}-${todayUTC.dd}`; -export const sevenDays = 7 * 24 * 60 * 60 * 1000; -export const threeDays = 3 * 24 * 60 * 60 * 1000; -export const oneDay = 24 * 60 * 60 * 1000; -export const allTime = -1; -export const customRange = -2; +export const rangeOptions = { + allTime: -1, + sevenDays: 7 * 24 * 60 * 60 * 1000, + threeDays: 3 * 24 * 60 * 60 * 1000, + oneDay: 24 * 60 * 60 * 1000, + customRange: -2, +}; export const getDateMsAgo = (durationMs: number, nowMs = Date.now()) => { return new Date(nowMs - durationMs); }; - -export const addMilliseconds = (dateString: string, secondsToAdd: number) => { - const originalDate = new Date(dateString); - originalDate.setMilliseconds(originalDate.getMilliseconds() + secondsToAdd); - return originalDate?.toISOString(); -}; - -export const subtractMilliseconds = ( - dateString: string, - secondsToSubtract: number, -) => { - const originalDate = new Date(dateString); - originalDate.setMilliseconds( - originalDate.getMilliseconds() - secondsToSubtract, - ); - return originalDate?.toISOString(); -}; - -export 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")}`; -}; - -const _getTimeElapsed = (dateString: string, startTime: string) => { - const detectionTime = new Date(dateString).getTime(); - const zeroTime = new Date(startTime).getTime(); - const seconds = detectionTime - zeroTime; - return formattedSeconds(seconds / 1000); -}; From efe603c598d5bf9ffe0d2accaeaa40a15e2dbf72 Mon Sep 17 00:00:00 2001 From: Adrian Date: Fri, 20 Feb 2026 22:20:39 +0000 Subject: [PATCH 07/12] added map pan/zoom behavior for navigating between [feed] pages, including hot-reload safety guards to avoid calling setView on a stale Leaflet instance --- ui/src/components/Map.tsx | 11 +++++--- ui/src/components/layouts/MapLayout.tsx | 36 +++++++++++++++++++------ ui/src/hooks/useCombinedData.ts | 2 +- ui/src/utils/mapHelpers.tsx | 20 -------------- 4 files changed, 37 insertions(+), 32 deletions(-) diff --git a/ui/src/components/Map.tsx b/ui/src/components/Map.tsx index aaefd1274..dbbcda82b 100644 --- a/ui/src/components/Map.tsx +++ b/ui/src/components/Map.tsx @@ -34,7 +34,7 @@ export default function Map({ sightings, detections, }: { - setMap?: (map: LeafletMap) => void; + setMap?: (map: LeafletMap | null) => void; // adding null to type to allow hot-reload safety guard currentFeed?: Pick; feeds: FeedsQuery["feeds"]; sightings: CascadiaSighting[] | undefined; @@ -51,15 +51,20 @@ export default function Map({ iconSize: [30, 30], }); + const defaultCenter: [number, number] = [48.1, -122.75]; + return ( <> { + // hot-reload safety guard to prevent setting map to a stale Leaflet instance + setMap?.(instance ?? null); + }} zoomControl={false} //TODO: Disable attribution on mobile only attributionControl={false} diff --git a/ui/src/components/layouts/MapLayout.tsx b/ui/src/components/layouts/MapLayout.tsx index d7f132cae..91bd1b82a 100644 --- a/ui/src/components/layouts/MapLayout.tsx +++ b/ui/src/components/layouts/MapLayout.tsx @@ -22,6 +22,10 @@ const MapWithNoSSR = dynamic(() => import("../Map"), { ssr: false, }); +const DEFAULT_CENTER: [number, number] = [48.1, -122.75]; +const DEFAULT_ZOOM = 8.5; +const FEED_ZOOM = 12; + const feedFromSlug = (feedSlug: string) => ({ id: feedSlug, name: feedSlug, @@ -47,9 +51,8 @@ function MapLayout({ children }: { children: ReactNode }) { const feed = isDynamic ? feedFromSlug(slug) : feedFromQuery; const [currentFeed, setCurrentFeed] = useState(feed); - const [map, setMap] = useState(); + const [map, setMap] = useState(); const feeds = useFeedsQuery().data?.feeds ?? []; - const firstOnlineFeed = feeds.filter(({ online }) => online)[0]; // Added: data call const sightings = useSightings().data?.results; @@ -61,13 +64,27 @@ function MapLayout({ children }: { children: ReactNode }) { useEffect(() => { if (feed && feed.slug !== currentFeed?.slug) { setCurrentFeed(feed); - map?.setZoom(9); - map?.panTo(feed.latLng); } - if (!feed && !currentFeed && firstOnlineFeed) { - setCurrentFeed(firstOnlineFeed); + }, [feed, currentFeed]); + + // update map zoom / center based on feed in url, separately from currentFeed, so that map returns to default view but UI still reflects most-recently selected feed in player + useEffect(() => { + if (!map) return; + + // hot-reload safety guard to prevent calling map.setView on a stale Leaflet instance + const mapWithPane = map as LeafletMap & { _mapPane?: unknown }; + if (!mapWithPane._mapPane) return; + + // Keep current viewport while route feed slug exists but feed query is still resolving to avoid jarring resets between page routes. + if (slug && !feed) return; + + if (feed) { + map.setView([feed.latLng.lat, feed.latLng.lng], FEED_ZOOM); + return; } - }, [feed, map, currentFeed, firstOnlineFeed]); + + map.setView(DEFAULT_CENTER, DEFAULT_ZOOM); + }, [map, feed, slug]); const invalidateSize = () => { if (map) { @@ -118,7 +135,10 @@ function MapLayout({ children }: { children: ReactNode }) { > { + // hot-reload safety guard to prevent setting map to a stale Leaflet instance + setMap(nextMap ?? undefined); + }} currentFeed={currentFeed} feeds={feeds} sightings={sightings} diff --git a/ui/src/hooks/useCombinedData.ts b/ui/src/hooks/useCombinedData.ts index 9e06547fc..d2ba49e53 100644 --- a/ui/src/hooks/useCombinedData.ts +++ b/ui/src/hooks/useCombinedData.ts @@ -37,7 +37,7 @@ export function useCombinedData(): CombinedDataObject { //// ACARTIA sightings // get detections const sightingResults = useSightings().data?.results; - // standardize data + // add standardized fields to sightings data const sightings = useMemo( () => transformSightings(sightingResults, feeds), [sightingResults, feeds], diff --git a/ui/src/utils/mapHelpers.tsx b/ui/src/utils/mapHelpers.tsx index d2b8f494b..9cbee9580 100644 --- a/ui/src/utils/mapHelpers.tsx +++ b/ui/src/utils/mapHelpers.tsx @@ -11,8 +11,6 @@ export function LeafletTooltipGlobalStyles() { maxWidth: "300px", minWidth: "200px", textWrap: "wrap", - // whiteSpace: "wrap", - // wordWrap: "break-word", fontSize: "0.875rem", // Or use theme.typography.body2.fontSize if inside a function borderRadius: "4px", padding: "8px", @@ -124,21 +122,3 @@ export function ReportCount({ return null; } - -export function MapUpdater({ - center, - zoom, -}: { - center: LatLngExpression; - zoom: number; -}) { - const map = useMap(); - - useEffect(() => { - if (center && zoom) { - map.setView(center, zoom); // or map.panTo(center); map.setZoom(zoom); - } - }, [center, zoom, map]); - - return null; -} From feda023f7141489c4b4588c968cf1b67973c1eae Mon Sep 17 00:00:00 2001 From: Adrian Date: Fri, 20 Feb 2026 22:25:03 +0000 Subject: [PATCH 08/12] fixed lint warning in seed.tsx and removed unused json/index.tsx --- ui/src/pages/json/index.tsx | 19 ------------------- ui/src/pages/seed.tsx | 7 +++++-- 2 files changed, 5 insertions(+), 21 deletions(-) delete mode 100644 ui/src/pages/json/index.tsx diff --git a/ui/src/pages/json/index.tsx b/ui/src/pages/json/index.tsx deleted file mode 100644 index 640712c18..000000000 --- a/ui/src/pages/json/index.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { getSimpleLayout } from "@/components/layouts/SimpleLayout"; -import { useDetectionsQuery } from "@/graphql/generated"; -import { useCombinedData } from "@/hooks/useCombinedData"; - -function JsonPage() { - const detections = useDetectionsQuery().data?.detections?.results ?? []; - const combinedData = useCombinedData().combined; - - return ( - <> - {/*
{JSON.stringify(detections, null, 2)}
*/} -
{JSON.stringify(combinedData, null, 2)}
- - ); -} - -JsonPage.getLayout = getSimpleLayout; - -export default JsonPage; diff --git a/ui/src/pages/seed.tsx b/ui/src/pages/seed.tsx index f5331df09..4d3faba0f 100644 --- a/ui/src/pages/seed.tsx +++ b/ui/src/pages/seed.tsx @@ -13,7 +13,7 @@ import { import { addMinutes, formatDuration, subHours } from "date-fns"; import _ from "lodash"; import Head from "next/head"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { getSimpleLayout } from "@/components/layouts/SimpleLayout"; import LoadingSpinner from "@/components/LoadingSpinner"; @@ -31,7 +31,10 @@ import { NextPageWithLayout } from "@/pages/_app"; const SeedPage: NextPageWithLayout = () => { const feedsQuery = useFeedsQuery(); - const feeds = feedsQuery.data?.feeds ?? []; + const feeds = useMemo( + () => feedsQuery.data?.feeds ?? [], + [feedsQuery.data?.feeds], + ); const resources = Object.values(SeedResource); const [startTime, setStartTime] = useState(() => subHours(new Date(), 1)); From 968c52298129e641796422c0dea14f0ed5886294 Mon Sep 17 00:00:00 2001 From: Adrian Date: Fri, 20 Feb 2026 23:02:51 +0000 Subject: [PATCH 09/12] default zoom 8 to show all locations --- ui/src/components/layouts/MapLayout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/components/layouts/MapLayout.tsx b/ui/src/components/layouts/MapLayout.tsx index 91bd1b82a..baf57b6e9 100644 --- a/ui/src/components/layouts/MapLayout.tsx +++ b/ui/src/components/layouts/MapLayout.tsx @@ -23,7 +23,7 @@ const MapWithNoSSR = dynamic(() => import("../Map"), { }); const DEFAULT_CENTER: [number, number] = [48.1, -122.75]; -const DEFAULT_ZOOM = 8.5; +const DEFAULT_ZOOM = 8; const FEED_ZOOM = 12; const feedFromSlug = (feedSlug: string) => ({ From 86aa95617021df7f7d0ff320dffca527a38eafde Mon Sep 17 00:00:00 2001 From: Adrian Date: Fri, 20 Feb 2026 23:22:54 +0000 Subject: [PATCH 10/12] add vitest test runner and npm test scripts --- ui/package-lock.json | 1848 +++++++++++++++++++++++++++++++++++++----- ui/package.json | 7 +- ui/vitest.config.ts | 13 + 3 files changed, 1666 insertions(+), 202 deletions(-) create mode 100644 ui/vitest.config.ts diff --git a/ui/package-lock.json b/ui/package-lock.json index 4ff252c2c..1fdfc96f0 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -64,7 +64,8 @@ "husky": "^9.1.6", "lint-staged": "^15.2.10", "prettier": "3.3.3", - "typescript": "5.6.3" + "typescript": "5.6.3", + "vitest": "^4.0.18" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -1302,6 +1303,448 @@ "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.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "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", @@ -2785,10 +3228,11 @@ } }, "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==", - "dev": true + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.25", @@ -3076,271 +3520,621 @@ "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.15.tgz", "integrity": "sha512-S1qaj25Wru2dUpcIZMjxeMVSwkt8BK4dmWHHiBuRstcIyOsMapqT4A4jSB6onvqeygkSSmOkyny9VVx8JIGamQ==" }, - "node_modules/@next/eslint-plugin-next": { - "version": "14.2.15", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-14.2.15.tgz", - "integrity": "sha512-pKU0iqKRBlFB/ocOI1Ip2CkKePZpYpnw5bEItEkuZ/Nr9FQP1+p7VDWr4VfOdff4i9bFmrOaeaU1bFEyAcxiMQ==", + "node_modules/@next/eslint-plugin-next": { + "version": "14.2.15", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-14.2.15.tgz", + "integrity": "sha512-pKU0iqKRBlFB/ocOI1Ip2CkKePZpYpnw5bEItEkuZ/Nr9FQP1+p7VDWr4VfOdff4i9bFmrOaeaU1bFEyAcxiMQ==", + "dev": true, + "dependencies": { + "glob": "10.3.10" + } + }, + "node_modules/@next/eslint-plugin-next/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@next/eslint-plugin-next/node_modules/glob": { + "version": "10.3.10", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz", + "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==", + "dev": true, + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^2.3.5", + "minimatch": "^9.0.1", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0", + "path-scurry": "^1.10.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@next/eslint-plugin-next/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "14.2.15", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.15.tgz", + "integrity": "sha512-Rvh7KU9hOUBnZ9TJ28n2Oa7dD9cvDBKua9IKx7cfQQ0GoYUwg9ig31O2oMwH3wm+pE3IkAQ67ZobPfEgurPZIA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "14.2.15", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.15.tgz", + "integrity": "sha512-5TGyjFcf8ampZP3e+FyCax5zFVHi+Oe7sZyaKOngsqyaNEpOgkKB3sqmymkZfowy3ufGA/tUgDPPxpQx931lHg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "14.2.15", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.15.tgz", + "integrity": "sha512-3Bwv4oc08ONiQ3FiOLKT72Q+ndEMyLNsc/D3qnLMbtUYTQAmkx9E/JRu0DBpHxNddBmNT5hxz1mYBphJ3mfrrw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "14.2.15", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.15.tgz", + "integrity": "sha512-k5xf/tg1FBv/M4CMd8S+JL3uV9BnnRmoe7F+GWC3DxkTCD9aewFRH1s5rJ1zkzDa+Do4zyN8qD0N8c84Hu96FQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "14.2.15", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.15.tgz", + "integrity": "sha512-kE6q38hbrRbKEkkVn62reLXhThLRh6/TvgSP56GkFNhU22TbIrQDEMrO7j0IcQHcew2wfykq8lZyHFabz0oBrA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "14.2.15", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.15.tgz", + "integrity": "sha512-PZ5YE9ouy/IdO7QVJeIcyLn/Rc4ml9M2G4y3kCM9MNf1YKvFY4heg3pVa/jQbMro+tP6yc4G2o9LjAz1zxD7tQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "14.2.15", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.15.tgz", + "integrity": "sha512-2raR16703kBvYEQD9HNLyb0/394yfqzmIeyp2nDzcPV4yPjqNUG3ohX6jX00WryXz6s1FXpVhsCo3i+g4RUX+g==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-ia32-msvc": { + "version": "14.2.15", + "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.15.tgz", + "integrity": "sha512-fyTE8cklgkyR1p03kJa5zXEaZ9El+kDNM5A+66+8evQS5e/6v0Gk28LqA0Jet8gKSOyP+OTm/tJHzMlGdQerdQ==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "14.2.15", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.15.tgz", + "integrity": "sha512-SzqGbsLsP9OwKNUG9nekShTwhj6JSB9ZLMWQ8g1gG6hdE5gQLncbnbymrwy2yVmH9nikSLYRYxYMFu78Ggp7/g==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", "dev": true, "dependencies": { - "glob": "10.3.10" + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" } }, - "node_modules/@next/eslint-plugin-next/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", "dev": true, - "dependencies": { - "balanced-match": "^1.0.0" + "engines": { + "node": ">= 8" } }, - "node_modules/@next/eslint-plugin-next/node_modules/glob": { - "version": "10.3.10", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz", - "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==", + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", "dev": true, "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^2.3.5", - "minimatch": "^9.0.1", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0", - "path-scurry": "^1.10.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" }, "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">= 8" } }, - "node_modules/@next/eslint-plugin-next/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", "dev": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, + "optional": true, "engines": { - "node": ">=16 || 14 >=14.17" - }, + "node": ">=14" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.25", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.25.tgz", + "integrity": "sha512-j7P6Rgr3mmtdkeDGTe0E/aYyWEWVtc5yFXtHCRHs28/jptDEWfaVOc5T7cblqy1XKPPfCxJc/8DwQ5YgLOZOVQ==", + "dev": true + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", "funding": { - "url": "https://github.com/sponsors/isaacs" + "type": "opencollective", + "url": "https://opencollective.com/popperjs" } }, - "node_modules/@next/swc-darwin-arm64": { - "version": "14.2.15", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.15.tgz", - "integrity": "sha512-Rvh7KU9hOUBnZ9TJ28n2Oa7dD9cvDBKua9IKx7cfQQ0GoYUwg9ig31O2oMwH3wm+pE3IkAQ67ZobPfEgurPZIA==", + "node_modules/@react-leaflet/core": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-2.1.0.tgz", + "integrity": "sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==", + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^18.0.0", + "react-dom": "^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.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.58.0.tgz", + "integrity": "sha512-mr0tmS/4FoVk1cnaeN244A/wjvGDNItZKR8hRhnmCzygyRXYtKF5jVDSIILR1U97CTzAYmbgIj/Dukg62ggG5w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.58.0.tgz", + "integrity": "sha512-+s++dbp+/RTte62mQD9wLSbiMTV+xr/PeRJEc/sFZFSBRlHPNPVaf5FXlzAL77Mr8FtSfQqCN+I598M8U41ccQ==", "cpu": [ "arm64" ], + "dev": true, + "license": "MIT", "optional": true, "os": [ - "darwin" + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.58.0.tgz", + "integrity": "sha512-MFWBwTcYs0jZbINQBXHfSrpSQJq3IUOakcKPzfeSznONop14Pxuqa0Kg19GD0rNBMPQI2tFtu3UzapZpH0Uc1Q==", + "cpu": [ + "arm64" ], - "engines": { - "node": ">= 10" - } + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] }, - "node_modules/@next/swc-darwin-x64": { - "version": "14.2.15", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.15.tgz", - "integrity": "sha512-5TGyjFcf8ampZP3e+FyCax5zFVHi+Oe7sZyaKOngsqyaNEpOgkKB3sqmymkZfowy3ufGA/tUgDPPxpQx931lHg==", + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.58.0.tgz", + "integrity": "sha512-yiKJY7pj9c9JwzuKYLFaDZw5gma3fI9bkPEIyofvVfsPqjCWPglSHdpdwXpKGvDeYDms3Qal8qGMEHZ1M/4Udg==", "cpu": [ "x64" ], + "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.58.0.tgz", + "integrity": "sha512-x97kCoBh5MOevpn/CNK9W1x8BEzO238541BGWBc315uOlN0AD/ifZ1msg+ZQB05Ux+VF6EcYqpiagfLJ8U3LvQ==", + "cpu": [ + "arm64" ], - "engines": { - "node": ">= 10" - } + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] }, - "node_modules/@next/swc-linux-arm64-gnu": { - "version": "14.2.15", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.15.tgz", - "integrity": "sha512-3Bwv4oc08ONiQ3FiOLKT72Q+ndEMyLNsc/D3qnLMbtUYTQAmkx9E/JRu0DBpHxNddBmNT5hxz1mYBphJ3mfrrw==", + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.58.0.tgz", + "integrity": "sha512-Aa8jPoZ6IQAG2eIrcXPpjRcMjROMFxCt1UYPZZtCxRV68WkuSigYtQ/7Zwrcr2IvtNJo7T2JfDXyMLxq5L4Jlg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.58.0.tgz", + "integrity": "sha512-Ob8YgT5kD/lSIYW2Rcngs5kNB/44Q2RzBSPz9brf2WEtcGR7/f/E9HeHn1wYaAwKBni+bdXEwgHvUd0x12lQSA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.58.0.tgz", + "integrity": "sha512-K+RI5oP1ceqoadvNt1FecL17Qtw/n9BgRSzxif3rTL2QlIu88ccvY+Y9nnHe/cmT5zbH9+bpiJuG1mGHRVwF4Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.58.0.tgz", + "integrity": "sha512-T+17JAsCKUjmbopcKepJjHWHXSjeW7O5PL7lEFaeQmiVyw4kkc5/lyYKzrv6ElWRX/MrEWfPiJWqbTvfIvjM1Q==", "cpu": [ "arm64" ], + "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.58.0.tgz", + "integrity": "sha512-cCePktb9+6R9itIJdeCFF9txPU7pQeEHB5AbHu/MKsfH/k70ZtOeq1k4YAtBv9Z7mmKI5/wOLYjQ+B9QdxR6LA==", + "cpu": [ + "arm64" ], - "engines": { - "node": ">= 10" - } + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@next/swc-linux-arm64-musl": { - "version": "14.2.15", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.15.tgz", - "integrity": "sha512-k5xf/tg1FBv/M4CMd8S+JL3uV9BnnRmoe7F+GWC3DxkTCD9aewFRH1s5rJ1zkzDa+Do4zyN8qD0N8c84Hu96FQ==", + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.58.0.tgz", + "integrity": "sha512-iekUaLkfliAsDl4/xSdoCJ1gnnIXvoNz85C8U8+ZxknM5pBStfZjeXgB8lXobDQvvPRCN8FPmmuTtH+z95HTmg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.58.0.tgz", + "integrity": "sha512-68ofRgJNl/jYJbxFjCKE7IwhbfxOl1muPN4KbIqAIe32lm22KmU7E8OPvyy68HTNkI2iV/c8y2kSPSm2mW/Q9Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.58.0.tgz", + "integrity": "sha512-dpz8vT0i+JqUKuSNPCP5SYyIV2Lh0sNL1+FhM7eLC457d5B9/BC3kDPp5BBftMmTNsBarcPcoz5UGSsnCiw4XQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.58.0.tgz", + "integrity": "sha512-4gdkkf9UJ7tafnweBCR/mk4jf3Jfl0cKX9Np80t5i78kjIH0ZdezUv/JDI2VtruE5lunfACqftJ8dIMGN4oHew==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.58.0.tgz", + "integrity": "sha512-YFS4vPnOkDTD/JriUeeZurFYoJhPf9GQQEF/v4lltp3mVcBmnsAdjEWhr2cjUCZzZNzxCG0HZOvJU44UGHSdzw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.58.0.tgz", + "integrity": "sha512-x2xgZlFne+QVNKV8b4wwaCS8pwq3y14zedZ5DqLzjdRITvreBk//4Knbcvm7+lWmms9V9qFp60MtUd0/t/PXPw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.58.0.tgz", + "integrity": "sha512-jIhrujyn4UnWF8S+DHSkAkDEO3hLX0cjzxJZPLF80xFyzyUIYgSMRcYQ3+uqEoyDD2beGq7Dj7edi8OnJcS/hg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.58.0.tgz", + "integrity": "sha512-+410Srdoh78MKSJxTQ+hZ/Mx+ajd6RjjPwBPNd0R3J9FtL6ZA0GqiiyNjCO9In0IzZkCNrpGymSfn+kgyPQocg==", "cpu": [ - "arm64" + "x64" ], + "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" - ], - "engines": { - "node": ">= 10" - } + ] }, - "node_modules/@next/swc-linux-x64-gnu": { - "version": "14.2.15", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.15.tgz", - "integrity": "sha512-kE6q38hbrRbKEkkVn62reLXhThLRh6/TvgSP56GkFNhU22TbIrQDEMrO7j0IcQHcew2wfykq8lZyHFabz0oBrA==", + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.58.0.tgz", + "integrity": "sha512-ZjMyby5SICi227y1MTR3VYBpFTdZs823Rs/hpakufleBoufoOIB6jtm9FEoxn/cgO7l6PM2rCEl5Kre5vX0QrQ==", "cpu": [ "x64" ], + "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" - ], - "engines": { - "node": ">= 10" - } + ] }, - "node_modules/@next/swc-linux-x64-musl": { - "version": "14.2.15", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.15.tgz", - "integrity": "sha512-PZ5YE9ouy/IdO7QVJeIcyLn/Rc4ml9M2G4y3kCM9MNf1YKvFY4heg3pVa/jQbMro+tP6yc4G2o9LjAz1zxD7tQ==", + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.58.0.tgz", + "integrity": "sha512-ds4iwfYkSQ0k1nb8LTcyXw//ToHOnNTJtceySpL3fa7tc/AsE+UpUFphW126A6fKBGJD5dhRvg8zw1rvoGFxmw==", "cpu": [ "x64" ], + "dev": true, + "license": "MIT", "optional": true, "os": [ - "linux" + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.58.0.tgz", + "integrity": "sha512-fd/zpJniln4ICdPkjWFhZYeY/bpnaN9pGa6ko+5WD38I0tTqk9lXMgXZg09MNdhpARngmxiCg0B0XUamNw/5BQ==", + "cpu": [ + "arm64" ], - "engines": { - "node": ">= 10" - } + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] }, - "node_modules/@next/swc-win32-arm64-msvc": { - "version": "14.2.15", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.15.tgz", - "integrity": "sha512-2raR16703kBvYEQD9HNLyb0/394yfqzmIeyp2nDzcPV4yPjqNUG3ohX6jX00WryXz6s1FXpVhsCo3i+g4RUX+g==", + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.58.0.tgz", + "integrity": "sha512-YpG8dUOip7DCz3nr/JUfPbIUo+2d/dy++5bFzgi4ugOGBIox+qMbbqt/JoORwvI/C9Kn2tz6+Bieoqd5+B1CjA==", "cpu": [ "arm64" ], + "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" - ], - "engines": { - "node": ">= 10" - } + ] }, - "node_modules/@next/swc-win32-ia32-msvc": { - "version": "14.2.15", - "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.15.tgz", - "integrity": "sha512-fyTE8cklgkyR1p03kJa5zXEaZ9El+kDNM5A+66+8evQS5e/6v0Gk28LqA0Jet8gKSOyP+OTm/tJHzMlGdQerdQ==", + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.58.0.tgz", + "integrity": "sha512-b9DI8jpFQVh4hIXFr0/+N/TzLdpBIoPzjt0Rt4xJbW3mzguV3mduR9cNgiuFcuL/TeORejJhCWiAXe3E/6PxWA==", "cpu": [ "ia32" ], + "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" - ], - "engines": { - "node": ">= 10" - } + ] }, - "node_modules/@next/swc-win32-x64-msvc": { - "version": "14.2.15", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.15.tgz", - "integrity": "sha512-SzqGbsLsP9OwKNUG9nekShTwhj6JSB9ZLMWQ8g1gG6hdE5gQLncbnbymrwy2yVmH9nikSLYRYxYMFu78Ggp7/g==", + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.58.0.tgz", + "integrity": "sha512-CSrVpmoRJFN06LL9xhkitkwUcTZtIotYAF5p6XOR2zW0Zz5mzb3IPpcoPhB02frzMHFNo1reQ9xSF5fFm3hUsQ==", "cpu": [ "x64" ], + "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } + ] }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.58.0.tgz", + "integrity": "sha512-QFsBgQNTnh5K0t/sBsjJLq24YVqEIVkGpfN2VHsnN90soZyhaiA9UUHufcctVNL4ypJY0wrwad0wslx2KJQ1/w==", + "cpu": [ + "x64" + ], "dev": true, + "license": "MIT", "optional": true, - "engines": { - "node": ">=14" - } - }, - "node_modules/@polka/url": { - "version": "1.0.0-next.25", - "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.25.tgz", - "integrity": "sha512-j7P6Rgr3mmtdkeDGTe0E/aYyWEWVtc5yFXtHCRHs28/jptDEWfaVOc5T7cblqy1XKPPfCxJc/8DwQ5YgLOZOVQ==", - "dev": true - }, - "node_modules/@popperjs/core": { - "version": "2.11.8", - "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", - "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/popperjs" - } - }, - "node_modules/@react-leaflet/core": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-2.1.0.tgz", - "integrity": "sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==", - "peerDependencies": { - "leaflet": "^1.9.0", - "react": "^18.0.0", - "react-dom": "^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 + "os": [ + "win32" + ] }, "node_modules/@rushstack/eslint-patch": { "version": "1.8.0", @@ -3348,6 +4142,13 @@ "integrity": "sha512-0HejFckBN2W+ucM6cUOlwsByTKt9/+0tWhqUffNIcHqCXkthY/mZ7AuYPK/2IIaGWhdl0h+tICDO0ssLMd6XMQ==", "dev": true }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@swc/counter": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", @@ -3413,10 +4214,28 @@ "react": "^18 || ^19" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", - "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "license": "MIT" }, "node_modules/@types/geojson": { @@ -3827,6 +4646,117 @@ "is-function": "^1.0.1" } }, + "node_modules/@vitest/expect": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.18", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.18", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@whatwg-node/fetch": { "version": "0.9.21", "resolved": "https://registry.npmjs.org/@whatwg-node/fetch/-/fetch-0.9.21.tgz", @@ -4169,6 +5099,16 @@ "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", "dev": true }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -4552,6 +5492,16 @@ "upper-case-first": "^2.0.2" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -5687,6 +6637,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz", @@ -5739,6 +6696,48 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, "node_modules/escalade": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", @@ -6240,8 +7239,18 @@ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, - "engines": { - "node": ">=4.0" + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" } }, "node_modules/esutils": { @@ -6259,6 +7268,16 @@ "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", "dev": true }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/external-editor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", @@ -6527,6 +7546,21 @@ "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==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "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", @@ -8600,6 +9634,16 @@ "global": "^4.4.0" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/map-cache": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/map-cache/-/map-cache-0.2.2.tgz", @@ -8773,9 +9817,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.8", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", - "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "funding": [ { "type": "github", @@ -9056,6 +10100,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -9352,15 +10407,23 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/phoenix": { "version": "1.7.14", "resolved": "https://registry.npmjs.org/phoenix/-/phoenix-1.7.14.tgz", "integrity": "sha512-3tZ76PiH/2g+Kyzhz8+GIFYrnx3lRnwi/Qt3ZUH04xpMxXL7Guerd5aaxtpWal73X+H8iLAjo2c+AgRy2KYQcQ==" }, "node_modules/picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" }, "node_modules/picomatch": { "version": "2.3.1", @@ -9801,6 +10864,51 @@ "license": "Unlicense", "peer": true }, + "node_modules/rollup": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.58.0.tgz", + "integrity": "sha512-wbT0mBmWbIvvq8NeEYWWvevvxnOyhKChir47S66WCxw1SXqhw7ssIYejnQEVt7XYQpsj2y8F9PM+Cr3SNEa0gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.58.0", + "@rollup/rollup-android-arm64": "4.58.0", + "@rollup/rollup-darwin-arm64": "4.58.0", + "@rollup/rollup-darwin-x64": "4.58.0", + "@rollup/rollup-freebsd-arm64": "4.58.0", + "@rollup/rollup-freebsd-x64": "4.58.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.58.0", + "@rollup/rollup-linux-arm-musleabihf": "4.58.0", + "@rollup/rollup-linux-arm64-gnu": "4.58.0", + "@rollup/rollup-linux-arm64-musl": "4.58.0", + "@rollup/rollup-linux-loong64-gnu": "4.58.0", + "@rollup/rollup-linux-loong64-musl": "4.58.0", + "@rollup/rollup-linux-ppc64-gnu": "4.58.0", + "@rollup/rollup-linux-ppc64-musl": "4.58.0", + "@rollup/rollup-linux-riscv64-gnu": "4.58.0", + "@rollup/rollup-linux-riscv64-musl": "4.58.0", + "@rollup/rollup-linux-s390x-gnu": "4.58.0", + "@rollup/rollup-linux-x64-gnu": "4.58.0", + "@rollup/rollup-linux-x64-musl": "4.58.0", + "@rollup/rollup-openbsd-x64": "4.58.0", + "@rollup/rollup-openharmony-arm64": "4.58.0", + "@rollup/rollup-win32-arm64-msvc": "4.58.0", + "@rollup/rollup-win32-ia32-msvc": "4.58.0", + "@rollup/rollup-win32-x64-gnu": "4.58.0", + "@rollup/rollup-win32-x64-msvc": "4.58.0", + "fsevents": "~2.3.2" + } + }, "node_modules/run-async": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", @@ -10103,6 +11211,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -10184,9 +11299,10 @@ } }, "node_modules/source-map-js": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", - "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", + "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==", + "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } @@ -10200,6 +11316,20 @@ "tslib": "^2.0.3" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz", @@ -10526,6 +11656,81 @@ "xtend": "~2.1.1" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/title-case": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/title-case/-/title-case-3.0.3.tgz", @@ -11587,6 +12792,232 @@ "global": "^4.3.1" } }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "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/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vite/node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "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" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/vitest": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@vitest/pretty-format": "4.0.18", + "@vitest/runner": "4.0.18", + "@vitest/snapshot": "4.0.18", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/wcwidth": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", @@ -11767,6 +13198,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", diff --git a/ui/package.json b/ui/package.json index fc1945da0..53abb58c2 100644 --- a/ui/package.json +++ b/ui/package.json @@ -13,7 +13,9 @@ "format": "prettier . --write", "codegen": "graphql-codegen", "analyze": "ANALYZE=true next build", - "prepare": "cd .. && husky ui/.husky" + "prepare": "cd .. && husky ui/.husky", + "test": "vitest run --passWithNoTests", + "test:watch": "vitest --passWithNoTests" }, "dependencies": { "@emotion/cache": "^11.13.1", @@ -72,6 +74,7 @@ "husky": "^9.1.6", "lint-staged": "^15.2.10", "prettier": "3.3.3", - "typescript": "5.6.3" + "typescript": "5.6.3", + "vitest": "^4.0.18" } } diff --git a/ui/vitest.config.ts b/ui/vitest.config.ts new file mode 100644 index 000000000..15508484d --- /dev/null +++ b/ui/vitest.config.ts @@ -0,0 +1,13 @@ +import path from "path"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + }, + resolve: { + alias: { + "@": path.resolve(__dirname, "src"), + }, + }, +}); From 42089862a871302c5bc8160c0cae12610b0602d8 Mon Sep 17 00:00:00 2001 From: Adrian Date: Fri, 20 Feb 2026 23:58:41 +0000 Subject: [PATCH 11/12] add unit tests for dataTransform helpers --- ui/src/utils/dataTransforms.test.ts | 123 ++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 ui/src/utils/dataTransforms.test.ts diff --git a/ui/src/utils/dataTransforms.test.ts b/ui/src/utils/dataTransforms.test.ts new file mode 100644 index 000000000..bedeaa333 --- /dev/null +++ b/ui/src/utils/dataTransforms.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; + +import type { Feed } from "@/graphql/generated"; +import type { CascadiaSighting, DetectionsResult } from "@/types/DataTypes"; + +import { transformAudioDetections, transformSightings } from "./dataTransforms"; + +const makeFeed = ( + id: string, + name: string, + slug: string, + lat: number, + lng: number, +): Feed => + ({ + id, + name, + slug, + latLng: { lat, lng }, + }) as unknown as Feed; + +const makeDetection = ( + overrides: Partial = {}, +): DetectionsResult => + ({ + id: "d-1", + feedId: "f-1", + source: "HUMAN", + category: "WHALE", + description: "demo", + playlistTimestamp: 123, + playerOffset: 2, + timestamp: new Date("2025-01-01T00:00:00Z"), + ...overrides, + }) as DetectionsResult; + +const makeSighting = ( + overrides: Partial = {}, +): CascadiaSighting => ({ + id: "s-1", + type: "sighting", + project_id: 1, + trip_id: 1, + name: "Killer Whale (Orca)", + scientific_name: "Orcinus orca", + number_sighted: 1, + latitude: 48.1, + longitude: -122.75, + created: "2025-01-01 17:25:00", + source: "whale_alert", + comments: "test", + icon: "dot-black", + photo_url: "", + usernm: "tester", + count_check: 0, + in_ocean: 1, + is_test: 0, + moderated: 1, + trusted: 1, + ...overrides, +}); + +describe("transformAudioDetections", () => { + it("returns empty array when inputs are missing", () => { + expect(transformAudioDetections([], [])).toEqual([]); + }); + + it("maps source/category to newCategory and enriches feed metadata", () => { + const feeds = [makeFeed("f-1", "North SJC", "north-sjc", 48.1, -122.75)]; + const machine = makeDetection({ + id: "d-machine", + source: "MACHINE", + category: "WHALE", + }); + const human = makeDetection({ + id: "d-human", + source: "HUMAN", + category: "WHALE", + }); + + const transformed = transformAudioDetections([machine, human], feeds); + + expect(transformed).toHaveLength(2); + expect(transformed[0].newCategory).toBe("WHALE (AI)"); + expect(transformed[1].newCategory).toBe("WHALE (HUMAN)"); + expect(transformed[0].standardizedFeedName).toBe("North San Juan Channel"); + expect(transformed[0].feedSlug).toBe("north-sjc"); + expect(transformed[0].type).toBe("audio"); + }); +}); + +describe("transformSightings", () => { + it("assigns matching feed metadata for in-range sightings", () => { + const feeds = [makeFeed("f-1", "North SJC", "north-sjc", 48.1, -122.75)]; + const sightings = [makeSighting()]; + + const transformed = transformSightings(sightings, feeds); + + expect(transformed).toHaveLength(1); + expect(transformed[0].type).toBe("sightings"); + expect(transformed[0].newCategory).toBe("SIGHTING"); + expect(transformed[0].standardizedFeedName).toBe("North San Juan Channel"); + expect(transformed[0].feedId).toBe("f-1"); + expect(transformed[0].feedSlug).toBe("north-sjc"); + expect(transformed[0].timestampString).toBe("2025-01-01T17:25:00Z"); + }); + + it("marks out-of-range sightings with fallback feed identifiers", () => { + const feeds = [makeFeed("f-1", "North SJC", "north-sjc", 48.1, -122.75)]; + const sightings = [ + makeSighting({ + latitude: 0, + longitude: 0, + }), + ]; + + const transformed = transformSightings(sightings, feeds); + + expect(transformed[0].standardizedFeedName).toBe("out of range"); + expect(transformed[0].feedId).toBe("feed id not found"); + expect(transformed[0].feedSlug).toBe("feed slug not found"); + }); +}); From 0f93d477c941229e57f9182ef38edd8af241b8b7 Mon Sep 17 00:00:00 2001 From: Adrian Date: Sat, 21 Feb 2026 00:25:18 +0000 Subject: [PATCH 12/12] Added vitest.config.ts to .eslintrc ignoreExports to suppress lint warning for default export --- ui/.eslintrc | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ui/.eslintrc b/ui/.eslintrc index aefb66850..6672c13d7 100644 --- a/ui/.eslintrc +++ b/ui/.eslintrc @@ -23,7 +23,8 @@ "unusedExports": true, "ignoreExports": [ "src/pages", // pages are automatically imported by nextjs - "codegen.ts" + "codegen.ts", + "vitest.config.ts" ] } ]